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
|
@@ -0,0 +1,1616 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/vitest/plugin.ts
|
|
5
|
+
import { resolve as resolve3 } from "node:path";
|
|
6
|
+
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
7
|
+
|
|
8
|
+
// src/vitest/reporter.ts
|
|
9
|
+
import { readdirSync, readFileSync, existsSync, rmSync, statSync } from "node:fs";
|
|
10
|
+
import { join as join2, resolve as resolve2 } from "node:path";
|
|
11
|
+
import chalk2 from "chalk";
|
|
12
|
+
import ora2 from "ora";
|
|
13
|
+
|
|
14
|
+
// src/vitest/profile-parser.ts
|
|
15
|
+
var MAX_HOT_FUNCTIONS = 50;
|
|
16
|
+
var MAX_CALL_TREES = 10;
|
|
17
|
+
var CALL_TREE_PRUNE_THRESHOLD = 0.01;
|
|
18
|
+
function parseCpuProfile(profile, profilePath) {
|
|
19
|
+
if (!profile.samples || profile.samples.length === 0) {
|
|
20
|
+
return emptySummary(profilePath);
|
|
21
|
+
}
|
|
22
|
+
const totalDurationUs = profile.endTime - profile.startTime;
|
|
23
|
+
const totalDurationMs = totalDurationUs / 1000;
|
|
24
|
+
const statsMap = buildNodeStats(profile);
|
|
25
|
+
computeSelfTime(profile, statsMap);
|
|
26
|
+
computeTotalTime(profile, statsMap);
|
|
27
|
+
const hotFunctions = extractHotFunctions(statsMap, totalDurationUs);
|
|
28
|
+
const expensiveCallTrees = extractCallTrees(profile, statsMap, totalDurationUs);
|
|
29
|
+
const { gcSamples, gcTimeUs, idleTimeUs } = computeSpecialCategories(profile, statsMap);
|
|
30
|
+
const scriptBreakdown = buildScriptBreakdown(statsMap, totalDurationUs);
|
|
31
|
+
return {
|
|
32
|
+
profilePath,
|
|
33
|
+
duration: round(totalDurationMs),
|
|
34
|
+
sampleCount: profile.samples.length,
|
|
35
|
+
hotFunctions,
|
|
36
|
+
expensiveCallTrees,
|
|
37
|
+
gcSamples,
|
|
38
|
+
gcPercentage: totalDurationUs > 0 ? round(gcTimeUs / totalDurationUs * 100) : 0,
|
|
39
|
+
idlePercentage: totalDurationUs > 0 ? round(idleTimeUs / totalDurationUs * 100) : 0,
|
|
40
|
+
scriptBreakdown
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function buildNodeStats(profile) {
|
|
44
|
+
const statsMap = new Map;
|
|
45
|
+
for (const node of profile.nodes) {
|
|
46
|
+
statsMap.set(node.id, {
|
|
47
|
+
node,
|
|
48
|
+
selfTime: 0,
|
|
49
|
+
totalTime: 0,
|
|
50
|
+
parentIds: new Set
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
for (const node of profile.nodes) {
|
|
54
|
+
if (node.children) {
|
|
55
|
+
for (const childId of node.children) {
|
|
56
|
+
const childStats = statsMap.get(childId);
|
|
57
|
+
if (childStats) {
|
|
58
|
+
childStats.parentIds.add(node.id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return statsMap;
|
|
64
|
+
}
|
|
65
|
+
function computeSelfTime(profile, statsMap) {
|
|
66
|
+
const { samples, timeDeltas } = profile;
|
|
67
|
+
for (let i = 0;i < samples.length; i++) {
|
|
68
|
+
const nodeId = samples[i];
|
|
69
|
+
const delta = timeDeltas[i] ?? 0;
|
|
70
|
+
const stats = statsMap.get(nodeId);
|
|
71
|
+
if (stats) {
|
|
72
|
+
stats.selfTime += Math.max(0, delta);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function computeTotalTime(profile, statsMap) {
|
|
77
|
+
const childCount = new Map;
|
|
78
|
+
const queue = [];
|
|
79
|
+
for (const node of profile.nodes) {
|
|
80
|
+
const numChildren = node.children?.length ?? 0;
|
|
81
|
+
childCount.set(node.id, numChildren);
|
|
82
|
+
if (numChildren === 0) {
|
|
83
|
+
queue.push(node.id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
while (queue.length > 0) {
|
|
87
|
+
const nodeId = queue.shift();
|
|
88
|
+
const stats = statsMap.get(nodeId);
|
|
89
|
+
if (!stats)
|
|
90
|
+
continue;
|
|
91
|
+
stats.totalTime = stats.selfTime;
|
|
92
|
+
const children = stats.node.children ?? [];
|
|
93
|
+
for (const childId of children) {
|
|
94
|
+
const childStats = statsMap.get(childId);
|
|
95
|
+
if (childStats) {
|
|
96
|
+
stats.totalTime += childStats.totalTime;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const parentId of stats.parentIds) {
|
|
100
|
+
const remaining = (childCount.get(parentId) ?? 1) - 1;
|
|
101
|
+
childCount.set(parentId, remaining);
|
|
102
|
+
if (remaining <= 0) {
|
|
103
|
+
queue.push(parentId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function extractHotFunctions(statsMap, totalDurationUs) {
|
|
109
|
+
const results = [];
|
|
110
|
+
for (const stats of statsMap.values()) {
|
|
111
|
+
if (stats.selfTime <= 0)
|
|
112
|
+
continue;
|
|
113
|
+
const fn = stats.node.callFrame.functionName;
|
|
114
|
+
if (fn === "(root)" || fn === "(idle)" || fn === "(program)")
|
|
115
|
+
continue;
|
|
116
|
+
results.push({
|
|
117
|
+
functionName: stats.node.callFrame.functionName || "(anonymous)",
|
|
118
|
+
scriptUrl: stats.node.callFrame.url,
|
|
119
|
+
lineNumber: stats.node.callFrame.lineNumber,
|
|
120
|
+
columnNumber: stats.node.callFrame.columnNumber,
|
|
121
|
+
selfTime: round(stats.selfTime / 1000),
|
|
122
|
+
totalTime: round(stats.totalTime / 1000),
|
|
123
|
+
hitCount: stats.node.hitCount,
|
|
124
|
+
selfPercent: totalDurationUs > 0 ? round(stats.selfTime / totalDurationUs * 100) : 0
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
results.sort((a, b) => b.selfTime - a.selfTime);
|
|
128
|
+
return results.slice(0, MAX_HOT_FUNCTIONS);
|
|
129
|
+
}
|
|
130
|
+
function extractCallTrees(profile, statsMap, totalDurationUs) {
|
|
131
|
+
const rootIds = [];
|
|
132
|
+
for (const stats of statsMap.values()) {
|
|
133
|
+
if (stats.parentIds.size === 0) {
|
|
134
|
+
rootIds.push(stats.node.id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const trees = [];
|
|
138
|
+
for (const rootId of rootIds) {
|
|
139
|
+
const tree = buildCallTreeNode(rootId, statsMap, totalDurationUs);
|
|
140
|
+
if (tree && tree.totalTime > 0) {
|
|
141
|
+
trees.push(tree);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
trees.sort((a, b) => b.totalTime - a.totalTime);
|
|
145
|
+
return trees.slice(0, MAX_CALL_TREES);
|
|
146
|
+
}
|
|
147
|
+
function buildCallTreeNode(nodeId, statsMap, totalDurationUs) {
|
|
148
|
+
const stats = statsMap.get(nodeId);
|
|
149
|
+
if (!stats)
|
|
150
|
+
return null;
|
|
151
|
+
const totalPercent = totalDurationUs > 0 ? stats.totalTime / totalDurationUs : 0;
|
|
152
|
+
if (totalPercent < CALL_TREE_PRUNE_THRESHOLD)
|
|
153
|
+
return null;
|
|
154
|
+
const children = [];
|
|
155
|
+
for (const childId of stats.node.children ?? []) {
|
|
156
|
+
const childTree = buildCallTreeNode(childId, statsMap, totalDurationUs);
|
|
157
|
+
if (childTree) {
|
|
158
|
+
children.push(childTree);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
children.sort((a, b) => b.totalTime - a.totalTime);
|
|
162
|
+
return {
|
|
163
|
+
functionName: stats.node.callFrame.functionName || "(anonymous)",
|
|
164
|
+
scriptUrl: stats.node.callFrame.url,
|
|
165
|
+
lineNumber: stats.node.callFrame.lineNumber,
|
|
166
|
+
totalTime: round(stats.totalTime / 1000),
|
|
167
|
+
totalPercent: round(totalPercent * 100),
|
|
168
|
+
children
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function computeSpecialCategories(profile, statsMap) {
|
|
172
|
+
let gcSamples = 0;
|
|
173
|
+
let gcTimeUs = 0;
|
|
174
|
+
let idleTimeUs = 0;
|
|
175
|
+
for (const stats of statsMap.values()) {
|
|
176
|
+
const fn = stats.node.callFrame.functionName;
|
|
177
|
+
if (fn.includes("(garbage collector)") || fn === "(GC)") {
|
|
178
|
+
gcSamples += stats.node.hitCount;
|
|
179
|
+
gcTimeUs += stats.selfTime;
|
|
180
|
+
}
|
|
181
|
+
if (fn === "(idle)") {
|
|
182
|
+
idleTimeUs += stats.selfTime;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { gcSamples, gcTimeUs, idleTimeUs };
|
|
186
|
+
}
|
|
187
|
+
function buildScriptBreakdown(statsMap, totalDurationUs) {
|
|
188
|
+
const scriptMap = new Map;
|
|
189
|
+
for (const stats of statsMap.values()) {
|
|
190
|
+
const url = stats.node.callFrame.url;
|
|
191
|
+
if (!url)
|
|
192
|
+
continue;
|
|
193
|
+
let entry = scriptMap.get(url);
|
|
194
|
+
if (!entry) {
|
|
195
|
+
entry = { selfTime: 0, functions: new Set };
|
|
196
|
+
scriptMap.set(url, entry);
|
|
197
|
+
}
|
|
198
|
+
entry.selfTime += stats.selfTime;
|
|
199
|
+
if (stats.selfTime > 0) {
|
|
200
|
+
entry.functions.add(`${stats.node.callFrame.functionName}:${stats.node.callFrame.lineNumber}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const results = [];
|
|
204
|
+
for (const [scriptUrl, data] of scriptMap) {
|
|
205
|
+
results.push({
|
|
206
|
+
scriptUrl,
|
|
207
|
+
selfTime: round(data.selfTime / 1000),
|
|
208
|
+
selfPercent: totalDurationUs > 0 ? round(data.selfTime / totalDurationUs * 100) : 0,
|
|
209
|
+
functionCount: data.functions.size
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
results.sort((a, b) => b.selfTime - a.selfTime);
|
|
213
|
+
return results;
|
|
214
|
+
}
|
|
215
|
+
function round(n) {
|
|
216
|
+
return Math.round(n * 100) / 100;
|
|
217
|
+
}
|
|
218
|
+
function emptySummary(profilePath) {
|
|
219
|
+
return {
|
|
220
|
+
profilePath,
|
|
221
|
+
duration: 0,
|
|
222
|
+
sampleCount: 0,
|
|
223
|
+
hotFunctions: [],
|
|
224
|
+
expensiveCallTrees: [],
|
|
225
|
+
gcSamples: 0,
|
|
226
|
+
gcPercentage: 0,
|
|
227
|
+
idlePercentage: 0,
|
|
228
|
+
scriptBreakdown: []
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/vitest/heap-profile-parser.ts
|
|
233
|
+
function parseHeapProfile(raw, profilePath) {
|
|
234
|
+
const nodeById = new Map;
|
|
235
|
+
const parentById = new Map;
|
|
236
|
+
const walk = (node, parent) => {
|
|
237
|
+
nodeById.set(node.id, node);
|
|
238
|
+
parentById.set(node.id, parent ? parent.id : null);
|
|
239
|
+
for (const child of node.children ?? [])
|
|
240
|
+
walk(child, node);
|
|
241
|
+
};
|
|
242
|
+
walk(raw.head, null);
|
|
243
|
+
const selfBytesByNode = new Map;
|
|
244
|
+
let totalAllocatedBytes = 0;
|
|
245
|
+
for (const s of raw.samples ?? []) {
|
|
246
|
+
const size = typeof s.size === "number" ? s.size : 0;
|
|
247
|
+
if (!Number.isFinite(size) || size <= 0)
|
|
248
|
+
continue;
|
|
249
|
+
totalAllocatedBytes += size;
|
|
250
|
+
selfBytesByNode.set(s.nodeId, (selfBytesByNode.get(s.nodeId) ?? 0) + size);
|
|
251
|
+
}
|
|
252
|
+
const hotspots = [];
|
|
253
|
+
const scriptMap = new Map;
|
|
254
|
+
for (const [nodeId, selfBytes] of selfBytesByNode.entries()) {
|
|
255
|
+
const node = nodeById.get(nodeId);
|
|
256
|
+
if (!node)
|
|
257
|
+
continue;
|
|
258
|
+
const cf = node.callFrame;
|
|
259
|
+
const scriptUrl = cf?.url ?? "";
|
|
260
|
+
const fnName = cf?.functionName ?? "(anonymous)";
|
|
261
|
+
hotspots.push({
|
|
262
|
+
functionName: fnName,
|
|
263
|
+
scriptUrl,
|
|
264
|
+
lineNumber: cf?.lineNumber ?? -1,
|
|
265
|
+
columnNumber: cf?.columnNumber ?? -1,
|
|
266
|
+
selfBytes,
|
|
267
|
+
selfPercent: 0
|
|
268
|
+
});
|
|
269
|
+
const key = scriptUrl || "(unknown)";
|
|
270
|
+
const entry = scriptMap.get(key) ?? { selfBytes: 0, functionIds: new Set };
|
|
271
|
+
entry.selfBytes += selfBytes;
|
|
272
|
+
entry.functionIds.add(nodeId);
|
|
273
|
+
scriptMap.set(key, entry);
|
|
274
|
+
}
|
|
275
|
+
hotspots.sort((a, b) => b.selfBytes - a.selfBytes);
|
|
276
|
+
const topAllocations = hotspots.slice(0, 50);
|
|
277
|
+
if (totalAllocatedBytes > 0) {
|
|
278
|
+
for (const h of topAllocations) {
|
|
279
|
+
h.selfPercent = round2(h.selfBytes / totalAllocatedBytes * 100);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const scriptBreakdown = Array.from(scriptMap.entries()).map(([scriptUrl, data]) => ({
|
|
283
|
+
scriptUrl,
|
|
284
|
+
selfBytes: data.selfBytes,
|
|
285
|
+
selfPercent: totalAllocatedBytes > 0 ? round2(data.selfBytes / totalAllocatedBytes * 100) : 0,
|
|
286
|
+
functionCount: data.functionIds.size
|
|
287
|
+
})).sort((a, b) => b.selfBytes - a.selfBytes);
|
|
288
|
+
return {
|
|
289
|
+
profilePath,
|
|
290
|
+
totalAllocatedBytes: Math.round(totalAllocatedBytes),
|
|
291
|
+
sampleCount: raw.samples?.length ?? 0,
|
|
292
|
+
topAllocations,
|
|
293
|
+
scriptBreakdown
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function round2(n) {
|
|
297
|
+
return Math.round(n * 100) / 100;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// src/vitest/workspace.ts
|
|
301
|
+
import { FilesystemBackend } from "deepagents";
|
|
302
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
303
|
+
import { join } from "node:path";
|
|
304
|
+
import { tmpdir } from "node:os";
|
|
305
|
+
var SOURCE_INCLUSION_THRESHOLD = 1;
|
|
306
|
+
var SLOW_TEST_THRESHOLD = 100;
|
|
307
|
+
async function createVitestWorkspace(options) {
|
|
308
|
+
const { testTiming, profiles, heapProfiles, testSources, sourcePaths } = options;
|
|
309
|
+
const files = {};
|
|
310
|
+
const totalTests = testTiming.reduce((s, t) => s + t.testCount, 0);
|
|
311
|
+
const totalDuration = testTiming.reduce((s, t) => s + t.duration, 0);
|
|
312
|
+
const passCount = testTiming.reduce((s, t) => s + t.passCount, 0);
|
|
313
|
+
const failCount = testTiming.reduce((s, t) => s + t.failCount, 0);
|
|
314
|
+
const slowest = testTiming.length > 0 ? testTiming.reduce((a, b) => a.duration > b.duration ? a : b) : null;
|
|
315
|
+
const totalGcTime = profiles.reduce((s, p) => s + p.summary.duration * p.summary.gcPercentage / 100, 0);
|
|
316
|
+
const gcPercentage = totalDuration > 0 ? round3(totalGcTime / totalDuration * 100) : 0;
|
|
317
|
+
files["/summary.json"] = JSON.stringify({
|
|
318
|
+
totalTests,
|
|
319
|
+
totalDuration: round3(totalDuration),
|
|
320
|
+
passCount,
|
|
321
|
+
failCount,
|
|
322
|
+
profileCount: profiles.length,
|
|
323
|
+
slowestFile: slowest?.file ?? null,
|
|
324
|
+
slowestFileDuration: slowest ? round3(slowest.duration) : 0,
|
|
325
|
+
totalGcTime: round3(totalGcTime),
|
|
326
|
+
gcPercentage
|
|
327
|
+
}, null, 2);
|
|
328
|
+
files["/timing/overview.json"] = JSON.stringify(testTiming, null, 2);
|
|
329
|
+
const slowTests = [];
|
|
330
|
+
for (const fileTiming of testTiming) {
|
|
331
|
+
for (const test of fileTiming.tests) {
|
|
332
|
+
if (test.duration > SLOW_TEST_THRESHOLD) {
|
|
333
|
+
slowTests.push({
|
|
334
|
+
file: fileTiming.file,
|
|
335
|
+
name: test.name,
|
|
336
|
+
duration: test.duration
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
slowTests.sort((a, b) => b.duration - a.duration);
|
|
342
|
+
files["/timing/slow-tests.json"] = JSON.stringify(slowTests, null, 2);
|
|
343
|
+
files["/profiles/index.json"] = JSON.stringify(profiles.map((p) => ({
|
|
344
|
+
testFile: p.testFile,
|
|
345
|
+
profilePath: p.profilePath
|
|
346
|
+
})), null, 2);
|
|
347
|
+
for (const profile of profiles) {
|
|
348
|
+
const safeName = sanitizeFilename(profile.testFile);
|
|
349
|
+
files[`/profiles/${safeName}.json`] = JSON.stringify(profile.summary, null, 2);
|
|
350
|
+
}
|
|
351
|
+
if (heapProfiles && heapProfiles.length > 0) {
|
|
352
|
+
files["/heap-profiles/index.json"] = JSON.stringify(heapProfiles.map((p) => ({
|
|
353
|
+
testFile: p.testFile,
|
|
354
|
+
profilePath: p.profilePath
|
|
355
|
+
})), null, 2);
|
|
356
|
+
for (const hp of heapProfiles) {
|
|
357
|
+
const safeName = sanitizeFilename(hp.testFile);
|
|
358
|
+
files[`/heap-profiles/${safeName}.json`] = JSON.stringify(hp.summary, null, 2);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const mergedHotFunctions = mergeHotFunctions(profiles);
|
|
362
|
+
files["/hot-functions/global.json"] = JSON.stringify(mergedHotFunctions, null, 2);
|
|
363
|
+
const appHotFunctions = mergedHotFunctions.filter((fn) => fn.sourceCategory === "application");
|
|
364
|
+
files["/hot-functions/application.json"] = JSON.stringify(appHotFunctions, null, 2);
|
|
365
|
+
const depHotFunctions = mergedHotFunctions.filter((fn) => fn.sourceCategory === "dependency");
|
|
366
|
+
files["/hot-functions/dependencies.json"] = JSON.stringify(depHotFunctions, null, 2);
|
|
367
|
+
const appScripts = profiles.flatMap((p) => p.summary.scriptBreakdown.filter((s) => s.sourceCategory === "application"));
|
|
368
|
+
const appScriptMap = new Map;
|
|
369
|
+
for (const s of appScripts) {
|
|
370
|
+
const existing = appScriptMap.get(s.scriptUrl);
|
|
371
|
+
if (existing) {
|
|
372
|
+
existing.selfTime += s.selfTime;
|
|
373
|
+
existing.functionCount = Math.max(existing.functionCount, s.functionCount);
|
|
374
|
+
} else {
|
|
375
|
+
appScriptMap.set(s.scriptUrl, {
|
|
376
|
+
selfTime: s.selfTime,
|
|
377
|
+
functionCount: s.functionCount
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const totalDurationMs = testTiming.reduce((s, t) => s + t.duration, 0);
|
|
382
|
+
const appScriptSummary = Array.from(appScriptMap.entries()).map(([scriptUrl, data]) => ({
|
|
383
|
+
scriptUrl,
|
|
384
|
+
selfTime: round3(data.selfTime),
|
|
385
|
+
selfPercent: totalDurationMs > 0 ? round3(data.selfTime / totalDurationMs * 100) : 0,
|
|
386
|
+
functionCount: data.functionCount
|
|
387
|
+
})).sort((a, b) => b.selfTime - a.selfTime);
|
|
388
|
+
files["/scripts/application.json"] = JSON.stringify(appScriptSummary, null, 2);
|
|
389
|
+
const depScripts = profiles.flatMap((p) => p.summary.scriptBreakdown.filter((s) => s.sourceCategory === "dependency"));
|
|
390
|
+
const depScriptMap = new Map;
|
|
391
|
+
for (const s of depScripts) {
|
|
392
|
+
const existing = depScriptMap.get(s.scriptUrl);
|
|
393
|
+
if (existing) {
|
|
394
|
+
existing.selfTime += s.selfTime;
|
|
395
|
+
existing.functionCount = Math.max(existing.functionCount, s.functionCount);
|
|
396
|
+
} else {
|
|
397
|
+
depScriptMap.set(s.scriptUrl, {
|
|
398
|
+
selfTime: s.selfTime,
|
|
399
|
+
functionCount: s.functionCount
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const depScriptSummary = Array.from(depScriptMap.entries()).map(([scriptUrl, data]) => ({
|
|
404
|
+
scriptUrl,
|
|
405
|
+
selfTime: round3(data.selfTime),
|
|
406
|
+
selfPercent: totalDurationMs > 0 ? round3(data.selfTime / totalDurationMs * 100) : 0,
|
|
407
|
+
functionCount: data.functionCount
|
|
408
|
+
})).sort((a, b) => b.selfTime - a.selfTime);
|
|
409
|
+
files["/scripts/dependencies.json"] = JSON.stringify(depScriptSummary, null, 2);
|
|
410
|
+
for (const [filePath, source] of testSources) {
|
|
411
|
+
const filename = filePath.split("/").pop() ?? filePath;
|
|
412
|
+
files[`/tests/${filename}`] = source;
|
|
413
|
+
}
|
|
414
|
+
if (sourcePaths) {
|
|
415
|
+
const hotScriptUrls = new Set;
|
|
416
|
+
for (const profile of profiles) {
|
|
417
|
+
for (const fn of profile.summary.hotFunctions) {
|
|
418
|
+
if (!fn.scriptUrl)
|
|
419
|
+
continue;
|
|
420
|
+
const threshold = fn.sourceCategory === "application" ? 0.1 : SOURCE_INCLUSION_THRESHOLD;
|
|
421
|
+
if (fn.selfPercent >= threshold) {
|
|
422
|
+
hotScriptUrls.add(fn.scriptUrl);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const [scriptUrl, source] of sourcePaths) {
|
|
427
|
+
if (!hotScriptUrls.has(scriptUrl))
|
|
428
|
+
continue;
|
|
429
|
+
const filename = scriptUrl.split("/").pop() ?? scriptUrl;
|
|
430
|
+
files[`/src/${filename}`] = source;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const tempDir = mkdtempSync(join(tmpdir(), "zeitzeuge-vitest-workspace-"));
|
|
434
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
435
|
+
const relPath = filePath.startsWith("/") ? filePath.slice(1) : filePath;
|
|
436
|
+
const fullPath = join(tempDir, relPath);
|
|
437
|
+
mkdirSync(join(fullPath, ".."), { recursive: true });
|
|
438
|
+
writeFileSync(fullPath, content, "utf-8");
|
|
439
|
+
}
|
|
440
|
+
const backend = new FilesystemBackend({
|
|
441
|
+
rootDir: tempDir,
|
|
442
|
+
virtualMode: true
|
|
443
|
+
});
|
|
444
|
+
const cleanup = () => {
|
|
445
|
+
try {
|
|
446
|
+
const { rmSync } = __require("node:fs");
|
|
447
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
448
|
+
} catch {}
|
|
449
|
+
};
|
|
450
|
+
return { backend, cleanup };
|
|
451
|
+
}
|
|
452
|
+
function mergeHotFunctions(profiles) {
|
|
453
|
+
const merged = new Map;
|
|
454
|
+
let totalDuration = 0;
|
|
455
|
+
for (const profile of profiles) {
|
|
456
|
+
totalDuration += profile.summary.duration;
|
|
457
|
+
for (const fn of profile.summary.hotFunctions) {
|
|
458
|
+
const key = `${fn.scriptUrl}:${fn.functionName}:${fn.lineNumber}`;
|
|
459
|
+
const existing = merged.get(key);
|
|
460
|
+
if (existing) {
|
|
461
|
+
existing.selfTime += fn.selfTime;
|
|
462
|
+
existing.totalTime += fn.totalTime;
|
|
463
|
+
existing.hitCount += fn.hitCount;
|
|
464
|
+
} else {
|
|
465
|
+
merged.set(key, { ...fn });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (totalDuration > 0) {
|
|
470
|
+
for (const fn of merged.values()) {
|
|
471
|
+
fn.selfPercent = round3(fn.selfTime / totalDuration * 100);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const results = Array.from(merged.values());
|
|
475
|
+
results.sort((a, b) => b.selfTime - a.selfTime);
|
|
476
|
+
return results.slice(0, 50);
|
|
477
|
+
}
|
|
478
|
+
function sanitizeFilename(filePath) {
|
|
479
|
+
return filePath.replace(/[/\\]/g, "_").replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+/, "");
|
|
480
|
+
}
|
|
481
|
+
function round3(n) {
|
|
482
|
+
return Math.round(n * 100) / 100;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/analysis/agent.ts
|
|
486
|
+
import { createDeepAgent } from "deepagents";
|
|
487
|
+
import { providerStrategy } from "langchain";
|
|
488
|
+
|
|
489
|
+
// src/analysis/prompts.ts
|
|
490
|
+
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.
|
|
491
|
+
|
|
492
|
+
## Workspace structure
|
|
493
|
+
|
|
494
|
+
- /heap/summary.json — Parsed V8 heap snapshot: largest objects, type stats, constructor stats, detached DOM nodes, closure stats
|
|
495
|
+
- /trace/summary.json — Page load metrics: timing, long tasks, render-blocking resources, resource breakdown
|
|
496
|
+
- /trace/network-waterfall.json — Every network request with timing, size, priority, render-blocking status
|
|
497
|
+
- /trace/asset-manifest.json — Index of all assets with paths to stored files
|
|
498
|
+
- /trace/runtime/summary.json — Runtime trace overview: frame breakdown (scripting/layout/paint/GC), blocking function count, listener imbalances, GC stats
|
|
499
|
+
- /trace/runtime/blocking-functions.json — Functions that blocked the main thread > 50ms, with script URL, line number, call stack, and duration
|
|
500
|
+
- /trace/runtime/event-listeners.json — Event listener add/remove counts per event type, with source locations
|
|
501
|
+
- /trace/runtime/frame-breakdown.json — Time spent in scripting vs layout vs paint vs GC
|
|
502
|
+
- /trace/runtime/raw-events.json — Full Chrome trace events (large file — read to investigate specific function calls, layouts, GC, and event dispatches)
|
|
503
|
+
- /scripts/*.js — Actual JavaScript source files captured during page load
|
|
504
|
+
- /styles/*.css — Actual CSS source files
|
|
505
|
+
- /html/document.html — The HTML document
|
|
506
|
+
|
|
507
|
+
## Your workflow
|
|
508
|
+
|
|
509
|
+
1. Read /heap/summary.json, /trace/summary.json, AND /trace/runtime/summary.json first for the big picture
|
|
510
|
+
2. Identify the highest-impact issues from all datasets
|
|
511
|
+
3. For each issue, dive into the relevant source files to understand the root cause
|
|
512
|
+
4. Provide specific, code-level fixes
|
|
513
|
+
|
|
514
|
+
## What to look for
|
|
515
|
+
|
|
516
|
+
### Memory issues (from heap data)
|
|
517
|
+
- Memory leaks: unbounded arrays, maps, caches that grow without bound
|
|
518
|
+
- Detached DOM nodes: DOM elements removed from the document but still referenced
|
|
519
|
+
- Large retained objects: single objects or trees retaining disproportionate memory
|
|
520
|
+
- Closure leaks: closures capturing variables they no longer need
|
|
521
|
+
|
|
522
|
+
### Page-load issues (from trace + source code)
|
|
523
|
+
- Render-blocking scripts: <script> in <head> without async/defer — read the script to judge if it must be synchronous
|
|
524
|
+
- Render-blocking CSS: large stylesheets blocking first paint
|
|
525
|
+
- Long tasks (> 50ms): identify the function/module causing the block by reading the source
|
|
526
|
+
- Large bundles: scripts > 100KB — search for unused imports or code that could be lazy-loaded
|
|
527
|
+
- Sequential waterfalls: resources chained sequentially that could load in parallel
|
|
528
|
+
|
|
529
|
+
### Runtime issues (from Chrome trace)
|
|
530
|
+
- 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
|
|
531
|
+
- Event listener leaks: check /trace/runtime/event-listeners.json for event types where addCount >> removeCount, then grep the scripts for those addEventListener calls
|
|
532
|
+
- GC pressure: high GC pause counts or duration suggest excessive short-lived object creation — look for hot loops creating objects
|
|
533
|
+
- Layout thrashing: forced synchronous layouts caused by reading layout properties (offsetHeight, getBoundingClientRect) after DOM writes
|
|
534
|
+
|
|
535
|
+
## Output guidelines
|
|
536
|
+
|
|
537
|
+
- Report 3–7 findings, ordered by impact (mix of memory, page-load, and runtime if all have issues)
|
|
538
|
+
- Be specific — name actual files, functions, object constructors, and retention paths
|
|
539
|
+
- Provide concrete code fixes, not generic advice
|
|
540
|
+
- If heap, trace, and runtime all look healthy, say so — don't manufacture issues`;
|
|
541
|
+
|
|
542
|
+
// src/vitest/prompts.ts
|
|
543
|
+
var VITEST_SYSTEM_PROMPT = `You are an expert in JavaScript/TypeScript performance optimization.
|
|
544
|
+
You have access to a workspace containing V8 CPU profiling data captured during
|
|
545
|
+
a Vitest test run. The workspace may also include V8 heap profiling data
|
|
546
|
+
captured via Node's \`--heap-prof\` (allocation sampling).
|
|
547
|
+
|
|
548
|
+
The profiling data covers BOTH the test code AND the application code being tested.
|
|
549
|
+
|
|
550
|
+
**Your primary goal is to analyze the PERFORMANCE OF THE APPLICATION CODE
|
|
551
|
+
being tested** — the functions, modules, and algorithms that the developer
|
|
552
|
+
wrote and is benchmarking or testing. Test infrastructure overhead (Vitest,
|
|
553
|
+
tinybench, test setup) is secondary context.
|
|
554
|
+
|
|
555
|
+
## Source categories
|
|
556
|
+
|
|
557
|
+
Every hot function and script in the workspace has a \`sourceCategory\` field:
|
|
558
|
+
|
|
559
|
+
- **application** — Code in the user's project (the code being tested).
|
|
560
|
+
This is your PRIMARY focus. Find bottlenecks, inefficiencies, and
|
|
561
|
+
optimization opportunities in these functions.
|
|
562
|
+
- **dependency** — Third-party code in node_modules. Report when a dependency
|
|
563
|
+
is a significant bottleneck, since the developer may be able to choose
|
|
564
|
+
an alternative, configure it differently, or avoid calling it in hot paths.
|
|
565
|
+
- **test** — Test files. Only mention if the test setup itself is creating
|
|
566
|
+
artificial overhead that masks application performance.
|
|
567
|
+
- **framework** — Vitest/tinybench/V8 internals. Generally ignore unless
|
|
568
|
+
they dominate the profile in an unexpected way.
|
|
569
|
+
|
|
570
|
+
## Workspace structure
|
|
571
|
+
|
|
572
|
+
- /summary.json — Overall test run: total tests, duration, pass/fail, GC stats
|
|
573
|
+
- /timing/overview.json — Per-file test durations and individual test times
|
|
574
|
+
- /timing/slow-tests.json — Tests exceeding the slow threshold
|
|
575
|
+
- /profiles/index.json — Manifest mapping test files to their CPU profiles
|
|
576
|
+
- /profiles/<file>.json — CPU profile summary: hot functions (with sourceCategory),
|
|
577
|
+
call trees, GC samples, script breakdown (with sourceCategory)
|
|
578
|
+
- /heap-profiles/index.json — (optional) Manifest mapping test files to heap profiles
|
|
579
|
+
- /heap-profiles/<file>.json — (optional) Heap profile summary: allocation hotspots,
|
|
580
|
+
per-script allocated bytes (with sourceCategory)
|
|
581
|
+
- /hot-functions/application.json — **START HERE**: Hot functions from application code only
|
|
582
|
+
- /hot-functions/dependencies.json — Hot functions from third-party dependencies
|
|
583
|
+
- /hot-functions/global.json — All hot functions across all categories
|
|
584
|
+
- /scripts/application.json — Per-script time breakdown for application code
|
|
585
|
+
- /scripts/dependencies.json — Per-script time breakdown for dependencies
|
|
586
|
+
- /tests/*.ts — Test source files
|
|
587
|
+
- /src/*.ts — Application and dependency source files referenced by hot functions
|
|
588
|
+
|
|
589
|
+
## Your workflow
|
|
590
|
+
|
|
591
|
+
1. Read /hot-functions/application.json FIRST — these are the application-level
|
|
592
|
+
bottlenecks the developer wants to optimize
|
|
593
|
+
2. Read /scripts/application.json for the per-file view of application code time
|
|
594
|
+
3. Read /hot-functions/dependencies.json for costly dependency calls
|
|
595
|
+
4. If present, read /heap-profiles/index.json and /heap-profiles/<file>.json to identify
|
|
596
|
+
allocation hotspots (functions/scripts allocating lots of bytes)
|
|
597
|
+
5. Read /summary.json and /timing/overview.json for the big picture
|
|
598
|
+
6. Read CPU profiles in /profiles/ for detailed call trees of the slowest tests
|
|
599
|
+
7. Read the actual source code in /src/ and /tests/ to understand root causes
|
|
600
|
+
8. Provide specific, actionable fixes targeting the application code
|
|
601
|
+
|
|
602
|
+
## What to look for
|
|
603
|
+
|
|
604
|
+
### Application code bottlenecks (PRIMARY FOCUS)
|
|
605
|
+
- Functions with high self time — where is the application spending CPU?
|
|
606
|
+
- Expensive algorithms: O(n²) loops, unnecessary sorting, repeated work
|
|
607
|
+
- String/JSON operations: excessive serialization, string concatenation in loops
|
|
608
|
+
- Object allocation hotspots: functions creating many short-lived objects
|
|
609
|
+
- Synchronous blocking: file I/O, crypto, or compression in hot paths
|
|
610
|
+
- Redundant computation: values computed repeatedly that could be cached/memoized
|
|
611
|
+
- Data structure choices: using arrays where Maps/Sets would be O(1)
|
|
612
|
+
|
|
613
|
+
### Dependency-related bottlenecks
|
|
614
|
+
- Dependencies consuming disproportionate CPU — suggest alternatives or configuration
|
|
615
|
+
- Unnecessary calls to expensive dependency APIs in hot paths
|
|
616
|
+
- Dependencies pulled in for simple operations that could be hand-written
|
|
617
|
+
|
|
618
|
+
### GC pressure from application code
|
|
619
|
+
- Application functions creating many temporary objects in tight loops
|
|
620
|
+
- Large array/object allocations that could be pooled or reused
|
|
621
|
+
- Closures capturing large scopes unnecessarily
|
|
622
|
+
|
|
623
|
+
### Allocation hotspots (from heap profiles, if present)
|
|
624
|
+
- Functions allocating a large share of total bytes (even if CPU isn't dominant)
|
|
625
|
+
- Scripts/modules responsible for most allocation — suggest caching, reuse, pooling,
|
|
626
|
+
or avoiding intermediate arrays/objects
|
|
627
|
+
- When allocation hotspots match CPU hotspots, prioritize fixes there first
|
|
628
|
+
|
|
629
|
+
### Call chain analysis
|
|
630
|
+
- Trace expensive call trees to find which APPLICATION function triggers them
|
|
631
|
+
- Follow the call tree from application entry points down to the hot leaf functions
|
|
632
|
+
- Identify which application-level design decisions lead to the bottleneck
|
|
633
|
+
|
|
634
|
+
### Test infrastructure (SECONDARY — only if impactful)
|
|
635
|
+
- Test setup creating artificial overhead that dwarfs application execution
|
|
636
|
+
- Benchmarks measuring setup cost instead of application performance
|
|
637
|
+
- Only mention if it prevents getting clean application performance data
|
|
638
|
+
|
|
639
|
+
## Finding categories
|
|
640
|
+
|
|
641
|
+
Each finding MUST use one of these exact category values:
|
|
642
|
+
|
|
643
|
+
- **algorithm** — Inefficient algorithm: O(n²) loops, brute-force search, repeated work
|
|
644
|
+
- **serialization** — Excessive JSON.stringify/parse, string concatenation, encoding
|
|
645
|
+
- **allocation** — Excessive object/array creation causing GC pressure
|
|
646
|
+
- **event-handling** — Listener leaks, unbounded event handler accumulation
|
|
647
|
+
- **hot-function** — Generic CPU-hot function that doesn't fit a more specific category
|
|
648
|
+
- **gc-pressure** — High garbage collection overhead
|
|
649
|
+
- **listener-leak** — Event listeners not cleaned up properly
|
|
650
|
+
- **unnecessary-computation** — Redundant work that could be cached or eliminated
|
|
651
|
+
- **blocking-io** — Synchronous I/O or blocking operations in hot paths
|
|
652
|
+
- **dependency-bottleneck** — Expensive dependency call the developer can optimize
|
|
653
|
+
- **slow-test** — Test itself is slow due to setup or teardown
|
|
654
|
+
- **expensive-setup** — Costly test setup (beforeAll/beforeEach)
|
|
655
|
+
- **import-overhead** — Expensive module imports at test time
|
|
656
|
+
- **other** — Doesn't fit any of the above
|
|
657
|
+
|
|
658
|
+
Prefer more specific categories (algorithm, serialization, allocation, event-handling)
|
|
659
|
+
over generic ones (hot-function, other) when the root cause is clear.
|
|
660
|
+
|
|
661
|
+
## Output guidelines
|
|
662
|
+
|
|
663
|
+
- Report 3–7 findings, ordered by impact ON THE APPLICATION CODE
|
|
664
|
+
- Focus findings on functions the developer CAN change (application code first,
|
|
665
|
+
then dependency usage patterns, then test structure)
|
|
666
|
+
- Be specific — name actual files, functions, line numbers from the source code
|
|
667
|
+
- Provide concrete code-level fixes, not generic advice
|
|
668
|
+
- When reporting a dependency bottleneck, explain what application code is
|
|
669
|
+
calling it and how the developer can reduce that cost
|
|
670
|
+
- If the application code is already efficient, say so — don't force findings
|
|
671
|
+
about test infrastructure just to fill the report`;
|
|
672
|
+
|
|
673
|
+
// src/schema.ts
|
|
674
|
+
import { z } from "zod";
|
|
675
|
+
var ALL_CATEGORIES = [
|
|
676
|
+
"memory-leak",
|
|
677
|
+
"large-retained-object",
|
|
678
|
+
"detached-dom",
|
|
679
|
+
"render-blocking",
|
|
680
|
+
"long-task",
|
|
681
|
+
"unused-code",
|
|
682
|
+
"waterfall-bottleneck",
|
|
683
|
+
"large-asset",
|
|
684
|
+
"frame-blocking-function",
|
|
685
|
+
"listener-leak",
|
|
686
|
+
"gc-pressure",
|
|
687
|
+
"slow-test",
|
|
688
|
+
"expensive-setup",
|
|
689
|
+
"hot-function",
|
|
690
|
+
"unnecessary-computation",
|
|
691
|
+
"import-overhead",
|
|
692
|
+
"dependency-bottleneck",
|
|
693
|
+
"algorithm",
|
|
694
|
+
"serialization",
|
|
695
|
+
"allocation",
|
|
696
|
+
"event-handling",
|
|
697
|
+
"blocking-io",
|
|
698
|
+
"other"
|
|
699
|
+
];
|
|
700
|
+
var FindingSchema = z.object({
|
|
701
|
+
severity: z.enum(["critical", "warning", "info"]),
|
|
702
|
+
title: z.string().describe("Short title for the finding"),
|
|
703
|
+
description: z.string().describe("Detailed explanation of the issue"),
|
|
704
|
+
category: z.string().describe(`Category of the performance issue. Use one of: ${ALL_CATEGORIES.join(", ")}`),
|
|
705
|
+
resourceUrl: z.string().optional().describe("URL of the resource involved"),
|
|
706
|
+
workspacePath: z.string().optional().describe("Path in the VFS workspace"),
|
|
707
|
+
impactMs: z.number().optional().describe("Impact on page load time in ms"),
|
|
708
|
+
retainedSize: z.number().optional().describe("Retained heap size in bytes"),
|
|
709
|
+
retainerPath: z.array(z.string()).optional().describe("Object retention path in the heap"),
|
|
710
|
+
suggestedFix: z.string().describe("Code snippet or guidance to fix the issue"),
|
|
711
|
+
testFile: z.string().optional().describe("Test file path (for test performance findings)"),
|
|
712
|
+
hotFunction: z.object({
|
|
713
|
+
name: z.string(),
|
|
714
|
+
scriptUrl: z.string(),
|
|
715
|
+
lineNumber: z.number(),
|
|
716
|
+
selfTime: z.number(),
|
|
717
|
+
selfPercent: z.number()
|
|
718
|
+
}).optional().describe("Hot function details (for hot-function findings)")
|
|
719
|
+
});
|
|
720
|
+
var FindingsSchema = z.object({
|
|
721
|
+
findings: z.array(FindingSchema)
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// src/analysis/agent.ts
|
|
725
|
+
async function analyze(model, backend) {
|
|
726
|
+
const agent = createDeepAgent({
|
|
727
|
+
model,
|
|
728
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
729
|
+
backend,
|
|
730
|
+
responseFormat: providerStrategy(FindingsSchema)
|
|
731
|
+
});
|
|
732
|
+
const userMessage = [
|
|
733
|
+
"Analyze the frontend performance data in this workspace.",
|
|
734
|
+
"",
|
|
735
|
+
"The workspace contains heap snapshot data, page-load trace data, and Chrome runtime trace data.",
|
|
736
|
+
"",
|
|
737
|
+
"Start by reading /heap/summary.json, /trace/summary.json, and /trace/runtime/summary.json",
|
|
738
|
+
"to understand the overall picture. Then explore:",
|
|
739
|
+
"",
|
|
740
|
+
"- /trace/network-waterfall.json for request timing",
|
|
741
|
+
"- /trace/runtime/blocking-functions.json for function-level main thread blocking",
|
|
742
|
+
"- /trace/runtime/event-listeners.json for listener add/remove imbalances",
|
|
743
|
+
"- /trace/runtime/frame-breakdown.json for frame breakdown (scripting vs layout vs paint vs GC)",
|
|
744
|
+
"- /scripts/ for the actual JavaScript source code",
|
|
745
|
+
"- /styles/ for CSS source",
|
|
746
|
+
"- /html/document.html for the page markup",
|
|
747
|
+
"",
|
|
748
|
+
"Look for memory issues (from the heap data), page-load issues (from the network trace),",
|
|
749
|
+
"and runtime issues (from the Chrome trace — blocking functions, listener leaks, GC pressure).",
|
|
750
|
+
"When you find a problem, read the actual source file to provide a specific, code-level fix."
|
|
751
|
+
].join(`
|
|
752
|
+
`);
|
|
753
|
+
const result = await agent.invoke({
|
|
754
|
+
messages: [{ role: "user", content: userMessage }]
|
|
755
|
+
});
|
|
756
|
+
return result.structuredResponse.findings;
|
|
757
|
+
}
|
|
758
|
+
async function analyzeTestPerformance(model, backend) {
|
|
759
|
+
const agent = createDeepAgent({
|
|
760
|
+
model,
|
|
761
|
+
systemPrompt: VITEST_SYSTEM_PROMPT,
|
|
762
|
+
backend,
|
|
763
|
+
responseFormat: providerStrategy(FindingsSchema)
|
|
764
|
+
});
|
|
765
|
+
const userMessage = [
|
|
766
|
+
"Analyze the performance of the APPLICATION CODE being tested in this Vitest workspace.",
|
|
767
|
+
"",
|
|
768
|
+
"Follow this order:",
|
|
769
|
+
"1. Read /hot-functions/application.json — these are the hotspots IN the user's own code",
|
|
770
|
+
"2. Read /scripts/application.json — per-file CPU time for application source files",
|
|
771
|
+
"3. Read /hot-functions/dependencies.json — expensive dependency calls",
|
|
772
|
+
"4. If present, read /heap-profiles/index.json and /heap-profiles/<file>.json for allocation hotspots",
|
|
773
|
+
"5. Read /summary.json and /timing/overview.json for the big picture",
|
|
774
|
+
"6. Read CPU profiles in /profiles/ for detailed call trees",
|
|
775
|
+
"7. Read source files in /src/ and /tests/ to understand root causes and propose code-level fixes",
|
|
776
|
+
"",
|
|
777
|
+
"Focus findings on the APPLICATION code — what can the developer change in their own codebase",
|
|
778
|
+
"to improve performance? Dependency bottlenecks are worth reporting if the developer can",
|
|
779
|
+
"reduce how they call the dependency or choose an alternative."
|
|
780
|
+
].join(`
|
|
781
|
+
`);
|
|
782
|
+
const result = await agent.invoke({
|
|
783
|
+
messages: [{ role: "user", content: userMessage }]
|
|
784
|
+
});
|
|
785
|
+
return result.structuredResponse.findings;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// src/models/init.ts
|
|
789
|
+
import { ChatOpenAI } from "@langchain/openai";
|
|
790
|
+
import { ChatAnthropic } from "@langchain/anthropic";
|
|
791
|
+
function initModel() {
|
|
792
|
+
const modelOverride = process.env.ZEITZEUGE_MODEL;
|
|
793
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
794
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
795
|
+
if (openaiKey) {
|
|
796
|
+
return new ChatOpenAI({
|
|
797
|
+
model: modelOverride ?? "gpt-5.2",
|
|
798
|
+
apiKey: openaiKey
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
if (anthropicKey) {
|
|
802
|
+
return new ChatAnthropic({
|
|
803
|
+
model: modelOverride ?? "claude-opus-4-6",
|
|
804
|
+
apiKey: anthropicKey
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
throw new Error(`No API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY in your environment.
|
|
808
|
+
|
|
809
|
+
` + ` export OPENAI_API_KEY=sk-...
|
|
810
|
+
` + ` # or
|
|
811
|
+
` + ` export ANTHROPIC_API_KEY=sk-ant-...
|
|
812
|
+
`);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/output/terminal.ts
|
|
816
|
+
import chalk from "chalk";
|
|
817
|
+
import ora from "ora";
|
|
818
|
+
var SEVERITY_ICONS = {
|
|
819
|
+
critical: chalk.red("\uD83D\uDD34 CRITICAL"),
|
|
820
|
+
warning: chalk.yellow("\uD83D\uDFE1 WARNING"),
|
|
821
|
+
info: chalk.green("\uD83D\uDFE2 INFO")
|
|
822
|
+
};
|
|
823
|
+
var CATEGORY_LABELS = {
|
|
824
|
+
"memory-leak": "Memory Leak",
|
|
825
|
+
"large-retained-object": "Large Retained Object",
|
|
826
|
+
"detached-dom": "Detached DOM",
|
|
827
|
+
"render-blocking": "Render-Blocking",
|
|
828
|
+
"long-task": "Long Task",
|
|
829
|
+
"unused-code": "Unused Code",
|
|
830
|
+
"waterfall-bottleneck": "Waterfall Bottleneck",
|
|
831
|
+
"large-asset": "Large Asset",
|
|
832
|
+
"frame-blocking-function": "Frame-Blocking Function",
|
|
833
|
+
"listener-leak": "Listener Leak",
|
|
834
|
+
"gc-pressure": "GC Pressure",
|
|
835
|
+
"slow-test": "Slow Test",
|
|
836
|
+
"expensive-setup": "Expensive Setup",
|
|
837
|
+
"hot-function": "Hot Function",
|
|
838
|
+
"unnecessary-computation": "Unnecessary Computation",
|
|
839
|
+
"import-overhead": "Import Overhead",
|
|
840
|
+
"dependency-bottleneck": "Dependency Bottleneck",
|
|
841
|
+
algorithm: "Inefficient Algorithm",
|
|
842
|
+
serialization: "Serialization Overhead",
|
|
843
|
+
allocation: "Excessive Allocation",
|
|
844
|
+
"event-handling": "Event Handling",
|
|
845
|
+
"blocking-io": "Blocking I/O",
|
|
846
|
+
other: "Other"
|
|
847
|
+
};
|
|
848
|
+
function printHeader(url, version) {
|
|
849
|
+
const urlDisplay = url.length > 44 ? url.slice(0, 41) + "..." : url;
|
|
850
|
+
console.log(chalk.cyan(`
|
|
851
|
+
┌${"─".repeat(57)}┐
|
|
852
|
+
` + `│ zeitzeuge v${version.padEnd(44)}│
|
|
853
|
+
` + `│ Analyzing: ${urlDisplay.padEnd(44)}│
|
|
854
|
+
` + `└${"─".repeat(57)}┘
|
|
855
|
+
`));
|
|
856
|
+
}
|
|
857
|
+
function createSpinner(text) {
|
|
858
|
+
return ora({ text, color: "cyan" }).start();
|
|
859
|
+
}
|
|
860
|
+
function printFindings(findings) {
|
|
861
|
+
console.log(chalk.dim(`
|
|
862
|
+
` + "━".repeat(58) + `
|
|
863
|
+
`));
|
|
864
|
+
if (findings.length === 0) {
|
|
865
|
+
console.log(chalk.green(` ✔ No significant performance issues found. Page looks healthy!
|
|
866
|
+
`));
|
|
867
|
+
console.log(chalk.dim("━".repeat(58)));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
for (const finding of findings) {
|
|
871
|
+
const icon = SEVERITY_ICONS[finding.severity];
|
|
872
|
+
const categoryLabel = CATEGORY_LABELS[finding.category] ?? finding.category;
|
|
873
|
+
console.log(`${icon} [${categoryLabel}]: ${chalk.bold(finding.title)}`);
|
|
874
|
+
if (finding.retainedSize != null) {
|
|
875
|
+
console.log(chalk.dim(` Retained size: ${formatBytes(finding.retainedSize)}`));
|
|
876
|
+
}
|
|
877
|
+
if (finding.impactMs != null) {
|
|
878
|
+
console.log(chalk.dim(` Impact: ${finding.impactMs.toFixed(0)}ms`));
|
|
879
|
+
}
|
|
880
|
+
if (finding.resourceUrl) {
|
|
881
|
+
console.log(chalk.dim(` Resource: ${finding.resourceUrl}`));
|
|
882
|
+
}
|
|
883
|
+
if (finding.retainerPath && finding.retainerPath.length > 0) {
|
|
884
|
+
console.log(chalk.dim(` Path: ${finding.retainerPath.join(" → ")}`));
|
|
885
|
+
}
|
|
886
|
+
if (finding.testFile) {
|
|
887
|
+
console.log(chalk.dim(` Test file: ${finding.testFile}`));
|
|
888
|
+
}
|
|
889
|
+
if (finding.hotFunction) {
|
|
890
|
+
const hf = finding.hotFunction;
|
|
891
|
+
console.log(chalk.dim(` Function: ${hf.name} at ${hf.scriptUrl}:${hf.lineNumber} (selfTime: ${hf.selfTime.toFixed(0)}ms, ${hf.selfPercent.toFixed(1)}%)`));
|
|
892
|
+
}
|
|
893
|
+
console.log(`
|
|
894
|
+
${finding.description}
|
|
895
|
+
`);
|
|
896
|
+
if (finding.suggestedFix) {
|
|
897
|
+
console.log(chalk.dim(" Suggested fix:"));
|
|
898
|
+
const lines = finding.suggestedFix.split(`
|
|
899
|
+
`);
|
|
900
|
+
const boxWidth = Math.max(...lines.map((l) => l.length), 20) + 4;
|
|
901
|
+
console.log(chalk.dim(` ┌${"─".repeat(boxWidth)}┐`));
|
|
902
|
+
for (const line of lines) {
|
|
903
|
+
console.log(chalk.dim(" │ ") + chalk.white(line.padEnd(boxWidth - 2)) + chalk.dim(" │"));
|
|
904
|
+
}
|
|
905
|
+
console.log(chalk.dim(` └${"─".repeat(boxWidth)}┘`));
|
|
906
|
+
}
|
|
907
|
+
console.log();
|
|
908
|
+
}
|
|
909
|
+
console.log(chalk.dim("━".repeat(58)));
|
|
910
|
+
const counts = {
|
|
911
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
912
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
913
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
914
|
+
};
|
|
915
|
+
console.log(`
|
|
916
|
+
Summary: ${chalk.red(`${counts.critical} critical`)}, ` + `${chalk.yellow(`${counts.warning} warning`)}, ` + `${chalk.green(`${counts.info} info`)}
|
|
917
|
+
`);
|
|
918
|
+
}
|
|
919
|
+
function printCaptureInfo(heapSummary, trace) {
|
|
920
|
+
console.log(chalk.dim(`Heap: ${formatBytes(heapSummary.metadata.totalSize)} | ` + `Nodes: ${heapSummary.metadata.nodeCount.toLocaleString()} | ` + `Requests: ${trace.networkRequests.length} | ` + `Long tasks: ${trace.metrics.longTasks.length}`));
|
|
921
|
+
}
|
|
922
|
+
function printError(err) {
|
|
923
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
924
|
+
console.error(chalk.red(`
|
|
925
|
+
✖ Error: ${message}
|
|
926
|
+
`));
|
|
927
|
+
}
|
|
928
|
+
function formatBytes(bytes) {
|
|
929
|
+
if (bytes < 1024)
|
|
930
|
+
return `${bytes} B`;
|
|
931
|
+
if (bytes < 1024 * 1024)
|
|
932
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
933
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// src/output/report.ts
|
|
937
|
+
import { writeFileSync as writeFileSync2 } from "node:fs";
|
|
938
|
+
var SEVERITY_EMOJI = {
|
|
939
|
+
critical: "\uD83D\uDD34",
|
|
940
|
+
warning: "\uD83D\uDFE1",
|
|
941
|
+
info: "ℹ️"
|
|
942
|
+
};
|
|
943
|
+
var CATEGORY_LABELS2 = {
|
|
944
|
+
"memory-leak": "Memory Leak",
|
|
945
|
+
"large-retained-object": "Large Retained Object",
|
|
946
|
+
"detached-dom": "Detached DOM",
|
|
947
|
+
"render-blocking": "Render-Blocking",
|
|
948
|
+
"long-task": "Long Task",
|
|
949
|
+
"unused-code": "Unused Code",
|
|
950
|
+
"waterfall-bottleneck": "Waterfall Bottleneck",
|
|
951
|
+
"large-asset": "Large Asset",
|
|
952
|
+
"frame-blocking-function": "Frame-Blocking Function",
|
|
953
|
+
"listener-leak": "Listener Leak",
|
|
954
|
+
"gc-pressure": "GC Pressure",
|
|
955
|
+
"slow-test": "Slow Test",
|
|
956
|
+
"expensive-setup": "Expensive Setup",
|
|
957
|
+
"hot-function": "Hot Function",
|
|
958
|
+
"unnecessary-computation": "Unnecessary Computation",
|
|
959
|
+
"import-overhead": "Import Overhead",
|
|
960
|
+
"dependency-bottleneck": "Dependency Bottleneck",
|
|
961
|
+
algorithm: "Inefficient Algorithm",
|
|
962
|
+
serialization: "Serialization Overhead",
|
|
963
|
+
allocation: "Excessive Allocation",
|
|
964
|
+
"event-handling": "Event Handling",
|
|
965
|
+
"blocking-io": "Blocking I/O",
|
|
966
|
+
other: "Other"
|
|
967
|
+
};
|
|
968
|
+
function writeReport(outputPath, options) {
|
|
969
|
+
const md = generateMarkdown(options);
|
|
970
|
+
writeFileSync2(outputPath, md, "utf-8");
|
|
971
|
+
return outputPath;
|
|
972
|
+
}
|
|
973
|
+
function generateMarkdown(options) {
|
|
974
|
+
const { url, version, findings, heapSummary, trace } = options;
|
|
975
|
+
const now = new Date;
|
|
976
|
+
const sections = [];
|
|
977
|
+
sections.push(`# Performance Report`);
|
|
978
|
+
sections.push("");
|
|
979
|
+
sections.push(`> **${url}** — analyzed ${now.toISOString().replace("T", " ").slice(0, 16)} UTC by zeitzeuge v${version}`);
|
|
980
|
+
sections.push("");
|
|
981
|
+
const totalTransfer = trace.networkRequests.reduce((s, r) => s + r.encodedSize, 0);
|
|
982
|
+
const loadSec = (trace.metrics.loadComplete / 1000).toFixed(1);
|
|
983
|
+
const fcpSec = (trace.metrics.firstContentfulPaint / 1000).toFixed(2);
|
|
984
|
+
const tbt = trace.metrics.totalBlockingTime.toFixed(0);
|
|
985
|
+
const heapSize = formatBytes(heapSummary.metadata.totalSize);
|
|
986
|
+
const reqCount = trace.networkRequests.length;
|
|
987
|
+
sections.push(`**Page load** ${loadSec}s · **FCP** ${fcpSec}s · **TBT** ${tbt}ms · ` + `**Heap** ${heapSize} · **${reqCount} requests** (${formatBytes(totalTransfer)} transferred)`);
|
|
988
|
+
sections.push("");
|
|
989
|
+
const counts = {
|
|
990
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
991
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
992
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
993
|
+
};
|
|
994
|
+
if (findings.length === 0) {
|
|
995
|
+
sections.push(`## ✅ No issues found`);
|
|
996
|
+
sections.push("");
|
|
997
|
+
sections.push(`No significant performance problems were detected. ` + `The page loads in ${loadSec}s with ${tbt}ms of total blocking time — looking healthy.`);
|
|
998
|
+
sections.push("");
|
|
999
|
+
} else {
|
|
1000
|
+
sections.push(`**${findings.length} issues found** — ` + `${counts.critical} critical, ${counts.warning} warning, ${counts.info} info`);
|
|
1001
|
+
sections.push("");
|
|
1002
|
+
for (let i = 0;i < findings.length; i++) {
|
|
1003
|
+
const f = findings[i];
|
|
1004
|
+
if (!f)
|
|
1005
|
+
continue;
|
|
1006
|
+
const emoji = SEVERITY_EMOJI[f.severity];
|
|
1007
|
+
const categoryLabel = CATEGORY_LABELS2[f.category] ?? f.category;
|
|
1008
|
+
sections.push(`---`);
|
|
1009
|
+
sections.push("");
|
|
1010
|
+
sections.push(`## ${emoji} ${f.title}`);
|
|
1011
|
+
sections.push("");
|
|
1012
|
+
const context = [`**${categoryLabel}**`];
|
|
1013
|
+
if (f.impactMs != null)
|
|
1014
|
+
context.push(`${f.impactMs.toFixed(0)}ms impact`);
|
|
1015
|
+
if (f.retainedSize != null)
|
|
1016
|
+
context.push(`${formatBytes(f.retainedSize)} retained`);
|
|
1017
|
+
if (f.resourceUrl)
|
|
1018
|
+
context.push(`\`${f.resourceUrl}\``);
|
|
1019
|
+
sections.push(context.join(" · "));
|
|
1020
|
+
sections.push("");
|
|
1021
|
+
sections.push(f.description);
|
|
1022
|
+
sections.push("");
|
|
1023
|
+
if (f.suggestedFix) {
|
|
1024
|
+
sections.push(`### How to fix`);
|
|
1025
|
+
sections.push("");
|
|
1026
|
+
const looksLikeCode = f.suggestedFix.includes("{") || f.suggestedFix.includes(";") || f.suggestedFix.includes("=>") || f.suggestedFix.includes("import ") || f.suggestedFix.includes("function ");
|
|
1027
|
+
if (looksLikeCode) {
|
|
1028
|
+
sections.push("```js");
|
|
1029
|
+
sections.push(f.suggestedFix);
|
|
1030
|
+
sections.push("```");
|
|
1031
|
+
} else {
|
|
1032
|
+
sections.push(f.suggestedFix);
|
|
1033
|
+
}
|
|
1034
|
+
sections.push("");
|
|
1035
|
+
}
|
|
1036
|
+
if (f.retainerPath && f.retainerPath.length > 0) {
|
|
1037
|
+
sections.push(`*Retention path:* ${f.retainerPath.map((p) => `\`${p}\``).join(" → ")}`);
|
|
1038
|
+
sections.push("");
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
sections.push(`---`);
|
|
1043
|
+
sections.push("");
|
|
1044
|
+
sections.push(`*Generated by zeitzeuge v${version}*`);
|
|
1045
|
+
sections.push("");
|
|
1046
|
+
return sections.join(`
|
|
1047
|
+
`);
|
|
1048
|
+
}
|
|
1049
|
+
function writeTestReport(outputPath, options) {
|
|
1050
|
+
const md = generateTestMarkdown(options);
|
|
1051
|
+
writeFileSync2(outputPath, md, "utf-8");
|
|
1052
|
+
return outputPath;
|
|
1053
|
+
}
|
|
1054
|
+
function generateTestMarkdown(options) {
|
|
1055
|
+
const { version, findings, testTiming, profiles } = options;
|
|
1056
|
+
const now = new Date;
|
|
1057
|
+
const sections = [];
|
|
1058
|
+
sections.push(`# Vitest Performance Report`);
|
|
1059
|
+
sections.push("");
|
|
1060
|
+
sections.push(`> Analyzed ${now.toISOString().replace("T", " ").slice(0, 16)} UTC by zeitzeuge v${version}`);
|
|
1061
|
+
sections.push("");
|
|
1062
|
+
const totalTests = testTiming.reduce((s, t) => s + t.testCount, 0);
|
|
1063
|
+
const totalFiles = testTiming.length;
|
|
1064
|
+
const totalDuration = testTiming.reduce((s, t) => s + t.duration, 0);
|
|
1065
|
+
const slowest = testTiming.length > 0 ? testTiming.reduce((a, b) => a.duration > b.duration ? a : b) : null;
|
|
1066
|
+
const totalGcTime = profiles.reduce((s, p) => s + p.summary.duration * p.summary.gcPercentage / 100, 0);
|
|
1067
|
+
const gcPercentage = totalDuration > 0 ? (totalGcTime / totalDuration * 100).toFixed(2) : "0";
|
|
1068
|
+
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)`);
|
|
1069
|
+
sections.push("");
|
|
1070
|
+
const counts = {
|
|
1071
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
1072
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
1073
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
1074
|
+
};
|
|
1075
|
+
if (findings.length === 0) {
|
|
1076
|
+
sections.push(`## ✅ No issues found`);
|
|
1077
|
+
sections.push("");
|
|
1078
|
+
sections.push(`No significant performance problems were detected. ` + `Tests complete in ${(totalDuration / 1000).toFixed(2)}s — looking healthy.`);
|
|
1079
|
+
sections.push("");
|
|
1080
|
+
} else {
|
|
1081
|
+
sections.push(`**${findings.length} issues found** — ` + `${counts.critical} critical, ${counts.warning} warning, ${counts.info} info`);
|
|
1082
|
+
sections.push("");
|
|
1083
|
+
for (const f of findings) {
|
|
1084
|
+
const emoji = SEVERITY_EMOJI[f.severity];
|
|
1085
|
+
const categoryLabel = CATEGORY_LABELS2[f.category] ?? f.category;
|
|
1086
|
+
sections.push(`---`);
|
|
1087
|
+
sections.push("");
|
|
1088
|
+
sections.push(`## ${emoji} ${f.title}`);
|
|
1089
|
+
sections.push("");
|
|
1090
|
+
const context = [`**${categoryLabel}**`];
|
|
1091
|
+
if (f.impactMs != null)
|
|
1092
|
+
context.push(`${f.impactMs.toFixed(0)}ms impact`);
|
|
1093
|
+
if (f.testFile)
|
|
1094
|
+
context.push(`\`${f.testFile}\``);
|
|
1095
|
+
if (f.hotFunction) {
|
|
1096
|
+
context.push(`\`${f.hotFunction.name}\` (${f.hotFunction.selfTime.toFixed(0)}ms, ${f.hotFunction.selfPercent.toFixed(1)}%)`);
|
|
1097
|
+
}
|
|
1098
|
+
if (f.resourceUrl)
|
|
1099
|
+
context.push(`\`${f.resourceUrl}\``);
|
|
1100
|
+
sections.push(context.join(" · "));
|
|
1101
|
+
sections.push("");
|
|
1102
|
+
sections.push(f.description);
|
|
1103
|
+
sections.push("");
|
|
1104
|
+
if (f.suggestedFix) {
|
|
1105
|
+
sections.push(`### How to fix`);
|
|
1106
|
+
sections.push("");
|
|
1107
|
+
const looksLikeCode = f.suggestedFix.includes("{") || f.suggestedFix.includes(";") || f.suggestedFix.includes("=>") || f.suggestedFix.includes("import ") || f.suggestedFix.includes("function ");
|
|
1108
|
+
if (looksLikeCode) {
|
|
1109
|
+
sections.push("```ts");
|
|
1110
|
+
sections.push(f.suggestedFix);
|
|
1111
|
+
sections.push("```");
|
|
1112
|
+
} else {
|
|
1113
|
+
sections.push(f.suggestedFix);
|
|
1114
|
+
}
|
|
1115
|
+
sections.push("");
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
sections.push(`---`);
|
|
1120
|
+
sections.push("");
|
|
1121
|
+
sections.push(`*Generated by zeitzeuge v${version}*`);
|
|
1122
|
+
sections.push("");
|
|
1123
|
+
return sections.join(`
|
|
1124
|
+
`);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// src/vitest/classify.ts
|
|
1128
|
+
import { resolve, relative } from "node:path";
|
|
1129
|
+
var TEST_FILE_PATTERNS = [/\.test\./, /\.spec\./, /\.bench\./, /__tests__\//, /__mocks__\//];
|
|
1130
|
+
var FRAMEWORK_PATTERNS = [
|
|
1131
|
+
/\/vitest\//,
|
|
1132
|
+
/\/tinybench\//,
|
|
1133
|
+
/\/vite\//,
|
|
1134
|
+
/\/@vitest\//,
|
|
1135
|
+
/node:internal\//,
|
|
1136
|
+
/node:v8/,
|
|
1137
|
+
/node:worker_threads/,
|
|
1138
|
+
/\.XdZDrNZV\./,
|
|
1139
|
+
/\.CJqBMi0u\./
|
|
1140
|
+
];
|
|
1141
|
+
function classifyScript(scriptUrl, projectRoot, testFiles) {
|
|
1142
|
+
if (!scriptUrl)
|
|
1143
|
+
return "unknown";
|
|
1144
|
+
let filePath = scriptUrl;
|
|
1145
|
+
if (filePath.startsWith("file://")) {
|
|
1146
|
+
try {
|
|
1147
|
+
filePath = new URL(filePath).pathname;
|
|
1148
|
+
} catch {}
|
|
1149
|
+
}
|
|
1150
|
+
if (filePath.startsWith("node:") || filePath.startsWith("v8:")) {
|
|
1151
|
+
return "framework";
|
|
1152
|
+
}
|
|
1153
|
+
for (const pattern of FRAMEWORK_PATTERNS) {
|
|
1154
|
+
if (pattern.test(filePath)) {
|
|
1155
|
+
return "framework";
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (filePath.includes("/node_modules/") || filePath.includes("\\node_modules\\")) {
|
|
1159
|
+
return "dependency";
|
|
1160
|
+
}
|
|
1161
|
+
if (testFiles) {
|
|
1162
|
+
const resolved = resolve(filePath);
|
|
1163
|
+
if (testFiles.has(resolved)) {
|
|
1164
|
+
return "test";
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
for (const pattern of TEST_FILE_PATTERNS) {
|
|
1168
|
+
if (pattern.test(filePath)) {
|
|
1169
|
+
return "test";
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
const resolvedProject = resolve(projectRoot);
|
|
1173
|
+
const resolvedFile = resolve(filePath);
|
|
1174
|
+
const rel = relative(resolvedProject, resolvedFile);
|
|
1175
|
+
if (!rel.startsWith("..") && !rel.startsWith("/")) {
|
|
1176
|
+
return "application";
|
|
1177
|
+
}
|
|
1178
|
+
return "unknown";
|
|
1179
|
+
}
|
|
1180
|
+
// package.json
|
|
1181
|
+
var package_default = {
|
|
1182
|
+
name: "zeitzeuge",
|
|
1183
|
+
version: "0.3.0",
|
|
1184
|
+
description: "A deepagent to witnessing slowdowns in your test runs.",
|
|
1185
|
+
keywords: [
|
|
1186
|
+
"analysis",
|
|
1187
|
+
"deepagent",
|
|
1188
|
+
"frontend",
|
|
1189
|
+
"performance",
|
|
1190
|
+
"zeitzeuge"
|
|
1191
|
+
],
|
|
1192
|
+
homepage: "https://github.com/christian-bromann/zeitzeuge",
|
|
1193
|
+
license: "MIT",
|
|
1194
|
+
author: "Christian Bromann <christian@bromann.dev>",
|
|
1195
|
+
repository: {
|
|
1196
|
+
type: "git",
|
|
1197
|
+
url: "https://github.com/christian-bromann/zeitzeuge.git"
|
|
1198
|
+
},
|
|
1199
|
+
bin: {
|
|
1200
|
+
zeitzeuge: "./dist/cli.js"
|
|
1201
|
+
},
|
|
1202
|
+
files: [
|
|
1203
|
+
"dist"
|
|
1204
|
+
],
|
|
1205
|
+
type: "module",
|
|
1206
|
+
module: "src/cli.ts",
|
|
1207
|
+
types: "./dist/cli.d.ts",
|
|
1208
|
+
exports: {
|
|
1209
|
+
".": {
|
|
1210
|
+
types: "./dist/cli.d.ts",
|
|
1211
|
+
default: "./dist/cli.js"
|
|
1212
|
+
},
|
|
1213
|
+
"./vitest": {
|
|
1214
|
+
types: "./dist/vitest/index.d.ts",
|
|
1215
|
+
default: "./dist/vitest/index.js"
|
|
1216
|
+
}
|
|
1217
|
+
},
|
|
1218
|
+
scripts: {
|
|
1219
|
+
"build:js": "bun build src/cli.ts src/vitest/index.ts --outdir dist --target node --format esm --packages external",
|
|
1220
|
+
"build:types": "bunx tsc -p tsconfig.build.json",
|
|
1221
|
+
build: "bun run build:js && bun run build:types",
|
|
1222
|
+
dev: "bun run src/cli.ts",
|
|
1223
|
+
fmt: "oxfmt",
|
|
1224
|
+
"fmt:check": "oxfmt --check",
|
|
1225
|
+
lint: "bun ./node_modules/oxlint/dist/cli.js",
|
|
1226
|
+
"lint:fix": "bun ./node_modules/oxlint/dist/cli.js --fix",
|
|
1227
|
+
test: "bun test"
|
|
1228
|
+
},
|
|
1229
|
+
dependencies: {
|
|
1230
|
+
"@langchain/anthropic": "^1.3.17",
|
|
1231
|
+
"@langchain/core": "^1.1.24",
|
|
1232
|
+
"@langchain/node-vfs": "^0.1",
|
|
1233
|
+
"@langchain/openai": "^1.2.7",
|
|
1234
|
+
chalk: "^5.6.2",
|
|
1235
|
+
deepagents: "^1.7.6",
|
|
1236
|
+
langchain: "^1.2.23",
|
|
1237
|
+
ora: "^9.3.0",
|
|
1238
|
+
"puppeteer-core": "^24.37.2",
|
|
1239
|
+
webdriverio: "^9.24.0",
|
|
1240
|
+
yargs: "^18.0.0",
|
|
1241
|
+
zod: "^4.3.6"
|
|
1242
|
+
},
|
|
1243
|
+
devDependencies: {
|
|
1244
|
+
"@types/bun": "latest",
|
|
1245
|
+
"@types/yargs": "^17",
|
|
1246
|
+
oxfmt: "^0.32.0",
|
|
1247
|
+
oxlint: "^1.47.0",
|
|
1248
|
+
typescript: "^5"
|
|
1249
|
+
},
|
|
1250
|
+
peerDependencies: {
|
|
1251
|
+
vitest: ">=3.1.0"
|
|
1252
|
+
},
|
|
1253
|
+
engines: {
|
|
1254
|
+
node: ">=24"
|
|
1255
|
+
},
|
|
1256
|
+
packageManager: "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
// src/vitest/reporter.ts
|
|
1260
|
+
class ZeitZeugeReporter {
|
|
1261
|
+
options;
|
|
1262
|
+
executionOrder = [];
|
|
1263
|
+
isCI = !!process.env.CI;
|
|
1264
|
+
constructor(options) {
|
|
1265
|
+
this.options = options;
|
|
1266
|
+
}
|
|
1267
|
+
onTestModuleStart(testModule) {
|
|
1268
|
+
const filePath = testModule?.moduleId ?? testModule?.id ?? "";
|
|
1269
|
+
if (filePath && !this.executionOrder.includes(filePath)) {
|
|
1270
|
+
this.executionOrder.push(filePath);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
async onTestRunEnd(testModules) {
|
|
1274
|
+
try {
|
|
1275
|
+
await this.runAnalysis(testModules);
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1278
|
+
console.error(chalk2.red(`
|
|
1279
|
+
[zeitzeuge] Analysis failed: ${message}
|
|
1280
|
+
`));
|
|
1281
|
+
if (this.options.verbose && err instanceof Error) {
|
|
1282
|
+
console.error(err.stack);
|
|
1283
|
+
}
|
|
1284
|
+
} finally {
|
|
1285
|
+
this.cleanupProfileDir();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
async runAnalysis(testModules) {
|
|
1289
|
+
const testTiming = this.collectTestTiming(testModules);
|
|
1290
|
+
if (testTiming.length === 0) {
|
|
1291
|
+
if (this.options.verbose) {
|
|
1292
|
+
console.log("[zeitzeuge] No test modules found, skipping analysis.");
|
|
1293
|
+
}
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const spinner = this.isCI ? null : ora2({ text: "zeitzeuge: Collecting CPU profiles...", color: "cyan" }).start();
|
|
1297
|
+
const profiles = this.collectAndParseProfiles(testTiming);
|
|
1298
|
+
const heapProfiles = this.collectAndParseHeapProfiles(testTiming);
|
|
1299
|
+
if (profiles.length === 0) {
|
|
1300
|
+
spinner?.warn("zeitzeuge: No .cpuprofile files found. " + "Try running with { verbose: true } for diagnostics.");
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
spinner?.succeed(`zeitzeuge: ${profiles.length} CPU profile(s) collected`);
|
|
1304
|
+
if (this.options.verbose && heapProfiles.length > 0) {
|
|
1305
|
+
console.log(`[zeitzeuge] ${heapProfiles.length} heap profile(s) collected`);
|
|
1306
|
+
}
|
|
1307
|
+
const testSources = this.readTestSources(testTiming);
|
|
1308
|
+
const sourcePaths = this.readHotFunctionSources(profiles);
|
|
1309
|
+
const wsSpinner = this.isCI ? null : ora2({ text: "zeitzeuge: Building analysis workspace...", color: "cyan" }).start();
|
|
1310
|
+
const workspace = await createVitestWorkspace({
|
|
1311
|
+
testTiming,
|
|
1312
|
+
profiles,
|
|
1313
|
+
heapProfiles,
|
|
1314
|
+
testSources,
|
|
1315
|
+
sourcePaths,
|
|
1316
|
+
projectRoot: this.options.projectRoot
|
|
1317
|
+
});
|
|
1318
|
+
wsSpinner?.succeed("zeitzeuge: Workspace ready");
|
|
1319
|
+
if (this.options.analyzeOnFinish) {
|
|
1320
|
+
const agentSpinner = this.isCI ? null : ora2({
|
|
1321
|
+
text: "zeitzeuge: Deep Agent analyzing test performance...",
|
|
1322
|
+
color: "cyan"
|
|
1323
|
+
}).start();
|
|
1324
|
+
try {
|
|
1325
|
+
const model = initModel();
|
|
1326
|
+
const findings = await analyzeTestPerformance(model, workspace.backend);
|
|
1327
|
+
agentSpinner?.succeed(`zeitzeuge: Analysis complete — ${findings.length} finding(s)`);
|
|
1328
|
+
console.log(chalk2.cyan(`
|
|
1329
|
+
${"─".repeat(3)} zeitzeuge Performance Analysis ${"─".repeat(3)}
|
|
1330
|
+
`));
|
|
1331
|
+
printFindings(findings);
|
|
1332
|
+
const version = this.getVersion();
|
|
1333
|
+
const reportPath = writeTestReport(this.options.output, {
|
|
1334
|
+
version,
|
|
1335
|
+
findings,
|
|
1336
|
+
testTiming,
|
|
1337
|
+
profiles
|
|
1338
|
+
});
|
|
1339
|
+
console.log(`
|
|
1340
|
+
\uD83D\uDCC4 Report written to ${reportPath}
|
|
1341
|
+
`);
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1344
|
+
if (message.includes("API key") || message.includes("OPENAI_API_KEY") || message.includes("ANTHROPIC_API_KEY")) {
|
|
1345
|
+
agentSpinner?.warn("zeitzeuge: No LLM API key found. Set OPENAI_API_KEY or ANTHROPIC_API_KEY for AI-powered analysis.");
|
|
1346
|
+
} else {
|
|
1347
|
+
agentSpinner?.fail(`zeitzeuge: Analysis failed — ${message}`);
|
|
1348
|
+
throw err;
|
|
1349
|
+
}
|
|
1350
|
+
} finally {
|
|
1351
|
+
workspace.cleanup();
|
|
1352
|
+
}
|
|
1353
|
+
} else {
|
|
1354
|
+
workspace.cleanup();
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
collectTestTiming(testModules) {
|
|
1358
|
+
const results = [];
|
|
1359
|
+
for (const mod of testModules) {
|
|
1360
|
+
const filePath = mod.moduleId ?? mod.id ?? "";
|
|
1361
|
+
if (!filePath)
|
|
1362
|
+
continue;
|
|
1363
|
+
const tests = [];
|
|
1364
|
+
let passCount = 0;
|
|
1365
|
+
let failCount = 0;
|
|
1366
|
+
if (mod.children) {
|
|
1367
|
+
this.walkTestCases(mod.children, tests);
|
|
1368
|
+
}
|
|
1369
|
+
for (const t of tests) {
|
|
1370
|
+
if (t.status === "pass")
|
|
1371
|
+
passCount++;
|
|
1372
|
+
else if (t.status === "fail")
|
|
1373
|
+
failCount++;
|
|
1374
|
+
}
|
|
1375
|
+
const diagnostic = typeof mod.diagnostic === "function" ? mod.diagnostic() : mod.diagnostic;
|
|
1376
|
+
const duration = diagnostic?.duration ?? tests.reduce((s, t) => s + t.duration, 0);
|
|
1377
|
+
const setupTime = diagnostic?.setupDuration ?? 0;
|
|
1378
|
+
results.push({
|
|
1379
|
+
file: filePath,
|
|
1380
|
+
duration,
|
|
1381
|
+
testCount: tests.length,
|
|
1382
|
+
passCount,
|
|
1383
|
+
failCount,
|
|
1384
|
+
setupTime,
|
|
1385
|
+
tests
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
return results;
|
|
1389
|
+
}
|
|
1390
|
+
walkTestCases(children, results) {
|
|
1391
|
+
for (const child of children) {
|
|
1392
|
+
if (child.type === "test" || child.type === "case") {
|
|
1393
|
+
const diagnostic = typeof child.diagnostic === "function" ? child.diagnostic() : child.diagnostic;
|
|
1394
|
+
results.push({
|
|
1395
|
+
name: child.fullName ?? child.name ?? "",
|
|
1396
|
+
duration: diagnostic?.duration ?? 0,
|
|
1397
|
+
status: child.result?.state === "passed" ? "pass" : child.result?.state === "failed" ? "fail" : "skip"
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
if (child.children) {
|
|
1401
|
+
this.walkTestCases(child.children, results);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
collectAndParseProfiles(testTiming) {
|
|
1406
|
+
const { profileDir } = this.options;
|
|
1407
|
+
if (!existsSync(profileDir)) {
|
|
1408
|
+
if (this.options.verbose) {
|
|
1409
|
+
console.log(`[zeitzeuge] Profile directory not found: ${profileDir}`);
|
|
1410
|
+
}
|
|
1411
|
+
return [];
|
|
1412
|
+
}
|
|
1413
|
+
const allFiles = readdirSync(profileDir);
|
|
1414
|
+
if (this.options.verbose) {
|
|
1415
|
+
console.log(`[zeitzeuge] Profile directory ${profileDir} contains ${allFiles.length} file(s): ${allFiles.join(", ") || "(empty)"}`);
|
|
1416
|
+
}
|
|
1417
|
+
const profileFiles = allFiles.filter((f) => f.endsWith(".cpuprofile")).map((f) => {
|
|
1418
|
+
const fullPath = join2(profileDir, f);
|
|
1419
|
+
try {
|
|
1420
|
+
const stat = statSync(fullPath);
|
|
1421
|
+
return { name: f, path: fullPath, lastModified: stat.mtimeMs };
|
|
1422
|
+
} catch {
|
|
1423
|
+
return { name: f, path: fullPath, lastModified: 0 };
|
|
1424
|
+
}
|
|
1425
|
+
}).sort((a, b) => a.lastModified - b.lastModified);
|
|
1426
|
+
if (profileFiles.length === 0) {
|
|
1427
|
+
if (this.options.verbose) {
|
|
1428
|
+
console.log(`[zeitzeuge] No .cpuprofile files in ${profileDir}. ` + `This usually means --cpu-prof wasn't passed to the worker process. ` + `Check that pool is set to 'forks' and execArgv includes '--cpu-prof'.`);
|
|
1429
|
+
}
|
|
1430
|
+
return [];
|
|
1431
|
+
}
|
|
1432
|
+
const results = [];
|
|
1433
|
+
const orderedTestFiles = this.executionOrder.length > 0 ? this.executionOrder : testTiming.map((t) => t.file);
|
|
1434
|
+
for (let i = 0;i < profileFiles.length; i++) {
|
|
1435
|
+
const pf = profileFiles[i];
|
|
1436
|
+
const testFile = orderedTestFiles[i] ?? `unknown-${i}`;
|
|
1437
|
+
try {
|
|
1438
|
+
const content = readFileSync(pf.path, "utf-8");
|
|
1439
|
+
const rawProfile = JSON.parse(content);
|
|
1440
|
+
const summary = parseCpuProfile(rawProfile, pf.path);
|
|
1441
|
+
const testFileSet = new Set(testTiming.map((t) => resolve2(t.file)));
|
|
1442
|
+
for (const fn of summary.hotFunctions) {
|
|
1443
|
+
fn.sourceCategory = classifyScript(fn.scriptUrl, this.options.projectRoot, testFileSet);
|
|
1444
|
+
}
|
|
1445
|
+
for (const script of summary.scriptBreakdown) {
|
|
1446
|
+
script.sourceCategory = classifyScript(script.scriptUrl, this.options.projectRoot, testFileSet);
|
|
1447
|
+
}
|
|
1448
|
+
results.push({
|
|
1449
|
+
testFile,
|
|
1450
|
+
profilePath: pf.path,
|
|
1451
|
+
summary
|
|
1452
|
+
});
|
|
1453
|
+
} catch (err) {
|
|
1454
|
+
if (this.options.verbose) {
|
|
1455
|
+
console.warn(`[zeitzeuge] Failed to parse profile ${pf.name}: ${err instanceof Error ? err.message : err}`);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
results.sort((a, b) => b.summary.duration - a.summary.duration);
|
|
1460
|
+
return results.slice(0, 10);
|
|
1461
|
+
}
|
|
1462
|
+
collectAndParseHeapProfiles(testTiming) {
|
|
1463
|
+
const { profileDir } = this.options;
|
|
1464
|
+
if (!existsSync(profileDir)) {
|
|
1465
|
+
return [];
|
|
1466
|
+
}
|
|
1467
|
+
const allFiles = readdirSync(profileDir);
|
|
1468
|
+
const heapFiles = allFiles.filter((f) => f.endsWith(".heapprofile")).map((f) => {
|
|
1469
|
+
const fullPath = join2(profileDir, f);
|
|
1470
|
+
try {
|
|
1471
|
+
const stat = statSync(fullPath);
|
|
1472
|
+
return { name: f, path: fullPath, lastModified: stat.mtimeMs };
|
|
1473
|
+
} catch {
|
|
1474
|
+
return { name: f, path: fullPath, lastModified: 0 };
|
|
1475
|
+
}
|
|
1476
|
+
}).sort((a, b) => a.lastModified - b.lastModified);
|
|
1477
|
+
if (heapFiles.length === 0) {
|
|
1478
|
+
return [];
|
|
1479
|
+
}
|
|
1480
|
+
const orderedTestFiles = this.executionOrder.length > 0 ? this.executionOrder : testTiming.map((t) => t.file);
|
|
1481
|
+
const results = [];
|
|
1482
|
+
const testFileSet = new Set(testTiming.map((t) => resolve2(t.file)));
|
|
1483
|
+
for (let i = 0;i < heapFiles.length; i++) {
|
|
1484
|
+
const hf = heapFiles[i];
|
|
1485
|
+
const testFile = orderedTestFiles[i] ?? `unknown-${i}`;
|
|
1486
|
+
try {
|
|
1487
|
+
const content = readFileSync(hf.path, "utf-8");
|
|
1488
|
+
const rawHeapProfile = JSON.parse(content);
|
|
1489
|
+
const summary = parseHeapProfile(rawHeapProfile, hf.path);
|
|
1490
|
+
for (const fn of summary.topAllocations) {
|
|
1491
|
+
fn.sourceCategory = classifyScript(fn.scriptUrl, this.options.projectRoot, testFileSet);
|
|
1492
|
+
}
|
|
1493
|
+
for (const script of summary.scriptBreakdown) {
|
|
1494
|
+
script.sourceCategory = classifyScript(script.scriptUrl, this.options.projectRoot, testFileSet);
|
|
1495
|
+
}
|
|
1496
|
+
results.push({
|
|
1497
|
+
testFile,
|
|
1498
|
+
profilePath: hf.path,
|
|
1499
|
+
summary
|
|
1500
|
+
});
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
if (this.options.verbose) {
|
|
1503
|
+
console.warn(`[zeitzeuge] Failed to parse heap profile ${hf.name}: ${err instanceof Error ? err.message : err}`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return results.slice(0, 10);
|
|
1508
|
+
}
|
|
1509
|
+
readTestSources(testTiming) {
|
|
1510
|
+
const sources = new Map;
|
|
1511
|
+
for (const timing of testTiming) {
|
|
1512
|
+
try {
|
|
1513
|
+
const resolvedPath = resolve2(timing.file);
|
|
1514
|
+
if (existsSync(resolvedPath)) {
|
|
1515
|
+
sources.set(timing.file, readFileSync(resolvedPath, "utf-8"));
|
|
1516
|
+
}
|
|
1517
|
+
} catch {}
|
|
1518
|
+
}
|
|
1519
|
+
return sources;
|
|
1520
|
+
}
|
|
1521
|
+
readHotFunctionSources(profiles) {
|
|
1522
|
+
const sources = new Map;
|
|
1523
|
+
const seen = new Set;
|
|
1524
|
+
for (const profile of profiles) {
|
|
1525
|
+
for (const fn of profile.summary.hotFunctions) {
|
|
1526
|
+
if (!fn.scriptUrl || seen.has(fn.scriptUrl)) {
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
const threshold = fn.sourceCategory === "application" ? 0.1 : 1;
|
|
1530
|
+
if (fn.selfPercent < threshold)
|
|
1531
|
+
continue;
|
|
1532
|
+
seen.add(fn.scriptUrl);
|
|
1533
|
+
try {
|
|
1534
|
+
let filePath = fn.scriptUrl;
|
|
1535
|
+
if (filePath.startsWith("file://")) {
|
|
1536
|
+
filePath = new URL(filePath).pathname;
|
|
1537
|
+
}
|
|
1538
|
+
if (existsSync(filePath)) {
|
|
1539
|
+
sources.set(fn.scriptUrl, readFileSync(filePath, "utf-8"));
|
|
1540
|
+
}
|
|
1541
|
+
} catch {}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
return sources;
|
|
1545
|
+
}
|
|
1546
|
+
cleanupProfileDir() {
|
|
1547
|
+
try {
|
|
1548
|
+
if (existsSync(this.options.profileDir)) {
|
|
1549
|
+
rmSync(this.options.profileDir, { recursive: true, force: true });
|
|
1550
|
+
}
|
|
1551
|
+
} catch {}
|
|
1552
|
+
}
|
|
1553
|
+
getVersion() {
|
|
1554
|
+
return package_default.version ?? "(unknown)";
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// src/vitest/plugin.ts
|
|
1559
|
+
function zeitzeuge(options = {}) {
|
|
1560
|
+
const {
|
|
1561
|
+
enabled = true,
|
|
1562
|
+
output = "zeitzeuge-report.md",
|
|
1563
|
+
profileDir = ".zeitzeuge-profiles",
|
|
1564
|
+
heapProf = false,
|
|
1565
|
+
analyzeOnFinish = true,
|
|
1566
|
+
verbose = false,
|
|
1567
|
+
projectRoot = process.cwd()
|
|
1568
|
+
} = options;
|
|
1569
|
+
return {
|
|
1570
|
+
name: "vitest:zeitzeuge",
|
|
1571
|
+
configureVitest(context) {
|
|
1572
|
+
if (!enabled)
|
|
1573
|
+
return;
|
|
1574
|
+
const { vitest } = context;
|
|
1575
|
+
const resolvedProfileDir = resolve3(profileDir);
|
|
1576
|
+
try {
|
|
1577
|
+
mkdirSync2(resolvedProfileDir, { recursive: true });
|
|
1578
|
+
} catch {}
|
|
1579
|
+
const cpuProfArgs = ["--cpu-prof", `--cpu-prof-dir=${resolvedProfileDir}`];
|
|
1580
|
+
const heapProfArgs = heapProf ? ["--heap-prof", `--heap-prof-dir=${resolvedProfileDir}`] : [];
|
|
1581
|
+
const profilingArgs = [...cpuProfArgs, ...heapProfArgs];
|
|
1582
|
+
const existingArgv = vitest.config.execArgv ?? [];
|
|
1583
|
+
vitest.config.execArgv = [...existingArgv, ...profilingArgs];
|
|
1584
|
+
vitest.config.pool = "forks";
|
|
1585
|
+
if (!vitest.config.poolOptions) {
|
|
1586
|
+
vitest.config.poolOptions = {};
|
|
1587
|
+
}
|
|
1588
|
+
if (!vitest.config.poolOptions.forks) {
|
|
1589
|
+
vitest.config.poolOptions.forks = {};
|
|
1590
|
+
}
|
|
1591
|
+
const existingForksArgv = vitest.config.poolOptions.forks.execArgv ?? [];
|
|
1592
|
+
vitest.config.poolOptions.forks.execArgv = [...existingForksArgv, ...profilingArgs];
|
|
1593
|
+
vitest.config.fileParallelism = false;
|
|
1594
|
+
const reporter = new ZeitZeugeReporter({
|
|
1595
|
+
output: resolve3(output),
|
|
1596
|
+
profileDir: resolvedProfileDir,
|
|
1597
|
+
analyzeOnFinish,
|
|
1598
|
+
verbose,
|
|
1599
|
+
projectRoot: resolve3(projectRoot)
|
|
1600
|
+
});
|
|
1601
|
+
if (Array.isArray(vitest.config.reporters)) {
|
|
1602
|
+
vitest.config.reporters.push(reporter);
|
|
1603
|
+
} else {
|
|
1604
|
+
vitest.config.reporters = [reporter];
|
|
1605
|
+
}
|
|
1606
|
+
if (verbose) {
|
|
1607
|
+
console.log(`[zeitzeuge] Plugin enabled — CPU profiling to ${resolvedProfileDir}`);
|
|
1608
|
+
console.log(`[zeitzeuge] execArgv: ${JSON.stringify(vitest.config.execArgv)}`);
|
|
1609
|
+
console.log(`[zeitzeuge] pool: ${vitest.config.pool}`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
export {
|
|
1615
|
+
zeitzeuge
|
|
1616
|
+
};
|