@frontmcp/testing 0.7.1 → 0.8.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/auth/index.d.ts +2 -0
- package/auth/index.d.ts.map +1 -1
- package/auth/mock-cimd-server.d.ts +174 -0
- package/auth/mock-cimd-server.d.ts.map +1 -0
- package/auth/mock-oauth-server.d.ts +136 -6
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts.map +1 -1
- package/client/index.d.ts +1 -1
- package/client/index.d.ts.map +1 -1
- package/client/mcp-test-client.builder.d.ts +12 -0
- package/client/mcp-test-client.builder.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +48 -2
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +60 -0
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +661 -83
- package/esm/index.mjs +3245 -219
- package/esm/package.json +5 -4
- package/esm/perf/index.mjs +4334 -0
- package/esm/perf/perf-setup.mjs +31 -0
- package/fixtures/fixture-types.d.ts +10 -1
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +661 -93
- package/fixtures/test-fixture.d.ts +1 -1
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.d.ts +5 -1
- package/index.d.ts.map +1 -1
- package/index.js +3271 -219
- package/interceptor/interceptor-chain.d.ts +1 -0
- package/interceptor/interceptor-chain.d.ts.map +1 -1
- package/package.json +5 -4
- package/perf/baseline-store.d.ts +67 -0
- package/perf/baseline-store.d.ts.map +1 -0
- package/perf/index.d.ts +44 -0
- package/perf/index.d.ts.map +1 -0
- package/perf/index.js +4404 -0
- package/perf/jest-perf-reporter.d.ts +6 -0
- package/perf/jest-perf-reporter.d.ts.map +1 -0
- package/perf/leak-detector.d.ts +81 -0
- package/perf/leak-detector.d.ts.map +1 -0
- package/perf/metrics-collector.d.ts +83 -0
- package/perf/metrics-collector.d.ts.map +1 -0
- package/perf/perf-fixtures.d.ts +107 -0
- package/perf/perf-fixtures.d.ts.map +1 -0
- package/perf/perf-setup.d.ts +9 -0
- package/perf/perf-setup.d.ts.map +1 -0
- package/perf/perf-setup.js +50 -0
- package/perf/perf-test.d.ts +69 -0
- package/perf/perf-test.d.ts.map +1 -0
- package/perf/regression-detector.d.ts +55 -0
- package/perf/regression-detector.d.ts.map +1 -0
- package/perf/report-generator.d.ts +66 -0
- package/perf/report-generator.d.ts.map +1 -0
- package/perf/types.d.ts +439 -0
- package/perf/types.d.ts.map +1 -0
- package/platform/platform-client-info.d.ts +18 -0
- package/platform/platform-client-info.d.ts.map +1 -1
- package/server/index.d.ts +2 -0
- package/server/index.d.ts.map +1 -1
- package/server/port-registry.d.ts +179 -0
- package/server/port-registry.d.ts.map +1 -0
- package/server/test-server.d.ts +9 -5
- package/server/test-server.d.ts.map +1 -1
- package/transport/streamable-http.transport.d.ts +26 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +9 -1
- package/transport/transport.interface.d.ts.map +1 -1
|
@@ -0,0 +1,4334 @@
|
|
|
1
|
+
// libs/testing/src/perf/metrics-collector.ts
|
|
2
|
+
function isGcAvailable() {
|
|
3
|
+
return typeof global.gc === "function";
|
|
4
|
+
}
|
|
5
|
+
function forceGc() {
|
|
6
|
+
if (typeof global.gc === "function") {
|
|
7
|
+
global.gc();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
async function forceFullGc(cycles = 3, delayMs = 10) {
|
|
11
|
+
if (!isGcAvailable()) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (let i = 0; i < cycles; i++) {
|
|
15
|
+
forceGc();
|
|
16
|
+
if (i < cycles - 1 && delayMs > 0) {
|
|
17
|
+
await sleep(delayMs);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
var MetricsCollector = class {
|
|
22
|
+
cpuStartUsage = null;
|
|
23
|
+
baseline = null;
|
|
24
|
+
measurements = [];
|
|
25
|
+
/**
|
|
26
|
+
* Capture the baseline snapshot.
|
|
27
|
+
* Forces GC to get a clean memory state.
|
|
28
|
+
*/
|
|
29
|
+
async captureBaseline(forceGcCycles = 3) {
|
|
30
|
+
await forceFullGc(forceGcCycles);
|
|
31
|
+
this.cpuStartUsage = process.cpuUsage();
|
|
32
|
+
const snapshot = this.captureSnapshot("baseline");
|
|
33
|
+
this.baseline = snapshot;
|
|
34
|
+
return snapshot;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Capture a snapshot of current memory and CPU state.
|
|
38
|
+
*/
|
|
39
|
+
captureSnapshot(label) {
|
|
40
|
+
const memUsage = process.memoryUsage();
|
|
41
|
+
const cpuUsage = this.cpuStartUsage ? process.cpuUsage(this.cpuStartUsage) : process.cpuUsage();
|
|
42
|
+
const snapshot = {
|
|
43
|
+
memory: {
|
|
44
|
+
heapUsed: memUsage.heapUsed,
|
|
45
|
+
heapTotal: memUsage.heapTotal,
|
|
46
|
+
external: memUsage.external,
|
|
47
|
+
rss: memUsage.rss,
|
|
48
|
+
arrayBuffers: memUsage.arrayBuffers
|
|
49
|
+
},
|
|
50
|
+
cpu: {
|
|
51
|
+
user: cpuUsage.user,
|
|
52
|
+
system: cpuUsage.system,
|
|
53
|
+
total: cpuUsage.user + cpuUsage.system
|
|
54
|
+
},
|
|
55
|
+
timestamp: Date.now(),
|
|
56
|
+
label
|
|
57
|
+
};
|
|
58
|
+
if (label !== "baseline") {
|
|
59
|
+
this.measurements.push(snapshot);
|
|
60
|
+
}
|
|
61
|
+
return snapshot;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Start CPU tracking from this point.
|
|
65
|
+
*/
|
|
66
|
+
startCpuTracking() {
|
|
67
|
+
this.cpuStartUsage = process.cpuUsage();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get CPU usage since tracking started.
|
|
71
|
+
*/
|
|
72
|
+
getCpuUsage() {
|
|
73
|
+
const usage = this.cpuStartUsage ? process.cpuUsage(this.cpuStartUsage) : process.cpuUsage();
|
|
74
|
+
return {
|
|
75
|
+
user: usage.user,
|
|
76
|
+
system: usage.system,
|
|
77
|
+
total: usage.user + usage.system
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Get current memory metrics.
|
|
82
|
+
*/
|
|
83
|
+
getMemoryMetrics() {
|
|
84
|
+
const memUsage = process.memoryUsage();
|
|
85
|
+
return {
|
|
86
|
+
heapUsed: memUsage.heapUsed,
|
|
87
|
+
heapTotal: memUsage.heapTotal,
|
|
88
|
+
external: memUsage.external,
|
|
89
|
+
rss: memUsage.rss,
|
|
90
|
+
arrayBuffers: memUsage.arrayBuffers
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get the baseline snapshot if captured.
|
|
95
|
+
*/
|
|
96
|
+
getBaseline() {
|
|
97
|
+
return this.baseline;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get all measurement snapshots.
|
|
101
|
+
*/
|
|
102
|
+
getMeasurements() {
|
|
103
|
+
return [...this.measurements];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Calculate memory delta from baseline.
|
|
107
|
+
*/
|
|
108
|
+
calculateMemoryDelta(current) {
|
|
109
|
+
if (!this.baseline) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
heapUsed: current.heapUsed - this.baseline.memory.heapUsed,
|
|
114
|
+
heapTotal: current.heapTotal - this.baseline.memory.heapTotal,
|
|
115
|
+
rss: current.rss - this.baseline.memory.rss
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Reset the collector state.
|
|
120
|
+
*/
|
|
121
|
+
reset() {
|
|
122
|
+
this.cpuStartUsage = null;
|
|
123
|
+
this.baseline = null;
|
|
124
|
+
this.measurements = [];
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
function formatBytes(bytes) {
|
|
128
|
+
const absBytes = Math.abs(bytes);
|
|
129
|
+
const sign = bytes < 0 ? "-" : "";
|
|
130
|
+
if (absBytes < 1024) {
|
|
131
|
+
return `${sign}${absBytes} B`;
|
|
132
|
+
}
|
|
133
|
+
if (absBytes < 1024 * 1024) {
|
|
134
|
+
return `${sign}${(absBytes / 1024).toFixed(2)} KB`;
|
|
135
|
+
}
|
|
136
|
+
if (absBytes < 1024 * 1024 * 1024) {
|
|
137
|
+
return `${sign}${(absBytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
138
|
+
}
|
|
139
|
+
return `${sign}${(absBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
140
|
+
}
|
|
141
|
+
function formatMicroseconds(us) {
|
|
142
|
+
if (us < 1e3) {
|
|
143
|
+
return `${us.toFixed(2)} \xB5s`;
|
|
144
|
+
}
|
|
145
|
+
if (us < 1e6) {
|
|
146
|
+
return `${(us / 1e3).toFixed(2)} ms`;
|
|
147
|
+
}
|
|
148
|
+
return `${(us / 1e6).toFixed(2)} s`;
|
|
149
|
+
}
|
|
150
|
+
function formatDuration(ms) {
|
|
151
|
+
if (ms < 1e3) {
|
|
152
|
+
return `${ms.toFixed(2)} ms`;
|
|
153
|
+
}
|
|
154
|
+
if (ms < 6e4) {
|
|
155
|
+
return `${(ms / 1e3).toFixed(2)} s`;
|
|
156
|
+
}
|
|
157
|
+
return `${(ms / 6e4).toFixed(2)} min`;
|
|
158
|
+
}
|
|
159
|
+
function sleep(ms) {
|
|
160
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
161
|
+
}
|
|
162
|
+
var globalCollector = null;
|
|
163
|
+
function getGlobalCollector() {
|
|
164
|
+
if (!globalCollector) {
|
|
165
|
+
globalCollector = new MetricsCollector();
|
|
166
|
+
}
|
|
167
|
+
return globalCollector;
|
|
168
|
+
}
|
|
169
|
+
function resetGlobalCollector() {
|
|
170
|
+
if (globalCollector) {
|
|
171
|
+
globalCollector.reset();
|
|
172
|
+
}
|
|
173
|
+
globalCollector = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// libs/testing/src/perf/leak-detector.ts
|
|
177
|
+
var DEFAULT_OPTIONS = {
|
|
178
|
+
iterations: 20,
|
|
179
|
+
threshold: 1024 * 1024,
|
|
180
|
+
// 1 MB
|
|
181
|
+
warmupIterations: 3,
|
|
182
|
+
forceGc: true,
|
|
183
|
+
delayMs: 10,
|
|
184
|
+
intervalSize: 10
|
|
185
|
+
// Default interval size for measurements
|
|
186
|
+
};
|
|
187
|
+
var DEFAULT_PARALLEL_OPTIONS = {
|
|
188
|
+
...DEFAULT_OPTIONS,
|
|
189
|
+
workers: 5
|
|
190
|
+
// Default number of parallel workers
|
|
191
|
+
};
|
|
192
|
+
function linearRegression(samples) {
|
|
193
|
+
const n = samples.length;
|
|
194
|
+
if (n < 2) {
|
|
195
|
+
return { slope: 0, intercept: samples[0] ?? 0, rSquared: 0 };
|
|
196
|
+
}
|
|
197
|
+
let sumX = 0;
|
|
198
|
+
let sumY = 0;
|
|
199
|
+
for (let i = 0; i < n; i++) {
|
|
200
|
+
sumX += i;
|
|
201
|
+
sumY += samples[i];
|
|
202
|
+
}
|
|
203
|
+
const meanX = sumX / n;
|
|
204
|
+
const meanY = sumY / n;
|
|
205
|
+
let numerator = 0;
|
|
206
|
+
let denominator = 0;
|
|
207
|
+
for (let i = 0; i < n; i++) {
|
|
208
|
+
const dx = i - meanX;
|
|
209
|
+
const dy = samples[i] - meanY;
|
|
210
|
+
numerator += dx * dy;
|
|
211
|
+
denominator += dx * dx;
|
|
212
|
+
}
|
|
213
|
+
const slope = denominator !== 0 ? numerator / denominator : 0;
|
|
214
|
+
const intercept = meanY - slope * meanX;
|
|
215
|
+
let ssRes = 0;
|
|
216
|
+
let ssTot = 0;
|
|
217
|
+
for (let i = 0; i < n; i++) {
|
|
218
|
+
const predicted = intercept + slope * i;
|
|
219
|
+
const residual = samples[i] - predicted;
|
|
220
|
+
ssRes += residual * residual;
|
|
221
|
+
ssTot += (samples[i] - meanY) * (samples[i] - meanY);
|
|
222
|
+
}
|
|
223
|
+
const rSquared = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
|
|
224
|
+
return { slope, intercept, rSquared };
|
|
225
|
+
}
|
|
226
|
+
var LeakDetector = class {
|
|
227
|
+
/**
|
|
228
|
+
* Run leak detection on an async operation.
|
|
229
|
+
*
|
|
230
|
+
* @param operation - The operation to test for leaks
|
|
231
|
+
* @param options - Detection options
|
|
232
|
+
* @returns Leak detection result
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* ```typescript
|
|
236
|
+
* const detector = new LeakDetector();
|
|
237
|
+
* const result = await detector.detectLeak(
|
|
238
|
+
* async () => mcp.tools.call('my-tool', {}),
|
|
239
|
+
* { iterations: 50, threshold: 5 * 1024 * 1024 }
|
|
240
|
+
* );
|
|
241
|
+
* expect(result.hasLeak).toBe(false);
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
244
|
+
async detectLeak(operation, options) {
|
|
245
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
246
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, delayMs, intervalSize } = opts;
|
|
247
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
248
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
249
|
+
}
|
|
250
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
251
|
+
await operation();
|
|
252
|
+
}
|
|
253
|
+
if (forceGc2) {
|
|
254
|
+
await forceFullGc();
|
|
255
|
+
}
|
|
256
|
+
const samples = [];
|
|
257
|
+
const startTime = Date.now();
|
|
258
|
+
for (let i = 0; i < iterations; i++) {
|
|
259
|
+
await operation();
|
|
260
|
+
if (forceGc2) {
|
|
261
|
+
await forceFullGc(2, 5);
|
|
262
|
+
}
|
|
263
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
264
|
+
if (delayMs > 0) {
|
|
265
|
+
await sleep2(delayMs);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const durationMs = Date.now() - startTime;
|
|
269
|
+
return this.analyzeLeakPattern(samples, threshold, intervalSize, durationMs);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Run leak detection on a sync operation.
|
|
273
|
+
*/
|
|
274
|
+
detectLeakSync(operation, options) {
|
|
275
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
276
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, intervalSize } = opts;
|
|
277
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
278
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
279
|
+
}
|
|
280
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
281
|
+
operation();
|
|
282
|
+
}
|
|
283
|
+
if (forceGc2 && isGcAvailable() && global.gc) {
|
|
284
|
+
global.gc();
|
|
285
|
+
global.gc();
|
|
286
|
+
global.gc();
|
|
287
|
+
}
|
|
288
|
+
const samples = [];
|
|
289
|
+
for (let i = 0; i < iterations; i++) {
|
|
290
|
+
operation();
|
|
291
|
+
if (forceGc2 && isGcAvailable() && global.gc) {
|
|
292
|
+
global.gc();
|
|
293
|
+
global.gc();
|
|
294
|
+
}
|
|
295
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
296
|
+
}
|
|
297
|
+
return this.analyzeLeakPattern(samples, threshold, intervalSize);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Run parallel leak detection using multiple clients.
|
|
301
|
+
* Each worker gets its own client for true parallel HTTP requests.
|
|
302
|
+
*
|
|
303
|
+
* @param operationFactory - Factory that receives a client and worker index, returns an operation function
|
|
304
|
+
* @param options - Detection options including worker count and client factory
|
|
305
|
+
* @returns Combined leak detection result with per-worker statistics
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* ```typescript
|
|
309
|
+
* const detector = new LeakDetector();
|
|
310
|
+
* const result = await detector.detectLeakParallel(
|
|
311
|
+
* (client, workerId) => async () => client.tools.call('my-tool', {}),
|
|
312
|
+
* {
|
|
313
|
+
* iterations: 1000,
|
|
314
|
+
* workers: 5,
|
|
315
|
+
* clientFactory: () => server.createClient(),
|
|
316
|
+
* }
|
|
317
|
+
* );
|
|
318
|
+
* // 5 workers × ~80 req/s = ~400 req/s total
|
|
319
|
+
* console.log(result.totalRequestsPerSecond);
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
async detectLeakParallel(operationFactory, options) {
|
|
323
|
+
const opts = { ...DEFAULT_PARALLEL_OPTIONS, ...options };
|
|
324
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, workers, intervalSize, clientFactory } = opts;
|
|
325
|
+
const safeIntervalSize = Math.max(1, intervalSize);
|
|
326
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
327
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
328
|
+
}
|
|
329
|
+
const clients = [];
|
|
330
|
+
let workerResults;
|
|
331
|
+
let globalDurationMs;
|
|
332
|
+
try {
|
|
333
|
+
console.log(`[LeakDetector] Creating ${workers} clients sequentially...`);
|
|
334
|
+
for (let i = 0; i < workers; i++) {
|
|
335
|
+
console.log(`[LeakDetector] Creating client ${i + 1}/${workers}...`);
|
|
336
|
+
const client = await clientFactory();
|
|
337
|
+
clients.push(client);
|
|
338
|
+
}
|
|
339
|
+
console.log(`[LeakDetector] All ${workers} clients connected`);
|
|
340
|
+
const operations = clients.map((client, workerId) => operationFactory(client, workerId));
|
|
341
|
+
console.log(`[LeakDetector] Running ${warmupIterations} warmup iterations per worker...`);
|
|
342
|
+
await Promise.all(
|
|
343
|
+
operations.map(async (operation) => {
|
|
344
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
345
|
+
await operation();
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
);
|
|
349
|
+
if (forceGc2) {
|
|
350
|
+
await forceFullGc();
|
|
351
|
+
}
|
|
352
|
+
console.log(`[LeakDetector] Starting parallel stress test: ${workers} workers \xD7 ${iterations} iterations`);
|
|
353
|
+
const globalStartTime = Date.now();
|
|
354
|
+
workerResults = await Promise.all(
|
|
355
|
+
operations.map(async (operation, workerId) => {
|
|
356
|
+
const samples = [];
|
|
357
|
+
const workerStartTime = Date.now();
|
|
358
|
+
for (let i = 0; i < iterations; i++) {
|
|
359
|
+
await operation();
|
|
360
|
+
if (forceGc2 && i > 0 && i % safeIntervalSize === 0) {
|
|
361
|
+
await forceFullGc(1, 2);
|
|
362
|
+
}
|
|
363
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
364
|
+
}
|
|
365
|
+
const workerDurationMs = Date.now() - workerStartTime;
|
|
366
|
+
const requestsPerSecond = iterations / workerDurationMs * 1e3;
|
|
367
|
+
return {
|
|
368
|
+
workerId,
|
|
369
|
+
samples,
|
|
370
|
+
durationMs: workerDurationMs,
|
|
371
|
+
requestsPerSecond,
|
|
372
|
+
iterationsCompleted: iterations
|
|
373
|
+
};
|
|
374
|
+
})
|
|
375
|
+
);
|
|
376
|
+
globalDurationMs = Date.now() - globalStartTime;
|
|
377
|
+
} finally {
|
|
378
|
+
await Promise.all(
|
|
379
|
+
clients.map(async (client) => {
|
|
380
|
+
if (client.disconnect) {
|
|
381
|
+
await client.disconnect();
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const allSamples = workerResults.flatMap((w) => w.samples);
|
|
387
|
+
const totalIterations = workers * iterations;
|
|
388
|
+
const totalRequestsPerSecond = totalIterations / globalDurationMs * 1e3;
|
|
389
|
+
const baseResult = this.analyzeLeakPattern(allSamples, threshold, intervalSize, globalDurationMs);
|
|
390
|
+
const workerSummary = workerResults.map((w) => ` Worker ${w.workerId}: ${w.requestsPerSecond.toFixed(1)} req/s`).join("\n");
|
|
391
|
+
const parallelMessage = `${baseResult.message}
|
|
392
|
+
|
|
393
|
+
Parallel execution summary:
|
|
394
|
+
Workers: ${workers}
|
|
395
|
+
Total iterations: ${totalIterations}
|
|
396
|
+
Combined throughput: ${totalRequestsPerSecond.toFixed(1)} req/s
|
|
397
|
+
Duration: ${(globalDurationMs / 1e3).toFixed(2)}s
|
|
398
|
+
Per-worker throughput:
|
|
399
|
+
${workerSummary}`;
|
|
400
|
+
return {
|
|
401
|
+
...baseResult,
|
|
402
|
+
message: parallelMessage,
|
|
403
|
+
workersUsed: workers,
|
|
404
|
+
totalRequestsPerSecond,
|
|
405
|
+
perWorkerStats: workerResults,
|
|
406
|
+
totalIterations,
|
|
407
|
+
durationMs: globalDurationMs,
|
|
408
|
+
requestsPerSecond: totalRequestsPerSecond
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Analyze heap samples for leak patterns using linear regression.
|
|
413
|
+
*/
|
|
414
|
+
analyzeLeakPattern(samples, threshold, intervalSize = 10, durationMs) {
|
|
415
|
+
if (samples.length < 2) {
|
|
416
|
+
return {
|
|
417
|
+
hasLeak: false,
|
|
418
|
+
leakSizePerIteration: 0,
|
|
419
|
+
totalGrowth: 0,
|
|
420
|
+
growthRate: 0,
|
|
421
|
+
rSquared: 0,
|
|
422
|
+
samples,
|
|
423
|
+
message: "Insufficient samples for leak detection",
|
|
424
|
+
intervals: [],
|
|
425
|
+
graphData: [],
|
|
426
|
+
durationMs,
|
|
427
|
+
requestsPerSecond: 0
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const { slope, rSquared } = linearRegression(samples);
|
|
431
|
+
const totalGrowth = samples[samples.length - 1] - samples[0];
|
|
432
|
+
const growthRate = slope;
|
|
433
|
+
const requestsPerSecond = durationMs && durationMs > 0 ? samples.length / durationMs * 1e3 : 0;
|
|
434
|
+
const intervals = this.generateIntervals(samples, intervalSize);
|
|
435
|
+
const graphData = this.generateGraphData(samples);
|
|
436
|
+
const isSignificantGrowth = totalGrowth > threshold;
|
|
437
|
+
const isLinearGrowth = rSquared > 0.7;
|
|
438
|
+
const isHighGrowthRate = growthRate > threshold / samples.length;
|
|
439
|
+
const hasLeak = isSignificantGrowth && (isLinearGrowth || isHighGrowthRate);
|
|
440
|
+
const intervalSummary = intervals.map((i) => ` ${i.startIteration}-${i.endIteration}: ${i.deltaFormatted}`).join("\n");
|
|
441
|
+
const perfStats = durationMs ? `
|
|
442
|
+
Performance: ${samples.length} iterations in ${(durationMs / 1e3).toFixed(2)}s (${requestsPerSecond.toFixed(1)} req/s)` : "";
|
|
443
|
+
let message;
|
|
444
|
+
if (hasLeak) {
|
|
445
|
+
message = `Memory leak detected: ${formatBytes(totalGrowth)} total growth, ${formatBytes(growthRate)}/iteration, R\xB2=${rSquared.toFixed(3)}
|
|
446
|
+
Interval breakdown:
|
|
447
|
+
${intervalSummary}${perfStats}`;
|
|
448
|
+
} else if (isSignificantGrowth) {
|
|
449
|
+
message = `Memory growth detected (${formatBytes(totalGrowth)}) but not linear (R\xB2=${rSquared.toFixed(3)}), may be normal allocation
|
|
450
|
+
Interval breakdown:
|
|
451
|
+
${intervalSummary}${perfStats}`;
|
|
452
|
+
} else {
|
|
453
|
+
message = `No leak detected: ${formatBytes(totalGrowth)} total, ${formatBytes(growthRate)}/iteration
|
|
454
|
+
Interval breakdown:
|
|
455
|
+
${intervalSummary}${perfStats}`;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
hasLeak,
|
|
459
|
+
leakSizePerIteration: hasLeak ? growthRate : 0,
|
|
460
|
+
totalGrowth,
|
|
461
|
+
growthRate,
|
|
462
|
+
rSquared,
|
|
463
|
+
samples,
|
|
464
|
+
message,
|
|
465
|
+
intervals,
|
|
466
|
+
graphData,
|
|
467
|
+
durationMs,
|
|
468
|
+
requestsPerSecond
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Generate interval-based measurements for detailed analysis.
|
|
473
|
+
*/
|
|
474
|
+
generateIntervals(samples, intervalSize) {
|
|
475
|
+
const intervals = [];
|
|
476
|
+
const safeIntervalSize = intervalSize <= 0 ? 1 : intervalSize;
|
|
477
|
+
const numIntervals = Math.ceil(samples.length / safeIntervalSize);
|
|
478
|
+
for (let i = 0; i < numIntervals; i++) {
|
|
479
|
+
const startIdx = i * safeIntervalSize;
|
|
480
|
+
const endIdx = Math.min((i + 1) * safeIntervalSize - 1, samples.length - 1);
|
|
481
|
+
if (startIdx >= samples.length) break;
|
|
482
|
+
const heapAtStart = samples[startIdx];
|
|
483
|
+
const heapAtEnd = samples[endIdx];
|
|
484
|
+
const delta = heapAtEnd - heapAtStart;
|
|
485
|
+
const iterationsInInterval = endIdx - startIdx + 1;
|
|
486
|
+
const growthRatePerIteration = iterationsInInterval > 1 ? delta / (iterationsInInterval - 1) : 0;
|
|
487
|
+
intervals.push({
|
|
488
|
+
startIteration: startIdx,
|
|
489
|
+
endIteration: endIdx,
|
|
490
|
+
heapAtStart,
|
|
491
|
+
heapAtEnd,
|
|
492
|
+
delta,
|
|
493
|
+
deltaFormatted: formatBytes(delta),
|
|
494
|
+
growthRatePerIteration
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return intervals;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Generate graph data points for visualization.
|
|
501
|
+
*/
|
|
502
|
+
generateGraphData(samples) {
|
|
503
|
+
if (samples.length === 0) return [];
|
|
504
|
+
const baselineHeap = samples[0];
|
|
505
|
+
return samples.map((heapUsed, iteration) => ({
|
|
506
|
+
iteration,
|
|
507
|
+
heapUsed,
|
|
508
|
+
heapUsedFormatted: formatBytes(heapUsed),
|
|
509
|
+
cumulativeDelta: heapUsed - baselineHeap,
|
|
510
|
+
cumulativeDeltaFormatted: formatBytes(heapUsed - baselineHeap)
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
function sleep2(ms) {
|
|
515
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
516
|
+
}
|
|
517
|
+
async function assertNoLeak(operation, options) {
|
|
518
|
+
const detector = new LeakDetector();
|
|
519
|
+
const result = await detector.detectLeak(operation, options);
|
|
520
|
+
if (result.hasLeak) {
|
|
521
|
+
throw new Error(`Memory leak detected: ${result.message}`);
|
|
522
|
+
}
|
|
523
|
+
return result;
|
|
524
|
+
}
|
|
525
|
+
function createLeakDetector() {
|
|
526
|
+
return new LeakDetector();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// libs/testing/src/perf/perf-fixtures.ts
|
|
530
|
+
function createPerfFixtures(testName, project) {
|
|
531
|
+
return new PerfFixturesImpl(testName, project);
|
|
532
|
+
}
|
|
533
|
+
var PerfFixturesImpl = class {
|
|
534
|
+
constructor(testName, project) {
|
|
535
|
+
this.testName = testName;
|
|
536
|
+
this.project = project;
|
|
537
|
+
this.collector = new MetricsCollector();
|
|
538
|
+
this.leakDetector = new LeakDetector();
|
|
539
|
+
}
|
|
540
|
+
collector;
|
|
541
|
+
leakDetector;
|
|
542
|
+
baselineSnapshot = null;
|
|
543
|
+
startTime = 0;
|
|
544
|
+
issues = [];
|
|
545
|
+
leakResults = [];
|
|
546
|
+
/**
|
|
547
|
+
* Capture baseline snapshot with GC.
|
|
548
|
+
*/
|
|
549
|
+
async baseline() {
|
|
550
|
+
this.startTime = Date.now();
|
|
551
|
+
this.baselineSnapshot = await this.collector.captureBaseline();
|
|
552
|
+
return this.baselineSnapshot;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Capture a measurement snapshot.
|
|
556
|
+
*/
|
|
557
|
+
measure(label) {
|
|
558
|
+
return this.collector.captureSnapshot(label);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Run leak detection on an operation.
|
|
562
|
+
*/
|
|
563
|
+
async checkLeak(operation, options) {
|
|
564
|
+
const result = await this.leakDetector.detectLeak(operation, options);
|
|
565
|
+
this.leakResults.push(result);
|
|
566
|
+
if (result.hasLeak) {
|
|
567
|
+
this.issues.push({
|
|
568
|
+
type: "memory-leak",
|
|
569
|
+
severity: "error",
|
|
570
|
+
message: result.message,
|
|
571
|
+
metric: "heapUsed",
|
|
572
|
+
actual: result.totalGrowth,
|
|
573
|
+
expected: options?.threshold ?? 1024 * 1024
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Run parallel leak detection using multiple clients.
|
|
580
|
+
* Each worker gets its own client for true parallel HTTP requests.
|
|
581
|
+
*
|
|
582
|
+
* @param operationFactory - Factory that receives a client and worker index, returns an operation function
|
|
583
|
+
* @param options - Detection options including worker count and client factory
|
|
584
|
+
* @returns Combined leak detection result with per-worker statistics
|
|
585
|
+
*
|
|
586
|
+
* @example
|
|
587
|
+
* ```typescript
|
|
588
|
+
* const result = await perf.checkLeakParallel(
|
|
589
|
+
* (client, workerId) => async () => {
|
|
590
|
+
* await client.tools.call('loadSkills', { skillIds: ['review-pr'] });
|
|
591
|
+
* },
|
|
592
|
+
* {
|
|
593
|
+
* iterations: 1000,
|
|
594
|
+
* workers: 5,
|
|
595
|
+
* clientFactory: () => server.createClient(),
|
|
596
|
+
* }
|
|
597
|
+
* );
|
|
598
|
+
* // 5 workers × ~80 req/s = ~400 req/s total
|
|
599
|
+
* expect(result.totalRequestsPerSecond).toBeGreaterThan(300);
|
|
600
|
+
* ```
|
|
601
|
+
*/
|
|
602
|
+
async checkLeakParallel(operationFactory, options) {
|
|
603
|
+
const result = await this.leakDetector.detectLeakParallel(operationFactory, options);
|
|
604
|
+
this.leakResults.push(result);
|
|
605
|
+
if (result.hasLeak) {
|
|
606
|
+
this.issues.push({
|
|
607
|
+
type: "memory-leak",
|
|
608
|
+
severity: "error",
|
|
609
|
+
message: result.message,
|
|
610
|
+
metric: "heapUsed",
|
|
611
|
+
actual: result.totalGrowth,
|
|
612
|
+
expected: options?.threshold ?? 1024 * 1024
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Assert that metrics are within thresholds.
|
|
619
|
+
*/
|
|
620
|
+
assertThresholds(thresholds) {
|
|
621
|
+
const finalSnapshot = this.collector.captureSnapshot("final");
|
|
622
|
+
const baseline = this.collector.getBaseline();
|
|
623
|
+
if (!baseline) {
|
|
624
|
+
throw new Error("Cannot assert thresholds without baseline. Call baseline() first.");
|
|
625
|
+
}
|
|
626
|
+
const duration = Date.now() - this.startTime;
|
|
627
|
+
const memoryDelta = this.collector.calculateMemoryDelta(finalSnapshot.memory);
|
|
628
|
+
if (thresholds.maxHeapDelta !== void 0 && memoryDelta) {
|
|
629
|
+
if (memoryDelta.heapUsed > thresholds.maxHeapDelta) {
|
|
630
|
+
const issue = {
|
|
631
|
+
type: "threshold-exceeded",
|
|
632
|
+
severity: "error",
|
|
633
|
+
message: `Heap delta ${formatBytes(memoryDelta.heapUsed)} exceeds threshold ${formatBytes(thresholds.maxHeapDelta)}`,
|
|
634
|
+
metric: "heapUsed",
|
|
635
|
+
actual: memoryDelta.heapUsed,
|
|
636
|
+
expected: thresholds.maxHeapDelta
|
|
637
|
+
};
|
|
638
|
+
this.issues.push(issue);
|
|
639
|
+
throw new Error(issue.message);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (thresholds.maxDurationMs !== void 0) {
|
|
643
|
+
if (duration > thresholds.maxDurationMs) {
|
|
644
|
+
const issue = {
|
|
645
|
+
type: "threshold-exceeded",
|
|
646
|
+
severity: "error",
|
|
647
|
+
message: `Duration ${formatDuration(duration)} exceeds threshold ${formatDuration(thresholds.maxDurationMs)}`,
|
|
648
|
+
metric: "durationMs",
|
|
649
|
+
actual: duration,
|
|
650
|
+
expected: thresholds.maxDurationMs
|
|
651
|
+
};
|
|
652
|
+
this.issues.push(issue);
|
|
653
|
+
throw new Error(issue.message);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (thresholds.maxCpuTime !== void 0) {
|
|
657
|
+
const cpuUsage = this.collector.getCpuUsage();
|
|
658
|
+
if (cpuUsage.total > thresholds.maxCpuTime) {
|
|
659
|
+
const issue = {
|
|
660
|
+
type: "threshold-exceeded",
|
|
661
|
+
severity: "error",
|
|
662
|
+
message: `CPU time ${cpuUsage.total}\xB5s exceeds threshold ${thresholds.maxCpuTime}\xB5s`,
|
|
663
|
+
metric: "cpuTime",
|
|
664
|
+
actual: cpuUsage.total,
|
|
665
|
+
expected: thresholds.maxCpuTime
|
|
666
|
+
};
|
|
667
|
+
this.issues.push(issue);
|
|
668
|
+
throw new Error(issue.message);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (thresholds.maxRssDelta !== void 0 && memoryDelta) {
|
|
672
|
+
if (memoryDelta.rss > thresholds.maxRssDelta) {
|
|
673
|
+
const issue = {
|
|
674
|
+
type: "threshold-exceeded",
|
|
675
|
+
severity: "error",
|
|
676
|
+
message: `RSS delta ${formatBytes(memoryDelta.rss)} exceeds threshold ${formatBytes(thresholds.maxRssDelta)}`,
|
|
677
|
+
metric: "rss",
|
|
678
|
+
actual: memoryDelta.rss,
|
|
679
|
+
expected: thresholds.maxRssDelta
|
|
680
|
+
};
|
|
681
|
+
this.issues.push(issue);
|
|
682
|
+
throw new Error(issue.message);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Get all measurements so far.
|
|
688
|
+
*/
|
|
689
|
+
getMeasurements() {
|
|
690
|
+
return this.collector.getMeasurements();
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Get the current test name.
|
|
694
|
+
*/
|
|
695
|
+
getTestName() {
|
|
696
|
+
return this.testName;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Get the project name.
|
|
700
|
+
*/
|
|
701
|
+
getProject() {
|
|
702
|
+
return this.project;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Get all detected issues.
|
|
706
|
+
*/
|
|
707
|
+
getIssues() {
|
|
708
|
+
return [...this.issues];
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Build a complete PerfMeasurement for this test.
|
|
712
|
+
*/
|
|
713
|
+
buildMeasurement() {
|
|
714
|
+
const endTime = Date.now();
|
|
715
|
+
const finalSnapshot = this.collector.captureSnapshot("final");
|
|
716
|
+
const baseline = this.collector.getBaseline();
|
|
717
|
+
const memoryDelta = baseline ? this.collector.calculateMemoryDelta(finalSnapshot.memory) : void 0;
|
|
718
|
+
return {
|
|
719
|
+
name: this.testName,
|
|
720
|
+
project: this.project,
|
|
721
|
+
baseline: baseline ?? finalSnapshot,
|
|
722
|
+
measurements: this.collector.getMeasurements(),
|
|
723
|
+
final: finalSnapshot,
|
|
724
|
+
timing: {
|
|
725
|
+
startTime: this.startTime || endTime,
|
|
726
|
+
endTime,
|
|
727
|
+
durationMs: endTime - (this.startTime || endTime)
|
|
728
|
+
},
|
|
729
|
+
memoryDelta: memoryDelta ?? void 0,
|
|
730
|
+
issues: this.getIssues(),
|
|
731
|
+
leakDetectionResults: this.leakResults.length > 0 ? this.leakResults : void 0
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Reset the fixture state.
|
|
736
|
+
*/
|
|
737
|
+
reset() {
|
|
738
|
+
this.collector.reset();
|
|
739
|
+
this.baselineSnapshot = null;
|
|
740
|
+
this.startTime = 0;
|
|
741
|
+
this.issues = [];
|
|
742
|
+
this.leakResults = [];
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
var MEASUREMENTS_KEY = "__FRONTMCP_PERF_MEASUREMENTS__";
|
|
746
|
+
function getGlobalMeasurementsArray() {
|
|
747
|
+
if (!globalThis[MEASUREMENTS_KEY]) {
|
|
748
|
+
globalThis[MEASUREMENTS_KEY] = [];
|
|
749
|
+
}
|
|
750
|
+
return globalThis[MEASUREMENTS_KEY];
|
|
751
|
+
}
|
|
752
|
+
function addGlobalMeasurement(measurement) {
|
|
753
|
+
getGlobalMeasurementsArray().push(measurement);
|
|
754
|
+
}
|
|
755
|
+
function getGlobalMeasurements() {
|
|
756
|
+
return [...getGlobalMeasurementsArray()];
|
|
757
|
+
}
|
|
758
|
+
function clearGlobalMeasurements() {
|
|
759
|
+
const arr = getGlobalMeasurementsArray();
|
|
760
|
+
arr.length = 0;
|
|
761
|
+
}
|
|
762
|
+
function getMeasurementsForProject(project) {
|
|
763
|
+
return getGlobalMeasurementsArray().filter((m) => m.project === project);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
767
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
768
|
+
function getPlatformClientInfo(platform) {
|
|
769
|
+
switch (platform) {
|
|
770
|
+
case "openai":
|
|
771
|
+
return {
|
|
772
|
+
name: "ChatGPT",
|
|
773
|
+
version: "1.0"
|
|
774
|
+
};
|
|
775
|
+
case "ext-apps":
|
|
776
|
+
return {
|
|
777
|
+
name: "mcp-ext-apps",
|
|
778
|
+
version: "1.0"
|
|
779
|
+
};
|
|
780
|
+
case "claude":
|
|
781
|
+
return {
|
|
782
|
+
name: "claude-desktop",
|
|
783
|
+
version: "1.0"
|
|
784
|
+
};
|
|
785
|
+
case "cursor":
|
|
786
|
+
return {
|
|
787
|
+
name: "cursor",
|
|
788
|
+
version: "1.0"
|
|
789
|
+
};
|
|
790
|
+
case "continue":
|
|
791
|
+
return {
|
|
792
|
+
name: "continue",
|
|
793
|
+
version: "1.0"
|
|
794
|
+
};
|
|
795
|
+
case "cody":
|
|
796
|
+
return {
|
|
797
|
+
name: "cody",
|
|
798
|
+
version: "1.0"
|
|
799
|
+
};
|
|
800
|
+
case "gemini":
|
|
801
|
+
return {
|
|
802
|
+
name: "gemini",
|
|
803
|
+
version: "1.0"
|
|
804
|
+
};
|
|
805
|
+
case "generic-mcp":
|
|
806
|
+
return {
|
|
807
|
+
name: "generic-mcp-client",
|
|
808
|
+
version: "1.0"
|
|
809
|
+
};
|
|
810
|
+
case "unknown":
|
|
811
|
+
default:
|
|
812
|
+
return {
|
|
813
|
+
name: "mcp-test-client",
|
|
814
|
+
version: "1.0"
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function getPlatformCapabilities(platform) {
|
|
819
|
+
const baseCapabilities = {
|
|
820
|
+
sampling: {},
|
|
821
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
822
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
823
|
+
elicitation: {
|
|
824
|
+
form: {}
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
if (platform === "ext-apps") {
|
|
828
|
+
return {
|
|
829
|
+
...baseCapabilities,
|
|
830
|
+
experimental: {
|
|
831
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
832
|
+
mimeTypes: ["text/html+mcp"]
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
return baseCapabilities;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
841
|
+
var McpTestClientBuilder = class {
|
|
842
|
+
config;
|
|
843
|
+
constructor(config) {
|
|
844
|
+
this.config = { ...config };
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Set the authentication configuration
|
|
848
|
+
*/
|
|
849
|
+
withAuth(auth) {
|
|
850
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
851
|
+
return this;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Set the bearer token for authentication
|
|
855
|
+
*/
|
|
856
|
+
withToken(token) {
|
|
857
|
+
this.config.auth = { ...this.config.auth, token };
|
|
858
|
+
return this;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Add custom headers to all requests
|
|
862
|
+
*/
|
|
863
|
+
withHeaders(headers) {
|
|
864
|
+
this.config.auth = {
|
|
865
|
+
...this.config.auth,
|
|
866
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
867
|
+
};
|
|
868
|
+
return this;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Set the transport type
|
|
872
|
+
*/
|
|
873
|
+
withTransport(transport) {
|
|
874
|
+
this.config.transport = transport;
|
|
875
|
+
return this;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Set the request timeout in milliseconds
|
|
879
|
+
*/
|
|
880
|
+
withTimeout(timeoutMs) {
|
|
881
|
+
this.config.timeout = timeoutMs;
|
|
882
|
+
return this;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Enable debug logging
|
|
886
|
+
*/
|
|
887
|
+
withDebug(enabled = true) {
|
|
888
|
+
this.config.debug = enabled;
|
|
889
|
+
return this;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Enable public mode - skip authentication entirely.
|
|
893
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
894
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
895
|
+
*/
|
|
896
|
+
withPublicMode(enabled = true) {
|
|
897
|
+
this.config.publicMode = enabled;
|
|
898
|
+
return this;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Set the MCP protocol version to request
|
|
902
|
+
*/
|
|
903
|
+
withProtocolVersion(version) {
|
|
904
|
+
this.config.protocolVersion = version;
|
|
905
|
+
return this;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Set the client info sent during initialization
|
|
909
|
+
*/
|
|
910
|
+
withClientInfo(info) {
|
|
911
|
+
this.config.clientInfo = info;
|
|
912
|
+
return this;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
916
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
917
|
+
*
|
|
918
|
+
* Platform-specific behavior:
|
|
919
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
920
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
921
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
922
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
923
|
+
* - Other platforms follow similar patterns
|
|
924
|
+
*
|
|
925
|
+
* @example
|
|
926
|
+
* ```typescript
|
|
927
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
928
|
+
* .withPlatform('openai')
|
|
929
|
+
* .buildAndConnect();
|
|
930
|
+
*
|
|
931
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
932
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
933
|
+
* .withPlatform('ext-apps')
|
|
934
|
+
* .buildAndConnect();
|
|
935
|
+
* ```
|
|
936
|
+
*/
|
|
937
|
+
withPlatform(platform) {
|
|
938
|
+
this.config.platform = platform;
|
|
939
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
940
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
941
|
+
return this;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Set custom client capabilities for MCP initialization.
|
|
945
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
946
|
+
*
|
|
947
|
+
* @example
|
|
948
|
+
* ```typescript
|
|
949
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
950
|
+
* .withCapabilities({
|
|
951
|
+
* sampling: {},
|
|
952
|
+
* experimental: {
|
|
953
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html+mcp'] }
|
|
954
|
+
* }
|
|
955
|
+
* })
|
|
956
|
+
* .buildAndConnect();
|
|
957
|
+
* ```
|
|
958
|
+
*/
|
|
959
|
+
withCapabilities(capabilities) {
|
|
960
|
+
this.config.capabilities = capabilities;
|
|
961
|
+
return this;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Set query parameters to append to the connection URL.
|
|
965
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
966
|
+
*
|
|
967
|
+
* @example
|
|
968
|
+
* ```typescript
|
|
969
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
970
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
971
|
+
* .buildAndConnect();
|
|
972
|
+
* ```
|
|
973
|
+
*/
|
|
974
|
+
withQueryParams(params) {
|
|
975
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
976
|
+
return this;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Build the McpTestClient instance (does not connect)
|
|
980
|
+
*/
|
|
981
|
+
build() {
|
|
982
|
+
return new McpTestClient(this.config);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Build the McpTestClient and connect to the server
|
|
986
|
+
*/
|
|
987
|
+
async buildAndConnect() {
|
|
988
|
+
const client = this.build();
|
|
989
|
+
await client.connect();
|
|
990
|
+
return client;
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// libs/testing/src/transport/streamable-http.transport.ts
|
|
995
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
996
|
+
var StreamableHttpTransport = class {
|
|
997
|
+
config;
|
|
998
|
+
state = "disconnected";
|
|
999
|
+
sessionId;
|
|
1000
|
+
authToken;
|
|
1001
|
+
connectionCount = 0;
|
|
1002
|
+
reconnectCount = 0;
|
|
1003
|
+
lastRequestHeaders = {};
|
|
1004
|
+
interceptors;
|
|
1005
|
+
publicMode;
|
|
1006
|
+
elicitationHandler;
|
|
1007
|
+
constructor(config) {
|
|
1008
|
+
this.config = {
|
|
1009
|
+
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
1010
|
+
// Remove trailing slash
|
|
1011
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
1012
|
+
auth: config.auth ?? {},
|
|
1013
|
+
publicMode: config.publicMode ?? false,
|
|
1014
|
+
debug: config.debug ?? false,
|
|
1015
|
+
interceptors: config.interceptors,
|
|
1016
|
+
clientInfo: config.clientInfo
|
|
1017
|
+
};
|
|
1018
|
+
this.authToken = config.auth?.token;
|
|
1019
|
+
this.interceptors = config.interceptors;
|
|
1020
|
+
this.publicMode = config.publicMode ?? false;
|
|
1021
|
+
this.elicitationHandler = config.elicitationHandler;
|
|
1022
|
+
}
|
|
1023
|
+
async connect() {
|
|
1024
|
+
this.state = "connecting";
|
|
1025
|
+
this.connectionCount++;
|
|
1026
|
+
try {
|
|
1027
|
+
if (this.publicMode) {
|
|
1028
|
+
this.log("Public mode: connecting without authentication");
|
|
1029
|
+
this.state = "connected";
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (!this.authToken) {
|
|
1033
|
+
await this.requestAnonymousToken();
|
|
1034
|
+
}
|
|
1035
|
+
this.state = "connected";
|
|
1036
|
+
this.log("Connected to StreamableHTTP transport");
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
this.state = "error";
|
|
1039
|
+
throw error;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Request an anonymous token from the FrontMCP OAuth endpoint
|
|
1044
|
+
* This allows the test client to authenticate without user interaction
|
|
1045
|
+
*/
|
|
1046
|
+
async requestAnonymousToken() {
|
|
1047
|
+
const clientId = crypto.randomUUID();
|
|
1048
|
+
const tokenUrl = `${this.config.baseUrl}/oauth/token`;
|
|
1049
|
+
this.log(`Requesting anonymous token from ${tokenUrl}`);
|
|
1050
|
+
try {
|
|
1051
|
+
const response = await fetch(tokenUrl, {
|
|
1052
|
+
method: "POST",
|
|
1053
|
+
headers: {
|
|
1054
|
+
"Content-Type": "application/json"
|
|
1055
|
+
},
|
|
1056
|
+
body: JSON.stringify({
|
|
1057
|
+
grant_type: "anonymous",
|
|
1058
|
+
client_id: clientId,
|
|
1059
|
+
resource: this.config.baseUrl
|
|
1060
|
+
})
|
|
1061
|
+
});
|
|
1062
|
+
if (!response.ok) {
|
|
1063
|
+
const errorText = await response.text();
|
|
1064
|
+
this.log(`Failed to get anonymous token: ${response.status} ${errorText}`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const tokenResponse = await response.json();
|
|
1068
|
+
if (tokenResponse.access_token) {
|
|
1069
|
+
this.authToken = tokenResponse.access_token;
|
|
1070
|
+
this.log("Anonymous token acquired successfully");
|
|
1071
|
+
}
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
this.log(`Error requesting anonymous token: ${error}`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async request(message) {
|
|
1077
|
+
this.ensureConnected();
|
|
1078
|
+
const startTime = Date.now();
|
|
1079
|
+
if (this.interceptors) {
|
|
1080
|
+
const interceptResult = await this.interceptors.processRequest(message, {
|
|
1081
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1082
|
+
transport: "streamable-http",
|
|
1083
|
+
sessionId: this.sessionId
|
|
1084
|
+
});
|
|
1085
|
+
switch (interceptResult.type) {
|
|
1086
|
+
case "mock": {
|
|
1087
|
+
const mockResponse2 = await this.interceptors.processResponse(
|
|
1088
|
+
message,
|
|
1089
|
+
interceptResult.response,
|
|
1090
|
+
Date.now() - startTime
|
|
1091
|
+
);
|
|
1092
|
+
return mockResponse2;
|
|
1093
|
+
}
|
|
1094
|
+
case "error":
|
|
1095
|
+
throw interceptResult.error;
|
|
1096
|
+
case "continue":
|
|
1097
|
+
message = interceptResult.request;
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const headers = this.buildHeaders();
|
|
1102
|
+
this.lastRequestHeaders = headers;
|
|
1103
|
+
const url = `${this.config.baseUrl}/`;
|
|
1104
|
+
this.log(`POST ${url}`, message);
|
|
1105
|
+
const controller = new AbortController();
|
|
1106
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
1107
|
+
try {
|
|
1108
|
+
const response = await fetch(url, {
|
|
1109
|
+
method: "POST",
|
|
1110
|
+
headers,
|
|
1111
|
+
body: JSON.stringify(message),
|
|
1112
|
+
signal: controller.signal
|
|
1113
|
+
});
|
|
1114
|
+
const newSessionId = response.headers.get("mcp-session-id");
|
|
1115
|
+
if (newSessionId) {
|
|
1116
|
+
this.sessionId = newSessionId;
|
|
1117
|
+
}
|
|
1118
|
+
let jsonResponse;
|
|
1119
|
+
if (!response.ok) {
|
|
1120
|
+
const errorText = await response.text();
|
|
1121
|
+
this.log(`HTTP Error ${response.status}: ${errorText}`);
|
|
1122
|
+
jsonResponse = {
|
|
1123
|
+
jsonrpc: "2.0",
|
|
1124
|
+
id: message.id ?? null,
|
|
1125
|
+
error: {
|
|
1126
|
+
code: -32e3,
|
|
1127
|
+
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
1128
|
+
data: errorText
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
} else {
|
|
1132
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
1133
|
+
if (contentType.includes("text/event-stream")) {
|
|
1134
|
+
jsonResponse = await this.handleSSEResponseWithElicitation(response, message);
|
|
1135
|
+
} else {
|
|
1136
|
+
const text = await response.text();
|
|
1137
|
+
this.log("Response:", text);
|
|
1138
|
+
if (!text.trim()) {
|
|
1139
|
+
jsonResponse = {
|
|
1140
|
+
jsonrpc: "2.0",
|
|
1141
|
+
id: message.id ?? null,
|
|
1142
|
+
result: void 0
|
|
1143
|
+
};
|
|
1144
|
+
} else {
|
|
1145
|
+
jsonResponse = JSON.parse(text);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (this.interceptors) {
|
|
1150
|
+
jsonResponse = await this.interceptors.processResponse(message, jsonResponse, Date.now() - startTime);
|
|
1151
|
+
}
|
|
1152
|
+
clearTimeout(timeoutId);
|
|
1153
|
+
return jsonResponse;
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
clearTimeout(timeoutId);
|
|
1156
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
1157
|
+
return {
|
|
1158
|
+
jsonrpc: "2.0",
|
|
1159
|
+
id: message.id ?? null,
|
|
1160
|
+
error: {
|
|
1161
|
+
code: -32e3,
|
|
1162
|
+
message: `Request timeout after ${this.config.timeout}ms`
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
async notify(message) {
|
|
1170
|
+
this.ensureConnected();
|
|
1171
|
+
const headers = this.buildHeaders();
|
|
1172
|
+
this.lastRequestHeaders = headers;
|
|
1173
|
+
const url = `${this.config.baseUrl}/`;
|
|
1174
|
+
this.log(`POST ${url} (notification)`, message);
|
|
1175
|
+
const controller = new AbortController();
|
|
1176
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
1177
|
+
try {
|
|
1178
|
+
const response = await fetch(url, {
|
|
1179
|
+
method: "POST",
|
|
1180
|
+
headers,
|
|
1181
|
+
body: JSON.stringify(message),
|
|
1182
|
+
signal: controller.signal
|
|
1183
|
+
});
|
|
1184
|
+
clearTimeout(timeoutId);
|
|
1185
|
+
const newSessionId = response.headers.get("mcp-session-id");
|
|
1186
|
+
if (newSessionId) {
|
|
1187
|
+
this.sessionId = newSessionId;
|
|
1188
|
+
}
|
|
1189
|
+
if (!response.ok) {
|
|
1190
|
+
const errorText = await response.text();
|
|
1191
|
+
this.log(`HTTP Error ${response.status} on notification: ${errorText}`);
|
|
1192
|
+
}
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
clearTimeout(timeoutId);
|
|
1195
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
1196
|
+
throw error;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
async sendRaw(data) {
|
|
1201
|
+
this.ensureConnected();
|
|
1202
|
+
const headers = this.buildHeaders();
|
|
1203
|
+
this.lastRequestHeaders = headers;
|
|
1204
|
+
const url = `${this.config.baseUrl}/`;
|
|
1205
|
+
this.log(`POST ${url} (raw)`, data);
|
|
1206
|
+
const controller = new AbortController();
|
|
1207
|
+
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
1208
|
+
try {
|
|
1209
|
+
const response = await fetch(url, {
|
|
1210
|
+
method: "POST",
|
|
1211
|
+
headers,
|
|
1212
|
+
body: data,
|
|
1213
|
+
signal: controller.signal
|
|
1214
|
+
});
|
|
1215
|
+
clearTimeout(timeoutId);
|
|
1216
|
+
const text = await response.text();
|
|
1217
|
+
if (!text.trim()) {
|
|
1218
|
+
return {
|
|
1219
|
+
jsonrpc: "2.0",
|
|
1220
|
+
id: null,
|
|
1221
|
+
error: {
|
|
1222
|
+
code: -32700,
|
|
1223
|
+
message: "Parse error"
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
return JSON.parse(text);
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
clearTimeout(timeoutId);
|
|
1230
|
+
return {
|
|
1231
|
+
jsonrpc: "2.0",
|
|
1232
|
+
id: null,
|
|
1233
|
+
error: {
|
|
1234
|
+
code: -32700,
|
|
1235
|
+
message: "Parse error",
|
|
1236
|
+
data: error instanceof Error ? error.message : "Unknown error"
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
async close() {
|
|
1242
|
+
this.state = "disconnected";
|
|
1243
|
+
this.sessionId = void 0;
|
|
1244
|
+
this.log("StreamableHTTP transport closed");
|
|
1245
|
+
}
|
|
1246
|
+
isConnected() {
|
|
1247
|
+
return this.state === "connected";
|
|
1248
|
+
}
|
|
1249
|
+
getState() {
|
|
1250
|
+
return this.state;
|
|
1251
|
+
}
|
|
1252
|
+
getSessionId() {
|
|
1253
|
+
return this.sessionId;
|
|
1254
|
+
}
|
|
1255
|
+
setAuthToken(token) {
|
|
1256
|
+
this.authToken = token;
|
|
1257
|
+
}
|
|
1258
|
+
setTimeout(ms) {
|
|
1259
|
+
this.config.timeout = ms;
|
|
1260
|
+
}
|
|
1261
|
+
setInterceptors(interceptors2) {
|
|
1262
|
+
this.interceptors = interceptors2;
|
|
1263
|
+
}
|
|
1264
|
+
getInterceptors() {
|
|
1265
|
+
return this.interceptors;
|
|
1266
|
+
}
|
|
1267
|
+
setElicitationHandler(handler) {
|
|
1268
|
+
this.elicitationHandler = handler;
|
|
1269
|
+
}
|
|
1270
|
+
getConnectionCount() {
|
|
1271
|
+
return this.connectionCount;
|
|
1272
|
+
}
|
|
1273
|
+
getReconnectCount() {
|
|
1274
|
+
return this.reconnectCount;
|
|
1275
|
+
}
|
|
1276
|
+
getLastRequestHeaders() {
|
|
1277
|
+
return { ...this.lastRequestHeaders };
|
|
1278
|
+
}
|
|
1279
|
+
async simulateDisconnect() {
|
|
1280
|
+
this.state = "disconnected";
|
|
1281
|
+
this.sessionId = void 0;
|
|
1282
|
+
}
|
|
1283
|
+
async waitForReconnect(timeoutMs) {
|
|
1284
|
+
const deadline = Date.now() + timeoutMs;
|
|
1285
|
+
this.reconnectCount++;
|
|
1286
|
+
await this.connect();
|
|
1287
|
+
while (Date.now() < deadline) {
|
|
1288
|
+
if (this.state === "connected") {
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1292
|
+
}
|
|
1293
|
+
throw new Error("Timeout waiting for reconnection");
|
|
1294
|
+
}
|
|
1295
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1296
|
+
// PRIVATE HELPERS
|
|
1297
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1298
|
+
/**
|
|
1299
|
+
* Handle SSE response with elicitation support.
|
|
1300
|
+
*
|
|
1301
|
+
* Streams the SSE response, detects elicitation/create requests, and handles them
|
|
1302
|
+
* by calling the registered handler and sending the response back to the server.
|
|
1303
|
+
*/
|
|
1304
|
+
async handleSSEResponseWithElicitation(response, originalRequest) {
|
|
1305
|
+
this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
|
|
1306
|
+
const reader = response.body?.getReader();
|
|
1307
|
+
if (!reader) {
|
|
1308
|
+
this.log("handleSSEResponseWithElicitation: no response body");
|
|
1309
|
+
return {
|
|
1310
|
+
jsonrpc: "2.0",
|
|
1311
|
+
id: originalRequest.id ?? null,
|
|
1312
|
+
error: { code: -32e3, message: "No response body" }
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
const decoder = new TextDecoder();
|
|
1316
|
+
let buffer = "";
|
|
1317
|
+
let finalResponse = null;
|
|
1318
|
+
let sseSessionId;
|
|
1319
|
+
try {
|
|
1320
|
+
let readCount = 0;
|
|
1321
|
+
while (true) {
|
|
1322
|
+
readCount++;
|
|
1323
|
+
this.log(`handleSSEResponseWithElicitation: reading chunk ${readCount}`);
|
|
1324
|
+
const { done, value } = await reader.read();
|
|
1325
|
+
this.log(`handleSSEResponseWithElicitation: read result`, { done, valueLength: value?.length });
|
|
1326
|
+
if (done) {
|
|
1327
|
+
if (buffer.trim()) {
|
|
1328
|
+
const parsed = this.parseSSEEvents(buffer, originalRequest.id);
|
|
1329
|
+
for (const event of parsed.events) {
|
|
1330
|
+
const handled = await this.handleSSEEvent(event);
|
|
1331
|
+
if (handled.isFinal) {
|
|
1332
|
+
finalResponse = handled.response;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (parsed.sessionId && !sseSessionId) {
|
|
1336
|
+
sseSessionId = parsed.sessionId;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
break;
|
|
1340
|
+
}
|
|
1341
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1342
|
+
const eventEndPattern = /\n\n/g;
|
|
1343
|
+
let lastEventEnd = 0;
|
|
1344
|
+
let match;
|
|
1345
|
+
while ((match = eventEndPattern.exec(buffer)) !== null) {
|
|
1346
|
+
const eventText = buffer.slice(lastEventEnd, match.index);
|
|
1347
|
+
lastEventEnd = match.index + 2;
|
|
1348
|
+
if (eventText.trim()) {
|
|
1349
|
+
const parsed = this.parseSSEEvents(eventText, originalRequest.id);
|
|
1350
|
+
for (const event of parsed.events) {
|
|
1351
|
+
const handled = await this.handleSSEEvent(event);
|
|
1352
|
+
if (handled.isFinal) {
|
|
1353
|
+
finalResponse = handled.response;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
if (parsed.sessionId && !sseSessionId) {
|
|
1357
|
+
sseSessionId = parsed.sessionId;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
buffer = buffer.slice(lastEventEnd);
|
|
1362
|
+
}
|
|
1363
|
+
} finally {
|
|
1364
|
+
reader.releaseLock();
|
|
1365
|
+
}
|
|
1366
|
+
if (sseSessionId && !this.sessionId) {
|
|
1367
|
+
this.sessionId = sseSessionId;
|
|
1368
|
+
this.log("Session ID from SSE:", this.sessionId);
|
|
1369
|
+
}
|
|
1370
|
+
if (finalResponse) {
|
|
1371
|
+
return finalResponse;
|
|
1372
|
+
}
|
|
1373
|
+
return {
|
|
1374
|
+
jsonrpc: "2.0",
|
|
1375
|
+
id: originalRequest.id ?? null,
|
|
1376
|
+
error: { code: -32e3, message: "No final response received in SSE stream" }
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Parse SSE event text into structured events
|
|
1381
|
+
*/
|
|
1382
|
+
parseSSEEvents(text, _requestId) {
|
|
1383
|
+
const lines = text.split("\n");
|
|
1384
|
+
const events = [];
|
|
1385
|
+
let currentEvent = { type: "message", data: [] };
|
|
1386
|
+
let sessionId;
|
|
1387
|
+
for (const line of lines) {
|
|
1388
|
+
if (line.startsWith("event: ")) {
|
|
1389
|
+
currentEvent.type = line.slice(7);
|
|
1390
|
+
} else if (line.startsWith("data: ")) {
|
|
1391
|
+
currentEvent.data.push(line.slice(6));
|
|
1392
|
+
} else if (line === "data:") {
|
|
1393
|
+
currentEvent.data.push("");
|
|
1394
|
+
} else if (line.startsWith("id: ")) {
|
|
1395
|
+
const idValue = line.slice(4);
|
|
1396
|
+
currentEvent.id = idValue;
|
|
1397
|
+
const colonIndex = idValue.lastIndexOf(":");
|
|
1398
|
+
if (colonIndex > 0) {
|
|
1399
|
+
sessionId = idValue.substring(0, colonIndex);
|
|
1400
|
+
} else {
|
|
1401
|
+
sessionId = idValue;
|
|
1402
|
+
}
|
|
1403
|
+
} else if (line === "" && currentEvent.data.length > 0) {
|
|
1404
|
+
events.push({
|
|
1405
|
+
type: currentEvent.type,
|
|
1406
|
+
data: currentEvent.data.join("\n"),
|
|
1407
|
+
id: currentEvent.id
|
|
1408
|
+
});
|
|
1409
|
+
currentEvent = { type: "message", data: [] };
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
if (currentEvent.data.length > 0) {
|
|
1413
|
+
events.push({
|
|
1414
|
+
type: currentEvent.type,
|
|
1415
|
+
data: currentEvent.data.join("\n"),
|
|
1416
|
+
id: currentEvent.id
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
return { events, sessionId };
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Handle a single SSE event, including elicitation requests
|
|
1423
|
+
*/
|
|
1424
|
+
async handleSSEEvent(event) {
|
|
1425
|
+
this.log("SSE Event:", { type: event.type, data: event.data.slice(0, 200) });
|
|
1426
|
+
try {
|
|
1427
|
+
const parsed = JSON.parse(event.data);
|
|
1428
|
+
if ("method" in parsed && parsed.method === "elicitation/create") {
|
|
1429
|
+
await this.handleElicitationRequest(parsed);
|
|
1430
|
+
return {
|
|
1431
|
+
isFinal: false,
|
|
1432
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
if ("result" in parsed || "error" in parsed) {
|
|
1436
|
+
return { isFinal: true, response: parsed };
|
|
1437
|
+
}
|
|
1438
|
+
return {
|
|
1439
|
+
isFinal: false,
|
|
1440
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1441
|
+
};
|
|
1442
|
+
} catch {
|
|
1443
|
+
this.log("Failed to parse SSE event data:", event.data);
|
|
1444
|
+
return {
|
|
1445
|
+
isFinal: false,
|
|
1446
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Handle an elicitation/create request from the server
|
|
1452
|
+
*/
|
|
1453
|
+
async handleElicitationRequest(request) {
|
|
1454
|
+
const params = request.params;
|
|
1455
|
+
this.log("Elicitation request received:", {
|
|
1456
|
+
mode: params?.mode,
|
|
1457
|
+
message: params?.message?.slice(0, 100)
|
|
1458
|
+
});
|
|
1459
|
+
const requestId = request.id;
|
|
1460
|
+
if (requestId === void 0 || requestId === null) {
|
|
1461
|
+
this.log("Elicitation request has no ID, cannot respond");
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (!this.elicitationHandler) {
|
|
1465
|
+
this.log("No elicitation handler registered, sending error");
|
|
1466
|
+
await this.sendElicitationResponse(requestId, {
|
|
1467
|
+
action: "decline"
|
|
1468
|
+
});
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
try {
|
|
1472
|
+
const response = await this.elicitationHandler(params);
|
|
1473
|
+
this.log("Elicitation handler response:", response);
|
|
1474
|
+
await this.sendElicitationResponse(requestId, response);
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
this.log("Elicitation handler error:", error);
|
|
1477
|
+
await this.sendElicitationResponse(requestId, {
|
|
1478
|
+
action: "cancel"
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Send an elicitation response back to the server
|
|
1484
|
+
*/
|
|
1485
|
+
async sendElicitationResponse(requestId, response) {
|
|
1486
|
+
const headers = this.buildHeaders();
|
|
1487
|
+
const url = `${this.config.baseUrl}/`;
|
|
1488
|
+
const rpcResponse = {
|
|
1489
|
+
jsonrpc: "2.0",
|
|
1490
|
+
id: requestId,
|
|
1491
|
+
result: response
|
|
1492
|
+
};
|
|
1493
|
+
this.log("Sending elicitation response:", rpcResponse);
|
|
1494
|
+
try {
|
|
1495
|
+
const fetchResponse = await fetch(url, {
|
|
1496
|
+
method: "POST",
|
|
1497
|
+
headers,
|
|
1498
|
+
body: JSON.stringify(rpcResponse)
|
|
1499
|
+
});
|
|
1500
|
+
if (!fetchResponse.ok) {
|
|
1501
|
+
this.log(`Elicitation response HTTP error: ${fetchResponse.status}`);
|
|
1502
|
+
}
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
this.log("Failed to send elicitation response:", error);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
buildHeaders() {
|
|
1508
|
+
const headers = {
|
|
1509
|
+
"Content-Type": "application/json",
|
|
1510
|
+
Accept: "application/json, text/event-stream"
|
|
1511
|
+
};
|
|
1512
|
+
if (this.config.clientInfo) {
|
|
1513
|
+
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1514
|
+
}
|
|
1515
|
+
if (this.authToken && !this.publicMode) {
|
|
1516
|
+
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1517
|
+
}
|
|
1518
|
+
if (this.sessionId) {
|
|
1519
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
1520
|
+
}
|
|
1521
|
+
if (this.config.auth.headers) {
|
|
1522
|
+
Object.assign(headers, this.config.auth.headers);
|
|
1523
|
+
}
|
|
1524
|
+
return headers;
|
|
1525
|
+
}
|
|
1526
|
+
ensureConnected() {
|
|
1527
|
+
if (this.state !== "connected") {
|
|
1528
|
+
throw new Error("Transport not connected. Call connect() first.");
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
log(message, data) {
|
|
1532
|
+
if (this.config.debug) {
|
|
1533
|
+
console.log(`[StreamableHTTP] ${message}`, data ?? "");
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Parse SSE (Server-Sent Events) response format with session ID extraction
|
|
1538
|
+
* SSE format is:
|
|
1539
|
+
* event: message
|
|
1540
|
+
* id: sessionId:messageId
|
|
1541
|
+
* data: {"jsonrpc":"2.0",...}
|
|
1542
|
+
*
|
|
1543
|
+
* The id field contains the session ID followed by a colon and the message ID.
|
|
1544
|
+
*
|
|
1545
|
+
* @param text - The raw SSE response text
|
|
1546
|
+
* @param requestId - The original request ID
|
|
1547
|
+
* @returns Object with parsed JSON-RPC response and session ID (if found)
|
|
1548
|
+
*/
|
|
1549
|
+
parseSSEResponseWithSession(text, requestId) {
|
|
1550
|
+
const lines = text.split("\n");
|
|
1551
|
+
const dataLines = [];
|
|
1552
|
+
let sseSessionId;
|
|
1553
|
+
for (const line of lines) {
|
|
1554
|
+
if (line.startsWith("data: ")) {
|
|
1555
|
+
dataLines.push(line.slice(6));
|
|
1556
|
+
} else if (line === "data:") {
|
|
1557
|
+
dataLines.push("");
|
|
1558
|
+
} else if (line.startsWith("id: ")) {
|
|
1559
|
+
const idValue = line.slice(4);
|
|
1560
|
+
const colonIndex = idValue.lastIndexOf(":");
|
|
1561
|
+
if (colonIndex > 0) {
|
|
1562
|
+
sseSessionId = idValue.substring(0, colonIndex);
|
|
1563
|
+
} else {
|
|
1564
|
+
sseSessionId = idValue;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if (dataLines.length > 0) {
|
|
1569
|
+
const jsonData = dataLines.join("\n");
|
|
1570
|
+
try {
|
|
1571
|
+
return {
|
|
1572
|
+
response: JSON.parse(jsonData),
|
|
1573
|
+
sseSessionId
|
|
1574
|
+
};
|
|
1575
|
+
} catch {
|
|
1576
|
+
this.log("Failed to parse SSE data as JSON:", jsonData);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return {
|
|
1580
|
+
response: {
|
|
1581
|
+
jsonrpc: "2.0",
|
|
1582
|
+
id: requestId ?? null,
|
|
1583
|
+
error: {
|
|
1584
|
+
code: -32700,
|
|
1585
|
+
message: "Failed to parse SSE response",
|
|
1586
|
+
data: text
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
sseSessionId
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
1595
|
+
var DefaultMockRegistry = class {
|
|
1596
|
+
mocks = [];
|
|
1597
|
+
add(mock) {
|
|
1598
|
+
const entry = {
|
|
1599
|
+
definition: mock,
|
|
1600
|
+
callCount: 0,
|
|
1601
|
+
calls: [],
|
|
1602
|
+
remainingUses: mock.times ?? Infinity
|
|
1603
|
+
};
|
|
1604
|
+
this.mocks.push(entry);
|
|
1605
|
+
return {
|
|
1606
|
+
remove: () => {
|
|
1607
|
+
const index = this.mocks.indexOf(entry);
|
|
1608
|
+
if (index !== -1) {
|
|
1609
|
+
this.mocks.splice(index, 1);
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1612
|
+
callCount: () => entry.callCount,
|
|
1613
|
+
calls: () => [...entry.calls]
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
clear() {
|
|
1617
|
+
this.mocks = [];
|
|
1618
|
+
}
|
|
1619
|
+
getAll() {
|
|
1620
|
+
return this.mocks.map((e) => e.definition);
|
|
1621
|
+
}
|
|
1622
|
+
match(request) {
|
|
1623
|
+
for (const entry of this.mocks) {
|
|
1624
|
+
if (entry.remainingUses <= 0) continue;
|
|
1625
|
+
const { definition } = entry;
|
|
1626
|
+
if (definition.method !== request.method) continue;
|
|
1627
|
+
if (definition.params !== void 0) {
|
|
1628
|
+
const params = request.params ?? {};
|
|
1629
|
+
if (typeof definition.params === "function") {
|
|
1630
|
+
if (!definition.params(params)) continue;
|
|
1631
|
+
} else {
|
|
1632
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
entry.callCount++;
|
|
1636
|
+
entry.calls.push(request);
|
|
1637
|
+
entry.remainingUses--;
|
|
1638
|
+
return definition;
|
|
1639
|
+
}
|
|
1640
|
+
return void 0;
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Check if request params match the mock params definition
|
|
1644
|
+
*/
|
|
1645
|
+
paramsMatch(expected, actual) {
|
|
1646
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
1647
|
+
if (!(key in actual)) return false;
|
|
1648
|
+
const actualValue = actual[key];
|
|
1649
|
+
if (Array.isArray(value)) {
|
|
1650
|
+
if (!Array.isArray(actualValue)) return false;
|
|
1651
|
+
if (value.length !== actualValue.length) return false;
|
|
1652
|
+
for (let i = 0; i < value.length; i++) {
|
|
1653
|
+
const expectedItem = value[i];
|
|
1654
|
+
const actualItem = actualValue[i];
|
|
1655
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1656
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1657
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1658
|
+
return false;
|
|
1659
|
+
}
|
|
1660
|
+
} else if (actualItem !== expectedItem) {
|
|
1661
|
+
return false;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
} else if (typeof value === "object" && value !== null) {
|
|
1665
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1666
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
1667
|
+
return false;
|
|
1668
|
+
}
|
|
1669
|
+
} else if (actualValue !== value) {
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return true;
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
var mockResponse = {
|
|
1677
|
+
/**
|
|
1678
|
+
* Create a successful JSON-RPC response
|
|
1679
|
+
*/
|
|
1680
|
+
success(result, id = 1) {
|
|
1681
|
+
return {
|
|
1682
|
+
jsonrpc: "2.0",
|
|
1683
|
+
id,
|
|
1684
|
+
result
|
|
1685
|
+
};
|
|
1686
|
+
},
|
|
1687
|
+
/**
|
|
1688
|
+
* Create an error JSON-RPC response
|
|
1689
|
+
*/
|
|
1690
|
+
error(code, message, data, id = 1) {
|
|
1691
|
+
return {
|
|
1692
|
+
jsonrpc: "2.0",
|
|
1693
|
+
id,
|
|
1694
|
+
error: { code, message, data }
|
|
1695
|
+
};
|
|
1696
|
+
},
|
|
1697
|
+
/**
|
|
1698
|
+
* Create a tool result response
|
|
1699
|
+
*/
|
|
1700
|
+
toolResult(content, id = 1) {
|
|
1701
|
+
return {
|
|
1702
|
+
jsonrpc: "2.0",
|
|
1703
|
+
id,
|
|
1704
|
+
result: { content }
|
|
1705
|
+
};
|
|
1706
|
+
},
|
|
1707
|
+
/**
|
|
1708
|
+
* Create a tools/list response
|
|
1709
|
+
*/
|
|
1710
|
+
toolsList(tools, id = 1) {
|
|
1711
|
+
return {
|
|
1712
|
+
jsonrpc: "2.0",
|
|
1713
|
+
id,
|
|
1714
|
+
result: { tools }
|
|
1715
|
+
};
|
|
1716
|
+
},
|
|
1717
|
+
/**
|
|
1718
|
+
* Create a resources/list response
|
|
1719
|
+
*/
|
|
1720
|
+
resourcesList(resources, id = 1) {
|
|
1721
|
+
return {
|
|
1722
|
+
jsonrpc: "2.0",
|
|
1723
|
+
id,
|
|
1724
|
+
result: { resources }
|
|
1725
|
+
};
|
|
1726
|
+
},
|
|
1727
|
+
/**
|
|
1728
|
+
* Create a resources/read response
|
|
1729
|
+
*/
|
|
1730
|
+
resourceRead(contents, id = 1) {
|
|
1731
|
+
return {
|
|
1732
|
+
jsonrpc: "2.0",
|
|
1733
|
+
id,
|
|
1734
|
+
result: { contents }
|
|
1735
|
+
};
|
|
1736
|
+
},
|
|
1737
|
+
/**
|
|
1738
|
+
* Common MCP errors
|
|
1739
|
+
*/
|
|
1740
|
+
errors: {
|
|
1741
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
1742
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
1743
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1744
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1745
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1746
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1747
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
1752
|
+
var DefaultInterceptorChain = class {
|
|
1753
|
+
request = [];
|
|
1754
|
+
response = [];
|
|
1755
|
+
mocks;
|
|
1756
|
+
constructor() {
|
|
1757
|
+
this.mocks = new DefaultMockRegistry();
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Add a request interceptor
|
|
1761
|
+
*/
|
|
1762
|
+
addRequestInterceptor(interceptor) {
|
|
1763
|
+
this.request.push(interceptor);
|
|
1764
|
+
return () => {
|
|
1765
|
+
const index = this.request.indexOf(interceptor);
|
|
1766
|
+
if (index !== -1) {
|
|
1767
|
+
this.request.splice(index, 1);
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Add a response interceptor
|
|
1773
|
+
*/
|
|
1774
|
+
addResponseInterceptor(interceptor) {
|
|
1775
|
+
this.response.push(interceptor);
|
|
1776
|
+
return () => {
|
|
1777
|
+
const index = this.response.indexOf(interceptor);
|
|
1778
|
+
if (index !== -1) {
|
|
1779
|
+
this.response.splice(index, 1);
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Process a request through the interceptor chain
|
|
1785
|
+
* Returns either:
|
|
1786
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
1787
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
1788
|
+
* - { type: 'error', error } - throw error
|
|
1789
|
+
*/
|
|
1790
|
+
async processRequest(request, meta) {
|
|
1791
|
+
let currentRequest = request;
|
|
1792
|
+
const mockDef = this.mocks.match(request);
|
|
1793
|
+
if (mockDef) {
|
|
1794
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
1795
|
+
await sleep3(mockDef.delay);
|
|
1796
|
+
}
|
|
1797
|
+
let mockResponse2;
|
|
1798
|
+
if (typeof mockDef.response === "function") {
|
|
1799
|
+
mockResponse2 = await mockDef.response(request);
|
|
1800
|
+
} else {
|
|
1801
|
+
mockResponse2 = mockDef.response;
|
|
1802
|
+
}
|
|
1803
|
+
return {
|
|
1804
|
+
type: "mock",
|
|
1805
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
for (const interceptor of this.request) {
|
|
1809
|
+
const ctx = {
|
|
1810
|
+
request: currentRequest,
|
|
1811
|
+
meta
|
|
1812
|
+
};
|
|
1813
|
+
const result = await interceptor(ctx);
|
|
1814
|
+
switch (result.action) {
|
|
1815
|
+
case "passthrough":
|
|
1816
|
+
break;
|
|
1817
|
+
case "modify":
|
|
1818
|
+
currentRequest = result.request;
|
|
1819
|
+
break;
|
|
1820
|
+
case "mock":
|
|
1821
|
+
return {
|
|
1822
|
+
type: "mock",
|
|
1823
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1824
|
+
};
|
|
1825
|
+
case "error":
|
|
1826
|
+
return { type: "error", error: result.error };
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return { type: "continue", request: currentRequest };
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Process a response through the interceptor chain
|
|
1833
|
+
*/
|
|
1834
|
+
async processResponse(request, response, durationMs) {
|
|
1835
|
+
let currentResponse = response;
|
|
1836
|
+
for (const interceptor of this.response) {
|
|
1837
|
+
const ctx = {
|
|
1838
|
+
request,
|
|
1839
|
+
response: currentResponse,
|
|
1840
|
+
durationMs
|
|
1841
|
+
};
|
|
1842
|
+
const result = await interceptor(ctx);
|
|
1843
|
+
switch (result.action) {
|
|
1844
|
+
case "passthrough":
|
|
1845
|
+
break;
|
|
1846
|
+
case "modify":
|
|
1847
|
+
currentResponse = result.response;
|
|
1848
|
+
break;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
return currentResponse;
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* Clear all interceptors and mocks
|
|
1855
|
+
*/
|
|
1856
|
+
clear() {
|
|
1857
|
+
this.request = [];
|
|
1858
|
+
this.response = [];
|
|
1859
|
+
this.mocks.clear();
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
function sleep3(ms) {
|
|
1863
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// libs/testing/src/client/mcp-test-client.ts
|
|
1867
|
+
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1868
|
+
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1869
|
+
var DEFAULT_CLIENT_INFO = {
|
|
1870
|
+
name: "@frontmcp/testing",
|
|
1871
|
+
version: "0.4.0"
|
|
1872
|
+
};
|
|
1873
|
+
var McpTestClient = class {
|
|
1874
|
+
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1875
|
+
config;
|
|
1876
|
+
transport = null;
|
|
1877
|
+
initResult = null;
|
|
1878
|
+
requestIdCounter = 0;
|
|
1879
|
+
_lastRequestId = 0;
|
|
1880
|
+
_sessionId;
|
|
1881
|
+
_sessionInfo = null;
|
|
1882
|
+
_authState = { isAnonymous: true, scopes: [] };
|
|
1883
|
+
// Logging and tracing
|
|
1884
|
+
_logs = [];
|
|
1885
|
+
_traces = [];
|
|
1886
|
+
_notifications = [];
|
|
1887
|
+
_progressUpdates = [];
|
|
1888
|
+
// Interceptor chain
|
|
1889
|
+
_interceptors;
|
|
1890
|
+
// Elicitation handler for server→client elicit requests
|
|
1891
|
+
_elicitationHandler;
|
|
1892
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1893
|
+
// CONSTRUCTOR & FACTORY
|
|
1894
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1895
|
+
constructor(config) {
|
|
1896
|
+
this.config = {
|
|
1897
|
+
baseUrl: config.baseUrl,
|
|
1898
|
+
transport: config.transport ?? "streamable-http",
|
|
1899
|
+
auth: config.auth ?? {},
|
|
1900
|
+
publicMode: config.publicMode ?? false,
|
|
1901
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT2,
|
|
1902
|
+
debug: config.debug ?? false,
|
|
1903
|
+
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
1904
|
+
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
1905
|
+
platform: config.platform,
|
|
1906
|
+
capabilities: config.capabilities,
|
|
1907
|
+
queryParams: config.queryParams
|
|
1908
|
+
};
|
|
1909
|
+
if (config.auth?.token) {
|
|
1910
|
+
this._authState = {
|
|
1911
|
+
isAnonymous: false,
|
|
1912
|
+
token: config.auth.token,
|
|
1913
|
+
scopes: this.parseScopesFromToken(config.auth.token),
|
|
1914
|
+
user: this.parseUserFromToken(config.auth.token)
|
|
1915
|
+
};
|
|
1916
|
+
}
|
|
1917
|
+
this._interceptors = new DefaultInterceptorChain();
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Create a new McpTestClientBuilder for fluent configuration
|
|
1921
|
+
*/
|
|
1922
|
+
static create(config) {
|
|
1923
|
+
return new McpTestClientBuilder(config);
|
|
1924
|
+
}
|
|
1925
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1926
|
+
// CONNECTION & LIFECYCLE
|
|
1927
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1928
|
+
/**
|
|
1929
|
+
* Connect to the MCP server and perform initialization
|
|
1930
|
+
*/
|
|
1931
|
+
async connect() {
|
|
1932
|
+
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1933
|
+
this.transport = this.createTransport();
|
|
1934
|
+
await this.transport.connect();
|
|
1935
|
+
const initResponse = await this.initialize();
|
|
1936
|
+
if (!initResponse.success || !initResponse.data) {
|
|
1937
|
+
throw new Error(`Failed to initialize MCP connection: ${initResponse.error?.message ?? "Unknown error"}`);
|
|
1938
|
+
}
|
|
1939
|
+
this.initResult = initResponse.data;
|
|
1940
|
+
this._sessionId = this.transport.getSessionId();
|
|
1941
|
+
this._sessionInfo = {
|
|
1942
|
+
id: this._sessionId ?? `session-${Date.now()}`,
|
|
1943
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1944
|
+
lastActivityAt: /* @__PURE__ */ new Date(),
|
|
1945
|
+
requestCount: 1
|
|
1946
|
+
};
|
|
1947
|
+
await this.transport.notify({
|
|
1948
|
+
jsonrpc: "2.0",
|
|
1949
|
+
method: "notifications/initialized"
|
|
1950
|
+
});
|
|
1951
|
+
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
1952
|
+
return this.initResult;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Disconnect from the MCP server
|
|
1956
|
+
*/
|
|
1957
|
+
async disconnect() {
|
|
1958
|
+
if (this.transport) {
|
|
1959
|
+
await this.transport.close();
|
|
1960
|
+
this.transport = null;
|
|
1961
|
+
}
|
|
1962
|
+
this.initResult = null;
|
|
1963
|
+
this.log("info", "Disconnected from MCP server");
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Reconnect to the server, optionally with an existing session ID
|
|
1967
|
+
*/
|
|
1968
|
+
async reconnect(options) {
|
|
1969
|
+
await this.disconnect();
|
|
1970
|
+
if (options?.sessionId && this.transport) {
|
|
1971
|
+
this._sessionId = options.sessionId;
|
|
1972
|
+
}
|
|
1973
|
+
await this.connect();
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Check if the client is currently connected
|
|
1977
|
+
*/
|
|
1978
|
+
isConnected() {
|
|
1979
|
+
return this.transport?.isConnected() ?? false;
|
|
1980
|
+
}
|
|
1981
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1982
|
+
// SESSION & AUTH PROPERTIES
|
|
1983
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1984
|
+
get sessionId() {
|
|
1985
|
+
return this._sessionId ?? "";
|
|
1986
|
+
}
|
|
1987
|
+
get session() {
|
|
1988
|
+
const info = this._sessionInfo ?? {
|
|
1989
|
+
id: "",
|
|
1990
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1991
|
+
lastActivityAt: /* @__PURE__ */ new Date(),
|
|
1992
|
+
requestCount: 0
|
|
1993
|
+
};
|
|
1994
|
+
return {
|
|
1995
|
+
...info,
|
|
1996
|
+
expire: async () => {
|
|
1997
|
+
this._sessionId = void 0;
|
|
1998
|
+
this._sessionInfo = null;
|
|
1999
|
+
}
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
get auth() {
|
|
2003
|
+
return this._authState;
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* Authenticate with a token
|
|
2007
|
+
*/
|
|
2008
|
+
async authenticate(token) {
|
|
2009
|
+
this._authState = {
|
|
2010
|
+
isAnonymous: false,
|
|
2011
|
+
token,
|
|
2012
|
+
scopes: this.parseScopesFromToken(token),
|
|
2013
|
+
user: this.parseUserFromToken(token)
|
|
2014
|
+
};
|
|
2015
|
+
if (this.transport) {
|
|
2016
|
+
this.transport.setAuthToken(token);
|
|
2017
|
+
}
|
|
2018
|
+
this.log("debug", "Authentication updated");
|
|
2019
|
+
}
|
|
2020
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2021
|
+
// SERVER INFO & CAPABILITIES
|
|
2022
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2023
|
+
get serverInfo() {
|
|
2024
|
+
return {
|
|
2025
|
+
name: this.initResult?.serverInfo?.name ?? "",
|
|
2026
|
+
version: this.initResult?.serverInfo?.version ?? ""
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
get protocolVersion() {
|
|
2030
|
+
return this.initResult?.protocolVersion ?? "";
|
|
2031
|
+
}
|
|
2032
|
+
get instructions() {
|
|
2033
|
+
return this.initResult?.instructions ?? "";
|
|
2034
|
+
}
|
|
2035
|
+
get capabilities() {
|
|
2036
|
+
return this.initResult?.capabilities ?? {};
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Check if server has a specific capability
|
|
2040
|
+
*/
|
|
2041
|
+
hasCapability(name) {
|
|
2042
|
+
return !!this.capabilities[name];
|
|
2043
|
+
}
|
|
2044
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2045
|
+
// TOOLS API
|
|
2046
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2047
|
+
tools = {
|
|
2048
|
+
/**
|
|
2049
|
+
* List all available tools
|
|
2050
|
+
*/
|
|
2051
|
+
list: async () => {
|
|
2052
|
+
const response = await this.listTools();
|
|
2053
|
+
if (!response.success || !response.data) {
|
|
2054
|
+
throw new Error(`Failed to list tools: ${response.error?.message}`);
|
|
2055
|
+
}
|
|
2056
|
+
return response.data.tools;
|
|
2057
|
+
},
|
|
2058
|
+
/**
|
|
2059
|
+
* Call a tool by name with arguments
|
|
2060
|
+
*/
|
|
2061
|
+
call: async (name, args) => {
|
|
2062
|
+
const response = await this.callTool(name, args);
|
|
2063
|
+
return this.wrapToolResult(response);
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2067
|
+
// RESOURCES API
|
|
2068
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2069
|
+
resources = {
|
|
2070
|
+
/**
|
|
2071
|
+
* List all static resources
|
|
2072
|
+
*/
|
|
2073
|
+
list: async () => {
|
|
2074
|
+
const response = await this.listResources();
|
|
2075
|
+
if (!response.success || !response.data) {
|
|
2076
|
+
throw new Error(`Failed to list resources: ${response.error?.message}`);
|
|
2077
|
+
}
|
|
2078
|
+
return response.data.resources;
|
|
2079
|
+
},
|
|
2080
|
+
/**
|
|
2081
|
+
* List all resource templates
|
|
2082
|
+
*/
|
|
2083
|
+
listTemplates: async () => {
|
|
2084
|
+
const response = await this.listResourceTemplates();
|
|
2085
|
+
if (!response.success || !response.data) {
|
|
2086
|
+
throw new Error(`Failed to list resource templates: ${response.error?.message}`);
|
|
2087
|
+
}
|
|
2088
|
+
return response.data.resourceTemplates;
|
|
2089
|
+
},
|
|
2090
|
+
/**
|
|
2091
|
+
* Read a resource by URI
|
|
2092
|
+
*/
|
|
2093
|
+
read: async (uri) => {
|
|
2094
|
+
const response = await this.readResource(uri);
|
|
2095
|
+
return this.wrapResourceContent(response);
|
|
2096
|
+
},
|
|
2097
|
+
/**
|
|
2098
|
+
* Subscribe to resource changes (placeholder for future implementation)
|
|
2099
|
+
*/
|
|
2100
|
+
subscribe: async (_uri) => {
|
|
2101
|
+
this.log("warn", "Resource subscription not yet implemented");
|
|
2102
|
+
},
|
|
2103
|
+
/**
|
|
2104
|
+
* Unsubscribe from resource changes (placeholder for future implementation)
|
|
2105
|
+
*/
|
|
2106
|
+
unsubscribe: async (_uri) => {
|
|
2107
|
+
this.log("warn", "Resource unsubscription not yet implemented");
|
|
2108
|
+
}
|
|
2109
|
+
};
|
|
2110
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2111
|
+
// PROMPTS API
|
|
2112
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2113
|
+
prompts = {
|
|
2114
|
+
/**
|
|
2115
|
+
* List all available prompts
|
|
2116
|
+
*/
|
|
2117
|
+
list: async () => {
|
|
2118
|
+
const response = await this.listPrompts();
|
|
2119
|
+
if (!response.success || !response.data) {
|
|
2120
|
+
throw new Error(`Failed to list prompts: ${response.error?.message}`);
|
|
2121
|
+
}
|
|
2122
|
+
return response.data.prompts;
|
|
2123
|
+
},
|
|
2124
|
+
/**
|
|
2125
|
+
* Get a prompt with arguments
|
|
2126
|
+
*/
|
|
2127
|
+
get: async (name, args) => {
|
|
2128
|
+
const response = await this.getPrompt(name, args);
|
|
2129
|
+
return this.wrapPromptResult(response);
|
|
2130
|
+
}
|
|
2131
|
+
};
|
|
2132
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2133
|
+
// RAW PROTOCOL ACCESS
|
|
2134
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2135
|
+
raw = {
|
|
2136
|
+
/**
|
|
2137
|
+
* Send any JSON-RPC request
|
|
2138
|
+
*/
|
|
2139
|
+
request: async (message) => {
|
|
2140
|
+
const transport = this.getConnectedTransport();
|
|
2141
|
+
const start = Date.now();
|
|
2142
|
+
const response = await transport.request(message);
|
|
2143
|
+
this.traceRequest(message.method, message.params, message.id, response, Date.now() - start);
|
|
2144
|
+
return response;
|
|
2145
|
+
},
|
|
2146
|
+
/**
|
|
2147
|
+
* Send a notification (no response expected)
|
|
2148
|
+
*/
|
|
2149
|
+
notify: async (message) => {
|
|
2150
|
+
const transport = this.getConnectedTransport();
|
|
2151
|
+
await transport.notify(message);
|
|
2152
|
+
},
|
|
2153
|
+
/**
|
|
2154
|
+
* Send raw string data (for error testing)
|
|
2155
|
+
*/
|
|
2156
|
+
sendRaw: async (data) => {
|
|
2157
|
+
const transport = this.getConnectedTransport();
|
|
2158
|
+
return transport.sendRaw(data);
|
|
2159
|
+
}
|
|
2160
|
+
};
|
|
2161
|
+
get lastRequestId() {
|
|
2162
|
+
return this._lastRequestId;
|
|
2163
|
+
}
|
|
2164
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2165
|
+
// TRANSPORT INFO
|
|
2166
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2167
|
+
/**
|
|
2168
|
+
* Get transport information and utilities
|
|
2169
|
+
*/
|
|
2170
|
+
get transport_info() {
|
|
2171
|
+
return {
|
|
2172
|
+
type: this.config.transport,
|
|
2173
|
+
isConnected: () => this.transport?.isConnected() ?? false,
|
|
2174
|
+
messageEndpoint: this.transport?.getMessageEndpoint?.(),
|
|
2175
|
+
connectionCount: this.transport?.getConnectionCount?.() ?? 0,
|
|
2176
|
+
reconnectCount: this.transport?.getReconnectCount?.() ?? 0,
|
|
2177
|
+
lastRequestHeaders: this.transport?.getLastRequestHeaders?.() ?? {},
|
|
2178
|
+
simulateDisconnect: async () => {
|
|
2179
|
+
await this.transport?.simulateDisconnect?.();
|
|
2180
|
+
},
|
|
2181
|
+
waitForReconnect: async (timeoutMs) => {
|
|
2182
|
+
await this.transport?.waitForReconnect?.(timeoutMs);
|
|
2183
|
+
}
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
// Alias for transport info
|
|
2187
|
+
get transport_() {
|
|
2188
|
+
return this.transport_info;
|
|
2189
|
+
}
|
|
2190
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2191
|
+
// NOTIFICATIONS
|
|
2192
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2193
|
+
notifications = {
|
|
2194
|
+
/**
|
|
2195
|
+
* Start collecting server notifications
|
|
2196
|
+
*/
|
|
2197
|
+
collect: () => {
|
|
2198
|
+
return new NotificationCollector(this._notifications);
|
|
2199
|
+
},
|
|
2200
|
+
/**
|
|
2201
|
+
* Collect progress notifications specifically
|
|
2202
|
+
*/
|
|
2203
|
+
collectProgress: () => {
|
|
2204
|
+
return new ProgressCollector(this._progressUpdates);
|
|
2205
|
+
},
|
|
2206
|
+
/**
|
|
2207
|
+
* Send a notification to the server
|
|
2208
|
+
*/
|
|
2209
|
+
send: async (method, params) => {
|
|
2210
|
+
await this.raw.notify({ jsonrpc: "2.0", method, params });
|
|
2211
|
+
}
|
|
2212
|
+
};
|
|
2213
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2214
|
+
// ELICITATION
|
|
2215
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2216
|
+
/**
|
|
2217
|
+
* Register a handler for elicitation requests from the server.
|
|
2218
|
+
*
|
|
2219
|
+
* When a tool calls `this.elicit()` during execution, the server sends an
|
|
2220
|
+
* `elicitation/create` request to the client. This handler is called to
|
|
2221
|
+
* provide the response that would normally come from user interaction.
|
|
2222
|
+
*
|
|
2223
|
+
* @param handler - Function that receives the elicitation request and returns a response
|
|
2224
|
+
*
|
|
2225
|
+
* @example
|
|
2226
|
+
* ```typescript
|
|
2227
|
+
* // Simple acceptance
|
|
2228
|
+
* mcp.onElicitation(async () => ({
|
|
2229
|
+
* action: 'accept',
|
|
2230
|
+
* content: { confirmed: true }
|
|
2231
|
+
* }));
|
|
2232
|
+
*
|
|
2233
|
+
* // Conditional response based on request
|
|
2234
|
+
* mcp.onElicitation(async (request) => {
|
|
2235
|
+
* if (request.message.includes('delete')) {
|
|
2236
|
+
* return { action: 'decline' };
|
|
2237
|
+
* }
|
|
2238
|
+
* return { action: 'accept', content: { approved: true } };
|
|
2239
|
+
* });
|
|
2240
|
+
*
|
|
2241
|
+
* // Multi-step wizard
|
|
2242
|
+
* let step = 0;
|
|
2243
|
+
* mcp.onElicitation(async () => {
|
|
2244
|
+
* step++;
|
|
2245
|
+
* if (step === 1) return { action: 'accept', content: { name: 'Alice' } };
|
|
2246
|
+
* return { action: 'accept', content: { color: 'blue' } };
|
|
2247
|
+
* });
|
|
2248
|
+
* ```
|
|
2249
|
+
*/
|
|
2250
|
+
onElicitation(handler) {
|
|
2251
|
+
this._elicitationHandler = handler;
|
|
2252
|
+
if (this.transport?.setElicitationHandler) {
|
|
2253
|
+
this.transport.setElicitationHandler(handler);
|
|
2254
|
+
}
|
|
2255
|
+
this.log("debug", "Elicitation handler registered");
|
|
2256
|
+
}
|
|
2257
|
+
/**
|
|
2258
|
+
* Clear the elicitation handler.
|
|
2259
|
+
*
|
|
2260
|
+
* After calling this, elicitation requests from the server will not be
|
|
2261
|
+
* handled automatically. This can be used to test timeout scenarios.
|
|
2262
|
+
*/
|
|
2263
|
+
clearElicitationHandler() {
|
|
2264
|
+
this._elicitationHandler = void 0;
|
|
2265
|
+
if (this.transport?.setElicitationHandler) {
|
|
2266
|
+
this.transport.setElicitationHandler(void 0);
|
|
2267
|
+
}
|
|
2268
|
+
this.log("debug", "Elicitation handler cleared");
|
|
2269
|
+
}
|
|
2270
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2271
|
+
// LOGGING & DEBUGGING
|
|
2272
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2273
|
+
logs = {
|
|
2274
|
+
all: () => [...this._logs],
|
|
2275
|
+
filter: (level) => this._logs.filter((l) => l.level === level),
|
|
2276
|
+
search: (text) => this._logs.filter((l) => l.message.includes(text)),
|
|
2277
|
+
last: () => this._logs[this._logs.length - 1],
|
|
2278
|
+
clear: () => {
|
|
2279
|
+
this._logs = [];
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
trace = {
|
|
2283
|
+
all: () => [...this._traces],
|
|
2284
|
+
last: () => this._traces[this._traces.length - 1],
|
|
2285
|
+
clear: () => {
|
|
2286
|
+
this._traces = [];
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2290
|
+
// MOCKING & INTERCEPTION
|
|
2291
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2292
|
+
/**
|
|
2293
|
+
* API for mocking MCP requests
|
|
2294
|
+
*
|
|
2295
|
+
* @example
|
|
2296
|
+
* ```typescript
|
|
2297
|
+
* // Mock a specific tool call
|
|
2298
|
+
* const handle = mcp.mock.tool('my-tool', { result: 'mocked!' });
|
|
2299
|
+
*
|
|
2300
|
+
* // Mock with params matching
|
|
2301
|
+
* mcp.mock.add({
|
|
2302
|
+
* method: 'tools/call',
|
|
2303
|
+
* params: { name: 'my-tool' },
|
|
2304
|
+
* response: mockResponse.toolResult([{ type: 'text', text: 'mocked' }]),
|
|
2305
|
+
* });
|
|
2306
|
+
*
|
|
2307
|
+
* // Clear all mocks after test
|
|
2308
|
+
* mcp.mock.clear();
|
|
2309
|
+
* ```
|
|
2310
|
+
*/
|
|
2311
|
+
mock = {
|
|
2312
|
+
/**
|
|
2313
|
+
* Add a mock definition
|
|
2314
|
+
*/
|
|
2315
|
+
add: (mock) => {
|
|
2316
|
+
return this._interceptors.mocks.add(mock);
|
|
2317
|
+
},
|
|
2318
|
+
/**
|
|
2319
|
+
* Mock a tools/call request for a specific tool
|
|
2320
|
+
*/
|
|
2321
|
+
tool: (name, result, options) => {
|
|
2322
|
+
return this._interceptors.mocks.add({
|
|
2323
|
+
method: "tools/call",
|
|
2324
|
+
params: { name },
|
|
2325
|
+
response: mockResponse.toolResult([
|
|
2326
|
+
{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }
|
|
2327
|
+
]),
|
|
2328
|
+
times: options?.times,
|
|
2329
|
+
delay: options?.delay
|
|
2330
|
+
});
|
|
2331
|
+
},
|
|
2332
|
+
/**
|
|
2333
|
+
* Mock a tools/call request to return an error
|
|
2334
|
+
*/
|
|
2335
|
+
toolError: (name, code, message, options) => {
|
|
2336
|
+
return this._interceptors.mocks.add({
|
|
2337
|
+
method: "tools/call",
|
|
2338
|
+
params: { name },
|
|
2339
|
+
response: mockResponse.error(code, message),
|
|
2340
|
+
times: options?.times
|
|
2341
|
+
});
|
|
2342
|
+
},
|
|
2343
|
+
/**
|
|
2344
|
+
* Mock a resources/read request
|
|
2345
|
+
*/
|
|
2346
|
+
resource: (uri, content, options) => {
|
|
2347
|
+
const contentObj = typeof content === "string" ? { uri, text: content } : { uri, ...content };
|
|
2348
|
+
return this._interceptors.mocks.add({
|
|
2349
|
+
method: "resources/read",
|
|
2350
|
+
params: { uri },
|
|
2351
|
+
response: mockResponse.resourceRead([contentObj]),
|
|
2352
|
+
times: options?.times,
|
|
2353
|
+
delay: options?.delay
|
|
2354
|
+
});
|
|
2355
|
+
},
|
|
2356
|
+
/**
|
|
2357
|
+
* Mock a resources/read request to return an error
|
|
2358
|
+
*/
|
|
2359
|
+
resourceError: (uri, options) => {
|
|
2360
|
+
return this._interceptors.mocks.add({
|
|
2361
|
+
method: "resources/read",
|
|
2362
|
+
params: { uri },
|
|
2363
|
+
response: mockResponse.errors.resourceNotFound(uri),
|
|
2364
|
+
times: options?.times
|
|
2365
|
+
});
|
|
2366
|
+
},
|
|
2367
|
+
/**
|
|
2368
|
+
* Mock the tools/list response
|
|
2369
|
+
*/
|
|
2370
|
+
toolsList: (tools, options) => {
|
|
2371
|
+
return this._interceptors.mocks.add({
|
|
2372
|
+
method: "tools/list",
|
|
2373
|
+
response: mockResponse.toolsList(tools),
|
|
2374
|
+
times: options?.times
|
|
2375
|
+
});
|
|
2376
|
+
},
|
|
2377
|
+
/**
|
|
2378
|
+
* Mock the resources/list response
|
|
2379
|
+
*/
|
|
2380
|
+
resourcesList: (resources, options) => {
|
|
2381
|
+
return this._interceptors.mocks.add({
|
|
2382
|
+
method: "resources/list",
|
|
2383
|
+
response: mockResponse.resourcesList(resources),
|
|
2384
|
+
times: options?.times
|
|
2385
|
+
});
|
|
2386
|
+
},
|
|
2387
|
+
/**
|
|
2388
|
+
* Clear all mocks
|
|
2389
|
+
*/
|
|
2390
|
+
clear: () => {
|
|
2391
|
+
this._interceptors.mocks.clear();
|
|
2392
|
+
},
|
|
2393
|
+
/**
|
|
2394
|
+
* Get all active mocks
|
|
2395
|
+
*/
|
|
2396
|
+
all: () => {
|
|
2397
|
+
return this._interceptors.mocks.getAll();
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
/**
|
|
2401
|
+
* API for intercepting requests and responses
|
|
2402
|
+
*
|
|
2403
|
+
* @example
|
|
2404
|
+
* ```typescript
|
|
2405
|
+
* // Log all requests
|
|
2406
|
+
* const remove = mcp.intercept.request((ctx) => {
|
|
2407
|
+
* console.log('Request:', ctx.request.method);
|
|
2408
|
+
* return { action: 'passthrough' };
|
|
2409
|
+
* });
|
|
2410
|
+
*
|
|
2411
|
+
* // Modify requests
|
|
2412
|
+
* mcp.intercept.request((ctx) => {
|
|
2413
|
+
* if (ctx.request.method === 'tools/call') {
|
|
2414
|
+
* return {
|
|
2415
|
+
* action: 'modify',
|
|
2416
|
+
* request: { ...ctx.request, params: { ...ctx.request.params, extra: true } },
|
|
2417
|
+
* };
|
|
2418
|
+
* }
|
|
2419
|
+
* return { action: 'passthrough' };
|
|
2420
|
+
* });
|
|
2421
|
+
*
|
|
2422
|
+
* // Add latency to all requests
|
|
2423
|
+
* mcp.intercept.delay(100);
|
|
2424
|
+
*
|
|
2425
|
+
* // Clean up
|
|
2426
|
+
* remove();
|
|
2427
|
+
* mcp.intercept.clear();
|
|
2428
|
+
* ```
|
|
2429
|
+
*/
|
|
2430
|
+
intercept = {
|
|
2431
|
+
/**
|
|
2432
|
+
* Add a request interceptor
|
|
2433
|
+
* @returns Function to remove the interceptor
|
|
2434
|
+
*/
|
|
2435
|
+
request: (interceptor) => {
|
|
2436
|
+
return this._interceptors.addRequestInterceptor(interceptor);
|
|
2437
|
+
},
|
|
2438
|
+
/**
|
|
2439
|
+
* Add a response interceptor
|
|
2440
|
+
* @returns Function to remove the interceptor
|
|
2441
|
+
*/
|
|
2442
|
+
response: (interceptor) => {
|
|
2443
|
+
return this._interceptors.addResponseInterceptor(interceptor);
|
|
2444
|
+
},
|
|
2445
|
+
/**
|
|
2446
|
+
* Add latency to all requests
|
|
2447
|
+
* @returns Function to remove the interceptor
|
|
2448
|
+
*/
|
|
2449
|
+
delay: (ms) => {
|
|
2450
|
+
return this._interceptors.addRequestInterceptor(async () => {
|
|
2451
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
2452
|
+
return { action: "passthrough" };
|
|
2453
|
+
});
|
|
2454
|
+
},
|
|
2455
|
+
/**
|
|
2456
|
+
* Fail requests matching a method
|
|
2457
|
+
* @returns Function to remove the interceptor
|
|
2458
|
+
*/
|
|
2459
|
+
failMethod: (method, error) => {
|
|
2460
|
+
return this._interceptors.addRequestInterceptor((ctx) => {
|
|
2461
|
+
if (ctx.request.method === method) {
|
|
2462
|
+
return { action: "error", error: new Error(error ?? `Intercepted: ${method}`) };
|
|
2463
|
+
}
|
|
2464
|
+
return { action: "passthrough" };
|
|
2465
|
+
});
|
|
2466
|
+
},
|
|
2467
|
+
/**
|
|
2468
|
+
* Clear all interceptors (but not mocks)
|
|
2469
|
+
*/
|
|
2470
|
+
clear: () => {
|
|
2471
|
+
this._interceptors.request = [];
|
|
2472
|
+
this._interceptors.response = [];
|
|
2473
|
+
},
|
|
2474
|
+
/**
|
|
2475
|
+
* Clear everything (interceptors and mocks)
|
|
2476
|
+
*/
|
|
2477
|
+
clearAll: () => {
|
|
2478
|
+
this._interceptors.clear();
|
|
2479
|
+
}
|
|
2480
|
+
};
|
|
2481
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2482
|
+
// TIMEOUT
|
|
2483
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2484
|
+
setTimeout(ms) {
|
|
2485
|
+
this.config.timeout = ms;
|
|
2486
|
+
if (this.transport) {
|
|
2487
|
+
this.transport.setTimeout(ms);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2491
|
+
// PRIVATE: MCP OPERATIONS
|
|
2492
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2493
|
+
async initialize() {
|
|
2494
|
+
const capabilities = this.config.capabilities ?? {
|
|
2495
|
+
sampling: {},
|
|
2496
|
+
elicitation: {
|
|
2497
|
+
form: {}
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
return this.request("initialize", {
|
|
2501
|
+
protocolVersion: this.config.protocolVersion,
|
|
2502
|
+
capabilities,
|
|
2503
|
+
clientInfo: this.config.clientInfo
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
async listTools() {
|
|
2507
|
+
return this.request("tools/list", {});
|
|
2508
|
+
}
|
|
2509
|
+
async callTool(name, args) {
|
|
2510
|
+
return this.request("tools/call", {
|
|
2511
|
+
name,
|
|
2512
|
+
arguments: args ?? {}
|
|
2513
|
+
});
|
|
2514
|
+
}
|
|
2515
|
+
async listResources() {
|
|
2516
|
+
return this.request("resources/list", {});
|
|
2517
|
+
}
|
|
2518
|
+
async listResourceTemplates() {
|
|
2519
|
+
return this.request("resources/templates/list", {});
|
|
2520
|
+
}
|
|
2521
|
+
async readResource(uri) {
|
|
2522
|
+
return this.request("resources/read", { uri });
|
|
2523
|
+
}
|
|
2524
|
+
async listPrompts() {
|
|
2525
|
+
return this.request("prompts/list", {});
|
|
2526
|
+
}
|
|
2527
|
+
async getPrompt(name, args) {
|
|
2528
|
+
return this.request("prompts/get", {
|
|
2529
|
+
name,
|
|
2530
|
+
arguments: args ?? {}
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2534
|
+
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
2535
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2536
|
+
createTransport() {
|
|
2537
|
+
let baseUrl = this.config.baseUrl;
|
|
2538
|
+
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
2539
|
+
const url = new URL(baseUrl);
|
|
2540
|
+
Object.entries(this.config.queryParams).forEach(([key, value]) => {
|
|
2541
|
+
url.searchParams.set(key, String(value));
|
|
2542
|
+
});
|
|
2543
|
+
baseUrl = url.toString();
|
|
2544
|
+
}
|
|
2545
|
+
switch (this.config.transport) {
|
|
2546
|
+
case "streamable-http":
|
|
2547
|
+
return new StreamableHttpTransport({
|
|
2548
|
+
baseUrl,
|
|
2549
|
+
timeout: this.config.timeout,
|
|
2550
|
+
auth: this.config.auth,
|
|
2551
|
+
publicMode: this.config.publicMode,
|
|
2552
|
+
debug: this.config.debug,
|
|
2553
|
+
interceptors: this._interceptors,
|
|
2554
|
+
clientInfo: this.config.clientInfo,
|
|
2555
|
+
elicitationHandler: this._elicitationHandler
|
|
2556
|
+
});
|
|
2557
|
+
case "sse":
|
|
2558
|
+
throw new Error("SSE transport not yet implemented");
|
|
2559
|
+
default:
|
|
2560
|
+
throw new Error(`Unknown transport type: ${this.config.transport}`);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
async request(method, params) {
|
|
2564
|
+
const transport = this.getConnectedTransport();
|
|
2565
|
+
const id = ++this.requestIdCounter;
|
|
2566
|
+
this._lastRequestId = id;
|
|
2567
|
+
const start = Date.now();
|
|
2568
|
+
try {
|
|
2569
|
+
const response = await transport.request({
|
|
2570
|
+
jsonrpc: "2.0",
|
|
2571
|
+
id,
|
|
2572
|
+
method,
|
|
2573
|
+
params
|
|
2574
|
+
});
|
|
2575
|
+
const durationMs = Date.now() - start;
|
|
2576
|
+
this.updateSessionActivity();
|
|
2577
|
+
if ("error" in response && response.error) {
|
|
2578
|
+
const error = response.error;
|
|
2579
|
+
this.traceRequest(method, params, id, response, durationMs);
|
|
2580
|
+
return {
|
|
2581
|
+
success: false,
|
|
2582
|
+
error,
|
|
2583
|
+
durationMs,
|
|
2584
|
+
requestId: id
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
this.traceRequest(method, params, id, response, durationMs);
|
|
2588
|
+
return {
|
|
2589
|
+
success: true,
|
|
2590
|
+
data: response.result,
|
|
2591
|
+
durationMs,
|
|
2592
|
+
requestId: id
|
|
2593
|
+
};
|
|
2594
|
+
} catch (err) {
|
|
2595
|
+
const durationMs = Date.now() - start;
|
|
2596
|
+
const error = {
|
|
2597
|
+
code: -32603,
|
|
2598
|
+
message: err instanceof Error ? err.message : "Unknown error"
|
|
2599
|
+
};
|
|
2600
|
+
return {
|
|
2601
|
+
success: false,
|
|
2602
|
+
error,
|
|
2603
|
+
durationMs,
|
|
2604
|
+
requestId: id
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
/**
|
|
2609
|
+
* Get the transport, throwing if not connected.
|
|
2610
|
+
*/
|
|
2611
|
+
getConnectedTransport() {
|
|
2612
|
+
if (!this.transport || !this.transport.isConnected()) {
|
|
2613
|
+
throw new Error("Not connected to MCP server. Call connect() first.");
|
|
2614
|
+
}
|
|
2615
|
+
return this.transport;
|
|
2616
|
+
}
|
|
2617
|
+
updateSessionActivity() {
|
|
2618
|
+
if (this._sessionInfo) {
|
|
2619
|
+
this._sessionInfo.lastActivityAt = /* @__PURE__ */ new Date();
|
|
2620
|
+
this._sessionInfo.requestCount++;
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2624
|
+
// PRIVATE: RESULT WRAPPERS
|
|
2625
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2626
|
+
wrapToolResult(response) {
|
|
2627
|
+
const raw = response.data ?? { content: [] };
|
|
2628
|
+
const isError = !response.success || raw.isError === true;
|
|
2629
|
+
const meta = raw._meta;
|
|
2630
|
+
const hasUI = meta?.["ui/html"] !== void 0 || meta?.["ui/component"] !== void 0 || meta?.["openai/html"] !== void 0 || meta?.["frontmcp/html"] !== void 0;
|
|
2631
|
+
const structuredContent = raw["structuredContent"];
|
|
2632
|
+
return {
|
|
2633
|
+
raw,
|
|
2634
|
+
isSuccess: !isError,
|
|
2635
|
+
isError,
|
|
2636
|
+
error: response.error,
|
|
2637
|
+
durationMs: response.durationMs,
|
|
2638
|
+
json() {
|
|
2639
|
+
if (hasUI && structuredContent !== void 0) {
|
|
2640
|
+
return structuredContent;
|
|
2641
|
+
}
|
|
2642
|
+
const textContent = raw.content?.find((c) => c.type === "text");
|
|
2643
|
+
if (textContent && "text" in textContent) {
|
|
2644
|
+
return JSON.parse(textContent.text);
|
|
2645
|
+
}
|
|
2646
|
+
throw new Error("No text content to parse as JSON");
|
|
2647
|
+
},
|
|
2648
|
+
text() {
|
|
2649
|
+
const textContent = raw.content?.find((c) => c.type === "text");
|
|
2650
|
+
if (textContent && "text" in textContent) {
|
|
2651
|
+
return textContent.text;
|
|
2652
|
+
}
|
|
2653
|
+
return void 0;
|
|
2654
|
+
},
|
|
2655
|
+
hasTextContent() {
|
|
2656
|
+
return raw.content?.some((c) => c.type === "text") ?? false;
|
|
2657
|
+
},
|
|
2658
|
+
hasImageContent() {
|
|
2659
|
+
return raw.content?.some((c) => c.type === "image") ?? false;
|
|
2660
|
+
},
|
|
2661
|
+
hasResourceContent() {
|
|
2662
|
+
return raw.content?.some((c) => c.type === "resource") ?? false;
|
|
2663
|
+
},
|
|
2664
|
+
hasToolUI() {
|
|
2665
|
+
return hasUI;
|
|
2666
|
+
}
|
|
2667
|
+
};
|
|
2668
|
+
}
|
|
2669
|
+
wrapResourceContent(response) {
|
|
2670
|
+
const raw = response.data ?? { contents: [] };
|
|
2671
|
+
const isError = !response.success;
|
|
2672
|
+
const firstContent = raw.contents?.[0];
|
|
2673
|
+
return {
|
|
2674
|
+
raw,
|
|
2675
|
+
isSuccess: !isError,
|
|
2676
|
+
isError,
|
|
2677
|
+
error: response.error,
|
|
2678
|
+
durationMs: response.durationMs,
|
|
2679
|
+
json() {
|
|
2680
|
+
if (firstContent && "text" in firstContent) {
|
|
2681
|
+
return JSON.parse(firstContent.text);
|
|
2682
|
+
}
|
|
2683
|
+
throw new Error("No text content to parse as JSON");
|
|
2684
|
+
},
|
|
2685
|
+
text() {
|
|
2686
|
+
if (firstContent && "text" in firstContent) {
|
|
2687
|
+
return firstContent.text;
|
|
2688
|
+
}
|
|
2689
|
+
return void 0;
|
|
2690
|
+
},
|
|
2691
|
+
mimeType() {
|
|
2692
|
+
return firstContent?.mimeType;
|
|
2693
|
+
},
|
|
2694
|
+
hasMimeType(type) {
|
|
2695
|
+
return firstContent?.mimeType === type;
|
|
2696
|
+
}
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
wrapPromptResult(response) {
|
|
2700
|
+
const raw = response.data ?? { messages: [] };
|
|
2701
|
+
const isError = !response.success;
|
|
2702
|
+
return {
|
|
2703
|
+
raw,
|
|
2704
|
+
isSuccess: !isError,
|
|
2705
|
+
isError,
|
|
2706
|
+
error: response.error,
|
|
2707
|
+
durationMs: response.durationMs,
|
|
2708
|
+
messages: raw.messages ?? [],
|
|
2709
|
+
description: raw.description
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2713
|
+
// PRIVATE: LOGGING & TRACING
|
|
2714
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2715
|
+
log(level, message, data) {
|
|
2716
|
+
const entry = {
|
|
2717
|
+
level,
|
|
2718
|
+
message,
|
|
2719
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2720
|
+
data
|
|
2721
|
+
};
|
|
2722
|
+
this._logs.push(entry);
|
|
2723
|
+
if (this.config.debug) {
|
|
2724
|
+
console.log(`[${level.toUpperCase()}] ${message}`, data ?? "");
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
traceRequest(method, params, id, response, durationMs) {
|
|
2728
|
+
this._traces.push({
|
|
2729
|
+
request: { method, params, id },
|
|
2730
|
+
response: {
|
|
2731
|
+
result: "result" in response ? response.result : void 0,
|
|
2732
|
+
error: "error" in response ? response.error : void 0
|
|
2733
|
+
},
|
|
2734
|
+
durationMs,
|
|
2735
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2739
|
+
// PRIVATE: TOKEN PARSING
|
|
2740
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2741
|
+
parseScopesFromToken(token) {
|
|
2742
|
+
try {
|
|
2743
|
+
const payload = this.decodeJwtPayload(token);
|
|
2744
|
+
if (!payload) return [];
|
|
2745
|
+
const scope = payload["scope"];
|
|
2746
|
+
const scopes = payload["scopes"];
|
|
2747
|
+
if (typeof scope === "string") {
|
|
2748
|
+
return scope.split(" ");
|
|
2749
|
+
}
|
|
2750
|
+
if (Array.isArray(scopes)) {
|
|
2751
|
+
return scopes;
|
|
2752
|
+
}
|
|
2753
|
+
return [];
|
|
2754
|
+
} catch {
|
|
2755
|
+
return [];
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
parseUserFromToken(token) {
|
|
2759
|
+
try {
|
|
2760
|
+
const payload = this.decodeJwtPayload(token);
|
|
2761
|
+
const sub = payload?.["sub"];
|
|
2762
|
+
if (!sub || typeof sub !== "string") return void 0;
|
|
2763
|
+
return {
|
|
2764
|
+
sub,
|
|
2765
|
+
email: payload["email"],
|
|
2766
|
+
name: payload["name"]
|
|
2767
|
+
};
|
|
2768
|
+
} catch {
|
|
2769
|
+
return void 0;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
decodeJwtPayload(token) {
|
|
2773
|
+
try {
|
|
2774
|
+
const parts = token.split(".");
|
|
2775
|
+
if (parts.length !== 3) return null;
|
|
2776
|
+
const payload = Buffer.from(parts[1], "base64url").toString("utf-8");
|
|
2777
|
+
return JSON.parse(payload);
|
|
2778
|
+
} catch {
|
|
2779
|
+
return null;
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
};
|
|
2783
|
+
var NotificationCollector = class {
|
|
2784
|
+
constructor(notifications) {
|
|
2785
|
+
this.notifications = notifications;
|
|
2786
|
+
}
|
|
2787
|
+
get received() {
|
|
2788
|
+
return [...this.notifications];
|
|
2789
|
+
}
|
|
2790
|
+
has(method) {
|
|
2791
|
+
return this.notifications.some((n) => n.method === method);
|
|
2792
|
+
}
|
|
2793
|
+
async waitFor(method, timeoutMs) {
|
|
2794
|
+
const deadline = Date.now() + timeoutMs;
|
|
2795
|
+
while (Date.now() < deadline) {
|
|
2796
|
+
const found = this.notifications.find((n) => n.method === method);
|
|
2797
|
+
if (found) return found;
|
|
2798
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2799
|
+
}
|
|
2800
|
+
throw new Error(`Timeout waiting for notification: ${method}`);
|
|
2801
|
+
}
|
|
2802
|
+
};
|
|
2803
|
+
var ProgressCollector = class {
|
|
2804
|
+
constructor(updates) {
|
|
2805
|
+
this.updates = updates;
|
|
2806
|
+
}
|
|
2807
|
+
get all() {
|
|
2808
|
+
return [...this.updates];
|
|
2809
|
+
}
|
|
2810
|
+
async waitForComplete(timeoutMs) {
|
|
2811
|
+
const deadline = Date.now() + timeoutMs;
|
|
2812
|
+
while (Date.now() < deadline) {
|
|
2813
|
+
const last = this.updates[this.updates.length - 1];
|
|
2814
|
+
if (last && last.total !== void 0 && last.progress >= last.total) {
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
2818
|
+
}
|
|
2819
|
+
throw new Error("Timeout waiting for progress to complete");
|
|
2820
|
+
}
|
|
2821
|
+
};
|
|
2822
|
+
|
|
2823
|
+
// libs/testing/src/auth/token-factory.ts
|
|
2824
|
+
import { SignJWT, generateKeyPair, exportJWK } from "jose";
|
|
2825
|
+
var TestTokenFactory = class {
|
|
2826
|
+
issuer;
|
|
2827
|
+
audience;
|
|
2828
|
+
privateKey = null;
|
|
2829
|
+
publicKey = null;
|
|
2830
|
+
jwk = null;
|
|
2831
|
+
keyId;
|
|
2832
|
+
constructor(options = {}) {
|
|
2833
|
+
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
2834
|
+
this.audience = options.audience ?? "frontmcp-test";
|
|
2835
|
+
this.keyId = `test-key-${Date.now()}`;
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* Initialize the key pair (called automatically on first use)
|
|
2839
|
+
*/
|
|
2840
|
+
async ensureKeys() {
|
|
2841
|
+
if (this.privateKey && this.publicKey) return;
|
|
2842
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
2843
|
+
extractable: true
|
|
2844
|
+
});
|
|
2845
|
+
this.privateKey = privateKey;
|
|
2846
|
+
this.publicKey = publicKey;
|
|
2847
|
+
this.jwk = await exportJWK(publicKey);
|
|
2848
|
+
this.jwk.kid = this.keyId;
|
|
2849
|
+
this.jwk.use = "sig";
|
|
2850
|
+
this.jwk.alg = "RS256";
|
|
2851
|
+
}
|
|
2852
|
+
/**
|
|
2853
|
+
* Create a JWT token with the specified claims
|
|
2854
|
+
*/
|
|
2855
|
+
async createTestToken(options) {
|
|
2856
|
+
await this.ensureKeys();
|
|
2857
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2858
|
+
const exp = options.exp ?? 3600;
|
|
2859
|
+
const payload = {
|
|
2860
|
+
iss: options.iss ?? this.issuer,
|
|
2861
|
+
sub: options.sub,
|
|
2862
|
+
aud: options.aud ?? this.audience,
|
|
2863
|
+
iat: now,
|
|
2864
|
+
exp: now + exp,
|
|
2865
|
+
scope: options.scopes?.join(" "),
|
|
2866
|
+
...options.claims
|
|
2867
|
+
};
|
|
2868
|
+
if (!this.privateKey) {
|
|
2869
|
+
throw new Error("Private key not initialized");
|
|
2870
|
+
}
|
|
2871
|
+
const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
2872
|
+
return token;
|
|
2873
|
+
}
|
|
2874
|
+
/**
|
|
2875
|
+
* Create an admin token with full access
|
|
2876
|
+
*/
|
|
2877
|
+
async createAdminToken(sub = "admin-001") {
|
|
2878
|
+
return this.createTestToken({
|
|
2879
|
+
sub,
|
|
2880
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
2881
|
+
claims: {
|
|
2882
|
+
email: "admin@test.local",
|
|
2883
|
+
name: "Test Admin",
|
|
2884
|
+
role: "admin"
|
|
2885
|
+
}
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* Create a regular user token
|
|
2890
|
+
*/
|
|
2891
|
+
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
2892
|
+
return this.createTestToken({
|
|
2893
|
+
sub,
|
|
2894
|
+
scopes,
|
|
2895
|
+
claims: {
|
|
2896
|
+
email: "user@test.local",
|
|
2897
|
+
name: "Test User",
|
|
2898
|
+
role: "user"
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Create an anonymous user token
|
|
2904
|
+
*/
|
|
2905
|
+
async createAnonymousToken() {
|
|
2906
|
+
return this.createTestToken({
|
|
2907
|
+
sub: `anon:${Date.now()}`,
|
|
2908
|
+
scopes: ["anonymous"],
|
|
2909
|
+
claims: {
|
|
2910
|
+
name: "Anonymous",
|
|
2911
|
+
role: "anonymous"
|
|
2912
|
+
}
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
/**
|
|
2916
|
+
* Create an expired token (for testing token expiration)
|
|
2917
|
+
*/
|
|
2918
|
+
async createExpiredToken(options) {
|
|
2919
|
+
await this.ensureKeys();
|
|
2920
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2921
|
+
const payload = {
|
|
2922
|
+
iss: this.issuer,
|
|
2923
|
+
sub: options.sub,
|
|
2924
|
+
aud: this.audience,
|
|
2925
|
+
iat: now - 7200,
|
|
2926
|
+
// 2 hours ago
|
|
2927
|
+
exp: now - 3600
|
|
2928
|
+
// Expired 1 hour ago
|
|
2929
|
+
};
|
|
2930
|
+
if (!this.privateKey) {
|
|
2931
|
+
throw new Error("Private key not initialized");
|
|
2932
|
+
}
|
|
2933
|
+
const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
2934
|
+
return token;
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Create a token with an invalid signature (for testing signature validation)
|
|
2938
|
+
*/
|
|
2939
|
+
createTokenWithInvalidSignature(options) {
|
|
2940
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
2941
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", kid: this.keyId })).toString("base64url");
|
|
2942
|
+
const payload = Buffer.from(
|
|
2943
|
+
JSON.stringify({
|
|
2944
|
+
iss: this.issuer,
|
|
2945
|
+
sub: options.sub,
|
|
2946
|
+
aud: this.audience,
|
|
2947
|
+
iat: now,
|
|
2948
|
+
exp: now + 3600
|
|
2949
|
+
})
|
|
2950
|
+
).toString("base64url");
|
|
2951
|
+
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
2952
|
+
return `${header}.${payload}.${signature}`;
|
|
2953
|
+
}
|
|
2954
|
+
/**
|
|
2955
|
+
* Get the public JWKS for verifying tokens
|
|
2956
|
+
*/
|
|
2957
|
+
async getPublicJwks() {
|
|
2958
|
+
await this.ensureKeys();
|
|
2959
|
+
if (!this.jwk) {
|
|
2960
|
+
throw new Error("JWK not initialized");
|
|
2961
|
+
}
|
|
2962
|
+
return {
|
|
2963
|
+
keys: [this.jwk]
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
/**
|
|
2967
|
+
* Get the issuer URL
|
|
2968
|
+
*/
|
|
2969
|
+
getIssuer() {
|
|
2970
|
+
return this.issuer;
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Get the audience
|
|
2974
|
+
*/
|
|
2975
|
+
getAudience() {
|
|
2976
|
+
return this.audience;
|
|
2977
|
+
}
|
|
2978
|
+
};
|
|
2979
|
+
|
|
2980
|
+
// libs/testing/src/server/test-server.ts
|
|
2981
|
+
import { spawn } from "child_process";
|
|
2982
|
+
|
|
2983
|
+
// libs/testing/src/errors/index.ts
|
|
2984
|
+
var TestClientError = class extends Error {
|
|
2985
|
+
constructor(message) {
|
|
2986
|
+
super(message);
|
|
2987
|
+
this.name = "TestClientError";
|
|
2988
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2989
|
+
}
|
|
2990
|
+
};
|
|
2991
|
+
var ServerStartError = class extends TestClientError {
|
|
2992
|
+
constructor(message, cause) {
|
|
2993
|
+
super(message);
|
|
2994
|
+
this.cause = cause;
|
|
2995
|
+
this.name = "ServerStartError";
|
|
2996
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
2997
|
+
}
|
|
2998
|
+
};
|
|
2999
|
+
|
|
3000
|
+
// libs/testing/src/server/port-registry.ts
|
|
3001
|
+
import { createServer } from "net";
|
|
3002
|
+
var E2E_PORT_RANGES = {
|
|
3003
|
+
// Core E2E tests (50000-50099)
|
|
3004
|
+
"demo-e2e-public": { start: 5e4, size: 10 },
|
|
3005
|
+
"demo-e2e-cache": { start: 50010, size: 10 },
|
|
3006
|
+
"demo-e2e-config": { start: 50020, size: 10 },
|
|
3007
|
+
"demo-e2e-direct": { start: 50030, size: 10 },
|
|
3008
|
+
"demo-e2e-errors": { start: 50040, size: 10 },
|
|
3009
|
+
"demo-e2e-hooks": { start: 50050, size: 10 },
|
|
3010
|
+
"demo-e2e-multiapp": { start: 50060, size: 10 },
|
|
3011
|
+
"demo-e2e-notifications": { start: 50070, size: 10 },
|
|
3012
|
+
"demo-e2e-providers": { start: 50080, size: 10 },
|
|
3013
|
+
"demo-e2e-standalone": { start: 50090, size: 10 },
|
|
3014
|
+
// Auth E2E tests (50100-50199)
|
|
3015
|
+
"demo-e2e-orchestrated": { start: 50100, size: 10 },
|
|
3016
|
+
"demo-e2e-transparent": { start: 50110, size: 10 },
|
|
3017
|
+
"demo-e2e-cimd": { start: 50120, size: 10 },
|
|
3018
|
+
// Feature E2E tests (50200-50299)
|
|
3019
|
+
"demo-e2e-skills": { start: 50200, size: 10 },
|
|
3020
|
+
"demo-e2e-remote": { start: 50210, size: 10 },
|
|
3021
|
+
"demo-e2e-openapi": { start: 50220, size: 10 },
|
|
3022
|
+
"demo-e2e-ui": { start: 50230, size: 10 },
|
|
3023
|
+
"demo-e2e-codecall": { start: 50240, size: 10 },
|
|
3024
|
+
"demo-e2e-remember": { start: 50250, size: 10 },
|
|
3025
|
+
"demo-e2e-elicitation": { start: 50260, size: 10 },
|
|
3026
|
+
"demo-e2e-agents": { start: 50270, size: 10 },
|
|
3027
|
+
"demo-e2e-transport-recreation": { start: 50280, size: 10 },
|
|
3028
|
+
// Infrastructure E2E tests (50300-50399)
|
|
3029
|
+
"demo-e2e-redis": { start: 50300, size: 10 },
|
|
3030
|
+
"demo-e2e-serverless": { start: 50310, size: 10 },
|
|
3031
|
+
// Mock servers and utilities (50900-50999)
|
|
3032
|
+
"mock-oauth": { start: 50900, size: 10 },
|
|
3033
|
+
"mock-api": { start: 50910, size: 10 },
|
|
3034
|
+
"mock-cimd": { start: 50920, size: 10 },
|
|
3035
|
+
// Dynamic/unknown projects (51000+)
|
|
3036
|
+
default: { start: 51e3, size: 100 }
|
|
3037
|
+
};
|
|
3038
|
+
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3039
|
+
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
3040
|
+
function getPortRange(project) {
|
|
3041
|
+
const key = project;
|
|
3042
|
+
if (key in E2E_PORT_RANGES) {
|
|
3043
|
+
return E2E_PORT_RANGES[key];
|
|
3044
|
+
}
|
|
3045
|
+
return E2E_PORT_RANGES.default;
|
|
3046
|
+
}
|
|
3047
|
+
async function reservePort(project, preferredPort) {
|
|
3048
|
+
const range = getPortRange(project);
|
|
3049
|
+
if (preferredPort !== void 0) {
|
|
3050
|
+
const reservation = await tryReservePort(preferredPort, project);
|
|
3051
|
+
if (reservation) {
|
|
3052
|
+
return {
|
|
3053
|
+
port: preferredPort,
|
|
3054
|
+
release: async () => {
|
|
3055
|
+
await releasePort(preferredPort);
|
|
3056
|
+
}
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
3060
|
+
}
|
|
3061
|
+
let index = projectPortIndex.get(project) ?? 0;
|
|
3062
|
+
const maxAttempts = range.size;
|
|
3063
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3064
|
+
const port = range.start + index % range.size;
|
|
3065
|
+
index = (index + 1) % range.size;
|
|
3066
|
+
if (reservedPorts.has(port)) {
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
const reservation = await tryReservePort(port, project);
|
|
3070
|
+
if (reservation) {
|
|
3071
|
+
projectPortIndex.set(project, index);
|
|
3072
|
+
return {
|
|
3073
|
+
port,
|
|
3074
|
+
release: async () => {
|
|
3075
|
+
await releasePort(port);
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
const dynamicPort = await findAvailablePortInRange(51e3, 52e3);
|
|
3081
|
+
if (dynamicPort) {
|
|
3082
|
+
const reservation = await tryReservePort(dynamicPort, project);
|
|
3083
|
+
if (reservation) {
|
|
3084
|
+
return {
|
|
3085
|
+
port: dynamicPort,
|
|
3086
|
+
release: async () => {
|
|
3087
|
+
await releasePort(dynamicPort);
|
|
3088
|
+
}
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
throw new Error(
|
|
3093
|
+
`[PortRegistry] Could not reserve a port for ${project}. Range: ${range.start}-${range.start + range.size - 1}. Currently reserved: ${Array.from(reservedPorts.keys()).join(", ")}`
|
|
3094
|
+
);
|
|
3095
|
+
}
|
|
3096
|
+
async function tryReservePort(port, project) {
|
|
3097
|
+
return new Promise((resolve) => {
|
|
3098
|
+
const server = createServer();
|
|
3099
|
+
server.once("error", () => {
|
|
3100
|
+
resolve(false);
|
|
3101
|
+
});
|
|
3102
|
+
server.listen(port, "::", () => {
|
|
3103
|
+
reservedPorts.set(port, {
|
|
3104
|
+
port,
|
|
3105
|
+
project,
|
|
3106
|
+
holder: server,
|
|
3107
|
+
reservedAt: Date.now()
|
|
3108
|
+
});
|
|
3109
|
+
resolve(true);
|
|
3110
|
+
});
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
3113
|
+
async function releasePort(port) {
|
|
3114
|
+
const reservation = reservedPorts.get(port);
|
|
3115
|
+
if (!reservation) {
|
|
3116
|
+
return;
|
|
3117
|
+
}
|
|
3118
|
+
return new Promise((resolve) => {
|
|
3119
|
+
reservation.holder.close(() => {
|
|
3120
|
+
reservedPorts.delete(port);
|
|
3121
|
+
resolve();
|
|
3122
|
+
});
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
async function findAvailablePortInRange(start, end) {
|
|
3126
|
+
for (let port = start; port < end; port++) {
|
|
3127
|
+
if (reservedPorts.has(port)) {
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
const available = await isPortAvailable(port);
|
|
3131
|
+
if (available) {
|
|
3132
|
+
return port;
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
return null;
|
|
3136
|
+
}
|
|
3137
|
+
async function isPortAvailable(port) {
|
|
3138
|
+
return new Promise((resolve) => {
|
|
3139
|
+
const server = createServer();
|
|
3140
|
+
server.once("error", () => {
|
|
3141
|
+
resolve(false);
|
|
3142
|
+
});
|
|
3143
|
+
server.listen(port, "::", () => {
|
|
3144
|
+
server.close(() => {
|
|
3145
|
+
resolve(true);
|
|
3146
|
+
});
|
|
3147
|
+
});
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
// libs/testing/src/server/test-server.ts
|
|
3152
|
+
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
3153
|
+
var TestServer = class _TestServer {
|
|
3154
|
+
process = null;
|
|
3155
|
+
options;
|
|
3156
|
+
_info;
|
|
3157
|
+
logs = [];
|
|
3158
|
+
portRelease = null;
|
|
3159
|
+
constructor(options, port, portRelease) {
|
|
3160
|
+
this.options = {
|
|
3161
|
+
port,
|
|
3162
|
+
project: options.project,
|
|
3163
|
+
command: options.command ?? "",
|
|
3164
|
+
cwd: options.cwd ?? process.cwd(),
|
|
3165
|
+
env: options.env ?? {},
|
|
3166
|
+
startupTimeout: options.startupTimeout ?? 3e4,
|
|
3167
|
+
healthCheckPath: options.healthCheckPath ?? "/health",
|
|
3168
|
+
debug: options.debug ?? DEBUG_SERVER
|
|
3169
|
+
};
|
|
3170
|
+
this.portRelease = portRelease ?? null;
|
|
3171
|
+
this._info = {
|
|
3172
|
+
baseUrl: `http://localhost:${port}`,
|
|
3173
|
+
port
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Start a test server with custom command
|
|
3178
|
+
*/
|
|
3179
|
+
static async start(options) {
|
|
3180
|
+
const project = options.project ?? "default";
|
|
3181
|
+
const { port, release } = await reservePort(project, options.port);
|
|
3182
|
+
await release();
|
|
3183
|
+
const server = new _TestServer(options, port);
|
|
3184
|
+
try {
|
|
3185
|
+
await server.startProcess();
|
|
3186
|
+
} catch (error) {
|
|
3187
|
+
await server.stop();
|
|
3188
|
+
throw error;
|
|
3189
|
+
}
|
|
3190
|
+
return server;
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Start an Nx project as test server
|
|
3194
|
+
*/
|
|
3195
|
+
static async startNx(project, options = {}) {
|
|
3196
|
+
if (!/^[\w-]+$/.test(project)) {
|
|
3197
|
+
throw new Error(
|
|
3198
|
+
`Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
const { port, release } = await reservePort(project, options.port);
|
|
3202
|
+
await release();
|
|
3203
|
+
const serverOptions = {
|
|
3204
|
+
...options,
|
|
3205
|
+
port,
|
|
3206
|
+
project,
|
|
3207
|
+
command: `npx nx serve ${project} --port ${port}`,
|
|
3208
|
+
cwd: options.cwd ?? process.cwd()
|
|
3209
|
+
};
|
|
3210
|
+
const server = new _TestServer(serverOptions, port);
|
|
3211
|
+
try {
|
|
3212
|
+
await server.startProcess();
|
|
3213
|
+
} catch (error) {
|
|
3214
|
+
await server.stop();
|
|
3215
|
+
throw error;
|
|
3216
|
+
}
|
|
3217
|
+
return server;
|
|
3218
|
+
}
|
|
3219
|
+
/**
|
|
3220
|
+
* Create a test server connected to an already running server
|
|
3221
|
+
*/
|
|
3222
|
+
static connect(baseUrl) {
|
|
3223
|
+
const url = new URL(baseUrl);
|
|
3224
|
+
const port = parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80);
|
|
3225
|
+
const server = new _TestServer(
|
|
3226
|
+
{
|
|
3227
|
+
command: "",
|
|
3228
|
+
port
|
|
3229
|
+
},
|
|
3230
|
+
port
|
|
3231
|
+
);
|
|
3232
|
+
server._info = {
|
|
3233
|
+
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
3234
|
+
port
|
|
3235
|
+
};
|
|
3236
|
+
return server;
|
|
3237
|
+
}
|
|
3238
|
+
/**
|
|
3239
|
+
* Get server information
|
|
3240
|
+
*/
|
|
3241
|
+
get info() {
|
|
3242
|
+
return { ...this._info };
|
|
3243
|
+
}
|
|
3244
|
+
/**
|
|
3245
|
+
* Stop the test server
|
|
3246
|
+
*/
|
|
3247
|
+
async stop() {
|
|
3248
|
+
if (this.process) {
|
|
3249
|
+
this.log("Stopping server...");
|
|
3250
|
+
this.process.kill("SIGTERM");
|
|
3251
|
+
const exitPromise = new Promise((resolve) => {
|
|
3252
|
+
if (this.process) {
|
|
3253
|
+
this.process.once("exit", () => resolve());
|
|
3254
|
+
} else {
|
|
3255
|
+
resolve();
|
|
3256
|
+
}
|
|
3257
|
+
});
|
|
3258
|
+
const killTimeout = setTimeout(() => {
|
|
3259
|
+
if (this.process) {
|
|
3260
|
+
this.log("Force killing server after timeout...");
|
|
3261
|
+
this.process.kill("SIGKILL");
|
|
3262
|
+
}
|
|
3263
|
+
}, 5e3);
|
|
3264
|
+
await exitPromise;
|
|
3265
|
+
clearTimeout(killTimeout);
|
|
3266
|
+
this.process = null;
|
|
3267
|
+
this.log("Server stopped");
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
/**
|
|
3271
|
+
* Wait for server to be ready
|
|
3272
|
+
*/
|
|
3273
|
+
async waitForReady(timeout) {
|
|
3274
|
+
const timeoutMs = timeout ?? this.options.startupTimeout;
|
|
3275
|
+
const deadline = Date.now() + timeoutMs;
|
|
3276
|
+
const checkInterval = 100;
|
|
3277
|
+
while (Date.now() < deadline) {
|
|
3278
|
+
try {
|
|
3279
|
+
const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {
|
|
3280
|
+
method: "GET",
|
|
3281
|
+
signal: AbortSignal.timeout(1e3)
|
|
3282
|
+
});
|
|
3283
|
+
if (response.ok || response.status === 404) {
|
|
3284
|
+
this.log("Server is ready");
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
} catch {
|
|
3288
|
+
}
|
|
3289
|
+
await sleep4(checkInterval);
|
|
3290
|
+
}
|
|
3291
|
+
throw new Error(`Server did not become ready within ${timeoutMs}ms`);
|
|
3292
|
+
}
|
|
3293
|
+
/**
|
|
3294
|
+
* Restart the server
|
|
3295
|
+
*/
|
|
3296
|
+
async restart() {
|
|
3297
|
+
await this.stop();
|
|
3298
|
+
await this.startProcess();
|
|
3299
|
+
}
|
|
3300
|
+
/**
|
|
3301
|
+
* Get captured server logs
|
|
3302
|
+
*/
|
|
3303
|
+
getLogs() {
|
|
3304
|
+
return [...this.logs];
|
|
3305
|
+
}
|
|
3306
|
+
/**
|
|
3307
|
+
* Clear captured logs
|
|
3308
|
+
*/
|
|
3309
|
+
clearLogs() {
|
|
3310
|
+
this.logs = [];
|
|
3311
|
+
}
|
|
3312
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3313
|
+
// PRIVATE METHODS
|
|
3314
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3315
|
+
async startProcess() {
|
|
3316
|
+
if (!this.options.command) {
|
|
3317
|
+
await this.waitForReady();
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3320
|
+
this.log(`Starting server: ${this.options.command}`);
|
|
3321
|
+
const env = {
|
|
3322
|
+
...process.env,
|
|
3323
|
+
...this.options.env,
|
|
3324
|
+
PORT: String(this.options.port)
|
|
3325
|
+
};
|
|
3326
|
+
this.process = spawn(this.options.command, [], {
|
|
3327
|
+
cwd: this.options.cwd,
|
|
3328
|
+
env,
|
|
3329
|
+
shell: true,
|
|
3330
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3331
|
+
});
|
|
3332
|
+
if (this.process.pid !== void 0) {
|
|
3333
|
+
this._info.pid = this.process.pid;
|
|
3334
|
+
}
|
|
3335
|
+
let processExited = false;
|
|
3336
|
+
let exitCode = null;
|
|
3337
|
+
let exitError = null;
|
|
3338
|
+
this.process.stdout?.on("data", (data) => {
|
|
3339
|
+
const text = data.toString();
|
|
3340
|
+
this.logs.push(text);
|
|
3341
|
+
if (this.options.debug) {
|
|
3342
|
+
console.log("[SERVER]", text);
|
|
3343
|
+
}
|
|
3344
|
+
});
|
|
3345
|
+
this.process.stderr?.on("data", (data) => {
|
|
3346
|
+
const text = data.toString();
|
|
3347
|
+
this.logs.push(`[ERROR] ${text}`);
|
|
3348
|
+
if (this.options.debug) {
|
|
3349
|
+
console.error("[SERVER ERROR]", text);
|
|
3350
|
+
}
|
|
3351
|
+
});
|
|
3352
|
+
this.process.on("error", (err) => {
|
|
3353
|
+
this.logs.push(`[SPAWN ERROR] ${err.message}`);
|
|
3354
|
+
exitError = err;
|
|
3355
|
+
if (this.options.debug) {
|
|
3356
|
+
console.error("[SERVER SPAWN ERROR]", err);
|
|
3357
|
+
}
|
|
3358
|
+
});
|
|
3359
|
+
this.process.once("exit", (code) => {
|
|
3360
|
+
processExited = true;
|
|
3361
|
+
exitCode = code;
|
|
3362
|
+
this.log(`Server process exited with code ${code}`);
|
|
3363
|
+
});
|
|
3364
|
+
try {
|
|
3365
|
+
await this.waitForReadyWithExitDetection(() => {
|
|
3366
|
+
if (exitError) {
|
|
3367
|
+
return { exited: true, error: exitError };
|
|
3368
|
+
}
|
|
3369
|
+
if (processExited) {
|
|
3370
|
+
const allLogs = this.logs.join("\n");
|
|
3371
|
+
const errorLogs = this.logs.filter((l) => l.includes("[ERROR]") || l.toLowerCase().includes("error")).join("\n");
|
|
3372
|
+
return {
|
|
3373
|
+
exited: true,
|
|
3374
|
+
error: new ServerStartError(
|
|
3375
|
+
`Server process exited unexpectedly with code ${exitCode}.
|
|
3376
|
+
|
|
3377
|
+
Command: ${this.options.command}
|
|
3378
|
+
CWD: ${this.options.cwd}
|
|
3379
|
+
Port: ${this.options.port}
|
|
3380
|
+
|
|
3381
|
+
=== Error Logs ===
|
|
3382
|
+
${errorLogs || "No error logs captured"}
|
|
3383
|
+
|
|
3384
|
+
=== Full Logs ===
|
|
3385
|
+
${allLogs || "No logs captured"}`
|
|
3386
|
+
)
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
return { exited: false };
|
|
3390
|
+
});
|
|
3391
|
+
} catch (error) {
|
|
3392
|
+
this.printLogsOnFailure("Server startup failed");
|
|
3393
|
+
throw error;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
/**
|
|
3397
|
+
* Print server logs on failure for debugging
|
|
3398
|
+
*/
|
|
3399
|
+
printLogsOnFailure(context) {
|
|
3400
|
+
const allLogs = this.logs.join("\n");
|
|
3401
|
+
if (allLogs) {
|
|
3402
|
+
console.error(`
|
|
3403
|
+
[TestServer] ${context}`);
|
|
3404
|
+
console.error(`[TestServer] Command: ${this.options.command}`);
|
|
3405
|
+
console.error(`[TestServer] Port: ${this.options.port}`);
|
|
3406
|
+
console.error(`[TestServer] CWD: ${this.options.cwd}`);
|
|
3407
|
+
console.error(`[TestServer] === Server Logs ===
|
|
3408
|
+
${allLogs}`);
|
|
3409
|
+
console.error(`[TestServer] === End Logs ===
|
|
3410
|
+
`);
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
/**
|
|
3414
|
+
* Wait for server to be ready, but also detect early process exit
|
|
3415
|
+
*/
|
|
3416
|
+
async waitForReadyWithExitDetection(checkExit) {
|
|
3417
|
+
const timeoutMs = this.options.startupTimeout;
|
|
3418
|
+
const deadline = Date.now() + timeoutMs;
|
|
3419
|
+
const checkInterval = 100;
|
|
3420
|
+
let lastHealthCheckError = null;
|
|
3421
|
+
let healthCheckAttempts = 0;
|
|
3422
|
+
this.log(`Waiting for server to be ready (timeout: ${timeoutMs}ms)...`);
|
|
3423
|
+
while (Date.now() < deadline) {
|
|
3424
|
+
const exitStatus = checkExit();
|
|
3425
|
+
if (exitStatus.exited) {
|
|
3426
|
+
throw exitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
3427
|
+
}
|
|
3428
|
+
healthCheckAttempts++;
|
|
3429
|
+
try {
|
|
3430
|
+
const healthUrl = `${this._info.baseUrl}${this.options.healthCheckPath}`;
|
|
3431
|
+
const response = await fetch(healthUrl, {
|
|
3432
|
+
method: "GET",
|
|
3433
|
+
signal: AbortSignal.timeout(1e3)
|
|
3434
|
+
});
|
|
3435
|
+
if (response.ok || response.status === 404) {
|
|
3436
|
+
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3439
|
+
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
3440
|
+
} catch (err) {
|
|
3441
|
+
lastHealthCheckError = err instanceof Error ? err.message : String(err);
|
|
3442
|
+
}
|
|
3443
|
+
const elapsed = Date.now() - (deadline - timeoutMs);
|
|
3444
|
+
if (elapsed > 0 && elapsed % 5e3 < checkInterval) {
|
|
3445
|
+
this.log(
|
|
3446
|
+
`Still waiting for server... (${Math.round(elapsed / 1e3)}s elapsed, last error: ${lastHealthCheckError})`
|
|
3447
|
+
);
|
|
3448
|
+
}
|
|
3449
|
+
await sleep4(checkInterval);
|
|
3450
|
+
}
|
|
3451
|
+
const finalExitStatus = checkExit();
|
|
3452
|
+
if (finalExitStatus.exited) {
|
|
3453
|
+
throw finalExitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
3454
|
+
}
|
|
3455
|
+
const allLogs = this.logs.join("\n");
|
|
3456
|
+
throw new ServerStartError(
|
|
3457
|
+
`Server did not become ready within ${timeoutMs}ms.
|
|
3458
|
+
|
|
3459
|
+
Command: ${this.options.command}
|
|
3460
|
+
CWD: ${this.options.cwd}
|
|
3461
|
+
Port: ${this.options.port}
|
|
3462
|
+
Health check URL: ${this._info.baseUrl}${this.options.healthCheckPath}
|
|
3463
|
+
Health check attempts: ${healthCheckAttempts}
|
|
3464
|
+
Last health check error: ${lastHealthCheckError ?? "none"}
|
|
3465
|
+
|
|
3466
|
+
=== Server Logs ===
|
|
3467
|
+
${allLogs || "No logs captured"}
|
|
3468
|
+
|
|
3469
|
+
TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
log(message) {
|
|
3473
|
+
if (this.options.debug) {
|
|
3474
|
+
console.log(`[TestServer] ${message}`);
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
};
|
|
3478
|
+
function sleep4(ms) {
|
|
3479
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3480
|
+
}
|
|
3481
|
+
|
|
3482
|
+
// libs/testing/src/perf/perf-test.ts
|
|
3483
|
+
var currentConfig = {};
|
|
3484
|
+
var serverInstance = null;
|
|
3485
|
+
var tokenFactory = null;
|
|
3486
|
+
var serverStartedByUs = false;
|
|
3487
|
+
async function initializeSharedResources() {
|
|
3488
|
+
if (!tokenFactory) {
|
|
3489
|
+
tokenFactory = new TestTokenFactory();
|
|
3490
|
+
}
|
|
3491
|
+
if (!serverInstance) {
|
|
3492
|
+
if (currentConfig.baseUrl) {
|
|
3493
|
+
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
3494
|
+
serverStartedByUs = false;
|
|
3495
|
+
} else if (currentConfig.server) {
|
|
3496
|
+
const serverCommand = resolveServerCommand(currentConfig.server);
|
|
3497
|
+
const isDebug = process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3498
|
+
if (isDebug) {
|
|
3499
|
+
console.log(`[PerfTest] Starting server: ${serverCommand}`);
|
|
3500
|
+
}
|
|
3501
|
+
try {
|
|
3502
|
+
serverInstance = await TestServer.start({
|
|
3503
|
+
project: currentConfig.project,
|
|
3504
|
+
port: currentConfig.port,
|
|
3505
|
+
command: serverCommand,
|
|
3506
|
+
env: currentConfig.env,
|
|
3507
|
+
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
3508
|
+
debug: isDebug
|
|
3509
|
+
});
|
|
3510
|
+
serverStartedByUs = true;
|
|
3511
|
+
if (isDebug) {
|
|
3512
|
+
console.log(`[PerfTest] Server started at ${serverInstance.info.baseUrl}`);
|
|
3513
|
+
}
|
|
3514
|
+
} catch (error) {
|
|
3515
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3516
|
+
throw new Error(
|
|
3517
|
+
`Failed to start test server.
|
|
3518
|
+
|
|
3519
|
+
Server entry: ${currentConfig.server}
|
|
3520
|
+
Project: ${currentConfig.project ?? "default"}
|
|
3521
|
+
Command: ${serverCommand}
|
|
3522
|
+
|
|
3523
|
+
Error: ${errMsg}`
|
|
3524
|
+
);
|
|
3525
|
+
}
|
|
3526
|
+
} else {
|
|
3527
|
+
throw new Error('perfTest.use() requires either "server" (entry file path) or "baseUrl" option');
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
async function createTestFixtures(testName) {
|
|
3532
|
+
await initializeSharedResources();
|
|
3533
|
+
if (!serverInstance) {
|
|
3534
|
+
throw new Error("Server instance not initialized");
|
|
3535
|
+
}
|
|
3536
|
+
if (!tokenFactory) {
|
|
3537
|
+
throw new Error("Token factory not initialized");
|
|
3538
|
+
}
|
|
3539
|
+
const clientInstance = await McpTestClient.create({
|
|
3540
|
+
baseUrl: serverInstance.info.baseUrl,
|
|
3541
|
+
transport: currentConfig.transport ?? "streamable-http",
|
|
3542
|
+
publicMode: currentConfig.publicMode
|
|
3543
|
+
}).buildAndConnect();
|
|
3544
|
+
const auth = createAuthFixture(tokenFactory);
|
|
3545
|
+
const server = createServerFixture(serverInstance);
|
|
3546
|
+
const perfImpl = createPerfFixtures(testName, currentConfig.project ?? "unknown");
|
|
3547
|
+
if (currentConfig.forceGcOnBaseline !== false) {
|
|
3548
|
+
await perfImpl.baseline();
|
|
3549
|
+
}
|
|
3550
|
+
return {
|
|
3551
|
+
fixtures: {
|
|
3552
|
+
mcp: clientInstance,
|
|
3553
|
+
auth,
|
|
3554
|
+
server,
|
|
3555
|
+
perf: perfImpl
|
|
3556
|
+
},
|
|
3557
|
+
perfImpl
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
async function cleanupTestFixtures(fixtures, perfImpl, testFailed = false) {
|
|
3561
|
+
const measurement = perfImpl.buildMeasurement();
|
|
3562
|
+
addGlobalMeasurement(measurement);
|
|
3563
|
+
if (testFailed && serverInstance) {
|
|
3564
|
+
const logs = serverInstance.getLogs();
|
|
3565
|
+
if (logs.length > 0) {
|
|
3566
|
+
console.error("\n[PerfTest] === Server Logs (test failed) ===");
|
|
3567
|
+
const recentLogs = logs.slice(-50);
|
|
3568
|
+
if (logs.length > 50) {
|
|
3569
|
+
console.error(`[PerfTest] (showing last 50 of ${logs.length} log entries)`);
|
|
3570
|
+
}
|
|
3571
|
+
console.error(recentLogs.join("\n"));
|
|
3572
|
+
console.error("[PerfTest] === End Server Logs ===\n");
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
if (fixtures.mcp.isConnected()) {
|
|
3576
|
+
await fixtures.mcp.disconnect();
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
async function cleanupSharedResources() {
|
|
3580
|
+
if (serverInstance && serverStartedByUs) {
|
|
3581
|
+
await serverInstance.stop();
|
|
3582
|
+
}
|
|
3583
|
+
serverInstance = null;
|
|
3584
|
+
tokenFactory = null;
|
|
3585
|
+
serverStartedByUs = false;
|
|
3586
|
+
}
|
|
3587
|
+
function createAuthFixture(factory) {
|
|
3588
|
+
const users = {
|
|
3589
|
+
admin: {
|
|
3590
|
+
sub: "admin-001",
|
|
3591
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
3592
|
+
email: "admin@test.local",
|
|
3593
|
+
name: "Test Admin"
|
|
3594
|
+
},
|
|
3595
|
+
user: {
|
|
3596
|
+
sub: "user-001",
|
|
3597
|
+
scopes: ["read", "write"],
|
|
3598
|
+
email: "user@test.local",
|
|
3599
|
+
name: "Test User"
|
|
3600
|
+
},
|
|
3601
|
+
readOnly: {
|
|
3602
|
+
sub: "readonly-001",
|
|
3603
|
+
scopes: ["read"],
|
|
3604
|
+
email: "readonly@test.local",
|
|
3605
|
+
name: "Read Only User"
|
|
3606
|
+
}
|
|
3607
|
+
};
|
|
3608
|
+
return {
|
|
3609
|
+
createToken: (opts) => factory.createTestToken({
|
|
3610
|
+
sub: opts.sub,
|
|
3611
|
+
scopes: opts.scopes,
|
|
3612
|
+
claims: {
|
|
3613
|
+
email: opts.email,
|
|
3614
|
+
name: opts.name,
|
|
3615
|
+
...opts.claims
|
|
3616
|
+
},
|
|
3617
|
+
exp: opts.expiresIn
|
|
3618
|
+
}),
|
|
3619
|
+
createExpiredToken: (opts) => factory.createExpiredToken(opts),
|
|
3620
|
+
createInvalidToken: (opts) => factory.createTokenWithInvalidSignature(opts),
|
|
3621
|
+
users: {
|
|
3622
|
+
admin: users["admin"],
|
|
3623
|
+
user: users["user"],
|
|
3624
|
+
readOnly: users["readOnly"]
|
|
3625
|
+
},
|
|
3626
|
+
getJwks: () => factory.getPublicJwks(),
|
|
3627
|
+
getIssuer: () => factory.getIssuer(),
|
|
3628
|
+
getAudience: () => factory.getAudience()
|
|
3629
|
+
};
|
|
3630
|
+
}
|
|
3631
|
+
function createServerFixture(server) {
|
|
3632
|
+
return {
|
|
3633
|
+
info: server.info,
|
|
3634
|
+
createClient: async (opts) => {
|
|
3635
|
+
return McpTestClient.create({
|
|
3636
|
+
baseUrl: server.info.baseUrl,
|
|
3637
|
+
transport: opts?.transport ?? "streamable-http",
|
|
3638
|
+
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3639
|
+
clientInfo: opts?.clientInfo,
|
|
3640
|
+
publicMode: currentConfig.publicMode
|
|
3641
|
+
}).buildAndConnect();
|
|
3642
|
+
},
|
|
3643
|
+
createClientBuilder: () => {
|
|
3644
|
+
return new McpTestClientBuilder({
|
|
3645
|
+
baseUrl: server.info.baseUrl,
|
|
3646
|
+
publicMode: currentConfig.publicMode
|
|
3647
|
+
});
|
|
3648
|
+
},
|
|
3649
|
+
restart: () => server.restart(),
|
|
3650
|
+
getLogs: () => server.getLogs(),
|
|
3651
|
+
clearLogs: () => server.clearLogs()
|
|
3652
|
+
};
|
|
3653
|
+
}
|
|
3654
|
+
function resolveServerCommand(server) {
|
|
3655
|
+
if (server.includes(" ")) {
|
|
3656
|
+
return server;
|
|
3657
|
+
}
|
|
3658
|
+
return `npx tsx ${server}`;
|
|
3659
|
+
}
|
|
3660
|
+
function perfTestWithFixtures(name, fn) {
|
|
3661
|
+
it(name, async () => {
|
|
3662
|
+
const { fixtures, perfImpl } = await createTestFixtures(name);
|
|
3663
|
+
let testFailed = false;
|
|
3664
|
+
try {
|
|
3665
|
+
await fn(fixtures);
|
|
3666
|
+
} catch (error) {
|
|
3667
|
+
testFailed = true;
|
|
3668
|
+
throw error;
|
|
3669
|
+
} finally {
|
|
3670
|
+
await cleanupTestFixtures(fixtures, perfImpl, testFailed);
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
function use(config) {
|
|
3675
|
+
currentConfig = { ...currentConfig, ...config };
|
|
3676
|
+
afterAll(async () => {
|
|
3677
|
+
await cleanupSharedResources();
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
function skip(name, fn) {
|
|
3681
|
+
it.skip(name, async () => {
|
|
3682
|
+
const { fixtures, perfImpl } = await createTestFixtures(name);
|
|
3683
|
+
let testFailed = false;
|
|
3684
|
+
try {
|
|
3685
|
+
await fn(fixtures);
|
|
3686
|
+
} catch (error) {
|
|
3687
|
+
testFailed = true;
|
|
3688
|
+
throw error;
|
|
3689
|
+
} finally {
|
|
3690
|
+
await cleanupTestFixtures(fixtures, perfImpl, testFailed);
|
|
3691
|
+
}
|
|
3692
|
+
});
|
|
3693
|
+
}
|
|
3694
|
+
function only(name, fn) {
|
|
3695
|
+
it.only(name, async () => {
|
|
3696
|
+
const { fixtures, perfImpl } = await createTestFixtures(name);
|
|
3697
|
+
let testFailed = false;
|
|
3698
|
+
try {
|
|
3699
|
+
await fn(fixtures);
|
|
3700
|
+
} catch (error) {
|
|
3701
|
+
testFailed = true;
|
|
3702
|
+
throw error;
|
|
3703
|
+
} finally {
|
|
3704
|
+
await cleanupTestFixtures(fixtures, perfImpl, testFailed);
|
|
3705
|
+
}
|
|
3706
|
+
});
|
|
3707
|
+
}
|
|
3708
|
+
function todo(name) {
|
|
3709
|
+
it.todo(name);
|
|
3710
|
+
}
|
|
3711
|
+
var perfTest = perfTestWithFixtures;
|
|
3712
|
+
perfTest.use = use;
|
|
3713
|
+
perfTest.describe = describe;
|
|
3714
|
+
perfTest.beforeAll = beforeAll;
|
|
3715
|
+
perfTest.beforeEach = beforeEach;
|
|
3716
|
+
perfTest.afterEach = afterEach;
|
|
3717
|
+
perfTest.afterAll = afterAll;
|
|
3718
|
+
perfTest.skip = skip;
|
|
3719
|
+
perfTest.only = only;
|
|
3720
|
+
perfTest.todo = todo;
|
|
3721
|
+
|
|
3722
|
+
// libs/testing/src/perf/baseline-store.ts
|
|
3723
|
+
var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
|
|
3724
|
+
var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
|
|
3725
|
+
var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
|
|
3726
|
+
var BaselineStore = class {
|
|
3727
|
+
baseline = null;
|
|
3728
|
+
baselinePath;
|
|
3729
|
+
constructor(baselinePath = DEFAULT_BASELINE_PATH) {
|
|
3730
|
+
this.baselinePath = baselinePath;
|
|
3731
|
+
}
|
|
3732
|
+
/**
|
|
3733
|
+
* Load baseline from local file.
|
|
3734
|
+
*/
|
|
3735
|
+
async load() {
|
|
3736
|
+
try {
|
|
3737
|
+
const { readFile, fileExists } = await import("@frontmcp/utils");
|
|
3738
|
+
if (!await fileExists(this.baselinePath)) {
|
|
3739
|
+
return null;
|
|
3740
|
+
}
|
|
3741
|
+
const content = await readFile(this.baselinePath);
|
|
3742
|
+
this.baseline = JSON.parse(content);
|
|
3743
|
+
return this.baseline;
|
|
3744
|
+
} catch {
|
|
3745
|
+
return null;
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
/**
|
|
3749
|
+
* Save baseline to local file.
|
|
3750
|
+
*/
|
|
3751
|
+
async save(baseline) {
|
|
3752
|
+
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
3753
|
+
const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
|
|
3754
|
+
await ensureDir(dir);
|
|
3755
|
+
await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
3756
|
+
this.baseline = baseline;
|
|
3757
|
+
}
|
|
3758
|
+
/**
|
|
3759
|
+
* Get a test baseline by ID.
|
|
3760
|
+
*/
|
|
3761
|
+
getTestBaseline(testId) {
|
|
3762
|
+
if (!this.baseline) {
|
|
3763
|
+
return null;
|
|
3764
|
+
}
|
|
3765
|
+
return this.baseline.tests[testId] ?? null;
|
|
3766
|
+
}
|
|
3767
|
+
/**
|
|
3768
|
+
* Check if baseline is loaded.
|
|
3769
|
+
*/
|
|
3770
|
+
isLoaded() {
|
|
3771
|
+
return this.baseline !== null;
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* Get the loaded baseline.
|
|
3775
|
+
*/
|
|
3776
|
+
getBaseline() {
|
|
3777
|
+
return this.baseline;
|
|
3778
|
+
}
|
|
3779
|
+
/**
|
|
3780
|
+
* Create a baseline from measurements.
|
|
3781
|
+
*/
|
|
3782
|
+
static createFromMeasurements(measurements, release, commitHash) {
|
|
3783
|
+
const tests = {};
|
|
3784
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3785
|
+
for (const m of measurements) {
|
|
3786
|
+
const key = `${m.project}::${m.name}`;
|
|
3787
|
+
if (!grouped.has(key)) {
|
|
3788
|
+
grouped.set(key, []);
|
|
3789
|
+
}
|
|
3790
|
+
const group = grouped.get(key);
|
|
3791
|
+
if (group) {
|
|
3792
|
+
group.push(m);
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
for (const [key, testMeasurements] of grouped) {
|
|
3796
|
+
const [project] = key.split("::");
|
|
3797
|
+
const heapSamples = testMeasurements.map((m) => m.final?.memory.heapUsed ?? 0);
|
|
3798
|
+
const durationSamples = testMeasurements.map((m) => m.timing.durationMs);
|
|
3799
|
+
const cpuSamples = testMeasurements.map((m) => m.final?.cpu.total ?? 0);
|
|
3800
|
+
tests[key] = {
|
|
3801
|
+
testId: key,
|
|
3802
|
+
project,
|
|
3803
|
+
heapUsed: calculateMetricBaseline(heapSamples),
|
|
3804
|
+
durationMs: calculateMetricBaseline(durationSamples),
|
|
3805
|
+
cpuTime: calculateMetricBaseline(cpuSamples),
|
|
3806
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3807
|
+
commitHash
|
|
3808
|
+
};
|
|
3809
|
+
}
|
|
3810
|
+
return {
|
|
3811
|
+
release,
|
|
3812
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3813
|
+
commitHash,
|
|
3814
|
+
tests
|
|
3815
|
+
};
|
|
3816
|
+
}
|
|
3817
|
+
};
|
|
3818
|
+
function parseBaselineFromComment(commentBody) {
|
|
3819
|
+
const startIdx = commentBody.indexOf(BASELINE_START_MARKER);
|
|
3820
|
+
const endIdx = commentBody.indexOf(BASELINE_END_MARKER);
|
|
3821
|
+
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
3822
|
+
return null;
|
|
3823
|
+
}
|
|
3824
|
+
const content = commentBody.substring(startIdx + BASELINE_START_MARKER.length, endIdx);
|
|
3825
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
|
|
3826
|
+
if (!jsonMatch) {
|
|
3827
|
+
return null;
|
|
3828
|
+
}
|
|
3829
|
+
try {
|
|
3830
|
+
return JSON.parse(jsonMatch[1]);
|
|
3831
|
+
} catch {
|
|
3832
|
+
return null;
|
|
3833
|
+
}
|
|
3834
|
+
}
|
|
3835
|
+
function formatBaselineAsComment(baseline) {
|
|
3836
|
+
const json = JSON.stringify(baseline, null, 2);
|
|
3837
|
+
return `## Performance Baseline
|
|
3838
|
+
|
|
3839
|
+
This comment contains the performance baseline for release ${baseline.release}.
|
|
3840
|
+
|
|
3841
|
+
${BASELINE_START_MARKER}
|
|
3842
|
+
\`\`\`json
|
|
3843
|
+
${json}
|
|
3844
|
+
\`\`\`
|
|
3845
|
+
${BASELINE_END_MARKER}
|
|
3846
|
+
|
|
3847
|
+
Generated at: ${baseline.timestamp}
|
|
3848
|
+
${baseline.commitHash ? `Commit: ${baseline.commitHash}` : ""}
|
|
3849
|
+
`;
|
|
3850
|
+
}
|
|
3851
|
+
function buildReleaseCommentsUrl(owner, repo, releaseId) {
|
|
3852
|
+
return `https://api.github.com/repos/${owner}/${repo}/releases/${releaseId}/comments`;
|
|
3853
|
+
}
|
|
3854
|
+
function calculateMetricBaseline(samples) {
|
|
3855
|
+
if (samples.length === 0) {
|
|
3856
|
+
return {
|
|
3857
|
+
mean: 0,
|
|
3858
|
+
stdDev: 0,
|
|
3859
|
+
min: 0,
|
|
3860
|
+
max: 0,
|
|
3861
|
+
p95: 0,
|
|
3862
|
+
sampleCount: 0
|
|
3863
|
+
};
|
|
3864
|
+
}
|
|
3865
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
3866
|
+
const n = sorted.length;
|
|
3867
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
3868
|
+
const mean = sum / n;
|
|
3869
|
+
const squaredDiffs = sorted.map((x) => Math.pow(x - mean, 2));
|
|
3870
|
+
const variance = squaredDiffs.reduce((a, b) => a + b, 0) / n;
|
|
3871
|
+
const stdDev = Math.sqrt(variance);
|
|
3872
|
+
const min = sorted[0];
|
|
3873
|
+
const max = sorted[n - 1];
|
|
3874
|
+
const p95Index = Math.ceil(n * 0.95) - 1;
|
|
3875
|
+
const p95 = sorted[Math.min(p95Index, n - 1)];
|
|
3876
|
+
return {
|
|
3877
|
+
mean,
|
|
3878
|
+
stdDev,
|
|
3879
|
+
min,
|
|
3880
|
+
max,
|
|
3881
|
+
p95,
|
|
3882
|
+
sampleCount: n
|
|
3883
|
+
};
|
|
3884
|
+
}
|
|
3885
|
+
var globalBaselineStore = null;
|
|
3886
|
+
function getBaselineStore(baselinePath) {
|
|
3887
|
+
if (!globalBaselineStore || baselinePath) {
|
|
3888
|
+
globalBaselineStore = new BaselineStore(baselinePath);
|
|
3889
|
+
}
|
|
3890
|
+
return globalBaselineStore;
|
|
3891
|
+
}
|
|
3892
|
+
function resetBaselineStore() {
|
|
3893
|
+
globalBaselineStore = null;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
// libs/testing/src/perf/regression-detector.ts
|
|
3897
|
+
var DEFAULT_CONFIG = {
|
|
3898
|
+
warningThresholdPercent: 10,
|
|
3899
|
+
errorThresholdPercent: 25,
|
|
3900
|
+
minAbsoluteChange: 1024
|
|
3901
|
+
// 1KB minimum to avoid noise
|
|
3902
|
+
};
|
|
3903
|
+
var RegressionDetector = class {
|
|
3904
|
+
config;
|
|
3905
|
+
constructor(config) {
|
|
3906
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
3907
|
+
}
|
|
3908
|
+
/**
|
|
3909
|
+
* Detect regressions in a measurement compared to baseline.
|
|
3910
|
+
*/
|
|
3911
|
+
detectRegression(measurement, baseline) {
|
|
3912
|
+
const testId = `${measurement.project}::${measurement.name}`;
|
|
3913
|
+
const metrics = [];
|
|
3914
|
+
const currentHeap = measurement.final?.memory.heapUsed ?? 0;
|
|
3915
|
+
const heapRegression = this.checkMetric("heapUsed", baseline.heapUsed.mean, currentHeap, formatBytes);
|
|
3916
|
+
metrics.push(heapRegression);
|
|
3917
|
+
const durationRegression = this.checkMetric(
|
|
3918
|
+
"durationMs",
|
|
3919
|
+
baseline.durationMs.mean,
|
|
3920
|
+
measurement.timing.durationMs,
|
|
3921
|
+
formatDuration
|
|
3922
|
+
);
|
|
3923
|
+
metrics.push(durationRegression);
|
|
3924
|
+
const currentCpu = measurement.final?.cpu.total ?? 0;
|
|
3925
|
+
const cpuRegression = this.checkMetric("cpuTime", baseline.cpuTime.mean, currentCpu, formatMicroseconds);
|
|
3926
|
+
metrics.push(cpuRegression);
|
|
3927
|
+
const hasRegression = metrics.some((m) => m.status === "regression");
|
|
3928
|
+
const hasWarning = metrics.some((m) => m.status === "warning");
|
|
3929
|
+
const status = hasRegression ? "regression" : hasWarning ? "warning" : "ok";
|
|
3930
|
+
const message = this.buildMessage(testId, metrics, status);
|
|
3931
|
+
return {
|
|
3932
|
+
testId,
|
|
3933
|
+
status,
|
|
3934
|
+
metrics,
|
|
3935
|
+
message
|
|
3936
|
+
};
|
|
3937
|
+
}
|
|
3938
|
+
/**
|
|
3939
|
+
* Detect regressions for multiple measurements.
|
|
3940
|
+
*/
|
|
3941
|
+
detectRegressions(measurements, baselines) {
|
|
3942
|
+
const results = [];
|
|
3943
|
+
for (const measurement of measurements) {
|
|
3944
|
+
const testId = `${measurement.project}::${measurement.name}`;
|
|
3945
|
+
const baseline = baselines.tests[testId];
|
|
3946
|
+
if (baseline) {
|
|
3947
|
+
results.push(this.detectRegression(measurement, baseline));
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
return results;
|
|
3951
|
+
}
|
|
3952
|
+
/**
|
|
3953
|
+
* Check a single metric for regression.
|
|
3954
|
+
*/
|
|
3955
|
+
checkMetric(name, baseline, current, _formatter) {
|
|
3956
|
+
const absoluteChange = current - baseline;
|
|
3957
|
+
const changePercent = baseline > 0 ? absoluteChange / baseline * 100 : 0;
|
|
3958
|
+
let status = "ok";
|
|
3959
|
+
if (Math.abs(absoluteChange) > this.config.minAbsoluteChange) {
|
|
3960
|
+
if (changePercent >= this.config.errorThresholdPercent) {
|
|
3961
|
+
status = "regression";
|
|
3962
|
+
} else if (changePercent >= this.config.warningThresholdPercent) {
|
|
3963
|
+
status = "warning";
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
return {
|
|
3967
|
+
metric: name,
|
|
3968
|
+
baseline,
|
|
3969
|
+
current,
|
|
3970
|
+
changePercent,
|
|
3971
|
+
absoluteChange,
|
|
3972
|
+
status
|
|
3973
|
+
};
|
|
3974
|
+
}
|
|
3975
|
+
/**
|
|
3976
|
+
* Build a human-readable message for regression result.
|
|
3977
|
+
*/
|
|
3978
|
+
buildMessage(testId, metrics, status) {
|
|
3979
|
+
if (status === "ok") {
|
|
3980
|
+
return `${testId}: All metrics within acceptable range`;
|
|
3981
|
+
}
|
|
3982
|
+
const issues = metrics.filter((m) => m.status !== "ok").map((m) => {
|
|
3983
|
+
const direction = m.absoluteChange > 0 ? "+" : "";
|
|
3984
|
+
return `${m.metric}: ${direction}${m.changePercent.toFixed(1)}%`;
|
|
3985
|
+
});
|
|
3986
|
+
const statusText = status === "regression" ? "REGRESSION" : "WARNING";
|
|
3987
|
+
return `${testId}: ${statusText} - ${issues.join(", ")}`;
|
|
3988
|
+
}
|
|
3989
|
+
};
|
|
3990
|
+
function summarizeRegressions(results) {
|
|
3991
|
+
const total = results.length;
|
|
3992
|
+
const ok = results.filter((r) => r.status === "ok").length;
|
|
3993
|
+
const warnings = results.filter((r) => r.status === "warning").length;
|
|
3994
|
+
const regressions = results.filter((r) => r.status === "regression").length;
|
|
3995
|
+
let summary;
|
|
3996
|
+
if (regressions > 0) {
|
|
3997
|
+
summary = `${regressions} regression(s) detected out of ${total} tests`;
|
|
3998
|
+
} else if (warnings > 0) {
|
|
3999
|
+
summary = `${warnings} warning(s) detected out of ${total} tests`;
|
|
4000
|
+
} else {
|
|
4001
|
+
summary = `All ${total} tests within acceptable range`;
|
|
4002
|
+
}
|
|
4003
|
+
return { total, ok, warnings, regressions, summary };
|
|
4004
|
+
}
|
|
4005
|
+
function filterByStatus(results, status) {
|
|
4006
|
+
return results.filter((r) => r.status === status);
|
|
4007
|
+
}
|
|
4008
|
+
function getMostSevereMetric(result) {
|
|
4009
|
+
const regressions = result.metrics.filter((m) => m.status === "regression");
|
|
4010
|
+
if (regressions.length > 0) {
|
|
4011
|
+
return regressions.reduce((max, m) => m.changePercent > max.changePercent ? m : max);
|
|
4012
|
+
}
|
|
4013
|
+
const warnings = result.metrics.filter((m) => m.status === "warning");
|
|
4014
|
+
if (warnings.length > 0) {
|
|
4015
|
+
return warnings.reduce((max, m) => m.changePercent > max.changePercent ? m : max);
|
|
4016
|
+
}
|
|
4017
|
+
return null;
|
|
4018
|
+
}
|
|
4019
|
+
var globalDetector = null;
|
|
4020
|
+
function getRegressionDetector(config) {
|
|
4021
|
+
if (!globalDetector || config) {
|
|
4022
|
+
globalDetector = new RegressionDetector(config);
|
|
4023
|
+
}
|
|
4024
|
+
return globalDetector;
|
|
4025
|
+
}
|
|
4026
|
+
function resetRegressionDetector() {
|
|
4027
|
+
globalDetector = null;
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
// libs/testing/src/perf/report-generator.ts
|
|
4031
|
+
var ReportGenerator = class {
|
|
4032
|
+
detector;
|
|
4033
|
+
constructor() {
|
|
4034
|
+
this.detector = new RegressionDetector();
|
|
4035
|
+
}
|
|
4036
|
+
/**
|
|
4037
|
+
* Generate a complete performance report.
|
|
4038
|
+
*/
|
|
4039
|
+
generateReport(measurements, baseline, gitInfo) {
|
|
4040
|
+
const projectGroups = /* @__PURE__ */ new Map();
|
|
4041
|
+
for (const m of measurements) {
|
|
4042
|
+
if (!projectGroups.has(m.project)) {
|
|
4043
|
+
projectGroups.set(m.project, []);
|
|
4044
|
+
}
|
|
4045
|
+
const group = projectGroups.get(m.project);
|
|
4046
|
+
if (group) {
|
|
4047
|
+
group.push(m);
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
const projects = [];
|
|
4051
|
+
for (const [projectName, projectMeasurements] of projectGroups) {
|
|
4052
|
+
const summary = this.calculateSummary(projectMeasurements);
|
|
4053
|
+
const regressions = baseline ? this.detector.detectRegressions(projectMeasurements, baseline) : void 0;
|
|
4054
|
+
projects.push({
|
|
4055
|
+
project: projectName,
|
|
4056
|
+
summary,
|
|
4057
|
+
measurements: projectMeasurements,
|
|
4058
|
+
regressions
|
|
4059
|
+
});
|
|
4060
|
+
}
|
|
4061
|
+
const overallSummary = this.calculateSummary(measurements);
|
|
4062
|
+
return {
|
|
4063
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4064
|
+
commitHash: gitInfo?.commitHash,
|
|
4065
|
+
branch: gitInfo?.branch,
|
|
4066
|
+
summary: overallSummary,
|
|
4067
|
+
projects,
|
|
4068
|
+
baseline: baseline ? { release: baseline.release, timestamp: baseline.timestamp } : void 0
|
|
4069
|
+
};
|
|
4070
|
+
}
|
|
4071
|
+
/**
|
|
4072
|
+
* Generate Markdown report for PR comments.
|
|
4073
|
+
*/
|
|
4074
|
+
generateMarkdownReport(report) {
|
|
4075
|
+
const lines = [];
|
|
4076
|
+
lines.push("## Performance Test Results");
|
|
4077
|
+
lines.push("");
|
|
4078
|
+
const statusEmoji = this.getStatusEmoji(report.summary);
|
|
4079
|
+
lines.push(`**Status:** ${statusEmoji} ${this.getSummaryText(report.summary)}`);
|
|
4080
|
+
lines.push("");
|
|
4081
|
+
lines.push("### Summary");
|
|
4082
|
+
lines.push("");
|
|
4083
|
+
lines.push("| Metric | Value |");
|
|
4084
|
+
lines.push("|--------|-------|");
|
|
4085
|
+
lines.push(`| Total Tests | ${report.summary.totalTests} |`);
|
|
4086
|
+
lines.push(`| Passed | ${report.summary.passedTests} |`);
|
|
4087
|
+
lines.push(`| Warnings | ${report.summary.warningTests} |`);
|
|
4088
|
+
lines.push(`| Failed | ${report.summary.failedTests} |`);
|
|
4089
|
+
lines.push(`| Memory Leaks | ${report.summary.leakTests} |`);
|
|
4090
|
+
lines.push("");
|
|
4091
|
+
if (report.baseline) {
|
|
4092
|
+
lines.push(`**Baseline:** ${report.baseline.release} (${report.baseline.timestamp})`);
|
|
4093
|
+
lines.push("");
|
|
4094
|
+
}
|
|
4095
|
+
lines.push("### Project Breakdown");
|
|
4096
|
+
lines.push("");
|
|
4097
|
+
for (const project of report.projects) {
|
|
4098
|
+
lines.push(`#### ${project.project}`);
|
|
4099
|
+
lines.push("");
|
|
4100
|
+
lines.push(this.generateProjectTable(project));
|
|
4101
|
+
lines.push("");
|
|
4102
|
+
const leakTests = project.measurements.filter((m) => m.leakDetectionResults && m.leakDetectionResults.length > 0);
|
|
4103
|
+
if (leakTests.length > 0) {
|
|
4104
|
+
const parallelTests = leakTests.filter((m) => m.leakDetectionResults?.some((r) => this.isParallelResult(r)));
|
|
4105
|
+
if (parallelTests.length > 0) {
|
|
4106
|
+
lines.push("**Parallel Stress Test Results:**");
|
|
4107
|
+
lines.push("");
|
|
4108
|
+
lines.push("| Test | Workers | Iterations | Duration | Total req/s |");
|
|
4109
|
+
lines.push("|------|---------|------------|----------|-------------|");
|
|
4110
|
+
for (const m of parallelTests) {
|
|
4111
|
+
for (const result of m.leakDetectionResults || []) {
|
|
4112
|
+
if (this.isParallelResult(result)) {
|
|
4113
|
+
const parallelResult = result;
|
|
4114
|
+
const durationStr = parallelResult.durationMs ? `${(parallelResult.durationMs / 1e3).toFixed(2)}s` : "N/A";
|
|
4115
|
+
lines.push(
|
|
4116
|
+
`| ${m.name} | ${parallelResult.workersUsed} | ${parallelResult.totalIterations} | ${durationStr} | ${parallelResult.totalRequestsPerSecond.toFixed(1)} |`
|
|
4117
|
+
);
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
lines.push("");
|
|
4122
|
+
}
|
|
4123
|
+
lines.push("**Memory Interval Analysis:**");
|
|
4124
|
+
lines.push("");
|
|
4125
|
+
for (const m of leakTests) {
|
|
4126
|
+
for (const result of m.leakDetectionResults || []) {
|
|
4127
|
+
if (result.intervals && result.intervals.length > 0) {
|
|
4128
|
+
lines.push(`*${m.name}:*`);
|
|
4129
|
+
lines.push("");
|
|
4130
|
+
if (this.isParallelResult(result)) {
|
|
4131
|
+
const parallelResult = result;
|
|
4132
|
+
lines.push("| Worker | req/s | Iterations |");
|
|
4133
|
+
lines.push("|--------|-------|------------|");
|
|
4134
|
+
for (const worker of parallelResult.perWorkerStats) {
|
|
4135
|
+
lines.push(
|
|
4136
|
+
`| ${worker.workerId} | ${worker.requestsPerSecond.toFixed(1)} | ${worker.iterationsCompleted} |`
|
|
4137
|
+
);
|
|
4138
|
+
}
|
|
4139
|
+
lines.push("");
|
|
4140
|
+
}
|
|
4141
|
+
lines.push("| Interval | Heap Start | Heap End | Delta | Rate/iter |");
|
|
4142
|
+
lines.push("|----------|------------|----------|-------|-----------|");
|
|
4143
|
+
for (const interval of result.intervals) {
|
|
4144
|
+
lines.push(
|
|
4145
|
+
`| ${interval.startIteration}-${interval.endIteration} | ${formatBytes(interval.heapAtStart)} | ${formatBytes(interval.heapAtEnd)} | ${interval.deltaFormatted} | ${formatBytes(interval.growthRatePerIteration)}/iter |`
|
|
4146
|
+
);
|
|
4147
|
+
}
|
|
4148
|
+
lines.push("");
|
|
4149
|
+
const durationStr = result.durationMs ? `${(result.durationMs / 1e3).toFixed(2)}s` : "N/A";
|
|
4150
|
+
const rpsStr = result.requestsPerSecond ? `${result.requestsPerSecond.toFixed(1)} req/s` : "N/A";
|
|
4151
|
+
lines.push(
|
|
4152
|
+
`Total: ${formatBytes(result.totalGrowth)}, R\xB2=${result.rSquared.toFixed(3)} | ${result.samples.length} iterations in ${durationStr} (${rpsStr})`
|
|
4153
|
+
);
|
|
4154
|
+
lines.push("");
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
if (project.regressions && project.regressions.length > 0) {
|
|
4160
|
+
const regressionsWithIssues = project.regressions.filter((r) => r.status !== "ok");
|
|
4161
|
+
if (regressionsWithIssues.length > 0) {
|
|
4162
|
+
lines.push("**Regressions:**");
|
|
4163
|
+
for (const r of regressionsWithIssues) {
|
|
4164
|
+
const emoji = r.status === "regression" ? "\u274C" : "\u26A0\uFE0F";
|
|
4165
|
+
lines.push(`- ${emoji} ${r.message}`);
|
|
4166
|
+
}
|
|
4167
|
+
lines.push("");
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
lines.push("---");
|
|
4172
|
+
lines.push(`Generated at: ${report.timestamp}`);
|
|
4173
|
+
if (report.commitHash) {
|
|
4174
|
+
lines.push(`Commit: \`${report.commitHash.substring(0, 8)}\``);
|
|
4175
|
+
}
|
|
4176
|
+
if (report.branch) {
|
|
4177
|
+
lines.push(`Branch: \`${report.branch}\``);
|
|
4178
|
+
}
|
|
4179
|
+
return lines.join("\n");
|
|
4180
|
+
}
|
|
4181
|
+
/**
|
|
4182
|
+
* Generate JSON report.
|
|
4183
|
+
*/
|
|
4184
|
+
generateJsonReport(report) {
|
|
4185
|
+
return JSON.stringify(report, null, 2);
|
|
4186
|
+
}
|
|
4187
|
+
/**
|
|
4188
|
+
* Calculate summary statistics for measurements.
|
|
4189
|
+
*/
|
|
4190
|
+
calculateSummary(measurements) {
|
|
4191
|
+
let passedTests = 0;
|
|
4192
|
+
let warningTests = 0;
|
|
4193
|
+
let failedTests = 0;
|
|
4194
|
+
let leakTests = 0;
|
|
4195
|
+
for (const m of measurements) {
|
|
4196
|
+
const hasError = m.issues.some((i) => i.severity === "error");
|
|
4197
|
+
const hasWarning = m.issues.some((i) => i.severity === "warning");
|
|
4198
|
+
const hasLeak = m.issues.some((i) => i.type === "memory-leak");
|
|
4199
|
+
if (hasLeak) {
|
|
4200
|
+
leakTests++;
|
|
4201
|
+
}
|
|
4202
|
+
if (hasError) {
|
|
4203
|
+
failedTests++;
|
|
4204
|
+
} else if (hasWarning) {
|
|
4205
|
+
warningTests++;
|
|
4206
|
+
} else {
|
|
4207
|
+
passedTests++;
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
return {
|
|
4211
|
+
totalTests: measurements.length,
|
|
4212
|
+
passedTests,
|
|
4213
|
+
warningTests,
|
|
4214
|
+
failedTests,
|
|
4215
|
+
leakTests
|
|
4216
|
+
};
|
|
4217
|
+
}
|
|
4218
|
+
/**
|
|
4219
|
+
* Get status emoji based on summary.
|
|
4220
|
+
*/
|
|
4221
|
+
getStatusEmoji(summary) {
|
|
4222
|
+
if (summary.failedTests > 0 || summary.leakTests > 0) {
|
|
4223
|
+
return "X";
|
|
4224
|
+
}
|
|
4225
|
+
if (summary.warningTests > 0) {
|
|
4226
|
+
return "!";
|
|
4227
|
+
}
|
|
4228
|
+
return "OK";
|
|
4229
|
+
}
|
|
4230
|
+
/**
|
|
4231
|
+
* Get summary text.
|
|
4232
|
+
*/
|
|
4233
|
+
getSummaryText(summary) {
|
|
4234
|
+
if (summary.failedTests > 0) {
|
|
4235
|
+
return `${summary.failedTests} test(s) failed`;
|
|
4236
|
+
}
|
|
4237
|
+
if (summary.leakTests > 0) {
|
|
4238
|
+
return `${summary.leakTests} memory leak(s) detected`;
|
|
4239
|
+
}
|
|
4240
|
+
if (summary.warningTests > 0) {
|
|
4241
|
+
return `${summary.warningTests} warning(s)`;
|
|
4242
|
+
}
|
|
4243
|
+
return "All tests passed";
|
|
4244
|
+
}
|
|
4245
|
+
/**
|
|
4246
|
+
* Generate markdown table for project measurements.
|
|
4247
|
+
*/
|
|
4248
|
+
generateProjectTable(project) {
|
|
4249
|
+
const lines = [];
|
|
4250
|
+
lines.push("| Test | Duration | Heap Delta | CPU Time | Status |");
|
|
4251
|
+
lines.push("|------|----------|------------|----------|--------|");
|
|
4252
|
+
for (const m of project.measurements) {
|
|
4253
|
+
const status = this.getTestStatus(m);
|
|
4254
|
+
const heapDelta = m.memoryDelta ? formatBytes(m.memoryDelta.heapUsed) : "N/A";
|
|
4255
|
+
const cpuTime = m.final?.cpu.total ? formatMicroseconds(m.final.cpu.total) : "N/A";
|
|
4256
|
+
lines.push(`| ${m.name} | ${formatDuration(m.timing.durationMs)} | ${heapDelta} | ${cpuTime} | ${status} |`);
|
|
4257
|
+
}
|
|
4258
|
+
return lines.join("\n");
|
|
4259
|
+
}
|
|
4260
|
+
/**
|
|
4261
|
+
* Get test status indicator.
|
|
4262
|
+
*/
|
|
4263
|
+
getTestStatus(m) {
|
|
4264
|
+
const hasError = m.issues.some((i) => i.severity === "error");
|
|
4265
|
+
const hasLeak = m.issues.some((i) => i.type === "memory-leak");
|
|
4266
|
+
const hasWarning = m.issues.some((i) => i.severity === "warning");
|
|
4267
|
+
if (hasLeak) {
|
|
4268
|
+
return "LEAK";
|
|
4269
|
+
}
|
|
4270
|
+
if (hasError) {
|
|
4271
|
+
return "FAIL";
|
|
4272
|
+
}
|
|
4273
|
+
if (hasWarning) {
|
|
4274
|
+
return "WARN";
|
|
4275
|
+
}
|
|
4276
|
+
return "OK";
|
|
4277
|
+
}
|
|
4278
|
+
/**
|
|
4279
|
+
* Check if a leak detection result is a parallel result.
|
|
4280
|
+
*/
|
|
4281
|
+
isParallelResult(result) {
|
|
4282
|
+
return typeof result === "object" && result !== null && "workersUsed" in result && "perWorkerStats" in result && "totalRequestsPerSecond" in result;
|
|
4283
|
+
}
|
|
4284
|
+
};
|
|
4285
|
+
async function saveReports(measurements, outputDir, baseline, gitInfo) {
|
|
4286
|
+
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
4287
|
+
await ensureDir(outputDir);
|
|
4288
|
+
const generator = new ReportGenerator();
|
|
4289
|
+
const report = generator.generateReport(measurements, baseline, gitInfo);
|
|
4290
|
+
const jsonPath = `${outputDir}/report.json`;
|
|
4291
|
+
const markdownPath = `${outputDir}/report.md`;
|
|
4292
|
+
await writeFile(jsonPath, generator.generateJsonReport(report));
|
|
4293
|
+
await writeFile(markdownPath, generator.generateMarkdownReport(report));
|
|
4294
|
+
return { jsonPath, markdownPath };
|
|
4295
|
+
}
|
|
4296
|
+
function createReportGenerator() {
|
|
4297
|
+
return new ReportGenerator();
|
|
4298
|
+
}
|
|
4299
|
+
export {
|
|
4300
|
+
BaselineStore,
|
|
4301
|
+
LeakDetector,
|
|
4302
|
+
MetricsCollector,
|
|
4303
|
+
PerfFixturesImpl,
|
|
4304
|
+
RegressionDetector,
|
|
4305
|
+
ReportGenerator,
|
|
4306
|
+
addGlobalMeasurement,
|
|
4307
|
+
assertNoLeak,
|
|
4308
|
+
buildReleaseCommentsUrl,
|
|
4309
|
+
clearGlobalMeasurements,
|
|
4310
|
+
createLeakDetector,
|
|
4311
|
+
createPerfFixtures,
|
|
4312
|
+
createReportGenerator,
|
|
4313
|
+
filterByStatus,
|
|
4314
|
+
forceFullGc,
|
|
4315
|
+
forceGc,
|
|
4316
|
+
formatBaselineAsComment,
|
|
4317
|
+
formatBytes,
|
|
4318
|
+
formatDuration,
|
|
4319
|
+
formatMicroseconds,
|
|
4320
|
+
getBaselineStore,
|
|
4321
|
+
getGlobalCollector,
|
|
4322
|
+
getGlobalMeasurements,
|
|
4323
|
+
getMeasurementsForProject,
|
|
4324
|
+
getMostSevereMetric,
|
|
4325
|
+
getRegressionDetector,
|
|
4326
|
+
isGcAvailable,
|
|
4327
|
+
parseBaselineFromComment,
|
|
4328
|
+
perfTest,
|
|
4329
|
+
resetBaselineStore,
|
|
4330
|
+
resetGlobalCollector,
|
|
4331
|
+
resetRegressionDetector,
|
|
4332
|
+
saveReports,
|
|
4333
|
+
summarizeRegressions
|
|
4334
|
+
};
|