zeitzeuge 0.3.0
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/LICENSE +21 -0
- package/README.md +212 -0
- package/dist/analysis/agent.d.ts +19 -0
- package/dist/analysis/agent.d.ts.map +1 -0
- package/dist/analysis/parser.d.ts +3 -0
- package/dist/analysis/parser.d.ts.map +1 -0
- package/dist/analysis/prompts.d.ts +2 -0
- package/dist/analysis/prompts.d.ts.map +1 -0
- package/dist/browser/capture.d.ts +14 -0
- package/dist/browser/capture.d.ts.map +1 -0
- package/dist/browser/launch.d.ts +6 -0
- package/dist/browser/launch.d.ts.map +1 -0
- package/dist/browser/runtime-trace.d.ts +52 -0
- package/dist/browser/runtime-trace.d.ts.map +1 -0
- package/dist/browser/trace.d.ts +8 -0
- package/dist/browser/trace.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1695 -0
- package/dist/models/init.d.ts +3 -0
- package/dist/models/init.d.ts.map +1 -0
- package/dist/output/report.d.ts +38 -0
- package/dist/output/report.d.ts.map +1 -0
- package/dist/output/terminal.d.ts +31 -0
- package/dist/output/terminal.d.ts.map +1 -0
- package/dist/sandbox/workspace.d.ts +33 -0
- package/dist/sandbox/workspace.d.ts.map +1 -0
- package/dist/schema.d.ts +64 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/types.d.ts +245 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/vitest/classify.d.ts +19 -0
- package/dist/vitest/classify.d.ts.map +1 -0
- package/dist/vitest/heap-profile-parser.d.ts +12 -0
- package/dist/vitest/heap-profile-parser.d.ts.map +1 -0
- package/dist/vitest/index.d.ts +17 -0
- package/dist/vitest/index.d.ts.map +1 -0
- package/dist/vitest/index.js +1616 -0
- package/dist/vitest/plugin.d.ts +17 -0
- package/dist/vitest/plugin.d.ts.map +1 -0
- package/dist/vitest/profile-parser.d.ts +13 -0
- package/dist/vitest/profile-parser.d.ts.map +1 -0
- package/dist/vitest/prompts.d.ts +10 -0
- package/dist/vitest/prompts.d.ts.map +1 -0
- package/dist/vitest/reporter.d.ts +79 -0
- package/dist/vitest/reporter.d.ts.map +1 -0
- package/dist/vitest/types.d.ts +231 -0
- package/dist/vitest/types.d.ts.map +1 -0
- package/dist/vitest/workspace.d.ts +25 -0
- package/dist/vitest/workspace.d.ts.map +1 -0
- package/package.json +76 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// src/cli.ts
|
|
6
|
+
import yargs from "yargs";
|
|
7
|
+
import { hideBin } from "yargs/helpers";
|
|
8
|
+
|
|
9
|
+
// src/models/init.ts
|
|
10
|
+
import { ChatOpenAI } from "@langchain/openai";
|
|
11
|
+
import { ChatAnthropic } from "@langchain/anthropic";
|
|
12
|
+
function initModel() {
|
|
13
|
+
const modelOverride = process.env.ZEITZEUGE_MODEL;
|
|
14
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
15
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
16
|
+
if (openaiKey) {
|
|
17
|
+
return new ChatOpenAI({
|
|
18
|
+
model: modelOverride ?? "gpt-5.2",
|
|
19
|
+
apiKey: openaiKey
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (anthropicKey) {
|
|
23
|
+
return new ChatAnthropic({
|
|
24
|
+
model: modelOverride ?? "claude-opus-4-6",
|
|
25
|
+
apiKey: anthropicKey
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`No API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY in your environment.
|
|
29
|
+
|
|
30
|
+
` + ` export OPENAI_API_KEY=sk-...
|
|
31
|
+
` + ` # or
|
|
32
|
+
` + ` export ANTHROPIC_API_KEY=sk-ant-...
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/browser/launch.ts
|
|
37
|
+
import { remote } from "webdriverio";
|
|
38
|
+
async function launchBrowser(options = {}) {
|
|
39
|
+
const { headless = true } = options;
|
|
40
|
+
const browser = await remote({
|
|
41
|
+
capabilities: {
|
|
42
|
+
browserName: "chrome",
|
|
43
|
+
"goog:chromeOptions": {
|
|
44
|
+
args: [
|
|
45
|
+
...headless ? ["--headless=new"] : [],
|
|
46
|
+
"--no-sandbox",
|
|
47
|
+
"--disable-gpu",
|
|
48
|
+
"--disable-dev-shm-usage"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
logLevel: "warn"
|
|
53
|
+
});
|
|
54
|
+
return browser;
|
|
55
|
+
}
|
|
56
|
+
async function closeBrowser(browser) {
|
|
57
|
+
try {
|
|
58
|
+
await browser.deleteSession();
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/browser/runtime-trace.ts
|
|
63
|
+
var SCRIPTING_EVENTS = new Set([
|
|
64
|
+
"FunctionCall",
|
|
65
|
+
"EvaluateScript",
|
|
66
|
+
"TimerFire",
|
|
67
|
+
"RequestAnimationFrame",
|
|
68
|
+
"FireAnimationFrame"
|
|
69
|
+
]);
|
|
70
|
+
var LAYOUT_EVENTS = new Set(["Layout", "UpdateLayoutTree", "RecalculateStyles"]);
|
|
71
|
+
var PAINTING_EVENTS = new Set(["Paint", "CompositeLayers", "RasterTask"]);
|
|
72
|
+
var GC_EVENT_NAMES = new Set(["MajorGC", "MinorGC"]);
|
|
73
|
+
var BLOCKING_EVENT_NAMES = new Set(["FunctionCall", "EvaluateScript"]);
|
|
74
|
+
var BLOCKING_THRESHOLD_US = 50000;
|
|
75
|
+
function parseRuntimeTrace(traceEvents, navigationStartTs) {
|
|
76
|
+
if (traceEvents.length === 0) {
|
|
77
|
+
return emptyRuntimeTrace();
|
|
78
|
+
}
|
|
79
|
+
const mainThreadId = findMainThread(traceEvents);
|
|
80
|
+
const mainEvents = traceEvents.filter((e) => e.tid === mainThreadId);
|
|
81
|
+
const blockingFunctions = extractBlockingFunctions(mainEvents, navigationStartTs);
|
|
82
|
+
const eventListeners = extractEventListenerInfo(mainEvents);
|
|
83
|
+
const frameBreakdown = buildFrameBreakdown(mainEvents);
|
|
84
|
+
const gcEvents = extractGCEvents(mainEvents, navigationStartTs);
|
|
85
|
+
const frequentEvents = findFrequentEvents(mainEvents);
|
|
86
|
+
let minTs = Infinity;
|
|
87
|
+
let maxTs = -Infinity;
|
|
88
|
+
for (const e of traceEvents) {
|
|
89
|
+
if (e.ts < minTs)
|
|
90
|
+
minTs = e.ts;
|
|
91
|
+
const endTs = e.ts + (e.dur ?? 0);
|
|
92
|
+
if (endTs > maxTs)
|
|
93
|
+
maxTs = endTs;
|
|
94
|
+
}
|
|
95
|
+
const traceDuration = (maxTs - minTs) / 1000;
|
|
96
|
+
return {
|
|
97
|
+
totalEvents: traceEvents.length,
|
|
98
|
+
mainThreadId,
|
|
99
|
+
traceDuration,
|
|
100
|
+
frameBreakdown,
|
|
101
|
+
blockingFunctions,
|
|
102
|
+
eventListeners,
|
|
103
|
+
gcEvents,
|
|
104
|
+
frequentEvents
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function findMainThread(events) {
|
|
108
|
+
const metadata = events.find((e) => e.cat === "__metadata" && e.name === "thread_name" && e.args?.name === "CrRendererMain");
|
|
109
|
+
if (metadata)
|
|
110
|
+
return metadata.tid;
|
|
111
|
+
const threadCounts = new Map;
|
|
112
|
+
for (const e of events) {
|
|
113
|
+
if (e.name === "FunctionCall") {
|
|
114
|
+
threadCounts.set(e.tid, (threadCounts.get(e.tid) ?? 0) + 1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
let maxTid = 0;
|
|
118
|
+
let maxCount = 0;
|
|
119
|
+
for (const [tid, count] of threadCounts) {
|
|
120
|
+
if (count > maxCount) {
|
|
121
|
+
maxTid = tid;
|
|
122
|
+
maxCount = count;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return maxTid;
|
|
126
|
+
}
|
|
127
|
+
function extractBlockingFunctions(mainEvents, navigationStartTs) {
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const e of mainEvents) {
|
|
130
|
+
if (e.ph !== "X" || !BLOCKING_EVENT_NAMES.has(e.name) || !e.dur || e.dur < BLOCKING_THRESHOLD_US) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const data = e.args?.data;
|
|
134
|
+
const functionName = data?.functionName || e.name;
|
|
135
|
+
const scriptUrl = data?.url || "";
|
|
136
|
+
const lineNumber = data?.lineNumber ?? 0;
|
|
137
|
+
const columnNumber = data?.columnNumber ?? 0;
|
|
138
|
+
const callStack = [];
|
|
139
|
+
if (data?.stackTrace) {
|
|
140
|
+
for (const frame of data.stackTrace) {
|
|
141
|
+
callStack.push({
|
|
142
|
+
functionName: frame.functionName || "(anonymous)",
|
|
143
|
+
scriptUrl: frame.url || "",
|
|
144
|
+
lineNumber: frame.lineNumber ?? 0
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
results.push({
|
|
149
|
+
functionName,
|
|
150
|
+
scriptUrl,
|
|
151
|
+
lineNumber,
|
|
152
|
+
columnNumber,
|
|
153
|
+
duration: e.dur / 1000,
|
|
154
|
+
startTime: (e.ts - navigationStartTs) / 1000,
|
|
155
|
+
callStack,
|
|
156
|
+
category: "scripting"
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
results.sort((a, b) => b.duration - a.duration);
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
function extractEventListenerInfo(mainEvents) {
|
|
163
|
+
const eventMap = new Map;
|
|
164
|
+
for (const e of mainEvents) {
|
|
165
|
+
if (e.name !== "EventDispatch" || e.ph !== "X")
|
|
166
|
+
continue;
|
|
167
|
+
const eventType = e.args?.data?.type || "unknown";
|
|
168
|
+
let entry = eventMap.get(eventType);
|
|
169
|
+
if (!entry) {
|
|
170
|
+
entry = { count: 0, totalDuration: 0, sources: new Map };
|
|
171
|
+
eventMap.set(eventType, entry);
|
|
172
|
+
}
|
|
173
|
+
entry.count++;
|
|
174
|
+
entry.totalDuration += (e.dur ?? 0) / 1000;
|
|
175
|
+
const url = e.args?.data?.url || "";
|
|
176
|
+
const line = e.args?.data?.lineNumber ?? 0;
|
|
177
|
+
if (url) {
|
|
178
|
+
const key = `${url}:${line}`;
|
|
179
|
+
const src = entry.sources.get(key);
|
|
180
|
+
if (src) {
|
|
181
|
+
src.count++;
|
|
182
|
+
} else {
|
|
183
|
+
entry.sources.set(key, { scriptUrl: url, lineNumber: line, count: 1 });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const results = [];
|
|
188
|
+
for (const [eventType, data] of eventMap) {
|
|
189
|
+
results.push({
|
|
190
|
+
eventType,
|
|
191
|
+
addCount: data.count,
|
|
192
|
+
removeCount: 0,
|
|
193
|
+
activeCount: data.count,
|
|
194
|
+
sources: Array.from(data.sources.values())
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
results.sort((a, b) => b.addCount - a.addCount);
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
function buildFrameBreakdown(mainEvents) {
|
|
201
|
+
let scripting = 0;
|
|
202
|
+
let layout = 0;
|
|
203
|
+
let painting = 0;
|
|
204
|
+
let gc = 0;
|
|
205
|
+
let other = 0;
|
|
206
|
+
for (const e of mainEvents) {
|
|
207
|
+
if (e.ph !== "X" || !e.dur)
|
|
208
|
+
continue;
|
|
209
|
+
const durMs = e.dur / 1000;
|
|
210
|
+
if (SCRIPTING_EVENTS.has(e.name)) {
|
|
211
|
+
scripting += durMs;
|
|
212
|
+
} else if (LAYOUT_EVENTS.has(e.name)) {
|
|
213
|
+
layout += durMs;
|
|
214
|
+
} else if (PAINTING_EVENTS.has(e.name)) {
|
|
215
|
+
painting += durMs;
|
|
216
|
+
} else if (GC_EVENT_NAMES.has(e.name)) {
|
|
217
|
+
gc += durMs;
|
|
218
|
+
} else {
|
|
219
|
+
other += durMs;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const totalTime = scripting + layout + painting + gc + other;
|
|
223
|
+
return {
|
|
224
|
+
totalTime: round(totalTime),
|
|
225
|
+
scripting: round(scripting),
|
|
226
|
+
layout: round(layout),
|
|
227
|
+
painting: round(painting),
|
|
228
|
+
gc: round(gc),
|
|
229
|
+
other: round(other)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function extractGCEvents(mainEvents, navigationStartTs) {
|
|
233
|
+
const results = [];
|
|
234
|
+
for (const e of mainEvents) {
|
|
235
|
+
if (e.ph !== "X" || !GC_EVENT_NAMES.has(e.name))
|
|
236
|
+
continue;
|
|
237
|
+
results.push({
|
|
238
|
+
startTime: round((e.ts - navigationStartTs) / 1000),
|
|
239
|
+
duration: round((e.dur ?? 0) / 1000),
|
|
240
|
+
type: e.name,
|
|
241
|
+
usedHeapSizeBefore: e.args?.data?.usedHeapSizeBefore,
|
|
242
|
+
usedHeapSizeAfter: e.args?.data?.usedHeapSizeAfter
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
results.sort((a, b) => a.startTime - b.startTime);
|
|
246
|
+
return results;
|
|
247
|
+
}
|
|
248
|
+
function findFrequentEvents(mainEvents) {
|
|
249
|
+
const counts = new Map;
|
|
250
|
+
for (const e of mainEvents) {
|
|
251
|
+
if (e.name !== "EventDispatch" || e.ph !== "X")
|
|
252
|
+
continue;
|
|
253
|
+
const eventType = e.args?.data?.type || "unknown";
|
|
254
|
+
const entry = counts.get(eventType);
|
|
255
|
+
if (entry) {
|
|
256
|
+
entry.count++;
|
|
257
|
+
entry.totalDuration += (e.dur ?? 0) / 1000;
|
|
258
|
+
} else {
|
|
259
|
+
counts.set(eventType, { count: 1, totalDuration: (e.dur ?? 0) / 1000 });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const results = [];
|
|
263
|
+
for (const [eventType, data] of counts) {
|
|
264
|
+
if (data.count > 10) {
|
|
265
|
+
results.push({
|
|
266
|
+
eventType,
|
|
267
|
+
count: data.count,
|
|
268
|
+
totalDuration: round(data.totalDuration)
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
results.sort((a, b) => b.count - a.count);
|
|
273
|
+
return results;
|
|
274
|
+
}
|
|
275
|
+
function round(n) {
|
|
276
|
+
return Math.round(n * 100) / 100;
|
|
277
|
+
}
|
|
278
|
+
function emptyRuntimeTrace() {
|
|
279
|
+
return {
|
|
280
|
+
totalEvents: 0,
|
|
281
|
+
mainThreadId: 0,
|
|
282
|
+
traceDuration: 0,
|
|
283
|
+
frameBreakdown: {
|
|
284
|
+
totalTime: 0,
|
|
285
|
+
scripting: 0,
|
|
286
|
+
layout: 0,
|
|
287
|
+
painting: 0,
|
|
288
|
+
gc: 0,
|
|
289
|
+
other: 0
|
|
290
|
+
},
|
|
291
|
+
blockingFunctions: [],
|
|
292
|
+
eventListeners: [],
|
|
293
|
+
gcEvents: [],
|
|
294
|
+
frequentEvents: []
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/browser/trace.ts
|
|
299
|
+
var TEXT_RESOURCE_TYPES = new Set(["Script", "Stylesheet", "Document", "XHR", "Fetch"]);
|
|
300
|
+
var MAX_BODY_SIZE = 2 * 1024 * 1024;
|
|
301
|
+
var TRACE_CATEGORIES = [
|
|
302
|
+
"devtools.timeline",
|
|
303
|
+
"disabled-by-default-devtools.timeline",
|
|
304
|
+
"disabled-by-default-devtools.timeline.stack",
|
|
305
|
+
"v8.execute",
|
|
306
|
+
"blink.user_timing",
|
|
307
|
+
"disabled-by-default-v8.gc"
|
|
308
|
+
];
|
|
309
|
+
var TRACING_COMPLETE_TIMEOUT = 1e4;
|
|
310
|
+
async function tracePageLoad(cdpSession, _options = {}) {
|
|
311
|
+
const requests = new Map;
|
|
312
|
+
const finishedIds = new Set;
|
|
313
|
+
const traceEvents = [];
|
|
314
|
+
let tracingStarted = false;
|
|
315
|
+
try {
|
|
316
|
+
cdpSession.on("Tracing.dataCollected", (params) => {
|
|
317
|
+
if (params.value && Array.isArray(params.value)) {
|
|
318
|
+
traceEvents.push(...params.value);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
await cdpSession.send("Tracing.start", {
|
|
322
|
+
traceConfig: {
|
|
323
|
+
includedCategories: TRACE_CATEGORIES,
|
|
324
|
+
recordMode: "recordUntilFull"
|
|
325
|
+
},
|
|
326
|
+
transferMode: "ReportEvents"
|
|
327
|
+
});
|
|
328
|
+
tracingStarted = true;
|
|
329
|
+
} catch {}
|
|
330
|
+
await cdpSession.send("Network.enable");
|
|
331
|
+
cdpSession.on("Network.requestWillBeSent", (params) => {
|
|
332
|
+
requests.set(params.requestId, {
|
|
333
|
+
requestId: params.requestId,
|
|
334
|
+
url: params.request.url,
|
|
335
|
+
method: params.request.method,
|
|
336
|
+
startTime: params.timestamp * 1000,
|
|
337
|
+
initiator: params.initiator?.type ?? "other",
|
|
338
|
+
resourceType: params.type ?? "Other"
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
cdpSession.on("Network.responseReceived", (params) => {
|
|
342
|
+
const req = requests.get(params.requestId);
|
|
343
|
+
if (req) {
|
|
344
|
+
req.status = params.response.status;
|
|
345
|
+
req.mimeType = params.response.mimeType;
|
|
346
|
+
req.encodedSize = params.response.encodedDataLength ?? 0;
|
|
347
|
+
req.priority = params.response.priority ?? "Medium";
|
|
348
|
+
req.isRenderBlocking = params.response.renderBlocking === "blocking";
|
|
349
|
+
req.resourceType = params.type ?? req.resourceType ?? "Other";
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
cdpSession.on("Network.loadingFinished", (params) => {
|
|
353
|
+
const req = requests.get(params.requestId);
|
|
354
|
+
if (req) {
|
|
355
|
+
req.endTime = params.timestamp * 1000;
|
|
356
|
+
req.duration = (req.endTime ?? 0) - (req.startTime ?? 0);
|
|
357
|
+
req.decodedSize = params.encodedDataLength ?? 0;
|
|
358
|
+
finishedIds.add(params.requestId);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
await cdpSession.send("Page.enable");
|
|
362
|
+
await cdpSession.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
363
|
+
source: `
|
|
364
|
+
window.__zeitzeuge_longTasks = [];
|
|
365
|
+
new PerformanceObserver((list) => {
|
|
366
|
+
for (const entry of list.getEntries()) {
|
|
367
|
+
window.__zeitzeuge_longTasks.push({
|
|
368
|
+
startTime: entry.startTime,
|
|
369
|
+
duration: entry.duration,
|
|
370
|
+
scriptUrl: null,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}).observe({ type: "longtask", buffered: true });
|
|
374
|
+
`
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
async stop() {
|
|
378
|
+
let runtimeTrace;
|
|
379
|
+
if (tracingStarted) {
|
|
380
|
+
try {
|
|
381
|
+
const tracingComplete = new Promise((resolve) => {
|
|
382
|
+
cdpSession.on("Tracing.tracingComplete", () => resolve());
|
|
383
|
+
});
|
|
384
|
+
await cdpSession.send("Tracing.end");
|
|
385
|
+
await Promise.race([
|
|
386
|
+
tracingComplete,
|
|
387
|
+
new Promise((resolve) => setTimeout(resolve, TRACING_COMPLETE_TIMEOUT))
|
|
388
|
+
]);
|
|
389
|
+
} catch {}
|
|
390
|
+
if (traceEvents.length > 0) {
|
|
391
|
+
const navEvent = traceEvents.find((e) => e.name === "navigationStart" || e.name === "NavigationStart");
|
|
392
|
+
const navigationStartTs = navEvent?.ts ?? traceEvents[0]?.ts ?? 0;
|
|
393
|
+
runtimeTrace = parseRuntimeTrace(traceEvents, navigationStartTs);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const networkRequests = [];
|
|
397
|
+
for (const [id, req] of requests) {
|
|
398
|
+
let responseBody = null;
|
|
399
|
+
if (finishedIds.has(id) && TEXT_RESOURCE_TYPES.has(req.resourceType ?? "")) {
|
|
400
|
+
try {
|
|
401
|
+
const bodyResult = await cdpSession.send("Network.getResponseBody", { requestId: id });
|
|
402
|
+
responseBody = bodyResult.body;
|
|
403
|
+
if (responseBody && responseBody.length > MAX_BODY_SIZE) {
|
|
404
|
+
responseBody = null;
|
|
405
|
+
}
|
|
406
|
+
} catch {}
|
|
407
|
+
}
|
|
408
|
+
networkRequests.push({
|
|
409
|
+
requestId: req.requestId ?? id,
|
|
410
|
+
url: req.url ?? "",
|
|
411
|
+
method: req.method ?? "GET",
|
|
412
|
+
resourceType: req.resourceType ?? "Other",
|
|
413
|
+
mimeType: req.mimeType ?? "",
|
|
414
|
+
status: req.status ?? 0,
|
|
415
|
+
encodedSize: req.encodedSize ?? 0,
|
|
416
|
+
decodedSize: req.decodedSize ?? 0,
|
|
417
|
+
startTime: req.startTime ?? 0,
|
|
418
|
+
endTime: req.endTime ?? 0,
|
|
419
|
+
duration: req.duration ?? 0,
|
|
420
|
+
isRenderBlocking: req.isRenderBlocking ?? false,
|
|
421
|
+
responseBody,
|
|
422
|
+
priority: req.priority ?? "Medium",
|
|
423
|
+
initiator: req.initiator ?? "other"
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
let longTasks = [];
|
|
427
|
+
try {
|
|
428
|
+
const longTaskResult = await cdpSession.send("Runtime.evaluate", {
|
|
429
|
+
expression: "JSON.stringify(window.__zeitzeuge_longTasks || [])"
|
|
430
|
+
});
|
|
431
|
+
longTasks = JSON.parse(longTaskResult.result.value || "[]");
|
|
432
|
+
} catch {}
|
|
433
|
+
let perfMetrics = {};
|
|
434
|
+
try {
|
|
435
|
+
const metricsResult = await cdpSession.send("Runtime.evaluate", {
|
|
436
|
+
expression: `JSON.stringify({
|
|
437
|
+
navigationStart: performance.timing.navigationStart,
|
|
438
|
+
domContentLoaded: performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart,
|
|
439
|
+
loadComplete: performance.timing.loadEventEnd - performance.timing.navigationStart,
|
|
440
|
+
})`
|
|
441
|
+
});
|
|
442
|
+
perfMetrics = JSON.parse(metricsResult.result.value || "{}");
|
|
443
|
+
} catch {}
|
|
444
|
+
let paintEntries = [];
|
|
445
|
+
try {
|
|
446
|
+
const paintResult = await cdpSession.send("Runtime.evaluate", {
|
|
447
|
+
expression: `JSON.stringify(
|
|
448
|
+
performance.getEntriesByType("paint").map(e => ({ name: e.name, startTime: e.startTime }))
|
|
449
|
+
)`
|
|
450
|
+
});
|
|
451
|
+
paintEntries = JSON.parse(paintResult.result.value || "[]");
|
|
452
|
+
} catch {}
|
|
453
|
+
const fp = paintEntries.find((e) => e.name === "first-paint");
|
|
454
|
+
const fcp = paintEntries.find((e) => e.name === "first-contentful-paint");
|
|
455
|
+
await cdpSession.send("Network.disable");
|
|
456
|
+
const metrics = {
|
|
457
|
+
navigationStart: perfMetrics.navigationStart ?? 0,
|
|
458
|
+
domContentLoaded: perfMetrics.domContentLoaded ?? 0,
|
|
459
|
+
loadComplete: perfMetrics.loadComplete ?? 0,
|
|
460
|
+
firstPaint: fp?.startTime ?? 0,
|
|
461
|
+
firstContentfulPaint: fcp?.startTime ?? 0,
|
|
462
|
+
largestContentfulPaint: 0,
|
|
463
|
+
totalBlockingTime: longTasks.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0),
|
|
464
|
+
longTasks
|
|
465
|
+
};
|
|
466
|
+
return {
|
|
467
|
+
networkRequests,
|
|
468
|
+
metrics,
|
|
469
|
+
runtimeTrace,
|
|
470
|
+
rawTraceEvents: tracingStarted ? traceEvents : undefined
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/browser/capture.ts
|
|
477
|
+
async function capturePage(browser, url, options = {}) {
|
|
478
|
+
const { timeout = 30000 } = options;
|
|
479
|
+
const puppeteerBrowser = await browser.getPuppeteer();
|
|
480
|
+
const pages = await puppeteerBrowser.pages();
|
|
481
|
+
const page = pages[0];
|
|
482
|
+
if (!page) {
|
|
483
|
+
throw new Error("No page found in Puppeteer browser");
|
|
484
|
+
}
|
|
485
|
+
const cdpSession = await page.createCDPSession();
|
|
486
|
+
const traceHandle = await tracePageLoad(cdpSession, options);
|
|
487
|
+
await page.goto(url, {
|
|
488
|
+
waitUntil: "load",
|
|
489
|
+
timeout
|
|
490
|
+
});
|
|
491
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
492
|
+
const traceResult = await traceHandle.stop();
|
|
493
|
+
await cdpSession.send("HeapProfiler.enable");
|
|
494
|
+
const chunks = [];
|
|
495
|
+
cdpSession.on("HeapProfiler.addHeapSnapshotChunk", (params) => {
|
|
496
|
+
chunks.push(params.chunk);
|
|
497
|
+
});
|
|
498
|
+
await cdpSession.send("HeapProfiler.collectGarbage");
|
|
499
|
+
await cdpSession.send("HeapProfiler.takeHeapSnapshot", {
|
|
500
|
+
reportProgress: false
|
|
501
|
+
});
|
|
502
|
+
await cdpSession.send("HeapProfiler.disable");
|
|
503
|
+
await cdpSession.detach();
|
|
504
|
+
return {
|
|
505
|
+
heapSnapshot: {
|
|
506
|
+
data: chunks.join(""),
|
|
507
|
+
capturedAt: Date.now(),
|
|
508
|
+
url
|
|
509
|
+
},
|
|
510
|
+
trace: traceResult
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/analysis/parser.ts
|
|
515
|
+
function parseSnapshot(rawSnapshot) {
|
|
516
|
+
const v8 = JSON.parse(rawSnapshot.data);
|
|
517
|
+
const meta = v8.snapshot.meta;
|
|
518
|
+
const nodeFieldCount = meta.node_fields.length;
|
|
519
|
+
const edgeFieldCount = meta.edge_fields.length;
|
|
520
|
+
const nodeTypes = meta.node_types[0];
|
|
521
|
+
const edgeTypes = meta.edge_types[0];
|
|
522
|
+
const nodeTypeIdx = meta.node_fields.indexOf("type");
|
|
523
|
+
const nodeNameIdx = meta.node_fields.indexOf("name");
|
|
524
|
+
const nodeIdIdx = meta.node_fields.indexOf("id");
|
|
525
|
+
const nodeSelfSizeIdx = meta.node_fields.indexOf("self_size");
|
|
526
|
+
const nodeEdgeCountIdx = meta.node_fields.indexOf("edge_count");
|
|
527
|
+
const nodeDetachednessIdx = meta.node_fields.indexOf("detachedness");
|
|
528
|
+
const edgeTypeIdx = meta.edge_fields.indexOf("type");
|
|
529
|
+
const edgeNameIdx = meta.edge_fields.indexOf("name_or_index");
|
|
530
|
+
const edgeToNodeIdx = meta.edge_fields.indexOf("to_node");
|
|
531
|
+
const nodeCount = v8.snapshot.node_count;
|
|
532
|
+
const nodes = [];
|
|
533
|
+
let edgeOffset = 0;
|
|
534
|
+
let totalSize = 0;
|
|
535
|
+
for (let i = 0;i < nodeCount; i++) {
|
|
536
|
+
const base = i * nodeFieldCount;
|
|
537
|
+
const selfSize = v8.nodes[base + nodeSelfSizeIdx] ?? 0;
|
|
538
|
+
totalSize += selfSize;
|
|
539
|
+
nodes.push({
|
|
540
|
+
ordinal: i,
|
|
541
|
+
type: nodeTypes[v8.nodes[base + nodeTypeIdx] ?? 0] ?? "unknown",
|
|
542
|
+
name: v8.strings[v8.nodes[base + nodeNameIdx] ?? 0] ?? "",
|
|
543
|
+
id: v8.nodes[base + nodeIdIdx] ?? 0,
|
|
544
|
+
selfSize,
|
|
545
|
+
edgeCount: v8.nodes[base + nodeEdgeCountIdx] ?? 0,
|
|
546
|
+
detachedness: nodeDetachednessIdx >= 0 ? v8.nodes[base + nodeDetachednessIdx] ?? 0 : 0,
|
|
547
|
+
edgeStartIndex: edgeOffset
|
|
548
|
+
});
|
|
549
|
+
edgeOffset += (v8.nodes[base + nodeEdgeCountIdx] ?? 0) * edgeFieldCount;
|
|
550
|
+
}
|
|
551
|
+
const adjacency = Array.from({ length: nodeCount }, () => []);
|
|
552
|
+
for (let i = 0;i < nodeCount; i++)
|
|
553
|
+
adjacency[i] = [];
|
|
554
|
+
const reverseAdj = Array.from({ length: nodeCount }, () => []);
|
|
555
|
+
for (let i = 0;i < nodeCount; i++)
|
|
556
|
+
reverseAdj[i] = [];
|
|
557
|
+
for (let i = 0;i < nodeCount; i++) {
|
|
558
|
+
const node = nodes[i];
|
|
559
|
+
for (let e = 0;e < node.edgeCount; e++) {
|
|
560
|
+
const edgeBase = node.edgeStartIndex + e * edgeFieldCount;
|
|
561
|
+
const edgeTypeVal = edgeTypes[v8.edges[edgeBase + edgeTypeIdx] ?? 0];
|
|
562
|
+
if (edgeTypeVal === "weak")
|
|
563
|
+
continue;
|
|
564
|
+
const toNodeArrayIdx = v8.edges[edgeBase + edgeToNodeIdx] ?? 0;
|
|
565
|
+
const toOrdinal = toNodeArrayIdx / nodeFieldCount;
|
|
566
|
+
if (toOrdinal >= 0 && toOrdinal < nodeCount) {
|
|
567
|
+
adjacency[i].push(toOrdinal);
|
|
568
|
+
reverseAdj[toOrdinal].push(i);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const visited = new Uint8Array(nodeCount);
|
|
573
|
+
const bfsOrder = [];
|
|
574
|
+
const queue = [0];
|
|
575
|
+
visited[0] = 1;
|
|
576
|
+
while (queue.length > 0) {
|
|
577
|
+
const curr = queue.shift();
|
|
578
|
+
bfsOrder.push(curr);
|
|
579
|
+
for (const next of adjacency[curr]) {
|
|
580
|
+
if (!visited[next]) {
|
|
581
|
+
visited[next] = 1;
|
|
582
|
+
queue.push(next);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const dominators = new Int32Array(nodeCount).fill(-1);
|
|
587
|
+
dominators[0] = 0;
|
|
588
|
+
const nodeToRpo = new Int32Array(nodeCount).fill(-1);
|
|
589
|
+
for (let i = 0;i < bfsOrder.length; i++) {
|
|
590
|
+
nodeToRpo[bfsOrder[i]] = i;
|
|
591
|
+
}
|
|
592
|
+
function intersect(b1Init, b2Init) {
|
|
593
|
+
let b1 = b1Init;
|
|
594
|
+
let b2 = b2Init;
|
|
595
|
+
let finger1 = nodeToRpo[b1];
|
|
596
|
+
let finger2 = nodeToRpo[b2];
|
|
597
|
+
while (finger1 !== finger2) {
|
|
598
|
+
while (finger1 > finger2) {
|
|
599
|
+
b1 = dominators[b1];
|
|
600
|
+
if (b1 < 0)
|
|
601
|
+
return 0;
|
|
602
|
+
finger1 = nodeToRpo[b1];
|
|
603
|
+
}
|
|
604
|
+
while (finger2 > finger1) {
|
|
605
|
+
b2 = dominators[b2];
|
|
606
|
+
if (b2 < 0)
|
|
607
|
+
return 0;
|
|
608
|
+
finger2 = nodeToRpo[b2];
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return b1;
|
|
612
|
+
}
|
|
613
|
+
let changed = true;
|
|
614
|
+
while (changed) {
|
|
615
|
+
changed = false;
|
|
616
|
+
for (let i = 1;i < bfsOrder.length; i++) {
|
|
617
|
+
const node = bfsOrder[i];
|
|
618
|
+
let newIdom = -1;
|
|
619
|
+
for (const pred of reverseAdj[node]) {
|
|
620
|
+
if (dominators[pred] < 0)
|
|
621
|
+
continue;
|
|
622
|
+
if (newIdom < 0) {
|
|
623
|
+
newIdom = pred;
|
|
624
|
+
} else {
|
|
625
|
+
newIdom = intersect(newIdom, pred);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (newIdom >= 0 && dominators[node] !== newIdom) {
|
|
629
|
+
dominators[node] = newIdom;
|
|
630
|
+
changed = true;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const retainedSizes = new Float64Array(nodeCount);
|
|
635
|
+
for (let i = 0;i < nodeCount; i++) {
|
|
636
|
+
retainedSizes[i] = nodes[i].selfSize;
|
|
637
|
+
}
|
|
638
|
+
for (let i = bfsOrder.length - 1;i > 0; i--) {
|
|
639
|
+
const node = bfsOrder[i];
|
|
640
|
+
const dom = dominators[node];
|
|
641
|
+
if (dom >= 0 && dom !== node) {
|
|
642
|
+
retainedSizes[dom] = retainedSizes[dom] + retainedSizes[node];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const indexed = bfsOrder.filter((i) => i > 0).map((i) => ({ ordinal: i, retainedSize: retainedSizes[i] })).sort((a, b) => b.retainedSize - a.retainedSize).slice(0, 50);
|
|
646
|
+
function getEdgeName(toOrdinal) {
|
|
647
|
+
const dom = dominators[toOrdinal];
|
|
648
|
+
if (dom < 0)
|
|
649
|
+
return "";
|
|
650
|
+
const domNode = nodes[dom];
|
|
651
|
+
for (let e = 0;e < domNode.edgeCount; e++) {
|
|
652
|
+
const edgeBase = domNode.edgeStartIndex + e * edgeFieldCount;
|
|
653
|
+
const toNodeArrayIdx = v8.edges[edgeBase + edgeToNodeIdx] ?? 0;
|
|
654
|
+
if (toNodeArrayIdx / nodeFieldCount === toOrdinal) {
|
|
655
|
+
const nameOrIndex = v8.edges[edgeBase + edgeNameIdx] ?? 0;
|
|
656
|
+
const edgeTypeVal = edgeTypes[v8.edges[edgeBase + edgeTypeIdx] ?? 0];
|
|
657
|
+
if (edgeTypeVal === "element")
|
|
658
|
+
return `[${nameOrIndex}]`;
|
|
659
|
+
return v8.strings[nameOrIndex] ?? String(nameOrIndex);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return "";
|
|
663
|
+
}
|
|
664
|
+
function getRetainerPath(targetOrdinal, maxDepth = 10) {
|
|
665
|
+
const path = [];
|
|
666
|
+
let current = targetOrdinal;
|
|
667
|
+
const pathVisited = new Set;
|
|
668
|
+
for (let depth = 0;depth < maxDepth; depth++) {
|
|
669
|
+
const node = nodes[current];
|
|
670
|
+
const edgeName = getEdgeName(current);
|
|
671
|
+
path.unshift(edgeName ? `${node.name || node.type}` : node.name || node.type);
|
|
672
|
+
if (current === 0)
|
|
673
|
+
break;
|
|
674
|
+
pathVisited.add(current);
|
|
675
|
+
const dom = dominators[current];
|
|
676
|
+
if (dom < 0 || dom === current || pathVisited.has(dom))
|
|
677
|
+
break;
|
|
678
|
+
current = dom;
|
|
679
|
+
}
|
|
680
|
+
return path;
|
|
681
|
+
}
|
|
682
|
+
const largestObjects = indexed.map(({ ordinal, retainedSize }) => {
|
|
683
|
+
const node = nodes[ordinal];
|
|
684
|
+
return {
|
|
685
|
+
name: node.name || `(${node.type})`,
|
|
686
|
+
type: node.type,
|
|
687
|
+
selfSize: node.selfSize,
|
|
688
|
+
retainedSize,
|
|
689
|
+
retainerPath: getRetainerPath(ordinal)
|
|
690
|
+
};
|
|
691
|
+
});
|
|
692
|
+
const typeMap = new Map;
|
|
693
|
+
for (const node of nodes) {
|
|
694
|
+
if (!visited[node.ordinal])
|
|
695
|
+
continue;
|
|
696
|
+
const existing = typeMap.get(node.type);
|
|
697
|
+
if (existing) {
|
|
698
|
+
existing.count++;
|
|
699
|
+
existing.totalSize += node.selfSize;
|
|
700
|
+
} else {
|
|
701
|
+
typeMap.set(node.type, { count: 1, totalSize: node.selfSize });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const typeStats = Array.from(typeMap.entries()).map(([type, stats]) => ({
|
|
705
|
+
type,
|
|
706
|
+
count: stats.count,
|
|
707
|
+
totalSize: stats.totalSize,
|
|
708
|
+
avgSize: stats.count > 0 ? Math.round(stats.totalSize / stats.count) : 0
|
|
709
|
+
})).sort((a, b) => b.totalSize - a.totalSize);
|
|
710
|
+
const ctorMap = new Map;
|
|
711
|
+
for (const node of nodes) {
|
|
712
|
+
if (!visited[node.ordinal])
|
|
713
|
+
continue;
|
|
714
|
+
if (node.type !== "object" || !node.name)
|
|
715
|
+
continue;
|
|
716
|
+
const existing = ctorMap.get(node.name);
|
|
717
|
+
if (existing) {
|
|
718
|
+
existing.count++;
|
|
719
|
+
existing.totalSize += node.selfSize;
|
|
720
|
+
} else {
|
|
721
|
+
ctorMap.set(node.name, { count: 1, totalSize: node.selfSize });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const constructorStats = Array.from(ctorMap.entries()).map(([ctor, stats]) => ({
|
|
725
|
+
constructor: ctor,
|
|
726
|
+
count: stats.count,
|
|
727
|
+
totalSize: stats.totalSize,
|
|
728
|
+
avgSize: stats.count > 0 ? Math.round(stats.totalSize / stats.count) : 0
|
|
729
|
+
})).sort((a, b) => b.totalSize - a.totalSize).slice(0, 30);
|
|
730
|
+
const detachedExamples = [];
|
|
731
|
+
let detachedCount = 0;
|
|
732
|
+
let detachedTotalSize = 0;
|
|
733
|
+
for (const node of nodes) {
|
|
734
|
+
if (!visited[node.ordinal])
|
|
735
|
+
continue;
|
|
736
|
+
const isDetached = nodeDetachednessIdx >= 0 && node.detachedness > 0 || node.name.includes("Detached") || node.type === "native" && /HTML\w*Element|Document|Node/.test(node.name) && node.name.includes("Detached");
|
|
737
|
+
if (isDetached) {
|
|
738
|
+
detachedCount++;
|
|
739
|
+
detachedTotalSize += node.selfSize;
|
|
740
|
+
if (detachedExamples.length < 10) {
|
|
741
|
+
detachedExamples.push({
|
|
742
|
+
name: node.name || `(${node.type})`,
|
|
743
|
+
retainerPath: getRetainerPath(node.ordinal)
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const detachedNodes = {
|
|
749
|
+
count: detachedCount,
|
|
750
|
+
totalSize: detachedTotalSize,
|
|
751
|
+
examples: detachedExamples
|
|
752
|
+
};
|
|
753
|
+
const closureNodes = nodes.filter((n) => visited[n.ordinal] && n.type === "closure");
|
|
754
|
+
const closureTotalSize = closureNodes.reduce((sum, n) => sum + n.selfSize, 0);
|
|
755
|
+
const topClosures = closureNodes.sort((a, b) => b.selfSize - a.selfSize).slice(0, 20).map((n) => ({
|
|
756
|
+
name: n.name || "(anonymous)",
|
|
757
|
+
contextSize: n.selfSize,
|
|
758
|
+
retainerPath: getRetainerPath(n.ordinal)
|
|
759
|
+
}));
|
|
760
|
+
const closureStats = {
|
|
761
|
+
count: closureNodes.length,
|
|
762
|
+
totalSize: closureTotalSize,
|
|
763
|
+
topClosures
|
|
764
|
+
};
|
|
765
|
+
return {
|
|
766
|
+
metadata: {
|
|
767
|
+
url: rawSnapshot.url,
|
|
768
|
+
capturedAt: rawSnapshot.capturedAt,
|
|
769
|
+
totalSize,
|
|
770
|
+
nodeCount: v8.snapshot.node_count,
|
|
771
|
+
edgeCount: v8.snapshot.edge_count
|
|
772
|
+
},
|
|
773
|
+
largestObjects,
|
|
774
|
+
typeStats,
|
|
775
|
+
constructorStats,
|
|
776
|
+
detachedNodes,
|
|
777
|
+
closureStats
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/analysis/agent.ts
|
|
782
|
+
import { createDeepAgent } from "deepagents";
|
|
783
|
+
import { providerStrategy } from "langchain";
|
|
784
|
+
|
|
785
|
+
// src/analysis/prompts.ts
|
|
786
|
+
var SYSTEM_PROMPT = `You are an expert web performance engineer. You have access to a virtual filesystem workspace containing captured data from a real page load: heap snapshot, network trace, and Chrome runtime trace.
|
|
787
|
+
|
|
788
|
+
## Workspace structure
|
|
789
|
+
|
|
790
|
+
- /heap/summary.json — Parsed V8 heap snapshot: largest objects, type stats, constructor stats, detached DOM nodes, closure stats
|
|
791
|
+
- /trace/summary.json — Page load metrics: timing, long tasks, render-blocking resources, resource breakdown
|
|
792
|
+
- /trace/network-waterfall.json — Every network request with timing, size, priority, render-blocking status
|
|
793
|
+
- /trace/asset-manifest.json — Index of all assets with paths to stored files
|
|
794
|
+
- /trace/runtime/summary.json — Runtime trace overview: frame breakdown (scripting/layout/paint/GC), blocking function count, listener imbalances, GC stats
|
|
795
|
+
- /trace/runtime/blocking-functions.json — Functions that blocked the main thread > 50ms, with script URL, line number, call stack, and duration
|
|
796
|
+
- /trace/runtime/event-listeners.json — Event listener add/remove counts per event type, with source locations
|
|
797
|
+
- /trace/runtime/frame-breakdown.json — Time spent in scripting vs layout vs paint vs GC
|
|
798
|
+
- /trace/runtime/raw-events.json — Full Chrome trace events (large file — read to investigate specific function calls, layouts, GC, and event dispatches)
|
|
799
|
+
- /scripts/*.js — Actual JavaScript source files captured during page load
|
|
800
|
+
- /styles/*.css — Actual CSS source files
|
|
801
|
+
- /html/document.html — The HTML document
|
|
802
|
+
|
|
803
|
+
## Your workflow
|
|
804
|
+
|
|
805
|
+
1. Read /heap/summary.json, /trace/summary.json, AND /trace/runtime/summary.json first for the big picture
|
|
806
|
+
2. Identify the highest-impact issues from all datasets
|
|
807
|
+
3. For each issue, dive into the relevant source files to understand the root cause
|
|
808
|
+
4. Provide specific, code-level fixes
|
|
809
|
+
|
|
810
|
+
## What to look for
|
|
811
|
+
|
|
812
|
+
### Memory issues (from heap data)
|
|
813
|
+
- Memory leaks: unbounded arrays, maps, caches that grow without bound
|
|
814
|
+
- Detached DOM nodes: DOM elements removed from the document but still referenced
|
|
815
|
+
- Large retained objects: single objects or trees retaining disproportionate memory
|
|
816
|
+
- Closure leaks: closures capturing variables they no longer need
|
|
817
|
+
|
|
818
|
+
### Page-load issues (from trace + source code)
|
|
819
|
+
- Render-blocking scripts: <script> in <head> without async/defer — read the script to judge if it must be synchronous
|
|
820
|
+
- Render-blocking CSS: large stylesheets blocking first paint
|
|
821
|
+
- Long tasks (> 50ms): identify the function/module causing the block by reading the source
|
|
822
|
+
- Large bundles: scripts > 100KB — search for unused imports or code that could be lazy-loaded
|
|
823
|
+
- Sequential waterfalls: resources chained sequentially that could load in parallel
|
|
824
|
+
|
|
825
|
+
### Runtime issues (from Chrome trace)
|
|
826
|
+
- Frame-blocking functions: read /trace/runtime/blocking-functions.json first, then inspect the actual script source at the reported line number to understand what the function does and how to optimize it
|
|
827
|
+
- Event listener leaks: check /trace/runtime/event-listeners.json for event types where addCount >> removeCount, then grep the scripts for those addEventListener calls
|
|
828
|
+
- GC pressure: high GC pause counts or duration suggest excessive short-lived object creation — look for hot loops creating objects
|
|
829
|
+
- Layout thrashing: forced synchronous layouts caused by reading layout properties (offsetHeight, getBoundingClientRect) after DOM writes
|
|
830
|
+
|
|
831
|
+
## Output guidelines
|
|
832
|
+
|
|
833
|
+
- Report 3–7 findings, ordered by impact (mix of memory, page-load, and runtime if all have issues)
|
|
834
|
+
- Be specific — name actual files, functions, object constructors, and retention paths
|
|
835
|
+
- Provide concrete code fixes, not generic advice
|
|
836
|
+
- If heap, trace, and runtime all look healthy, say so — don't manufacture issues`;
|
|
837
|
+
|
|
838
|
+
// src/vitest/prompts.ts
|
|
839
|
+
var VITEST_SYSTEM_PROMPT = `You are an expert in JavaScript/TypeScript performance optimization.
|
|
840
|
+
You have access to a workspace containing V8 CPU profiling data captured during
|
|
841
|
+
a Vitest test run. The workspace may also include V8 heap profiling data
|
|
842
|
+
captured via Node's \`--heap-prof\` (allocation sampling).
|
|
843
|
+
|
|
844
|
+
The profiling data covers BOTH the test code AND the application code being tested.
|
|
845
|
+
|
|
846
|
+
**Your primary goal is to analyze the PERFORMANCE OF THE APPLICATION CODE
|
|
847
|
+
being tested** — the functions, modules, and algorithms that the developer
|
|
848
|
+
wrote and is benchmarking or testing. Test infrastructure overhead (Vitest,
|
|
849
|
+
tinybench, test setup) is secondary context.
|
|
850
|
+
|
|
851
|
+
## Source categories
|
|
852
|
+
|
|
853
|
+
Every hot function and script in the workspace has a \`sourceCategory\` field:
|
|
854
|
+
|
|
855
|
+
- **application** — Code in the user's project (the code being tested).
|
|
856
|
+
This is your PRIMARY focus. Find bottlenecks, inefficiencies, and
|
|
857
|
+
optimization opportunities in these functions.
|
|
858
|
+
- **dependency** — Third-party code in node_modules. Report when a dependency
|
|
859
|
+
is a significant bottleneck, since the developer may be able to choose
|
|
860
|
+
an alternative, configure it differently, or avoid calling it in hot paths.
|
|
861
|
+
- **test** — Test files. Only mention if the test setup itself is creating
|
|
862
|
+
artificial overhead that masks application performance.
|
|
863
|
+
- **framework** — Vitest/tinybench/V8 internals. Generally ignore unless
|
|
864
|
+
they dominate the profile in an unexpected way.
|
|
865
|
+
|
|
866
|
+
## Workspace structure
|
|
867
|
+
|
|
868
|
+
- /summary.json — Overall test run: total tests, duration, pass/fail, GC stats
|
|
869
|
+
- /timing/overview.json — Per-file test durations and individual test times
|
|
870
|
+
- /timing/slow-tests.json — Tests exceeding the slow threshold
|
|
871
|
+
- /profiles/index.json — Manifest mapping test files to their CPU profiles
|
|
872
|
+
- /profiles/<file>.json — CPU profile summary: hot functions (with sourceCategory),
|
|
873
|
+
call trees, GC samples, script breakdown (with sourceCategory)
|
|
874
|
+
- /heap-profiles/index.json — (optional) Manifest mapping test files to heap profiles
|
|
875
|
+
- /heap-profiles/<file>.json — (optional) Heap profile summary: allocation hotspots,
|
|
876
|
+
per-script allocated bytes (with sourceCategory)
|
|
877
|
+
- /hot-functions/application.json — **START HERE**: Hot functions from application code only
|
|
878
|
+
- /hot-functions/dependencies.json — Hot functions from third-party dependencies
|
|
879
|
+
- /hot-functions/global.json — All hot functions across all categories
|
|
880
|
+
- /scripts/application.json — Per-script time breakdown for application code
|
|
881
|
+
- /scripts/dependencies.json — Per-script time breakdown for dependencies
|
|
882
|
+
- /tests/*.ts — Test source files
|
|
883
|
+
- /src/*.ts — Application and dependency source files referenced by hot functions
|
|
884
|
+
|
|
885
|
+
## Your workflow
|
|
886
|
+
|
|
887
|
+
1. Read /hot-functions/application.json FIRST — these are the application-level
|
|
888
|
+
bottlenecks the developer wants to optimize
|
|
889
|
+
2. Read /scripts/application.json for the per-file view of application code time
|
|
890
|
+
3. Read /hot-functions/dependencies.json for costly dependency calls
|
|
891
|
+
4. If present, read /heap-profiles/index.json and /heap-profiles/<file>.json to identify
|
|
892
|
+
allocation hotspots (functions/scripts allocating lots of bytes)
|
|
893
|
+
5. Read /summary.json and /timing/overview.json for the big picture
|
|
894
|
+
6. Read CPU profiles in /profiles/ for detailed call trees of the slowest tests
|
|
895
|
+
7. Read the actual source code in /src/ and /tests/ to understand root causes
|
|
896
|
+
8. Provide specific, actionable fixes targeting the application code
|
|
897
|
+
|
|
898
|
+
## What to look for
|
|
899
|
+
|
|
900
|
+
### Application code bottlenecks (PRIMARY FOCUS)
|
|
901
|
+
- Functions with high self time — where is the application spending CPU?
|
|
902
|
+
- Expensive algorithms: O(n²) loops, unnecessary sorting, repeated work
|
|
903
|
+
- String/JSON operations: excessive serialization, string concatenation in loops
|
|
904
|
+
- Object allocation hotspots: functions creating many short-lived objects
|
|
905
|
+
- Synchronous blocking: file I/O, crypto, or compression in hot paths
|
|
906
|
+
- Redundant computation: values computed repeatedly that could be cached/memoized
|
|
907
|
+
- Data structure choices: using arrays where Maps/Sets would be O(1)
|
|
908
|
+
|
|
909
|
+
### Dependency-related bottlenecks
|
|
910
|
+
- Dependencies consuming disproportionate CPU — suggest alternatives or configuration
|
|
911
|
+
- Unnecessary calls to expensive dependency APIs in hot paths
|
|
912
|
+
- Dependencies pulled in for simple operations that could be hand-written
|
|
913
|
+
|
|
914
|
+
### GC pressure from application code
|
|
915
|
+
- Application functions creating many temporary objects in tight loops
|
|
916
|
+
- Large array/object allocations that could be pooled or reused
|
|
917
|
+
- Closures capturing large scopes unnecessarily
|
|
918
|
+
|
|
919
|
+
### Allocation hotspots (from heap profiles, if present)
|
|
920
|
+
- Functions allocating a large share of total bytes (even if CPU isn't dominant)
|
|
921
|
+
- Scripts/modules responsible for most allocation — suggest caching, reuse, pooling,
|
|
922
|
+
or avoiding intermediate arrays/objects
|
|
923
|
+
- When allocation hotspots match CPU hotspots, prioritize fixes there first
|
|
924
|
+
|
|
925
|
+
### Call chain analysis
|
|
926
|
+
- Trace expensive call trees to find which APPLICATION function triggers them
|
|
927
|
+
- Follow the call tree from application entry points down to the hot leaf functions
|
|
928
|
+
- Identify which application-level design decisions lead to the bottleneck
|
|
929
|
+
|
|
930
|
+
### Test infrastructure (SECONDARY — only if impactful)
|
|
931
|
+
- Test setup creating artificial overhead that dwarfs application execution
|
|
932
|
+
- Benchmarks measuring setup cost instead of application performance
|
|
933
|
+
- Only mention if it prevents getting clean application performance data
|
|
934
|
+
|
|
935
|
+
## Finding categories
|
|
936
|
+
|
|
937
|
+
Each finding MUST use one of these exact category values:
|
|
938
|
+
|
|
939
|
+
- **algorithm** — Inefficient algorithm: O(n²) loops, brute-force search, repeated work
|
|
940
|
+
- **serialization** — Excessive JSON.stringify/parse, string concatenation, encoding
|
|
941
|
+
- **allocation** — Excessive object/array creation causing GC pressure
|
|
942
|
+
- **event-handling** — Listener leaks, unbounded event handler accumulation
|
|
943
|
+
- **hot-function** — Generic CPU-hot function that doesn't fit a more specific category
|
|
944
|
+
- **gc-pressure** — High garbage collection overhead
|
|
945
|
+
- **listener-leak** — Event listeners not cleaned up properly
|
|
946
|
+
- **unnecessary-computation** — Redundant work that could be cached or eliminated
|
|
947
|
+
- **blocking-io** — Synchronous I/O or blocking operations in hot paths
|
|
948
|
+
- **dependency-bottleneck** — Expensive dependency call the developer can optimize
|
|
949
|
+
- **slow-test** — Test itself is slow due to setup or teardown
|
|
950
|
+
- **expensive-setup** — Costly test setup (beforeAll/beforeEach)
|
|
951
|
+
- **import-overhead** — Expensive module imports at test time
|
|
952
|
+
- **other** — Doesn't fit any of the above
|
|
953
|
+
|
|
954
|
+
Prefer more specific categories (algorithm, serialization, allocation, event-handling)
|
|
955
|
+
over generic ones (hot-function, other) when the root cause is clear.
|
|
956
|
+
|
|
957
|
+
## Output guidelines
|
|
958
|
+
|
|
959
|
+
- Report 3–7 findings, ordered by impact ON THE APPLICATION CODE
|
|
960
|
+
- Focus findings on functions the developer CAN change (application code first,
|
|
961
|
+
then dependency usage patterns, then test structure)
|
|
962
|
+
- Be specific — name actual files, functions, line numbers from the source code
|
|
963
|
+
- Provide concrete code-level fixes, not generic advice
|
|
964
|
+
- When reporting a dependency bottleneck, explain what application code is
|
|
965
|
+
calling it and how the developer can reduce that cost
|
|
966
|
+
- If the application code is already efficient, say so — don't force findings
|
|
967
|
+
about test infrastructure just to fill the report`;
|
|
968
|
+
|
|
969
|
+
// src/schema.ts
|
|
970
|
+
import { z } from "zod";
|
|
971
|
+
var ALL_CATEGORIES = [
|
|
972
|
+
"memory-leak",
|
|
973
|
+
"large-retained-object",
|
|
974
|
+
"detached-dom",
|
|
975
|
+
"render-blocking",
|
|
976
|
+
"long-task",
|
|
977
|
+
"unused-code",
|
|
978
|
+
"waterfall-bottleneck",
|
|
979
|
+
"large-asset",
|
|
980
|
+
"frame-blocking-function",
|
|
981
|
+
"listener-leak",
|
|
982
|
+
"gc-pressure",
|
|
983
|
+
"slow-test",
|
|
984
|
+
"expensive-setup",
|
|
985
|
+
"hot-function",
|
|
986
|
+
"unnecessary-computation",
|
|
987
|
+
"import-overhead",
|
|
988
|
+
"dependency-bottleneck",
|
|
989
|
+
"algorithm",
|
|
990
|
+
"serialization",
|
|
991
|
+
"allocation",
|
|
992
|
+
"event-handling",
|
|
993
|
+
"blocking-io",
|
|
994
|
+
"other"
|
|
995
|
+
];
|
|
996
|
+
var FindingSchema = z.object({
|
|
997
|
+
severity: z.enum(["critical", "warning", "info"]),
|
|
998
|
+
title: z.string().describe("Short title for the finding"),
|
|
999
|
+
description: z.string().describe("Detailed explanation of the issue"),
|
|
1000
|
+
category: z.string().describe(`Category of the performance issue. Use one of: ${ALL_CATEGORIES.join(", ")}`),
|
|
1001
|
+
resourceUrl: z.string().optional().describe("URL of the resource involved"),
|
|
1002
|
+
workspacePath: z.string().optional().describe("Path in the VFS workspace"),
|
|
1003
|
+
impactMs: z.number().optional().describe("Impact on page load time in ms"),
|
|
1004
|
+
retainedSize: z.number().optional().describe("Retained heap size in bytes"),
|
|
1005
|
+
retainerPath: z.array(z.string()).optional().describe("Object retention path in the heap"),
|
|
1006
|
+
suggestedFix: z.string().describe("Code snippet or guidance to fix the issue"),
|
|
1007
|
+
testFile: z.string().optional().describe("Test file path (for test performance findings)"),
|
|
1008
|
+
hotFunction: z.object({
|
|
1009
|
+
name: z.string(),
|
|
1010
|
+
scriptUrl: z.string(),
|
|
1011
|
+
lineNumber: z.number(),
|
|
1012
|
+
selfTime: z.number(),
|
|
1013
|
+
selfPercent: z.number()
|
|
1014
|
+
}).optional().describe("Hot function details (for hot-function findings)")
|
|
1015
|
+
});
|
|
1016
|
+
var FindingsSchema = z.object({
|
|
1017
|
+
findings: z.array(FindingSchema)
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// src/analysis/agent.ts
|
|
1021
|
+
async function analyze(model, backend) {
|
|
1022
|
+
const agent = createDeepAgent({
|
|
1023
|
+
model,
|
|
1024
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
1025
|
+
backend,
|
|
1026
|
+
responseFormat: providerStrategy(FindingsSchema)
|
|
1027
|
+
});
|
|
1028
|
+
const userMessage = [
|
|
1029
|
+
"Analyze the frontend performance data in this workspace.",
|
|
1030
|
+
"",
|
|
1031
|
+
"The workspace contains heap snapshot data, page-load trace data, and Chrome runtime trace data.",
|
|
1032
|
+
"",
|
|
1033
|
+
"Start by reading /heap/summary.json, /trace/summary.json, and /trace/runtime/summary.json",
|
|
1034
|
+
"to understand the overall picture. Then explore:",
|
|
1035
|
+
"",
|
|
1036
|
+
"- /trace/network-waterfall.json for request timing",
|
|
1037
|
+
"- /trace/runtime/blocking-functions.json for function-level main thread blocking",
|
|
1038
|
+
"- /trace/runtime/event-listeners.json for listener add/remove imbalances",
|
|
1039
|
+
"- /trace/runtime/frame-breakdown.json for frame breakdown (scripting vs layout vs paint vs GC)",
|
|
1040
|
+
"- /scripts/ for the actual JavaScript source code",
|
|
1041
|
+
"- /styles/ for CSS source",
|
|
1042
|
+
"- /html/document.html for the page markup",
|
|
1043
|
+
"",
|
|
1044
|
+
"Look for memory issues (from the heap data), page-load issues (from the network trace),",
|
|
1045
|
+
"and runtime issues (from the Chrome trace — blocking functions, listener leaks, GC pressure).",
|
|
1046
|
+
"When you find a problem, read the actual source file to provide a specific, code-level fix."
|
|
1047
|
+
].join(`
|
|
1048
|
+
`);
|
|
1049
|
+
const result = await agent.invoke({
|
|
1050
|
+
messages: [{ role: "user", content: userMessage }]
|
|
1051
|
+
});
|
|
1052
|
+
return result.structuredResponse.findings;
|
|
1053
|
+
}
|
|
1054
|
+
async function analyzeTestPerformance(model, backend) {
|
|
1055
|
+
const agent = createDeepAgent({
|
|
1056
|
+
model,
|
|
1057
|
+
systemPrompt: VITEST_SYSTEM_PROMPT,
|
|
1058
|
+
backend,
|
|
1059
|
+
responseFormat: providerStrategy(FindingsSchema)
|
|
1060
|
+
});
|
|
1061
|
+
const userMessage = [
|
|
1062
|
+
"Analyze the performance of the APPLICATION CODE being tested in this Vitest workspace.",
|
|
1063
|
+
"",
|
|
1064
|
+
"Follow this order:",
|
|
1065
|
+
"1. Read /hot-functions/application.json — these are the hotspots IN the user's own code",
|
|
1066
|
+
"2. Read /scripts/application.json — per-file CPU time for application source files",
|
|
1067
|
+
"3. Read /hot-functions/dependencies.json — expensive dependency calls",
|
|
1068
|
+
"4. If present, read /heap-profiles/index.json and /heap-profiles/<file>.json for allocation hotspots",
|
|
1069
|
+
"5. Read /summary.json and /timing/overview.json for the big picture",
|
|
1070
|
+
"6. Read CPU profiles in /profiles/ for detailed call trees",
|
|
1071
|
+
"7. Read source files in /src/ and /tests/ to understand root causes and propose code-level fixes",
|
|
1072
|
+
"",
|
|
1073
|
+
"Focus findings on the APPLICATION code — what can the developer change in their own codebase",
|
|
1074
|
+
"to improve performance? Dependency bottlenecks are worth reporting if the developer can",
|
|
1075
|
+
"reduce how they call the dependency or choose an alternative."
|
|
1076
|
+
].join(`
|
|
1077
|
+
`);
|
|
1078
|
+
const result = await agent.invoke({
|
|
1079
|
+
messages: [{ role: "user", content: userMessage }]
|
|
1080
|
+
});
|
|
1081
|
+
return result.structuredResponse.findings;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/sandbox/workspace.ts
|
|
1085
|
+
import { FilesystemBackend } from "deepagents";
|
|
1086
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
1087
|
+
import { join } from "node:path";
|
|
1088
|
+
import { tmpdir } from "node:os";
|
|
1089
|
+
async function createWorkspace(options) {
|
|
1090
|
+
const { heapSummary, traceResult, url, maxAssetSize = 10 * 1024 * 1024 } = options;
|
|
1091
|
+
const files = {};
|
|
1092
|
+
files["/heap/summary.json"] = JSON.stringify(heapSummary, null, 2);
|
|
1093
|
+
files["/trace/summary.json"] = JSON.stringify({
|
|
1094
|
+
url,
|
|
1095
|
+
timing: traceResult.metrics,
|
|
1096
|
+
requestCount: traceResult.networkRequests.length,
|
|
1097
|
+
totalTransferSize: traceResult.networkRequests.reduce((s, r) => s + r.encodedSize, 0),
|
|
1098
|
+
totalDecodedSize: traceResult.networkRequests.reduce((s, r) => s + r.decodedSize, 0),
|
|
1099
|
+
renderBlockingResources: traceResult.networkRequests.filter((r) => r.isRenderBlocking).map((r) => ({
|
|
1100
|
+
url: r.url,
|
|
1101
|
+
type: r.resourceType,
|
|
1102
|
+
size: r.decodedSize,
|
|
1103
|
+
duration: r.duration,
|
|
1104
|
+
path: r.responseBody ? getAssetPath(r) : null
|
|
1105
|
+
})),
|
|
1106
|
+
longTasks: traceResult.metrics.longTasks,
|
|
1107
|
+
resourceBreakdown: buildResourceBreakdown(traceResult.networkRequests)
|
|
1108
|
+
}, null, 2);
|
|
1109
|
+
files["/trace/network-waterfall.json"] = JSON.stringify(traceResult.networkRequests.sort((a, b) => a.startTime - b.startTime).map((r) => ({
|
|
1110
|
+
url: r.url,
|
|
1111
|
+
type: r.resourceType,
|
|
1112
|
+
status: r.status,
|
|
1113
|
+
size: r.decodedSize,
|
|
1114
|
+
startTime: Math.round(r.startTime),
|
|
1115
|
+
endTime: Math.round(r.endTime),
|
|
1116
|
+
duration: Math.round(r.duration),
|
|
1117
|
+
isRenderBlocking: r.isRenderBlocking,
|
|
1118
|
+
priority: r.priority,
|
|
1119
|
+
path: r.responseBody ? getAssetPath(r) : null
|
|
1120
|
+
})), null, 2);
|
|
1121
|
+
let totalStored = 0;
|
|
1122
|
+
for (const req of traceResult.networkRequests) {
|
|
1123
|
+
if (!req.responseBody)
|
|
1124
|
+
continue;
|
|
1125
|
+
if (totalStored + req.responseBody.length > maxAssetSize)
|
|
1126
|
+
continue;
|
|
1127
|
+
files[getAssetPath(req)] = req.responseBody;
|
|
1128
|
+
totalStored += req.responseBody.length;
|
|
1129
|
+
}
|
|
1130
|
+
files["/trace/asset-manifest.json"] = JSON.stringify(traceResult.networkRequests.map((r) => ({
|
|
1131
|
+
url: r.url,
|
|
1132
|
+
type: r.resourceType,
|
|
1133
|
+
size: r.decodedSize,
|
|
1134
|
+
duration: r.duration,
|
|
1135
|
+
isRenderBlocking: r.isRenderBlocking,
|
|
1136
|
+
stored: !!r.responseBody,
|
|
1137
|
+
path: r.responseBody ? getAssetPath(r) : null
|
|
1138
|
+
})), null, 2);
|
|
1139
|
+
if (traceResult.runtimeTrace) {
|
|
1140
|
+
const rt = traceResult.runtimeTrace;
|
|
1141
|
+
files["/trace/runtime/summary.json"] = JSON.stringify({
|
|
1142
|
+
totalEvents: rt.totalEvents,
|
|
1143
|
+
traceDuration: rt.traceDuration,
|
|
1144
|
+
mainThreadId: rt.mainThreadId,
|
|
1145
|
+
frameBreakdown: rt.frameBreakdown,
|
|
1146
|
+
blockingFunctionCount: rt.blockingFunctions.length,
|
|
1147
|
+
listenerImbalances: rt.eventListeners.filter((l) => l.activeCount > l.removeCount + 10).length,
|
|
1148
|
+
gcPauseCount: rt.gcEvents.length,
|
|
1149
|
+
gcTotalDuration: rt.gcEvents.reduce((s, e) => s + e.duration, 0),
|
|
1150
|
+
frequentEventTypes: rt.frequentEvents.map((e) => e.eventType)
|
|
1151
|
+
}, null, 2);
|
|
1152
|
+
files["/trace/runtime/blocking-functions.json"] = JSON.stringify(rt.blockingFunctions.slice(0, 50), null, 2);
|
|
1153
|
+
files["/trace/runtime/event-listeners.json"] = JSON.stringify(rt.eventListeners.filter((l) => l.addCount > 0), null, 2);
|
|
1154
|
+
files["/trace/runtime/frame-breakdown.json"] = JSON.stringify(rt.frameBreakdown, null, 2);
|
|
1155
|
+
}
|
|
1156
|
+
if (traceResult.rawTraceEvents && traceResult.rawTraceEvents.length > 0) {
|
|
1157
|
+
const rawJson = JSON.stringify(traceResult.rawTraceEvents);
|
|
1158
|
+
if (rawJson.length < 5 * 1024 * 1024) {
|
|
1159
|
+
files["/trace/runtime/raw-events.json"] = rawJson;
|
|
1160
|
+
} else {
|
|
1161
|
+
const mainTid = traceResult.runtimeTrace?.mainThreadId;
|
|
1162
|
+
const filtered = traceResult.rawTraceEvents.filter((e) => e.tid === mainTid && e.dur && e.dur > 0);
|
|
1163
|
+
const filteredJson = JSON.stringify(filtered);
|
|
1164
|
+
if (filteredJson.length < 5 * 1024 * 1024) {
|
|
1165
|
+
files["/trace/runtime/raw-events.json"] = filteredJson;
|
|
1166
|
+
} else {
|
|
1167
|
+
const important = filtered.filter((e) => (e.dur ?? 0) > 1000);
|
|
1168
|
+
files["/trace/runtime/raw-events.json"] = JSON.stringify(important);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
const tempDir = mkdtempSync(join(tmpdir(), "zeitzeuge-workspace-"));
|
|
1173
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
1174
|
+
const relPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
|
1175
|
+
const fullPath = join(tempDir, relPath);
|
|
1176
|
+
mkdirSync(join(fullPath, ".."), { recursive: true });
|
|
1177
|
+
writeFileSync(fullPath, content, "utf-8");
|
|
1178
|
+
}
|
|
1179
|
+
const backend = new FilesystemBackend({
|
|
1180
|
+
rootDir: tempDir,
|
|
1181
|
+
virtualMode: true
|
|
1182
|
+
});
|
|
1183
|
+
const cleanup = () => {
|
|
1184
|
+
console.log(111, tempDir);
|
|
1185
|
+
};
|
|
1186
|
+
return { backend, cleanup };
|
|
1187
|
+
}
|
|
1188
|
+
function getAssetPath(req) {
|
|
1189
|
+
let filename;
|
|
1190
|
+
try {
|
|
1191
|
+
const pathname = new URL(req.url).pathname;
|
|
1192
|
+
filename = pathname.split("/").pop() || "index";
|
|
1193
|
+
} catch {
|
|
1194
|
+
filename = "unknown";
|
|
1195
|
+
}
|
|
1196
|
+
switch (req.resourceType) {
|
|
1197
|
+
case "Script":
|
|
1198
|
+
return `/scripts/${filename}`;
|
|
1199
|
+
case "Stylesheet":
|
|
1200
|
+
return `/styles/${filename}`;
|
|
1201
|
+
case "Font":
|
|
1202
|
+
return `/fonts/${filename}`;
|
|
1203
|
+
case "Document":
|
|
1204
|
+
return `/html/${filename}`;
|
|
1205
|
+
default:
|
|
1206
|
+
return `/other/${filename}`;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
function buildResourceBreakdown(requests) {
|
|
1210
|
+
const groups = {
|
|
1211
|
+
scripts: { count: 0, totalSize: 0 },
|
|
1212
|
+
stylesheets: { count: 0, totalSize: 0 },
|
|
1213
|
+
fonts: { count: 0, totalSize: 0 },
|
|
1214
|
+
images: { count: 0, totalSize: 0 },
|
|
1215
|
+
other: { count: 0, totalSize: 0 }
|
|
1216
|
+
};
|
|
1217
|
+
for (const r of requests) {
|
|
1218
|
+
const key = r.resourceType === "Script" ? "scripts" : r.resourceType === "Stylesheet" ? "stylesheets" : r.resourceType === "Font" ? "fonts" : r.resourceType === "Image" ? "images" : "other";
|
|
1219
|
+
groups[key].count++;
|
|
1220
|
+
groups[key].totalSize += r.decodedSize;
|
|
1221
|
+
}
|
|
1222
|
+
return groups;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// src/output/terminal.ts
|
|
1226
|
+
import chalk from "chalk";
|
|
1227
|
+
import ora from "ora";
|
|
1228
|
+
var SEVERITY_ICONS = {
|
|
1229
|
+
critical: chalk.red("\uD83D\uDD34 CRITICAL"),
|
|
1230
|
+
warning: chalk.yellow("\uD83D\uDFE1 WARNING"),
|
|
1231
|
+
info: chalk.green("\uD83D\uDFE2 INFO")
|
|
1232
|
+
};
|
|
1233
|
+
var CATEGORY_LABELS = {
|
|
1234
|
+
"memory-leak": "Memory Leak",
|
|
1235
|
+
"large-retained-object": "Large Retained Object",
|
|
1236
|
+
"detached-dom": "Detached DOM",
|
|
1237
|
+
"render-blocking": "Render-Blocking",
|
|
1238
|
+
"long-task": "Long Task",
|
|
1239
|
+
"unused-code": "Unused Code",
|
|
1240
|
+
"waterfall-bottleneck": "Waterfall Bottleneck",
|
|
1241
|
+
"large-asset": "Large Asset",
|
|
1242
|
+
"frame-blocking-function": "Frame-Blocking Function",
|
|
1243
|
+
"listener-leak": "Listener Leak",
|
|
1244
|
+
"gc-pressure": "GC Pressure",
|
|
1245
|
+
"slow-test": "Slow Test",
|
|
1246
|
+
"expensive-setup": "Expensive Setup",
|
|
1247
|
+
"hot-function": "Hot Function",
|
|
1248
|
+
"unnecessary-computation": "Unnecessary Computation",
|
|
1249
|
+
"import-overhead": "Import Overhead",
|
|
1250
|
+
"dependency-bottleneck": "Dependency Bottleneck",
|
|
1251
|
+
algorithm: "Inefficient Algorithm",
|
|
1252
|
+
serialization: "Serialization Overhead",
|
|
1253
|
+
allocation: "Excessive Allocation",
|
|
1254
|
+
"event-handling": "Event Handling",
|
|
1255
|
+
"blocking-io": "Blocking I/O",
|
|
1256
|
+
other: "Other"
|
|
1257
|
+
};
|
|
1258
|
+
function printHeader(url, version) {
|
|
1259
|
+
const urlDisplay = url.length > 44 ? url.slice(0, 41) + "..." : url;
|
|
1260
|
+
console.log(chalk.cyan(`
|
|
1261
|
+
┌${"─".repeat(57)}┐
|
|
1262
|
+
` + `│ zeitzeuge v${version.padEnd(44)}│
|
|
1263
|
+
` + `│ Analyzing: ${urlDisplay.padEnd(44)}│
|
|
1264
|
+
` + `└${"─".repeat(57)}┘
|
|
1265
|
+
`));
|
|
1266
|
+
}
|
|
1267
|
+
function createSpinner(text) {
|
|
1268
|
+
return ora({ text, color: "cyan" }).start();
|
|
1269
|
+
}
|
|
1270
|
+
function printFindings(findings) {
|
|
1271
|
+
console.log(chalk.dim(`
|
|
1272
|
+
` + "━".repeat(58) + `
|
|
1273
|
+
`));
|
|
1274
|
+
if (findings.length === 0) {
|
|
1275
|
+
console.log(chalk.green(` ✔ No significant performance issues found. Page looks healthy!
|
|
1276
|
+
`));
|
|
1277
|
+
console.log(chalk.dim("━".repeat(58)));
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
for (const finding of findings) {
|
|
1281
|
+
const icon = SEVERITY_ICONS[finding.severity];
|
|
1282
|
+
const categoryLabel = CATEGORY_LABELS[finding.category] ?? finding.category;
|
|
1283
|
+
console.log(`${icon} [${categoryLabel}]: ${chalk.bold(finding.title)}`);
|
|
1284
|
+
if (finding.retainedSize != null) {
|
|
1285
|
+
console.log(chalk.dim(` Retained size: ${formatBytes(finding.retainedSize)}`));
|
|
1286
|
+
}
|
|
1287
|
+
if (finding.impactMs != null) {
|
|
1288
|
+
console.log(chalk.dim(` Impact: ${finding.impactMs.toFixed(0)}ms`));
|
|
1289
|
+
}
|
|
1290
|
+
if (finding.resourceUrl) {
|
|
1291
|
+
console.log(chalk.dim(` Resource: ${finding.resourceUrl}`));
|
|
1292
|
+
}
|
|
1293
|
+
if (finding.retainerPath && finding.retainerPath.length > 0) {
|
|
1294
|
+
console.log(chalk.dim(` Path: ${finding.retainerPath.join(" → ")}`));
|
|
1295
|
+
}
|
|
1296
|
+
if (finding.testFile) {
|
|
1297
|
+
console.log(chalk.dim(` Test file: ${finding.testFile}`));
|
|
1298
|
+
}
|
|
1299
|
+
if (finding.hotFunction) {
|
|
1300
|
+
const hf = finding.hotFunction;
|
|
1301
|
+
console.log(chalk.dim(` Function: ${hf.name} at ${hf.scriptUrl}:${hf.lineNumber} (selfTime: ${hf.selfTime.toFixed(0)}ms, ${hf.selfPercent.toFixed(1)}%)`));
|
|
1302
|
+
}
|
|
1303
|
+
console.log(`
|
|
1304
|
+
${finding.description}
|
|
1305
|
+
`);
|
|
1306
|
+
if (finding.suggestedFix) {
|
|
1307
|
+
console.log(chalk.dim(" Suggested fix:"));
|
|
1308
|
+
const lines = finding.suggestedFix.split(`
|
|
1309
|
+
`);
|
|
1310
|
+
const boxWidth = Math.max(...lines.map((l) => l.length), 20) + 4;
|
|
1311
|
+
console.log(chalk.dim(` ┌${"─".repeat(boxWidth)}┐`));
|
|
1312
|
+
for (const line of lines) {
|
|
1313
|
+
console.log(chalk.dim(" │ ") + chalk.white(line.padEnd(boxWidth - 2)) + chalk.dim(" │"));
|
|
1314
|
+
}
|
|
1315
|
+
console.log(chalk.dim(` └${"─".repeat(boxWidth)}┘`));
|
|
1316
|
+
}
|
|
1317
|
+
console.log();
|
|
1318
|
+
}
|
|
1319
|
+
console.log(chalk.dim("━".repeat(58)));
|
|
1320
|
+
const counts = {
|
|
1321
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
1322
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
1323
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
1324
|
+
};
|
|
1325
|
+
console.log(`
|
|
1326
|
+
Summary: ${chalk.red(`${counts.critical} critical`)}, ` + `${chalk.yellow(`${counts.warning} warning`)}, ` + `${chalk.green(`${counts.info} info`)}
|
|
1327
|
+
`);
|
|
1328
|
+
}
|
|
1329
|
+
function printCaptureInfo(heapSummary, trace) {
|
|
1330
|
+
console.log(chalk.dim(`Heap: ${formatBytes(heapSummary.metadata.totalSize)} | ` + `Nodes: ${heapSummary.metadata.nodeCount.toLocaleString()} | ` + `Requests: ${trace.networkRequests.length} | ` + `Long tasks: ${trace.metrics.longTasks.length}`));
|
|
1331
|
+
}
|
|
1332
|
+
function printError(err) {
|
|
1333
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1334
|
+
console.error(chalk.red(`
|
|
1335
|
+
✖ Error: ${message}
|
|
1336
|
+
`));
|
|
1337
|
+
}
|
|
1338
|
+
function formatBytes(bytes) {
|
|
1339
|
+
if (bytes < 1024)
|
|
1340
|
+
return `${bytes} B`;
|
|
1341
|
+
if (bytes < 1024 * 1024)
|
|
1342
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1343
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// src/output/report.ts
|
|
1347
|
+
import { writeFileSync as writeFileSync2 } from "node:fs";
|
|
1348
|
+
var SEVERITY_EMOJI = {
|
|
1349
|
+
critical: "\uD83D\uDD34",
|
|
1350
|
+
warning: "\uD83D\uDFE1",
|
|
1351
|
+
info: "ℹ️"
|
|
1352
|
+
};
|
|
1353
|
+
var CATEGORY_LABELS2 = {
|
|
1354
|
+
"memory-leak": "Memory Leak",
|
|
1355
|
+
"large-retained-object": "Large Retained Object",
|
|
1356
|
+
"detached-dom": "Detached DOM",
|
|
1357
|
+
"render-blocking": "Render-Blocking",
|
|
1358
|
+
"long-task": "Long Task",
|
|
1359
|
+
"unused-code": "Unused Code",
|
|
1360
|
+
"waterfall-bottleneck": "Waterfall Bottleneck",
|
|
1361
|
+
"large-asset": "Large Asset",
|
|
1362
|
+
"frame-blocking-function": "Frame-Blocking Function",
|
|
1363
|
+
"listener-leak": "Listener Leak",
|
|
1364
|
+
"gc-pressure": "GC Pressure",
|
|
1365
|
+
"slow-test": "Slow Test",
|
|
1366
|
+
"expensive-setup": "Expensive Setup",
|
|
1367
|
+
"hot-function": "Hot Function",
|
|
1368
|
+
"unnecessary-computation": "Unnecessary Computation",
|
|
1369
|
+
"import-overhead": "Import Overhead",
|
|
1370
|
+
"dependency-bottleneck": "Dependency Bottleneck",
|
|
1371
|
+
algorithm: "Inefficient Algorithm",
|
|
1372
|
+
serialization: "Serialization Overhead",
|
|
1373
|
+
allocation: "Excessive Allocation",
|
|
1374
|
+
"event-handling": "Event Handling",
|
|
1375
|
+
"blocking-io": "Blocking I/O",
|
|
1376
|
+
other: "Other"
|
|
1377
|
+
};
|
|
1378
|
+
function writeReport(outputPath, options) {
|
|
1379
|
+
const md = generateMarkdown(options);
|
|
1380
|
+
writeFileSync2(outputPath, md, "utf-8");
|
|
1381
|
+
return outputPath;
|
|
1382
|
+
}
|
|
1383
|
+
function generateMarkdown(options) {
|
|
1384
|
+
const { url, version, findings, heapSummary, trace } = options;
|
|
1385
|
+
const now = new Date;
|
|
1386
|
+
const sections = [];
|
|
1387
|
+
sections.push(`# Performance Report`);
|
|
1388
|
+
sections.push("");
|
|
1389
|
+
sections.push(`> **${url}** — analyzed ${now.toISOString().replace("T", " ").slice(0, 16)} UTC by zeitzeuge v${version}`);
|
|
1390
|
+
sections.push("");
|
|
1391
|
+
const totalTransfer = trace.networkRequests.reduce((s, r) => s + r.encodedSize, 0);
|
|
1392
|
+
const loadSec = (trace.metrics.loadComplete / 1000).toFixed(1);
|
|
1393
|
+
const fcpSec = (trace.metrics.firstContentfulPaint / 1000).toFixed(2);
|
|
1394
|
+
const tbt = trace.metrics.totalBlockingTime.toFixed(0);
|
|
1395
|
+
const heapSize = formatBytes(heapSummary.metadata.totalSize);
|
|
1396
|
+
const reqCount = trace.networkRequests.length;
|
|
1397
|
+
sections.push(`**Page load** ${loadSec}s · **FCP** ${fcpSec}s · **TBT** ${tbt}ms · ` + `**Heap** ${heapSize} · **${reqCount} requests** (${formatBytes(totalTransfer)} transferred)`);
|
|
1398
|
+
sections.push("");
|
|
1399
|
+
const counts = {
|
|
1400
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
1401
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
1402
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
1403
|
+
};
|
|
1404
|
+
if (findings.length === 0) {
|
|
1405
|
+
sections.push(`## ✅ No issues found`);
|
|
1406
|
+
sections.push("");
|
|
1407
|
+
sections.push(`No significant performance problems were detected. ` + `The page loads in ${loadSec}s with ${tbt}ms of total blocking time — looking healthy.`);
|
|
1408
|
+
sections.push("");
|
|
1409
|
+
} else {
|
|
1410
|
+
sections.push(`**${findings.length} issues found** — ` + `${counts.critical} critical, ${counts.warning} warning, ${counts.info} info`);
|
|
1411
|
+
sections.push("");
|
|
1412
|
+
for (let i = 0;i < findings.length; i++) {
|
|
1413
|
+
const f = findings[i];
|
|
1414
|
+
if (!f)
|
|
1415
|
+
continue;
|
|
1416
|
+
const emoji = SEVERITY_EMOJI[f.severity];
|
|
1417
|
+
const categoryLabel = CATEGORY_LABELS2[f.category] ?? f.category;
|
|
1418
|
+
sections.push(`---`);
|
|
1419
|
+
sections.push("");
|
|
1420
|
+
sections.push(`## ${emoji} ${f.title}`);
|
|
1421
|
+
sections.push("");
|
|
1422
|
+
const context = [`**${categoryLabel}**`];
|
|
1423
|
+
if (f.impactMs != null)
|
|
1424
|
+
context.push(`${f.impactMs.toFixed(0)}ms impact`);
|
|
1425
|
+
if (f.retainedSize != null)
|
|
1426
|
+
context.push(`${formatBytes(f.retainedSize)} retained`);
|
|
1427
|
+
if (f.resourceUrl)
|
|
1428
|
+
context.push(`\`${f.resourceUrl}\``);
|
|
1429
|
+
sections.push(context.join(" · "));
|
|
1430
|
+
sections.push("");
|
|
1431
|
+
sections.push(f.description);
|
|
1432
|
+
sections.push("");
|
|
1433
|
+
if (f.suggestedFix) {
|
|
1434
|
+
sections.push(`### How to fix`);
|
|
1435
|
+
sections.push("");
|
|
1436
|
+
const looksLikeCode = f.suggestedFix.includes("{") || f.suggestedFix.includes(";") || f.suggestedFix.includes("=>") || f.suggestedFix.includes("import ") || f.suggestedFix.includes("function ");
|
|
1437
|
+
if (looksLikeCode) {
|
|
1438
|
+
sections.push("```js");
|
|
1439
|
+
sections.push(f.suggestedFix);
|
|
1440
|
+
sections.push("```");
|
|
1441
|
+
} else {
|
|
1442
|
+
sections.push(f.suggestedFix);
|
|
1443
|
+
}
|
|
1444
|
+
sections.push("");
|
|
1445
|
+
}
|
|
1446
|
+
if (f.retainerPath && f.retainerPath.length > 0) {
|
|
1447
|
+
sections.push(`*Retention path:* ${f.retainerPath.map((p) => `\`${p}\``).join(" → ")}`);
|
|
1448
|
+
sections.push("");
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
sections.push(`---`);
|
|
1453
|
+
sections.push("");
|
|
1454
|
+
sections.push(`*Generated by zeitzeuge v${version}*`);
|
|
1455
|
+
sections.push("");
|
|
1456
|
+
return sections.join(`
|
|
1457
|
+
`);
|
|
1458
|
+
}
|
|
1459
|
+
function writeTestReport(outputPath, options) {
|
|
1460
|
+
const md = generateTestMarkdown(options);
|
|
1461
|
+
writeFileSync2(outputPath, md, "utf-8");
|
|
1462
|
+
return outputPath;
|
|
1463
|
+
}
|
|
1464
|
+
function generateTestMarkdown(options) {
|
|
1465
|
+
const { version, findings, testTiming, profiles } = options;
|
|
1466
|
+
const now = new Date;
|
|
1467
|
+
const sections = [];
|
|
1468
|
+
sections.push(`# Vitest Performance Report`);
|
|
1469
|
+
sections.push("");
|
|
1470
|
+
sections.push(`> Analyzed ${now.toISOString().replace("T", " ").slice(0, 16)} UTC by zeitzeuge v${version}`);
|
|
1471
|
+
sections.push("");
|
|
1472
|
+
const totalTests = testTiming.reduce((s, t) => s + t.testCount, 0);
|
|
1473
|
+
const totalFiles = testTiming.length;
|
|
1474
|
+
const totalDuration = testTiming.reduce((s, t) => s + t.duration, 0);
|
|
1475
|
+
const slowest = testTiming.length > 0 ? testTiming.reduce((a, b) => a.duration > b.duration ? a : b) : null;
|
|
1476
|
+
const totalGcTime = profiles.reduce((s, p) => s + p.summary.duration * p.summary.gcPercentage / 100, 0);
|
|
1477
|
+
const gcPercentage = totalDuration > 0 ? (totalGcTime / totalDuration * 100).toFixed(2) : "0";
|
|
1478
|
+
sections.push(`**Test run** ${totalTests} tests across ${totalFiles} files · ` + `**Total duration** ${(totalDuration / 1000).toFixed(2)}s · ` + `**Slowest file** ${slowest ? `${slowest.file} (${(slowest.duration / 1000).toFixed(2)}s)` : "—"} · ` + `**GC overhead** ${gcPercentage}% (${totalGcTime.toFixed(0)}ms)`);
|
|
1479
|
+
sections.push("");
|
|
1480
|
+
const counts = {
|
|
1481
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
1482
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
1483
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
1484
|
+
};
|
|
1485
|
+
if (findings.length === 0) {
|
|
1486
|
+
sections.push(`## ✅ No issues found`);
|
|
1487
|
+
sections.push("");
|
|
1488
|
+
sections.push(`No significant performance problems were detected. ` + `Tests complete in ${(totalDuration / 1000).toFixed(2)}s — looking healthy.`);
|
|
1489
|
+
sections.push("");
|
|
1490
|
+
} else {
|
|
1491
|
+
sections.push(`**${findings.length} issues found** — ` + `${counts.critical} critical, ${counts.warning} warning, ${counts.info} info`);
|
|
1492
|
+
sections.push("");
|
|
1493
|
+
for (const f of findings) {
|
|
1494
|
+
const emoji = SEVERITY_EMOJI[f.severity];
|
|
1495
|
+
const categoryLabel = CATEGORY_LABELS2[f.category] ?? f.category;
|
|
1496
|
+
sections.push(`---`);
|
|
1497
|
+
sections.push("");
|
|
1498
|
+
sections.push(`## ${emoji} ${f.title}`);
|
|
1499
|
+
sections.push("");
|
|
1500
|
+
const context = [`**${categoryLabel}**`];
|
|
1501
|
+
if (f.impactMs != null)
|
|
1502
|
+
context.push(`${f.impactMs.toFixed(0)}ms impact`);
|
|
1503
|
+
if (f.testFile)
|
|
1504
|
+
context.push(`\`${f.testFile}\``);
|
|
1505
|
+
if (f.hotFunction) {
|
|
1506
|
+
context.push(`\`${f.hotFunction.name}\` (${f.hotFunction.selfTime.toFixed(0)}ms, ${f.hotFunction.selfPercent.toFixed(1)}%)`);
|
|
1507
|
+
}
|
|
1508
|
+
if (f.resourceUrl)
|
|
1509
|
+
context.push(`\`${f.resourceUrl}\``);
|
|
1510
|
+
sections.push(context.join(" · "));
|
|
1511
|
+
sections.push("");
|
|
1512
|
+
sections.push(f.description);
|
|
1513
|
+
sections.push("");
|
|
1514
|
+
if (f.suggestedFix) {
|
|
1515
|
+
sections.push(`### How to fix`);
|
|
1516
|
+
sections.push("");
|
|
1517
|
+
const looksLikeCode = f.suggestedFix.includes("{") || f.suggestedFix.includes(";") || f.suggestedFix.includes("=>") || f.suggestedFix.includes("import ") || f.suggestedFix.includes("function ");
|
|
1518
|
+
if (looksLikeCode) {
|
|
1519
|
+
sections.push("```ts");
|
|
1520
|
+
sections.push(f.suggestedFix);
|
|
1521
|
+
sections.push("```");
|
|
1522
|
+
} else {
|
|
1523
|
+
sections.push(f.suggestedFix);
|
|
1524
|
+
}
|
|
1525
|
+
sections.push("");
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
sections.push(`---`);
|
|
1530
|
+
sections.push("");
|
|
1531
|
+
sections.push(`*Generated by zeitzeuge v${version}*`);
|
|
1532
|
+
sections.push("");
|
|
1533
|
+
return sections.join(`
|
|
1534
|
+
`);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// src/cli.ts
|
|
1538
|
+
import { readFileSync } from "node:fs";
|
|
1539
|
+
import { resolve, dirname } from "node:path";
|
|
1540
|
+
import { fileURLToPath } from "node:url";
|
|
1541
|
+
function getVersion() {
|
|
1542
|
+
try {
|
|
1543
|
+
const __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
1544
|
+
const pkgPath = resolve(__dirname2, "..", "package.json");
|
|
1545
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
1546
|
+
return pkg.version ?? "0.3.0";
|
|
1547
|
+
} catch {
|
|
1548
|
+
return "0.3.0";
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
var VERSION = getVersion();
|
|
1552
|
+
var argv = yargs(hideBin(process.argv)).scriptName("zeitzeuge").usage("Usage: $0 <url> [options]").command("$0 <url>", "Analyze frontend performance of a URL", (yargs2) => {
|
|
1553
|
+
return yargs2.positional("url", {
|
|
1554
|
+
describe: "Target URL to analyze (e.g. http://localhost:3000)",
|
|
1555
|
+
type: "string",
|
|
1556
|
+
demandOption: true
|
|
1557
|
+
});
|
|
1558
|
+
}).option("verbose", {
|
|
1559
|
+
alias: "v",
|
|
1560
|
+
type: "boolean",
|
|
1561
|
+
default: false,
|
|
1562
|
+
describe: "Enable verbose/debug logging"
|
|
1563
|
+
}).option("headless", {
|
|
1564
|
+
type: "boolean",
|
|
1565
|
+
default: true,
|
|
1566
|
+
describe: "Run Chrome in headless mode"
|
|
1567
|
+
}).option("timeout", {
|
|
1568
|
+
type: "number",
|
|
1569
|
+
default: 30000,
|
|
1570
|
+
describe: "Page load timeout in milliseconds"
|
|
1571
|
+
}).option("output", {
|
|
1572
|
+
alias: "o",
|
|
1573
|
+
type: "string",
|
|
1574
|
+
default: "zeitzeuge-report.md",
|
|
1575
|
+
describe: "Output path for the Markdown report"
|
|
1576
|
+
}).help("help", "Show help").alias("h", "help").version(VERSION).strict().parseSync();
|
|
1577
|
+
function validateUrl(url) {
|
|
1578
|
+
try {
|
|
1579
|
+
const parsed = new URL(url);
|
|
1580
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
1581
|
+
throw new Error("URL must use http:// or https:// protocol");
|
|
1582
|
+
}
|
|
1583
|
+
} catch (err) {
|
|
1584
|
+
if (err instanceof Error && err.message.includes("protocol")) {
|
|
1585
|
+
throw err;
|
|
1586
|
+
}
|
|
1587
|
+
throw new Error(`Invalid URL: "${url}". Please provide a valid URL (e.g. http://localhost:3000)`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
async function main() {
|
|
1591
|
+
const url = argv.url;
|
|
1592
|
+
const verbose = argv.verbose;
|
|
1593
|
+
let browser;
|
|
1594
|
+
try {
|
|
1595
|
+
validateUrl(url);
|
|
1596
|
+
printHeader(url, VERSION);
|
|
1597
|
+
if (verbose)
|
|
1598
|
+
console.log("[verbose] Detecting API key and initializing model...");
|
|
1599
|
+
const model = initModel();
|
|
1600
|
+
if (verbose)
|
|
1601
|
+
console.log(`[verbose] Model initialized: ${model.constructor.name}`);
|
|
1602
|
+
const browserSpinner = createSpinner("Launching browser...");
|
|
1603
|
+
try {
|
|
1604
|
+
browser = activeBrowser = await launchBrowser({ headless: argv.headless });
|
|
1605
|
+
browserSpinner.succeed(`Browser launched (${argv.headless ? "headless" : "headed"})`);
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
browserSpinner.fail("Failed to launch browser");
|
|
1608
|
+
throw new Error(`Could not launch Chrome. Make sure Chrome/Chromium is installed.
|
|
1609
|
+
` + ` Install: https://www.google.com/chrome/
|
|
1610
|
+
` + (err instanceof Error ? ` Details: ${err.message}` : ""));
|
|
1611
|
+
}
|
|
1612
|
+
const captureSpinner = createSpinner(`Loading ${url} & capturing data...`);
|
|
1613
|
+
let captureResult;
|
|
1614
|
+
try {
|
|
1615
|
+
captureResult = await capturePage(browser, url, {
|
|
1616
|
+
timeout: argv.timeout
|
|
1617
|
+
});
|
|
1618
|
+
const heapSizeMB = (captureResult.heapSnapshot.data.length / (1024 * 1024)).toFixed(1);
|
|
1619
|
+
const reqCount = captureResult.trace.networkRequests.length;
|
|
1620
|
+
const longTaskCount = captureResult.trace.metrics.longTasks.length;
|
|
1621
|
+
const runtimeTraceInfo = captureResult.trace.runtimeTrace ? `
|
|
1622
|
+
Runtime trace: ${captureResult.trace.runtimeTrace.totalEvents.toLocaleString()} events captured` : "";
|
|
1623
|
+
captureSpinner.succeed(`Page loaded in ${(captureResult.trace.metrics.loadComplete / 1000).toFixed(1)}s
|
|
1624
|
+
` + ` Heap snapshot: ${heapSizeMB} MB
|
|
1625
|
+
` + ` Network requests: ${reqCount} captured
|
|
1626
|
+
` + ` Long tasks: ${longTaskCount} detected` + runtimeTraceInfo);
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
captureSpinner.fail("Failed to capture page data");
|
|
1629
|
+
throw new Error(`Failed to capture data from ${url}.
|
|
1630
|
+
` + ` Try running with --no-headless if the page requires interaction.
|
|
1631
|
+
` + (err instanceof Error ? ` Details: ${err.message}` : ""));
|
|
1632
|
+
}
|
|
1633
|
+
const parseSpinner = createSpinner("Parsing heap snapshot...");
|
|
1634
|
+
const heapSummary = parseSnapshot(captureResult.heapSnapshot);
|
|
1635
|
+
parseSpinner.succeed(`Parsed: ${heapSummary.metadata.nodeCount.toLocaleString()} nodes, ${heapSummary.metadata.edgeCount.toLocaleString()} edges`);
|
|
1636
|
+
const workspaceSpinner = createSpinner("Building workspace...");
|
|
1637
|
+
let workspace;
|
|
1638
|
+
try {
|
|
1639
|
+
workspace = await createWorkspace({
|
|
1640
|
+
heapSummary,
|
|
1641
|
+
traceResult: captureResult.trace,
|
|
1642
|
+
url
|
|
1643
|
+
});
|
|
1644
|
+
const storedCount = captureResult.trace.networkRequests.filter((r) => r.responseBody).length;
|
|
1645
|
+
const totalSize = captureResult.trace.networkRequests.filter((r) => r.responseBody).reduce((sum, r) => sum + (r.responseBody?.length ?? 0), 0);
|
|
1646
|
+
const runtimeWorkspaceInfo = captureResult.trace.runtimeTrace ? `
|
|
1647
|
+
Runtime trace: summaries + raw events` : "";
|
|
1648
|
+
workspaceSpinner.succeed(`${storedCount} assets stored in workspace (${formatBytes(totalSize)} total)` + runtimeWorkspaceInfo);
|
|
1649
|
+
} catch (err) {
|
|
1650
|
+
workspaceSpinner.fail("Failed to build workspace");
|
|
1651
|
+
throw new Error(`Failed to create workspace.
|
|
1652
|
+
` + (err instanceof Error ? ` Details: ${err.message}` : ""));
|
|
1653
|
+
}
|
|
1654
|
+
const agentSpinner = createSpinner("Deep Agent analyzing...");
|
|
1655
|
+
let findings;
|
|
1656
|
+
try {
|
|
1657
|
+
findings = await analyze(model, workspace.backend);
|
|
1658
|
+
agentSpinner.succeed(`Analysis complete — ${findings.length} findings`);
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
agentSpinner.fail(`Analysis failed: ${err instanceof Error ? err.message : "Unknown error"}`);
|
|
1661
|
+
throw new Error(`LLM analysis failed. Check your API key and network connection.
|
|
1662
|
+
` + (err instanceof Error ? ` Details: ${err.message}` : ""));
|
|
1663
|
+
} finally {
|
|
1664
|
+
workspace.cleanup();
|
|
1665
|
+
}
|
|
1666
|
+
printFindings(findings);
|
|
1667
|
+
printCaptureInfo(heapSummary, captureResult.trace);
|
|
1668
|
+
const outputPath = argv.output;
|
|
1669
|
+
const reportPath = writeReport(resolve(outputPath), {
|
|
1670
|
+
url,
|
|
1671
|
+
version: VERSION,
|
|
1672
|
+
findings,
|
|
1673
|
+
heapSummary,
|
|
1674
|
+
trace: captureResult.trace
|
|
1675
|
+
});
|
|
1676
|
+
console.log(`
|
|
1677
|
+
\uD83D\uDCC4 Report written to ${reportPath}
|
|
1678
|
+
`);
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
printError(err);
|
|
1681
|
+
process.exit(1);
|
|
1682
|
+
} finally {
|
|
1683
|
+
if (browser) {
|
|
1684
|
+
await closeBrowser(browser);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
var activeBrowser;
|
|
1689
|
+
process.on("SIGINT", async () => {
|
|
1690
|
+
if (activeBrowser) {
|
|
1691
|
+
await closeBrowser(activeBrowser);
|
|
1692
|
+
}
|
|
1693
|
+
process.exit(130);
|
|
1694
|
+
});
|
|
1695
|
+
main();
|