@frontmcp/testing 0.7.2 → 0.8.1
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
package/esm/index.mjs
CHANGED
|
@@ -71,7 +71,12 @@ var PLATFORM_DETECTION_PATTERNS = {
|
|
|
71
71
|
};
|
|
72
72
|
function getPlatformCapabilities(platform) {
|
|
73
73
|
const baseCapabilities = {
|
|
74
|
-
sampling: {}
|
|
74
|
+
sampling: {},
|
|
75
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
76
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
77
|
+
elicitation: {
|
|
78
|
+
form: {}
|
|
79
|
+
}
|
|
75
80
|
};
|
|
76
81
|
if (platform === "ext-apps") {
|
|
77
82
|
return {
|
|
@@ -209,6 +214,21 @@ var McpTestClientBuilder = class {
|
|
|
209
214
|
this.config.capabilities = capabilities;
|
|
210
215
|
return this;
|
|
211
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Set query parameters to append to the connection URL.
|
|
219
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
224
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
225
|
+
* .buildAndConnect();
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
withQueryParams(params) {
|
|
229
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
230
|
+
return this;
|
|
231
|
+
}
|
|
212
232
|
/**
|
|
213
233
|
* Build the McpTestClient instance (does not connect)
|
|
214
234
|
*/
|
|
@@ -237,6 +257,7 @@ var StreamableHttpTransport = class {
|
|
|
237
257
|
lastRequestHeaders = {};
|
|
238
258
|
interceptors;
|
|
239
259
|
publicMode;
|
|
260
|
+
elicitationHandler;
|
|
240
261
|
constructor(config) {
|
|
241
262
|
this.config = {
|
|
242
263
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
@@ -251,6 +272,7 @@ var StreamableHttpTransport = class {
|
|
|
251
272
|
this.authToken = config.auth?.token;
|
|
252
273
|
this.interceptors = config.interceptors;
|
|
253
274
|
this.publicMode = config.publicMode ?? false;
|
|
275
|
+
this.elicitationHandler = config.elicitationHandler;
|
|
254
276
|
}
|
|
255
277
|
async connect() {
|
|
256
278
|
this.state = "connecting";
|
|
@@ -343,7 +365,6 @@ var StreamableHttpTransport = class {
|
|
|
343
365
|
body: JSON.stringify(message),
|
|
344
366
|
signal: controller.signal
|
|
345
367
|
});
|
|
346
|
-
clearTimeout(timeoutId);
|
|
347
368
|
const newSessionId = response.headers.get("mcp-session-id");
|
|
348
369
|
if (newSessionId) {
|
|
349
370
|
this.sessionId = newSessionId;
|
|
@@ -363,28 +384,26 @@ var StreamableHttpTransport = class {
|
|
|
363
384
|
};
|
|
364
385
|
} else {
|
|
365
386
|
const contentType = response.headers.get("content-type") ?? "";
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!text.trim()) {
|
|
369
|
-
jsonResponse = {
|
|
370
|
-
jsonrpc: "2.0",
|
|
371
|
-
id: message.id ?? null,
|
|
372
|
-
result: void 0
|
|
373
|
-
};
|
|
374
|
-
} else if (contentType.includes("text/event-stream")) {
|
|
375
|
-
const { response: sseResponse, sseSessionId } = this.parseSSEResponseWithSession(text, message.id);
|
|
376
|
-
jsonResponse = sseResponse;
|
|
377
|
-
if (sseSessionId && !this.sessionId) {
|
|
378
|
-
this.sessionId = sseSessionId;
|
|
379
|
-
this.log("Session ID from SSE:", this.sessionId);
|
|
380
|
-
}
|
|
387
|
+
if (contentType.includes("text/event-stream")) {
|
|
388
|
+
jsonResponse = await this.handleSSEResponseWithElicitation(response, message);
|
|
381
389
|
} else {
|
|
382
|
-
|
|
390
|
+
const text = await response.text();
|
|
391
|
+
this.log("Response:", text);
|
|
392
|
+
if (!text.trim()) {
|
|
393
|
+
jsonResponse = {
|
|
394
|
+
jsonrpc: "2.0",
|
|
395
|
+
id: message.id ?? null,
|
|
396
|
+
result: void 0
|
|
397
|
+
};
|
|
398
|
+
} else {
|
|
399
|
+
jsonResponse = JSON.parse(text);
|
|
400
|
+
}
|
|
383
401
|
}
|
|
384
402
|
}
|
|
385
403
|
if (this.interceptors) {
|
|
386
404
|
jsonResponse = await this.interceptors.processResponse(message, jsonResponse, Date.now() - startTime);
|
|
387
405
|
}
|
|
406
|
+
clearTimeout(timeoutId);
|
|
388
407
|
return jsonResponse;
|
|
389
408
|
} catch (error) {
|
|
390
409
|
clearTimeout(timeoutId);
|
|
@@ -499,6 +518,9 @@ var StreamableHttpTransport = class {
|
|
|
499
518
|
getInterceptors() {
|
|
500
519
|
return this.interceptors;
|
|
501
520
|
}
|
|
521
|
+
setElicitationHandler(handler) {
|
|
522
|
+
this.elicitationHandler = handler;
|
|
523
|
+
}
|
|
502
524
|
getConnectionCount() {
|
|
503
525
|
return this.connectionCount;
|
|
504
526
|
}
|
|
@@ -527,6 +549,215 @@ var StreamableHttpTransport = class {
|
|
|
527
549
|
// ═══════════════════════════════════════════════════════════════════
|
|
528
550
|
// PRIVATE HELPERS
|
|
529
551
|
// ═══════════════════════════════════════════════════════════════════
|
|
552
|
+
/**
|
|
553
|
+
* Handle SSE response with elicitation support.
|
|
554
|
+
*
|
|
555
|
+
* Streams the SSE response, detects elicitation/create requests, and handles them
|
|
556
|
+
* by calling the registered handler and sending the response back to the server.
|
|
557
|
+
*/
|
|
558
|
+
async handleSSEResponseWithElicitation(response, originalRequest) {
|
|
559
|
+
this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
|
|
560
|
+
const reader = response.body?.getReader();
|
|
561
|
+
if (!reader) {
|
|
562
|
+
this.log("handleSSEResponseWithElicitation: no response body");
|
|
563
|
+
return {
|
|
564
|
+
jsonrpc: "2.0",
|
|
565
|
+
id: originalRequest.id ?? null,
|
|
566
|
+
error: { code: -32e3, message: "No response body" }
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const decoder = new TextDecoder();
|
|
570
|
+
let buffer = "";
|
|
571
|
+
let finalResponse = null;
|
|
572
|
+
let sseSessionId;
|
|
573
|
+
try {
|
|
574
|
+
let readCount = 0;
|
|
575
|
+
while (true) {
|
|
576
|
+
readCount++;
|
|
577
|
+
this.log(`handleSSEResponseWithElicitation: reading chunk ${readCount}`);
|
|
578
|
+
const { done, value } = await reader.read();
|
|
579
|
+
this.log(`handleSSEResponseWithElicitation: read result`, { done, valueLength: value?.length });
|
|
580
|
+
if (done) {
|
|
581
|
+
if (buffer.trim()) {
|
|
582
|
+
const parsed = this.parseSSEEvents(buffer, originalRequest.id);
|
|
583
|
+
for (const event of parsed.events) {
|
|
584
|
+
const handled = await this.handleSSEEvent(event);
|
|
585
|
+
if (handled.isFinal) {
|
|
586
|
+
finalResponse = handled.response;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (parsed.sessionId && !sseSessionId) {
|
|
590
|
+
sseSessionId = parsed.sessionId;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
buffer += decoder.decode(value, { stream: true });
|
|
596
|
+
const eventEndPattern = /\n\n/g;
|
|
597
|
+
let lastEventEnd = 0;
|
|
598
|
+
let match;
|
|
599
|
+
while ((match = eventEndPattern.exec(buffer)) !== null) {
|
|
600
|
+
const eventText = buffer.slice(lastEventEnd, match.index);
|
|
601
|
+
lastEventEnd = match.index + 2;
|
|
602
|
+
if (eventText.trim()) {
|
|
603
|
+
const parsed = this.parseSSEEvents(eventText, originalRequest.id);
|
|
604
|
+
for (const event of parsed.events) {
|
|
605
|
+
const handled = await this.handleSSEEvent(event);
|
|
606
|
+
if (handled.isFinal) {
|
|
607
|
+
finalResponse = handled.response;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (parsed.sessionId && !sseSessionId) {
|
|
611
|
+
sseSessionId = parsed.sessionId;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
buffer = buffer.slice(lastEventEnd);
|
|
616
|
+
}
|
|
617
|
+
} finally {
|
|
618
|
+
reader.releaseLock();
|
|
619
|
+
}
|
|
620
|
+
if (sseSessionId && !this.sessionId) {
|
|
621
|
+
this.sessionId = sseSessionId;
|
|
622
|
+
this.log("Session ID from SSE:", this.sessionId);
|
|
623
|
+
}
|
|
624
|
+
if (finalResponse) {
|
|
625
|
+
return finalResponse;
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
jsonrpc: "2.0",
|
|
629
|
+
id: originalRequest.id ?? null,
|
|
630
|
+
error: { code: -32e3, message: "No final response received in SSE stream" }
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Parse SSE event text into structured events
|
|
635
|
+
*/
|
|
636
|
+
parseSSEEvents(text, _requestId) {
|
|
637
|
+
const lines = text.split("\n");
|
|
638
|
+
const events = [];
|
|
639
|
+
let currentEvent = { type: "message", data: [] };
|
|
640
|
+
let sessionId;
|
|
641
|
+
for (const line of lines) {
|
|
642
|
+
if (line.startsWith("event: ")) {
|
|
643
|
+
currentEvent.type = line.slice(7);
|
|
644
|
+
} else if (line.startsWith("data: ")) {
|
|
645
|
+
currentEvent.data.push(line.slice(6));
|
|
646
|
+
} else if (line === "data:") {
|
|
647
|
+
currentEvent.data.push("");
|
|
648
|
+
} else if (line.startsWith("id: ")) {
|
|
649
|
+
const idValue = line.slice(4);
|
|
650
|
+
currentEvent.id = idValue;
|
|
651
|
+
const colonIndex = idValue.lastIndexOf(":");
|
|
652
|
+
if (colonIndex > 0) {
|
|
653
|
+
sessionId = idValue.substring(0, colonIndex);
|
|
654
|
+
} else {
|
|
655
|
+
sessionId = idValue;
|
|
656
|
+
}
|
|
657
|
+
} else if (line === "" && currentEvent.data.length > 0) {
|
|
658
|
+
events.push({
|
|
659
|
+
type: currentEvent.type,
|
|
660
|
+
data: currentEvent.data.join("\n"),
|
|
661
|
+
id: currentEvent.id
|
|
662
|
+
});
|
|
663
|
+
currentEvent = { type: "message", data: [] };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (currentEvent.data.length > 0) {
|
|
667
|
+
events.push({
|
|
668
|
+
type: currentEvent.type,
|
|
669
|
+
data: currentEvent.data.join("\n"),
|
|
670
|
+
id: currentEvent.id
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return { events, sessionId };
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Handle a single SSE event, including elicitation requests
|
|
677
|
+
*/
|
|
678
|
+
async handleSSEEvent(event) {
|
|
679
|
+
this.log("SSE Event:", { type: event.type, data: event.data.slice(0, 200) });
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(event.data);
|
|
682
|
+
if ("method" in parsed && parsed.method === "elicitation/create") {
|
|
683
|
+
await this.handleElicitationRequest(parsed);
|
|
684
|
+
return {
|
|
685
|
+
isFinal: false,
|
|
686
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
if ("result" in parsed || "error" in parsed) {
|
|
690
|
+
return { isFinal: true, response: parsed };
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
isFinal: false,
|
|
694
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
695
|
+
};
|
|
696
|
+
} catch {
|
|
697
|
+
this.log("Failed to parse SSE event data:", event.data);
|
|
698
|
+
return {
|
|
699
|
+
isFinal: false,
|
|
700
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Handle an elicitation/create request from the server
|
|
706
|
+
*/
|
|
707
|
+
async handleElicitationRequest(request) {
|
|
708
|
+
const params = request.params;
|
|
709
|
+
this.log("Elicitation request received:", {
|
|
710
|
+
mode: params?.mode,
|
|
711
|
+
message: params?.message?.slice(0, 100)
|
|
712
|
+
});
|
|
713
|
+
const requestId = request.id;
|
|
714
|
+
if (requestId === void 0 || requestId === null) {
|
|
715
|
+
this.log("Elicitation request has no ID, cannot respond");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (!this.elicitationHandler) {
|
|
719
|
+
this.log("No elicitation handler registered, sending error");
|
|
720
|
+
await this.sendElicitationResponse(requestId, {
|
|
721
|
+
action: "decline"
|
|
722
|
+
});
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
const response = await this.elicitationHandler(params);
|
|
727
|
+
this.log("Elicitation handler response:", response);
|
|
728
|
+
await this.sendElicitationResponse(requestId, response);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
this.log("Elicitation handler error:", error);
|
|
731
|
+
await this.sendElicitationResponse(requestId, {
|
|
732
|
+
action: "cancel"
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Send an elicitation response back to the server
|
|
738
|
+
*/
|
|
739
|
+
async sendElicitationResponse(requestId, response) {
|
|
740
|
+
const headers = this.buildHeaders();
|
|
741
|
+
const url = `${this.config.baseUrl}/`;
|
|
742
|
+
const rpcResponse = {
|
|
743
|
+
jsonrpc: "2.0",
|
|
744
|
+
id: requestId,
|
|
745
|
+
result: response
|
|
746
|
+
};
|
|
747
|
+
this.log("Sending elicitation response:", rpcResponse);
|
|
748
|
+
try {
|
|
749
|
+
const fetchResponse = await fetch(url, {
|
|
750
|
+
method: "POST",
|
|
751
|
+
headers,
|
|
752
|
+
body: JSON.stringify(rpcResponse)
|
|
753
|
+
});
|
|
754
|
+
if (!fetchResponse.ok) {
|
|
755
|
+
this.log(`Elicitation response HTTP error: ${fetchResponse.status}`);
|
|
756
|
+
}
|
|
757
|
+
} catch (error) {
|
|
758
|
+
this.log("Failed to send elicitation response:", error);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
530
761
|
buildHeaders() {
|
|
531
762
|
const headers = {
|
|
532
763
|
"Content-Type": "application/json",
|
|
@@ -958,7 +1189,7 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
958
1189
|
version: "0.4.0"
|
|
959
1190
|
};
|
|
960
1191
|
var McpTestClient = class {
|
|
961
|
-
// Platform and
|
|
1192
|
+
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
962
1193
|
config;
|
|
963
1194
|
transport = null;
|
|
964
1195
|
initResult = null;
|
|
@@ -974,6 +1205,8 @@ var McpTestClient = class {
|
|
|
974
1205
|
_progressUpdates = [];
|
|
975
1206
|
// Interceptor chain
|
|
976
1207
|
_interceptors;
|
|
1208
|
+
// Elicitation handler for server→client elicit requests
|
|
1209
|
+
_elicitationHandler;
|
|
977
1210
|
// ═══════════════════════════════════════════════════════════════════
|
|
978
1211
|
// CONSTRUCTOR & FACTORY
|
|
979
1212
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -988,7 +1221,8 @@ var McpTestClient = class {
|
|
|
988
1221
|
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
989
1222
|
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
990
1223
|
platform: config.platform,
|
|
991
|
-
capabilities: config.capabilities
|
|
1224
|
+
capabilities: config.capabilities,
|
|
1225
|
+
queryParams: config.queryParams
|
|
992
1226
|
};
|
|
993
1227
|
if (config.auth?.token) {
|
|
994
1228
|
this._authState = {
|
|
@@ -1221,9 +1455,9 @@ var McpTestClient = class {
|
|
|
1221
1455
|
* Send any JSON-RPC request
|
|
1222
1456
|
*/
|
|
1223
1457
|
request: async (message) => {
|
|
1224
|
-
this.
|
|
1458
|
+
const transport = this.getConnectedTransport();
|
|
1225
1459
|
const start = Date.now();
|
|
1226
|
-
const response = await
|
|
1460
|
+
const response = await transport.request(message);
|
|
1227
1461
|
this.traceRequest(message.method, message.params, message.id, response, Date.now() - start);
|
|
1228
1462
|
return response;
|
|
1229
1463
|
},
|
|
@@ -1231,15 +1465,15 @@ var McpTestClient = class {
|
|
|
1231
1465
|
* Send a notification (no response expected)
|
|
1232
1466
|
*/
|
|
1233
1467
|
notify: async (message) => {
|
|
1234
|
-
this.
|
|
1235
|
-
await
|
|
1468
|
+
const transport = this.getConnectedTransport();
|
|
1469
|
+
await transport.notify(message);
|
|
1236
1470
|
},
|
|
1237
1471
|
/**
|
|
1238
1472
|
* Send raw string data (for error testing)
|
|
1239
1473
|
*/
|
|
1240
1474
|
sendRaw: async (data) => {
|
|
1241
|
-
this.
|
|
1242
|
-
return
|
|
1475
|
+
const transport = this.getConnectedTransport();
|
|
1476
|
+
return transport.sendRaw(data);
|
|
1243
1477
|
}
|
|
1244
1478
|
};
|
|
1245
1479
|
get lastRequestId() {
|
|
@@ -1295,6 +1529,63 @@ var McpTestClient = class {
|
|
|
1295
1529
|
}
|
|
1296
1530
|
};
|
|
1297
1531
|
// ═══════════════════════════════════════════════════════════════════
|
|
1532
|
+
// ELICITATION
|
|
1533
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1534
|
+
/**
|
|
1535
|
+
* Register a handler for elicitation requests from the server.
|
|
1536
|
+
*
|
|
1537
|
+
* When a tool calls `this.elicit()` during execution, the server sends an
|
|
1538
|
+
* `elicitation/create` request to the client. This handler is called to
|
|
1539
|
+
* provide the response that would normally come from user interaction.
|
|
1540
|
+
*
|
|
1541
|
+
* @param handler - Function that receives the elicitation request and returns a response
|
|
1542
|
+
*
|
|
1543
|
+
* @example
|
|
1544
|
+
* ```typescript
|
|
1545
|
+
* // Simple acceptance
|
|
1546
|
+
* mcp.onElicitation(async () => ({
|
|
1547
|
+
* action: 'accept',
|
|
1548
|
+
* content: { confirmed: true }
|
|
1549
|
+
* }));
|
|
1550
|
+
*
|
|
1551
|
+
* // Conditional response based on request
|
|
1552
|
+
* mcp.onElicitation(async (request) => {
|
|
1553
|
+
* if (request.message.includes('delete')) {
|
|
1554
|
+
* return { action: 'decline' };
|
|
1555
|
+
* }
|
|
1556
|
+
* return { action: 'accept', content: { approved: true } };
|
|
1557
|
+
* });
|
|
1558
|
+
*
|
|
1559
|
+
* // Multi-step wizard
|
|
1560
|
+
* let step = 0;
|
|
1561
|
+
* mcp.onElicitation(async () => {
|
|
1562
|
+
* step++;
|
|
1563
|
+
* if (step === 1) return { action: 'accept', content: { name: 'Alice' } };
|
|
1564
|
+
* return { action: 'accept', content: { color: 'blue' } };
|
|
1565
|
+
* });
|
|
1566
|
+
* ```
|
|
1567
|
+
*/
|
|
1568
|
+
onElicitation(handler) {
|
|
1569
|
+
this._elicitationHandler = handler;
|
|
1570
|
+
if (this.transport?.setElicitationHandler) {
|
|
1571
|
+
this.transport.setElicitationHandler(handler);
|
|
1572
|
+
}
|
|
1573
|
+
this.log("debug", "Elicitation handler registered");
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Clear the elicitation handler.
|
|
1577
|
+
*
|
|
1578
|
+
* After calling this, elicitation requests from the server will not be
|
|
1579
|
+
* handled automatically. This can be used to test timeout scenarios.
|
|
1580
|
+
*/
|
|
1581
|
+
clearElicitationHandler() {
|
|
1582
|
+
this._elicitationHandler = void 0;
|
|
1583
|
+
if (this.transport?.setElicitationHandler) {
|
|
1584
|
+
this.transport.setElicitationHandler(void 0);
|
|
1585
|
+
}
|
|
1586
|
+
this.log("debug", "Elicitation handler cleared");
|
|
1587
|
+
}
|
|
1588
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1298
1589
|
// LOGGING & DEBUGGING
|
|
1299
1590
|
// ═══════════════════════════════════════════════════════════════════
|
|
1300
1591
|
logs = {
|
|
@@ -1519,7 +1810,10 @@ var McpTestClient = class {
|
|
|
1519
1810
|
// ═══════════════════════════════════════════════════════════════════
|
|
1520
1811
|
async initialize() {
|
|
1521
1812
|
const capabilities = this.config.capabilities ?? {
|
|
1522
|
-
sampling: {}
|
|
1813
|
+
sampling: {},
|
|
1814
|
+
elicitation: {
|
|
1815
|
+
form: {}
|
|
1816
|
+
}
|
|
1523
1817
|
};
|
|
1524
1818
|
return this.request("initialize", {
|
|
1525
1819
|
protocolVersion: this.config.protocolVersion,
|
|
@@ -1558,16 +1852,25 @@ var McpTestClient = class {
|
|
|
1558
1852
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1559
1853
|
// ═══════════════════════════════════════════════════════════════════
|
|
1560
1854
|
createTransport() {
|
|
1855
|
+
let baseUrl = this.config.baseUrl;
|
|
1856
|
+
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
1857
|
+
const url = new URL(baseUrl);
|
|
1858
|
+
Object.entries(this.config.queryParams).forEach(([key, value]) => {
|
|
1859
|
+
url.searchParams.set(key, String(value));
|
|
1860
|
+
});
|
|
1861
|
+
baseUrl = url.toString();
|
|
1862
|
+
}
|
|
1561
1863
|
switch (this.config.transport) {
|
|
1562
1864
|
case "streamable-http":
|
|
1563
1865
|
return new StreamableHttpTransport({
|
|
1564
|
-
baseUrl
|
|
1866
|
+
baseUrl,
|
|
1565
1867
|
timeout: this.config.timeout,
|
|
1566
1868
|
auth: this.config.auth,
|
|
1567
1869
|
publicMode: this.config.publicMode,
|
|
1568
1870
|
debug: this.config.debug,
|
|
1569
1871
|
interceptors: this._interceptors,
|
|
1570
|
-
clientInfo: this.config.clientInfo
|
|
1872
|
+
clientInfo: this.config.clientInfo,
|
|
1873
|
+
elicitationHandler: this._elicitationHandler
|
|
1571
1874
|
});
|
|
1572
1875
|
case "sse":
|
|
1573
1876
|
throw new Error("SSE transport not yet implemented");
|
|
@@ -1576,12 +1879,12 @@ var McpTestClient = class {
|
|
|
1576
1879
|
}
|
|
1577
1880
|
}
|
|
1578
1881
|
async request(method, params) {
|
|
1579
|
-
this.
|
|
1882
|
+
const transport = this.getConnectedTransport();
|
|
1580
1883
|
const id = ++this.requestIdCounter;
|
|
1581
1884
|
this._lastRequestId = id;
|
|
1582
1885
|
const start = Date.now();
|
|
1583
1886
|
try {
|
|
1584
|
-
const response = await
|
|
1887
|
+
const response = await transport.request({
|
|
1585
1888
|
jsonrpc: "2.0",
|
|
1586
1889
|
id,
|
|
1587
1890
|
method,
|
|
@@ -1620,10 +1923,14 @@ var McpTestClient = class {
|
|
|
1620
1923
|
};
|
|
1621
1924
|
}
|
|
1622
1925
|
}
|
|
1623
|
-
|
|
1624
|
-
|
|
1926
|
+
/**
|
|
1927
|
+
* Get the transport, throwing if not connected.
|
|
1928
|
+
*/
|
|
1929
|
+
getConnectedTransport() {
|
|
1930
|
+
if (!this.transport || !this.transport.isConnected()) {
|
|
1625
1931
|
throw new Error("Not connected to MCP server. Call connect() first.");
|
|
1626
1932
|
}
|
|
1933
|
+
return this.transport;
|
|
1627
1934
|
}
|
|
1628
1935
|
updateSessionActivity() {
|
|
1629
1936
|
if (this._sessionInfo) {
|
|
@@ -1876,6 +2183,9 @@ var TestTokenFactory = class {
|
|
|
1876
2183
|
scope: options.scopes?.join(" "),
|
|
1877
2184
|
...options.claims
|
|
1878
2185
|
};
|
|
2186
|
+
if (!this.privateKey) {
|
|
2187
|
+
throw new Error("Private key not initialized");
|
|
2188
|
+
}
|
|
1879
2189
|
const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
1880
2190
|
return token;
|
|
1881
2191
|
}
|
|
@@ -1935,6 +2245,9 @@ var TestTokenFactory = class {
|
|
|
1935
2245
|
exp: now - 3600
|
|
1936
2246
|
// Expired 1 hour ago
|
|
1937
2247
|
};
|
|
2248
|
+
if (!this.privateKey) {
|
|
2249
|
+
throw new Error("Private key not initialized");
|
|
2250
|
+
}
|
|
1938
2251
|
const token = await new SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
1939
2252
|
return token;
|
|
1940
2253
|
}
|
|
@@ -1961,6 +2274,9 @@ var TestTokenFactory = class {
|
|
|
1961
2274
|
*/
|
|
1962
2275
|
async getPublicJwks() {
|
|
1963
2276
|
await this.ensureKeys();
|
|
2277
|
+
if (!this.jwk) {
|
|
2278
|
+
throw new Error("JWK not initialized");
|
|
2279
|
+
}
|
|
1964
2280
|
return {
|
|
1965
2281
|
keys: [this.jwk]
|
|
1966
2282
|
};
|
|
@@ -2113,15 +2429,66 @@ function createTestUser(overrides) {
|
|
|
2113
2429
|
|
|
2114
2430
|
// libs/testing/src/auth/mock-oauth-server.ts
|
|
2115
2431
|
import { createServer } from "http";
|
|
2432
|
+
var _randomBytes;
|
|
2433
|
+
var _sha256Base64url;
|
|
2434
|
+
var _base64urlEncode;
|
|
2435
|
+
async function loadCryptoUtils() {
|
|
2436
|
+
if (!_randomBytes) {
|
|
2437
|
+
const utils = await import("@frontmcp/utils");
|
|
2438
|
+
_randomBytes = utils.randomBytes;
|
|
2439
|
+
_sha256Base64url = utils.sha256Base64url;
|
|
2440
|
+
_base64urlEncode = utils.base64urlEncode;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2116
2443
|
var MockOAuthServer = class {
|
|
2117
2444
|
tokenFactory;
|
|
2118
2445
|
options;
|
|
2119
2446
|
server = null;
|
|
2120
2447
|
_info = null;
|
|
2121
2448
|
connections = /* @__PURE__ */ new Set();
|
|
2122
|
-
|
|
2123
|
-
|
|
2449
|
+
/** Authorization code storage (code -> record) */
|
|
2450
|
+
authCodes = /* @__PURE__ */ new Map();
|
|
2451
|
+
/** Refresh token storage (token -> record) */
|
|
2452
|
+
refreshTokens = /* @__PURE__ */ new Map();
|
|
2453
|
+
/** Access token TTL in seconds */
|
|
2454
|
+
accessTokenTtlSeconds;
|
|
2455
|
+
/** Refresh token TTL in seconds */
|
|
2456
|
+
refreshTokenTtlSeconds;
|
|
2457
|
+
constructor(tokenFactory3, options = {}) {
|
|
2458
|
+
this.tokenFactory = tokenFactory3;
|
|
2124
2459
|
this.options = options;
|
|
2460
|
+
this.accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 3600;
|
|
2461
|
+
this.refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? 30 * 24 * 3600;
|
|
2462
|
+
}
|
|
2463
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2464
|
+
// CONFIGURATION METHODS
|
|
2465
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2466
|
+
/**
|
|
2467
|
+
* Set auto-approve mode for authorization requests
|
|
2468
|
+
*/
|
|
2469
|
+
setAutoApprove(enabled) {
|
|
2470
|
+
this.options.autoApprove = enabled;
|
|
2471
|
+
}
|
|
2472
|
+
/**
|
|
2473
|
+
* Set the test user returned on authorization
|
|
2474
|
+
*/
|
|
2475
|
+
setTestUser(user) {
|
|
2476
|
+
this.options.testUser = user;
|
|
2477
|
+
}
|
|
2478
|
+
/**
|
|
2479
|
+
* Add a valid redirect URI
|
|
2480
|
+
*/
|
|
2481
|
+
addValidRedirectUri(uri) {
|
|
2482
|
+
const uris = this.options.validRedirectUris ?? [];
|
|
2483
|
+
uris.push(uri);
|
|
2484
|
+
this.options.validRedirectUris = uris;
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Clear all stored authorization codes and refresh tokens
|
|
2488
|
+
*/
|
|
2489
|
+
clearStoredTokens() {
|
|
2490
|
+
this.authCodes.clear();
|
|
2491
|
+
this.refreshTokens.clear();
|
|
2125
2492
|
}
|
|
2126
2493
|
/**
|
|
2127
2494
|
* Start the mock OAuth server
|
|
@@ -2205,8 +2572,9 @@ var MockOAuthServer = class {
|
|
|
2205
2572
|
// PRIVATE
|
|
2206
2573
|
// ═══════════════════════════════════════════════════════════════════
|
|
2207
2574
|
async handleRequest(req, res) {
|
|
2208
|
-
const
|
|
2209
|
-
|
|
2575
|
+
const fullUrl = req.url ?? "/";
|
|
2576
|
+
const [urlPath, queryString] = fullUrl.split("?");
|
|
2577
|
+
this.log(`${req.method} ${urlPath}`);
|
|
2210
2578
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
2211
2579
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
2212
2580
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
@@ -2216,20 +2584,28 @@ var MockOAuthServer = class {
|
|
|
2216
2584
|
return;
|
|
2217
2585
|
}
|
|
2218
2586
|
try {
|
|
2219
|
-
if (
|
|
2587
|
+
if (urlPath === "/.well-known/jwks.json" || urlPath === "/.well-known/jwks") {
|
|
2220
2588
|
await this.handleJwks(req, res);
|
|
2221
|
-
} else if (
|
|
2589
|
+
} else if (urlPath === "/.well-known/openid-configuration") {
|
|
2222
2590
|
await this.handleOidcConfig(req, res);
|
|
2223
|
-
} else if (
|
|
2591
|
+
} else if (urlPath === "/.well-known/oauth-authorization-server") {
|
|
2224
2592
|
await this.handleOAuthMetadata(req, res);
|
|
2225
|
-
} else if (
|
|
2593
|
+
} else if (urlPath === "/oauth/authorize") {
|
|
2594
|
+
await this.handleAuthorizeEndpoint(req, res, queryString);
|
|
2595
|
+
} else if (urlPath === "/oauth/token") {
|
|
2226
2596
|
await this.handleTokenEndpoint(req, res);
|
|
2597
|
+
} else if (urlPath === "/userinfo") {
|
|
2598
|
+
await this.handleUserInfoEndpoint(req, res);
|
|
2599
|
+
} else if (urlPath === "/oauth/authorize/submit" && req.method === "POST") {
|
|
2600
|
+
await this.handleAuthorizeSubmit(req, res);
|
|
2227
2601
|
} else {
|
|
2228
2602
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2229
2603
|
res.end(JSON.stringify({ error: "not_found", error_description: "Endpoint not found" }));
|
|
2230
2604
|
}
|
|
2231
2605
|
} catch (error) {
|
|
2232
|
-
|
|
2606
|
+
const errorMsg = error instanceof Error ? `${error.message}
|
|
2607
|
+
${error.stack}` : String(error);
|
|
2608
|
+
this.logError(`Error handling request to ${urlPath}: ${errorMsg}`);
|
|
2233
2609
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2234
2610
|
res.end(JSON.stringify({ error: "server_error", error_description: "Internal server error" }));
|
|
2235
2611
|
}
|
|
@@ -2247,13 +2623,13 @@ var MockOAuthServer = class {
|
|
|
2247
2623
|
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
2248
2624
|
token_endpoint: `${issuer}/oauth/token`,
|
|
2249
2625
|
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
2250
|
-
response_types_supported: ["code"
|
|
2626
|
+
response_types_supported: ["code"],
|
|
2251
2627
|
subject_types_supported: ["public"],
|
|
2252
2628
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
2253
2629
|
scopes_supported: ["openid", "profile", "email"],
|
|
2254
2630
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
2255
2631
|
claims_supported: ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
|
2256
|
-
grant_types_supported: ["authorization_code", "refresh_token", "
|
|
2632
|
+
grant_types_supported: ["authorization_code", "refresh_token", "anonymous"]
|
|
2257
2633
|
};
|
|
2258
2634
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2259
2635
|
res.end(JSON.stringify(config));
|
|
@@ -2266,8 +2642,8 @@ var MockOAuthServer = class {
|
|
|
2266
2642
|
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
2267
2643
|
token_endpoint: `${issuer}/oauth/token`,
|
|
2268
2644
|
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
2269
|
-
response_types_supported: ["code"
|
|
2270
|
-
grant_types_supported: ["authorization_code", "refresh_token", "
|
|
2645
|
+
response_types_supported: ["code"],
|
|
2646
|
+
grant_types_supported: ["authorization_code", "refresh_token", "anonymous"],
|
|
2271
2647
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
2272
2648
|
scopes_supported: ["openid", "profile", "email", "anonymous"]
|
|
2273
2649
|
};
|
|
@@ -2275,9 +2651,112 @@ var MockOAuthServer = class {
|
|
|
2275
2651
|
res.end(JSON.stringify(metadata));
|
|
2276
2652
|
this.log("Served OAuth metadata");
|
|
2277
2653
|
}
|
|
2654
|
+
/**
|
|
2655
|
+
* Handle authorization endpoint (GET /oauth/authorize)
|
|
2656
|
+
* Supports auto-approve mode for E2E testing
|
|
2657
|
+
*/
|
|
2658
|
+
async handleAuthorizeEndpoint(_req, res, queryString) {
|
|
2659
|
+
const params = new URLSearchParams(queryString ?? "");
|
|
2660
|
+
const clientId = params.get("client_id");
|
|
2661
|
+
const redirectUri = params.get("redirect_uri");
|
|
2662
|
+
const responseType = params.get("response_type");
|
|
2663
|
+
const state = params.get("state") ?? void 0;
|
|
2664
|
+
const scope = params.get("scope") ?? "openid";
|
|
2665
|
+
const codeChallenge = params.get("code_challenge") ?? void 0;
|
|
2666
|
+
const codeChallengeMethod = params.get("code_challenge_method") ?? void 0;
|
|
2667
|
+
if (!clientId || !redirectUri || !responseType) {
|
|
2668
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2669
|
+
res.end(
|
|
2670
|
+
JSON.stringify({
|
|
2671
|
+
error: "invalid_request",
|
|
2672
|
+
error_description: "Missing required parameters: client_id, redirect_uri, response_type"
|
|
2673
|
+
})
|
|
2674
|
+
);
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
if (responseType !== "code") {
|
|
2678
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2679
|
+
res.end(
|
|
2680
|
+
JSON.stringify({
|
|
2681
|
+
error: "unsupported_response_type",
|
|
2682
|
+
error_description: "Only response_type=code is supported"
|
|
2683
|
+
})
|
|
2684
|
+
);
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
if (!this.isValidRedirectUri(redirectUri)) {
|
|
2688
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2689
|
+
res.end(
|
|
2690
|
+
JSON.stringify({
|
|
2691
|
+
error: "invalid_request",
|
|
2692
|
+
error_description: "Invalid redirect_uri"
|
|
2693
|
+
})
|
|
2694
|
+
);
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
if (this.options.clientId && clientId !== this.options.clientId) {
|
|
2698
|
+
this.redirectWithError(res, redirectUri, "unauthorized_client", "Invalid client_id", state);
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
if (this.options.autoApprove) {
|
|
2702
|
+
const testUser = this.options.testUser;
|
|
2703
|
+
if (!testUser) {
|
|
2704
|
+
this.logError("autoApprove is enabled but no testUser configured. Set testUser in MockOAuthServerOptions.");
|
|
2705
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2706
|
+
res.end(
|
|
2707
|
+
JSON.stringify({
|
|
2708
|
+
error: "server_error",
|
|
2709
|
+
error_description: "autoApprove is enabled but no testUser configured"
|
|
2710
|
+
})
|
|
2711
|
+
);
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
const code = this.generateCode();
|
|
2715
|
+
const scopes = scope.split(" ").filter(Boolean);
|
|
2716
|
+
this.authCodes.set(code, {
|
|
2717
|
+
code,
|
|
2718
|
+
clientId,
|
|
2719
|
+
redirectUri,
|
|
2720
|
+
codeChallenge,
|
|
2721
|
+
codeChallengeMethod,
|
|
2722
|
+
scopes,
|
|
2723
|
+
user: testUser,
|
|
2724
|
+
state,
|
|
2725
|
+
expiresAt: Date.now() + 5 * 60 * 1e3,
|
|
2726
|
+
// 5 minutes
|
|
2727
|
+
used: false
|
|
2728
|
+
});
|
|
2729
|
+
const callbackUrl = new URL(redirectUri);
|
|
2730
|
+
callbackUrl.searchParams.set("code", code);
|
|
2731
|
+
if (state) {
|
|
2732
|
+
callbackUrl.searchParams.set("state", state);
|
|
2733
|
+
}
|
|
2734
|
+
this.log(`Auto-approved auth request, redirecting with code to ${callbackUrl.origin}${callbackUrl.pathname}`);
|
|
2735
|
+
res.writeHead(302, { Location: callbackUrl.toString() });
|
|
2736
|
+
res.end();
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
const html = this.renderLoginPage(clientId, redirectUri, scope, state, codeChallenge, codeChallengeMethod);
|
|
2740
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2741
|
+
res.end(html);
|
|
2742
|
+
}
|
|
2278
2743
|
async handleTokenEndpoint(req, res) {
|
|
2279
2744
|
const body = await this.readBody(req);
|
|
2280
2745
|
const params = new URLSearchParams(body);
|
|
2746
|
+
const authHeader = req.headers["authorization"];
|
|
2747
|
+
let clientSecret = params.get("client_secret") ?? void 0;
|
|
2748
|
+
if (!clientSecret && authHeader?.startsWith("Basic ")) {
|
|
2749
|
+
const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf8");
|
|
2750
|
+
const colonIndex = decoded.indexOf(":");
|
|
2751
|
+
if (colonIndex >= 0) {
|
|
2752
|
+
clientSecret = decoded.slice(colonIndex + 1);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
if (this.options.clientSecret && clientSecret !== this.options.clientSecret) {
|
|
2756
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2757
|
+
res.end(JSON.stringify({ error: "invalid_client", error_description: "Invalid client_secret" }));
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2281
2760
|
const grantType = params.get("grant_type");
|
|
2282
2761
|
if (grantType === "anonymous") {
|
|
2283
2762
|
const token = await this.tokenFactory.createAnonymousToken();
|
|
@@ -2286,84 +2765,596 @@ var MockOAuthServer = class {
|
|
|
2286
2765
|
JSON.stringify({
|
|
2287
2766
|
access_token: token,
|
|
2288
2767
|
token_type: "Bearer",
|
|
2289
|
-
expires_in:
|
|
2768
|
+
expires_in: this.accessTokenTtlSeconds
|
|
2290
2769
|
})
|
|
2291
2770
|
);
|
|
2292
2771
|
this.log("Issued anonymous token");
|
|
2772
|
+
} else if (grantType === "authorization_code") {
|
|
2773
|
+
await this.handleAuthorizationCodeGrant(params, res);
|
|
2774
|
+
} else if (grantType === "refresh_token") {
|
|
2775
|
+
await this.handleRefreshTokenGrant(params, res);
|
|
2293
2776
|
} else {
|
|
2294
2777
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2295
2778
|
res.end(
|
|
2296
2779
|
JSON.stringify({
|
|
2297
2780
|
error: "unsupported_grant_type",
|
|
2298
|
-
error_description:
|
|
2781
|
+
error_description: `Unsupported grant_type: ${grantType}`
|
|
2299
2782
|
})
|
|
2300
2783
|
);
|
|
2301
2784
|
}
|
|
2302
2785
|
}
|
|
2303
|
-
readBody(req) {
|
|
2304
|
-
return new Promise((resolve, reject) => {
|
|
2305
|
-
const chunks = [];
|
|
2306
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
2307
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
2308
|
-
req.on("error", reject);
|
|
2309
|
-
});
|
|
2310
|
-
}
|
|
2311
|
-
log(message) {
|
|
2312
|
-
if (this.options.debug) {
|
|
2313
|
-
console.log(`[MockOAuthServer] ${message}`);
|
|
2314
|
-
}
|
|
2315
|
-
}
|
|
2316
|
-
};
|
|
2317
|
-
|
|
2318
|
-
// libs/testing/src/auth/mock-api-server.ts
|
|
2319
|
-
import { createServer as createServer2 } from "http";
|
|
2320
|
-
var MockAPIServer = class {
|
|
2321
|
-
options;
|
|
2322
|
-
server = null;
|
|
2323
|
-
_info = null;
|
|
2324
|
-
routes;
|
|
2325
|
-
constructor(options) {
|
|
2326
|
-
this.options = options;
|
|
2327
|
-
this.routes = options.routes ?? [];
|
|
2328
|
-
for (const route of this.routes) {
|
|
2329
|
-
this.validateRoute(route);
|
|
2330
|
-
}
|
|
2331
|
-
}
|
|
2332
2786
|
/**
|
|
2333
|
-
*
|
|
2787
|
+
* Handle authorization_code grant type
|
|
2334
2788
|
*/
|
|
2335
|
-
async
|
|
2336
|
-
|
|
2337
|
-
|
|
2789
|
+
async handleAuthorizationCodeGrant(params, res) {
|
|
2790
|
+
const code = params.get("code");
|
|
2791
|
+
const redirectUri = params.get("redirect_uri");
|
|
2792
|
+
const clientId = params.get("client_id");
|
|
2793
|
+
const codeVerifier = params.get("code_verifier");
|
|
2794
|
+
if (!code || !redirectUri || !clientId) {
|
|
2795
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2796
|
+
res.end(
|
|
2797
|
+
JSON.stringify({
|
|
2798
|
+
error: "invalid_request",
|
|
2799
|
+
error_description: "Missing required parameters: code, redirect_uri, client_id"
|
|
2800
|
+
})
|
|
2801
|
+
);
|
|
2802
|
+
return;
|
|
2338
2803
|
}
|
|
2339
|
-
const
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2804
|
+
const codeRecord = this.authCodes.get(code);
|
|
2805
|
+
if (!codeRecord) {
|
|
2806
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2807
|
+
res.end(
|
|
2808
|
+
JSON.stringify({
|
|
2809
|
+
error: "invalid_grant",
|
|
2810
|
+
error_description: "Authorization code not found or expired"
|
|
2811
|
+
})
|
|
2812
|
+
);
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
if (codeRecord.used) {
|
|
2816
|
+
this.authCodes.delete(code);
|
|
2817
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2818
|
+
res.end(
|
|
2819
|
+
JSON.stringify({
|
|
2820
|
+
error: "invalid_grant",
|
|
2821
|
+
error_description: "Authorization code has already been used"
|
|
2822
|
+
})
|
|
2823
|
+
);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (codeRecord.expiresAt < Date.now()) {
|
|
2827
|
+
this.authCodes.delete(code);
|
|
2828
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2829
|
+
res.end(
|
|
2830
|
+
JSON.stringify({
|
|
2831
|
+
error: "invalid_grant",
|
|
2832
|
+
error_description: "Authorization code has expired"
|
|
2833
|
+
})
|
|
2834
|
+
);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
if (codeRecord.clientId !== clientId) {
|
|
2838
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2839
|
+
res.end(
|
|
2840
|
+
JSON.stringify({
|
|
2841
|
+
error: "invalid_grant",
|
|
2842
|
+
error_description: "client_id mismatch"
|
|
2843
|
+
})
|
|
2844
|
+
);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
if (codeRecord.redirectUri !== redirectUri) {
|
|
2848
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2849
|
+
res.end(
|
|
2850
|
+
JSON.stringify({
|
|
2851
|
+
error: "invalid_grant",
|
|
2852
|
+
error_description: "redirect_uri mismatch"
|
|
2853
|
+
})
|
|
2854
|
+
);
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
if (codeRecord.codeChallenge) {
|
|
2858
|
+
if (!codeVerifier) {
|
|
2859
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2860
|
+
res.end(
|
|
2861
|
+
JSON.stringify({
|
|
2862
|
+
error: "invalid_grant",
|
|
2863
|
+
error_description: "code_verifier required"
|
|
2864
|
+
})
|
|
2865
|
+
);
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const method = codeRecord.codeChallengeMethod ?? "plain";
|
|
2869
|
+
if (method !== "S256" && method !== "plain") {
|
|
2870
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2871
|
+
res.end(
|
|
2872
|
+
JSON.stringify({
|
|
2873
|
+
error: "invalid_grant",
|
|
2874
|
+
error_description: `Unsupported code_challenge_method: ${method}`
|
|
2875
|
+
})
|
|
2876
|
+
);
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
const expectedChallenge = await this.computeCodeChallengeAsync(codeVerifier, method);
|
|
2880
|
+
if (expectedChallenge !== codeRecord.codeChallenge) {
|
|
2881
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2882
|
+
res.end(
|
|
2883
|
+
JSON.stringify({
|
|
2884
|
+
error: "invalid_grant",
|
|
2885
|
+
error_description: "PKCE verification failed"
|
|
2886
|
+
})
|
|
2887
|
+
);
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
codeRecord.used = true;
|
|
2892
|
+
const accessToken = await this.tokenFactory.createTestToken({
|
|
2893
|
+
sub: codeRecord.user.sub,
|
|
2894
|
+
claims: {
|
|
2895
|
+
email: codeRecord.user.email,
|
|
2896
|
+
name: codeRecord.user.name,
|
|
2897
|
+
...codeRecord.user.claims ?? {}
|
|
2898
|
+
}
|
|
2899
|
+
});
|
|
2900
|
+
const idToken = await this.tokenFactory.createTestToken({
|
|
2901
|
+
sub: codeRecord.user.sub,
|
|
2902
|
+
claims: {
|
|
2903
|
+
email: codeRecord.user.email,
|
|
2904
|
+
name: codeRecord.user.name,
|
|
2905
|
+
...codeRecord.user.claims ?? {}
|
|
2906
|
+
}
|
|
2907
|
+
});
|
|
2908
|
+
const refreshToken = this.generateCode();
|
|
2909
|
+
this.refreshTokens.set(refreshToken, {
|
|
2910
|
+
token: refreshToken,
|
|
2911
|
+
clientId,
|
|
2912
|
+
user: codeRecord.user,
|
|
2913
|
+
scopes: codeRecord.scopes,
|
|
2914
|
+
expiresAt: Date.now() + this.refreshTokenTtlSeconds * 1e3
|
|
2915
|
+
});
|
|
2916
|
+
this.log(`Issued tokens for user: ${codeRecord.user.sub}`);
|
|
2917
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2918
|
+
res.end(
|
|
2919
|
+
JSON.stringify({
|
|
2920
|
+
access_token: accessToken,
|
|
2921
|
+
token_type: "Bearer",
|
|
2922
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
2923
|
+
refresh_token: refreshToken,
|
|
2924
|
+
id_token: idToken,
|
|
2925
|
+
scope: codeRecord.scopes.join(" ")
|
|
2926
|
+
})
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2929
|
+
/**
|
|
2930
|
+
* Handle refresh_token grant type
|
|
2931
|
+
*/
|
|
2932
|
+
async handleRefreshTokenGrant(params, res) {
|
|
2933
|
+
const refreshToken = params.get("refresh_token");
|
|
2934
|
+
const clientId = params.get("client_id");
|
|
2935
|
+
if (!refreshToken || !clientId) {
|
|
2936
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2937
|
+
res.end(
|
|
2938
|
+
JSON.stringify({
|
|
2939
|
+
error: "invalid_request",
|
|
2940
|
+
error_description: "Missing required parameters: refresh_token, client_id"
|
|
2941
|
+
})
|
|
2942
|
+
);
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
const tokenRecord = this.refreshTokens.get(refreshToken);
|
|
2946
|
+
if (!tokenRecord) {
|
|
2947
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2948
|
+
res.end(
|
|
2949
|
+
JSON.stringify({
|
|
2950
|
+
error: "invalid_grant",
|
|
2951
|
+
error_description: "Refresh token not found or expired"
|
|
2952
|
+
})
|
|
2953
|
+
);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
if (tokenRecord.expiresAt < Date.now()) {
|
|
2957
|
+
this.refreshTokens.delete(refreshToken);
|
|
2958
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2959
|
+
res.end(
|
|
2960
|
+
JSON.stringify({
|
|
2961
|
+
error: "invalid_grant",
|
|
2962
|
+
error_description: "Refresh token has expired"
|
|
2963
|
+
})
|
|
2964
|
+
);
|
|
2965
|
+
return;
|
|
2966
|
+
}
|
|
2967
|
+
if (tokenRecord.clientId !== clientId) {
|
|
2968
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2969
|
+
res.end(
|
|
2970
|
+
JSON.stringify({
|
|
2971
|
+
error: "invalid_grant",
|
|
2972
|
+
error_description: "client_id mismatch"
|
|
2973
|
+
})
|
|
2974
|
+
);
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
const accessToken = await this.tokenFactory.createTestToken({
|
|
2978
|
+
sub: tokenRecord.user.sub,
|
|
2979
|
+
claims: {
|
|
2980
|
+
email: tokenRecord.user.email,
|
|
2981
|
+
name: tokenRecord.user.name,
|
|
2982
|
+
...tokenRecord.user.claims ?? {}
|
|
2983
|
+
}
|
|
2984
|
+
});
|
|
2985
|
+
this.refreshTokens.delete(refreshToken);
|
|
2986
|
+
const newRefreshToken = this.generateCode();
|
|
2987
|
+
this.refreshTokens.set(newRefreshToken, {
|
|
2988
|
+
...tokenRecord,
|
|
2989
|
+
token: newRefreshToken,
|
|
2990
|
+
expiresAt: Date.now() + this.refreshTokenTtlSeconds * 1e3
|
|
2991
|
+
});
|
|
2992
|
+
this.log(`Refreshed tokens for user: ${tokenRecord.user.sub}`);
|
|
2993
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2994
|
+
res.end(
|
|
2995
|
+
JSON.stringify({
|
|
2996
|
+
access_token: accessToken,
|
|
2997
|
+
token_type: "Bearer",
|
|
2998
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
2999
|
+
refresh_token: newRefreshToken,
|
|
3000
|
+
scope: tokenRecord.scopes.join(" ")
|
|
3001
|
+
})
|
|
3002
|
+
);
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Handle userinfo endpoint (GET /userinfo)
|
|
3006
|
+
*/
|
|
3007
|
+
async handleUserInfoEndpoint(req, res) {
|
|
3008
|
+
const authHeader = req.headers["authorization"];
|
|
3009
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
3010
|
+
res.writeHead(401, { "WWW-Authenticate": 'Bearer error="invalid_token"' });
|
|
3011
|
+
res.end(JSON.stringify({ error: "invalid_token", error_description: "Missing or invalid Authorization header" }));
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
const testUser = this.options.testUser;
|
|
3015
|
+
if (!testUser) {
|
|
3016
|
+
this.logError("UserInfo endpoint called but no testUser configured. Set testUser in MockOAuthServerOptions.");
|
|
3017
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3018
|
+
res.end(JSON.stringify({ error: "server_error", error_description: "No test user configured" }));
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3022
|
+
res.end(
|
|
3023
|
+
JSON.stringify({
|
|
3024
|
+
sub: testUser.sub,
|
|
3025
|
+
email: testUser.email,
|
|
3026
|
+
name: testUser.name,
|
|
3027
|
+
picture: testUser.picture,
|
|
3028
|
+
...testUser.claims ?? {}
|
|
3029
|
+
})
|
|
3030
|
+
);
|
|
3031
|
+
}
|
|
3032
|
+
/**
|
|
3033
|
+
* Handle authorize form submission (POST /oauth/authorize/submit)
|
|
3034
|
+
* Processes the manual login form for non-autoApprove testing
|
|
3035
|
+
*/
|
|
3036
|
+
async handleAuthorizeSubmit(req, res) {
|
|
3037
|
+
const body = await this.readBody(req);
|
|
3038
|
+
const params = new URLSearchParams(body);
|
|
3039
|
+
const clientId = params.get("client_id");
|
|
3040
|
+
const redirectUri = params.get("redirect_uri");
|
|
3041
|
+
const scope = params.get("scope") ?? "openid";
|
|
3042
|
+
const state = params.get("state") ?? void 0;
|
|
3043
|
+
const codeChallenge = params.get("code_challenge") ?? void 0;
|
|
3044
|
+
const codeChallengeMethod = params.get("code_challenge_method") ?? void 0;
|
|
3045
|
+
const action = params.get("action");
|
|
3046
|
+
const sub = params.get("sub");
|
|
3047
|
+
const email = params.get("email") ?? void 0;
|
|
3048
|
+
const name = params.get("name") ?? void 0;
|
|
3049
|
+
if (!clientId || !redirectUri || !sub) {
|
|
3050
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3051
|
+
res.end(
|
|
3052
|
+
JSON.stringify({
|
|
3053
|
+
error: "invalid_request",
|
|
3054
|
+
error_description: "Missing required fields"
|
|
3055
|
+
})
|
|
3056
|
+
);
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
if (!this.isValidRedirectUri(redirectUri)) {
|
|
3060
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3061
|
+
res.end(
|
|
3062
|
+
JSON.stringify({
|
|
3063
|
+
error: "invalid_request",
|
|
3064
|
+
error_description: "Invalid redirect_uri"
|
|
3065
|
+
})
|
|
3066
|
+
);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
if (this.options.clientId && clientId !== this.options.clientId) {
|
|
3070
|
+
this.redirectWithError(res, redirectUri, "unauthorized_client", "Invalid client_id", state);
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
if (action === "deny") {
|
|
3074
|
+
this.redirectWithError(res, redirectUri, "access_denied", "User denied the authorization request", state);
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
const testUser = {
|
|
3078
|
+
sub,
|
|
3079
|
+
email,
|
|
3080
|
+
name
|
|
3081
|
+
};
|
|
3082
|
+
const code = this.generateCode();
|
|
3083
|
+
const scopes = scope.split(" ").filter(Boolean);
|
|
3084
|
+
this.authCodes.set(code, {
|
|
3085
|
+
code,
|
|
3086
|
+
clientId,
|
|
3087
|
+
redirectUri,
|
|
3088
|
+
codeChallenge,
|
|
3089
|
+
codeChallengeMethod,
|
|
3090
|
+
scopes,
|
|
3091
|
+
user: testUser,
|
|
3092
|
+
state,
|
|
3093
|
+
expiresAt: Date.now() + 5 * 60 * 1e3,
|
|
3094
|
+
// 5 minutes
|
|
3095
|
+
used: false
|
|
3096
|
+
});
|
|
3097
|
+
const callbackUrl = new URL(redirectUri);
|
|
3098
|
+
callbackUrl.searchParams.set("code", code);
|
|
3099
|
+
if (state) {
|
|
3100
|
+
callbackUrl.searchParams.set("state", state);
|
|
3101
|
+
}
|
|
3102
|
+
this.log(
|
|
3103
|
+
`Manual auth approved for user: ${sub}, redirecting with code to ${callbackUrl.origin}${callbackUrl.pathname}`
|
|
3104
|
+
);
|
|
3105
|
+
res.writeHead(302, { Location: callbackUrl.toString() });
|
|
3106
|
+
res.end();
|
|
3107
|
+
}
|
|
3108
|
+
readBody(req) {
|
|
3109
|
+
return new Promise((resolve, reject) => {
|
|
3110
|
+
const chunks = [];
|
|
3111
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3112
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
3113
|
+
req.on("error", reject);
|
|
3114
|
+
});
|
|
3115
|
+
}
|
|
3116
|
+
log(message) {
|
|
3117
|
+
const shouldLog = this.options.debug || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3118
|
+
if (shouldLog) {
|
|
3119
|
+
console.log(`[MockOAuthServer] ${message}`);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
logError(message) {
|
|
3123
|
+
console.error(`[MockOAuthServer ERROR] ${message}`);
|
|
3124
|
+
}
|
|
3125
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3126
|
+
// HELPER METHODS
|
|
3127
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3128
|
+
/**
|
|
3129
|
+
* Validate redirect URI against configured valid URIs
|
|
3130
|
+
* Supports wildcards for port (e.g., http://localhost:*)
|
|
3131
|
+
*/
|
|
3132
|
+
isValidRedirectUri(redirectUri) {
|
|
3133
|
+
const validUris = this.options.validRedirectUris;
|
|
3134
|
+
if (!validUris || validUris.length === 0) {
|
|
3135
|
+
return true;
|
|
3136
|
+
}
|
|
3137
|
+
if (redirectUri.length > 2048) {
|
|
3138
|
+
return false;
|
|
3139
|
+
}
|
|
3140
|
+
for (const pattern of validUris) {
|
|
3141
|
+
if (pattern === redirectUri) {
|
|
3142
|
+
return true;
|
|
3143
|
+
}
|
|
3144
|
+
if (pattern.includes("*")) {
|
|
3145
|
+
if (this.matchWildcardPattern(pattern, redirectUri)) {
|
|
3146
|
+
return true;
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
return false;
|
|
3151
|
+
}
|
|
3152
|
+
/**
|
|
3153
|
+
* Safe string-based wildcard matching (O(n) complexity)
|
|
3154
|
+
* Avoids regex to prevent ReDoS vulnerabilities
|
|
3155
|
+
*/
|
|
3156
|
+
matchWildcardPattern(pattern, input) {
|
|
3157
|
+
const parts = pattern.split("*");
|
|
3158
|
+
let remaining = input;
|
|
3159
|
+
if (!remaining.startsWith(parts[0])) {
|
|
3160
|
+
return false;
|
|
3161
|
+
}
|
|
3162
|
+
remaining = remaining.slice(parts[0].length);
|
|
3163
|
+
for (let i = 1; i < parts.length; i++) {
|
|
3164
|
+
const part = parts[i];
|
|
3165
|
+
if (i === parts.length - 1) {
|
|
3166
|
+
if (!remaining.endsWith(part)) {
|
|
3167
|
+
return false;
|
|
3168
|
+
}
|
|
3169
|
+
} else {
|
|
3170
|
+
const idx = remaining.indexOf(part);
|
|
3171
|
+
if (idx === -1) {
|
|
3172
|
+
return false;
|
|
3173
|
+
}
|
|
3174
|
+
remaining = remaining.slice(idx + part.length);
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
return true;
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Redirect with OAuth error
|
|
3181
|
+
*/
|
|
3182
|
+
redirectWithError(res, redirectUri, error, errorDescription, state) {
|
|
3183
|
+
const url = new URL(redirectUri);
|
|
3184
|
+
url.searchParams.set("error", error);
|
|
3185
|
+
url.searchParams.set("error_description", errorDescription);
|
|
3186
|
+
if (state) {
|
|
3187
|
+
url.searchParams.set("state", state);
|
|
3188
|
+
}
|
|
3189
|
+
res.writeHead(302, { Location: url.toString() });
|
|
3190
|
+
res.end();
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Generate a random authorization code
|
|
3194
|
+
*/
|
|
3195
|
+
async generateCodeAsync() {
|
|
3196
|
+
await loadCryptoUtils();
|
|
3197
|
+
return _base64urlEncode(_randomBytes(32));
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* Generate a random authorization code (sync wrapper for compatibility)
|
|
3201
|
+
*/
|
|
3202
|
+
generateCode() {
|
|
3203
|
+
const bytes = new Uint8Array(32);
|
|
3204
|
+
crypto.getRandomValues(bytes);
|
|
3205
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
3206
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
3207
|
+
}
|
|
3208
|
+
/**
|
|
3209
|
+
* Compute PKCE code challenge from verifier
|
|
3210
|
+
*/
|
|
3211
|
+
async computeCodeChallengeAsync(verifier, method) {
|
|
3212
|
+
if (method === "S256") {
|
|
3213
|
+
await loadCryptoUtils();
|
|
3214
|
+
return _sha256Base64url(verifier);
|
|
3215
|
+
}
|
|
3216
|
+
return verifier;
|
|
3217
|
+
}
|
|
3218
|
+
/**
|
|
3219
|
+
* Render a simple login page for manual testing
|
|
3220
|
+
*/
|
|
3221
|
+
renderLoginPage(clientId, redirectUri, scope, state, codeChallenge, codeChallengeMethod) {
|
|
3222
|
+
const issuer = this._info?.issuer ?? "http://localhost";
|
|
3223
|
+
return `<!DOCTYPE html>
|
|
3224
|
+
<html lang="en">
|
|
3225
|
+
<head>
|
|
3226
|
+
<meta charset="UTF-8">
|
|
3227
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3228
|
+
<title>Mock OAuth Login</title>
|
|
3229
|
+
<style>
|
|
3230
|
+
body {
|
|
3231
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
3232
|
+
background: #f5f5f5;
|
|
3233
|
+
min-height: 100vh;
|
|
3234
|
+
display: flex;
|
|
3235
|
+
align-items: center;
|
|
3236
|
+
justify-content: center;
|
|
3237
|
+
padding: 20px;
|
|
3238
|
+
}
|
|
3239
|
+
.container {
|
|
3240
|
+
background: white;
|
|
3241
|
+
padding: 40px;
|
|
3242
|
+
border-radius: 12px;
|
|
3243
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
3244
|
+
max-width: 400px;
|
|
3245
|
+
width: 100%;
|
|
3246
|
+
}
|
|
3247
|
+
h1 { margin-top: 0; color: #333; }
|
|
3248
|
+
.info { color: #666; font-size: 14px; margin-bottom: 20px; }
|
|
3249
|
+
.field { margin-bottom: 15px; }
|
|
3250
|
+
label { display: block; margin-bottom: 5px; font-weight: 500; }
|
|
3251
|
+
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
|
|
3252
|
+
button {
|
|
3253
|
+
width: 100%;
|
|
3254
|
+
padding: 12px;
|
|
3255
|
+
background: #667eea;
|
|
3256
|
+
color: white;
|
|
3257
|
+
border: none;
|
|
3258
|
+
border-radius: 6px;
|
|
3259
|
+
cursor: pointer;
|
|
3260
|
+
font-size: 16px;
|
|
3261
|
+
}
|
|
3262
|
+
button:hover { background: #5a6fd6; }
|
|
3263
|
+
.deny { background: #e53e3e; margin-top: 10px; }
|
|
3264
|
+
.deny:hover { background: #c53030; }
|
|
3265
|
+
</style>
|
|
3266
|
+
</head>
|
|
3267
|
+
<body>
|
|
3268
|
+
<div class="container">
|
|
3269
|
+
<h1>Mock OAuth Login</h1>
|
|
3270
|
+
<p class="info">
|
|
3271
|
+
<strong>Client:</strong> ${this.escapeHtml(clientId)}<br>
|
|
3272
|
+
<strong>Scopes:</strong> ${this.escapeHtml(scope)}<br>
|
|
3273
|
+
<strong>Issuer:</strong> ${this.escapeHtml(issuer)}
|
|
3274
|
+
</p>
|
|
3275
|
+
<form method="POST" action="/oauth/authorize/submit">
|
|
3276
|
+
<input type="hidden" name="client_id" value="${this.escapeHtml(clientId)}">
|
|
3277
|
+
<input type="hidden" name="redirect_uri" value="${this.escapeHtml(redirectUri)}">
|
|
3278
|
+
<input type="hidden" name="scope" value="${this.escapeHtml(scope)}">
|
|
3279
|
+
${state ? `<input type="hidden" name="state" value="${this.escapeHtml(state)}">` : ""}
|
|
3280
|
+
${codeChallenge ? `<input type="hidden" name="code_challenge" value="${this.escapeHtml(codeChallenge)}">` : ""}
|
|
3281
|
+
${codeChallengeMethod ? `<input type="hidden" name="code_challenge_method" value="${this.escapeHtml(codeChallengeMethod)}">` : ""}
|
|
3282
|
+
<div class="field">
|
|
3283
|
+
<label for="sub">User ID (sub)</label>
|
|
3284
|
+
<input type="text" id="sub" name="sub" value="test-user-123" required>
|
|
3285
|
+
</div>
|
|
3286
|
+
<div class="field">
|
|
3287
|
+
<label for="email">Email</label>
|
|
3288
|
+
<input type="email" id="email" name="email" value="test@example.com">
|
|
3289
|
+
</div>
|
|
3290
|
+
<div class="field">
|
|
3291
|
+
<label for="name">Name</label>
|
|
3292
|
+
<input type="text" id="name" name="name" value="Test User">
|
|
3293
|
+
</div>
|
|
3294
|
+
<button type="submit" name="action" value="approve">Approve</button>
|
|
3295
|
+
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
|
3296
|
+
</form>
|
|
3297
|
+
</div>
|
|
3298
|
+
</body>
|
|
3299
|
+
</html>`;
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* Escape HTML to prevent XSS
|
|
3303
|
+
*/
|
|
3304
|
+
escapeHtml(text) {
|
|
3305
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3306
|
+
}
|
|
3307
|
+
};
|
|
3308
|
+
|
|
3309
|
+
// libs/testing/src/auth/mock-api-server.ts
|
|
3310
|
+
import { createServer as createServer2 } from "http";
|
|
3311
|
+
var MockAPIServer = class {
|
|
3312
|
+
options;
|
|
3313
|
+
server = null;
|
|
3314
|
+
_info = null;
|
|
3315
|
+
routes;
|
|
3316
|
+
constructor(options) {
|
|
3317
|
+
this.options = options;
|
|
3318
|
+
this.routes = options.routes ?? [];
|
|
3319
|
+
for (const route of this.routes) {
|
|
3320
|
+
this.validateRoute(route);
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
/**
|
|
3324
|
+
* Start the mock API server
|
|
3325
|
+
*/
|
|
3326
|
+
async start() {
|
|
3327
|
+
if (this.server) {
|
|
3328
|
+
throw new Error("Mock API server is already running");
|
|
3329
|
+
}
|
|
3330
|
+
const port = this.options.port ?? 0;
|
|
3331
|
+
return new Promise((resolve, reject) => {
|
|
3332
|
+
const server = createServer2(this.handleRequest.bind(this));
|
|
3333
|
+
this.server = server;
|
|
3334
|
+
server.on("error", (err) => {
|
|
3335
|
+
this.log(`Server error: ${err.message}`);
|
|
3336
|
+
reject(err);
|
|
3337
|
+
});
|
|
3338
|
+
server.listen(port, () => {
|
|
3339
|
+
const address = server.address();
|
|
3340
|
+
if (!address || typeof address === "string") {
|
|
3341
|
+
reject(new Error("Failed to get server address"));
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
const actualPort = address.port;
|
|
3345
|
+
this._info = {
|
|
3346
|
+
baseUrl: `http://localhost:${actualPort}`,
|
|
3347
|
+
port: actualPort,
|
|
3348
|
+
specUrl: `http://localhost:${actualPort}/openapi.json`
|
|
3349
|
+
};
|
|
3350
|
+
this.log(`Mock API server started at ${this._info.baseUrl}`);
|
|
3351
|
+
resolve(this._info);
|
|
3352
|
+
});
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
3355
|
+
/**
|
|
3356
|
+
* Stop the mock API server
|
|
3357
|
+
*/
|
|
2367
3358
|
async stop() {
|
|
2368
3359
|
const server = this.server;
|
|
2369
3360
|
if (!server) {
|
|
@@ -2586,23 +3577,449 @@ var MockAPIServer = class {
|
|
|
2586
3577
|
}
|
|
2587
3578
|
};
|
|
2588
3579
|
|
|
2589
|
-
// libs/testing/src/
|
|
2590
|
-
import {
|
|
2591
|
-
var
|
|
2592
|
-
process = null;
|
|
3580
|
+
// libs/testing/src/auth/mock-cimd-server.ts
|
|
3581
|
+
import { createServer as createServer3 } from "http";
|
|
3582
|
+
var MockCimdServer = class {
|
|
2593
3583
|
options;
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
3584
|
+
server = null;
|
|
3585
|
+
_info = null;
|
|
3586
|
+
/** Map of path -> client */
|
|
3587
|
+
clients = /* @__PURE__ */ new Map();
|
|
3588
|
+
/** Map of path -> custom response (for error testing) */
|
|
3589
|
+
customResponses = /* @__PURE__ */ new Map();
|
|
3590
|
+
constructor(options = {}) {
|
|
3591
|
+
this.options = options;
|
|
3592
|
+
}
|
|
3593
|
+
/**
|
|
3594
|
+
* Start the mock CIMD server.
|
|
3595
|
+
*/
|
|
3596
|
+
async start() {
|
|
3597
|
+
if (this.server) {
|
|
3598
|
+
throw new Error("Mock CIMD server is already running");
|
|
3599
|
+
}
|
|
3600
|
+
const port = this.options.port ?? 0;
|
|
3601
|
+
return new Promise((resolve, reject) => {
|
|
3602
|
+
const server = createServer3(this.handleRequest.bind(this));
|
|
3603
|
+
this.server = server;
|
|
3604
|
+
server.on("error", (err) => {
|
|
3605
|
+
this.log(`Server error: ${err.message}`);
|
|
3606
|
+
reject(err);
|
|
3607
|
+
});
|
|
3608
|
+
server.listen(port, () => {
|
|
3609
|
+
const address = server.address();
|
|
3610
|
+
if (!address || typeof address === "string") {
|
|
3611
|
+
reject(new Error("Failed to get server address"));
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
const actualPort = address.port;
|
|
3615
|
+
this._info = {
|
|
3616
|
+
baseUrl: `http://localhost:${actualPort}`,
|
|
3617
|
+
port: actualPort
|
|
3618
|
+
};
|
|
3619
|
+
this.log(`Mock CIMD server started at ${this._info.baseUrl}`);
|
|
3620
|
+
resolve(this._info);
|
|
3621
|
+
});
|
|
3622
|
+
});
|
|
3623
|
+
}
|
|
3624
|
+
/**
|
|
3625
|
+
* Stop the mock CIMD server.
|
|
3626
|
+
*/
|
|
3627
|
+
async stop() {
|
|
3628
|
+
const server = this.server;
|
|
3629
|
+
if (!server) {
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
return new Promise((resolve, reject) => {
|
|
3633
|
+
server.close((err) => {
|
|
3634
|
+
if (err) {
|
|
3635
|
+
reject(err);
|
|
3636
|
+
} else {
|
|
3637
|
+
this.server = null;
|
|
3638
|
+
this._info = null;
|
|
3639
|
+
this.log("Mock CIMD server stopped");
|
|
3640
|
+
resolve();
|
|
3641
|
+
}
|
|
3642
|
+
});
|
|
3643
|
+
});
|
|
3644
|
+
}
|
|
3645
|
+
/**
|
|
3646
|
+
* Get server info.
|
|
3647
|
+
*/
|
|
3648
|
+
get info() {
|
|
3649
|
+
if (!this._info) {
|
|
3650
|
+
throw new Error("Mock CIMD server is not running");
|
|
3651
|
+
}
|
|
3652
|
+
return this._info;
|
|
3653
|
+
}
|
|
3654
|
+
/**
|
|
3655
|
+
* Register a client and return its client_id URL.
|
|
3656
|
+
*
|
|
3657
|
+
* @param options - Client configuration
|
|
3658
|
+
* @returns The client_id URL to use in OAuth flows
|
|
3659
|
+
*/
|
|
3660
|
+
registerClient(options) {
|
|
3661
|
+
if (!this._info) {
|
|
3662
|
+
throw new Error("Mock CIMD server is not running. Call start() first.");
|
|
3663
|
+
}
|
|
3664
|
+
const path = options.path ?? this.nameToPath(options.name);
|
|
3665
|
+
const fullPath = `/clients/${path}/metadata.json`;
|
|
3666
|
+
const clientId = `${this._info.baseUrl}${fullPath}`;
|
|
3667
|
+
const document = {
|
|
3668
|
+
client_id: clientId,
|
|
3669
|
+
client_name: options.name,
|
|
3670
|
+
redirect_uris: options.redirectUris ?? ["http://localhost:3000/callback"],
|
|
3671
|
+
token_endpoint_auth_method: options.tokenEndpointAuthMethod ?? "none",
|
|
3672
|
+
grant_types: options.grantTypes ?? ["authorization_code"],
|
|
3673
|
+
response_types: options.responseTypes ?? ["code"],
|
|
3674
|
+
...options.clientUri && { client_uri: options.clientUri },
|
|
3675
|
+
...options.logoUri && { logo_uri: options.logoUri },
|
|
3676
|
+
...options.scope && { scope: options.scope },
|
|
3677
|
+
...options.contacts && { contacts: options.contacts }
|
|
3678
|
+
};
|
|
3679
|
+
this.clients.set(fullPath, { path: fullPath, document });
|
|
3680
|
+
this.log(`Registered client: ${options.name} at ${fullPath}`);
|
|
3681
|
+
return clientId;
|
|
3682
|
+
}
|
|
3683
|
+
/**
|
|
3684
|
+
* Get the client_id URL for a previously registered client.
|
|
3685
|
+
*
|
|
3686
|
+
* @param name - The client name used during registration
|
|
3687
|
+
* @returns The client_id URL
|
|
3688
|
+
*/
|
|
3689
|
+
getClientId(name) {
|
|
3690
|
+
if (!this._info) {
|
|
3691
|
+
throw new Error("Mock CIMD server is not running");
|
|
3692
|
+
}
|
|
3693
|
+
const path = this.nameToPath(name);
|
|
3694
|
+
const fullPath = `/clients/${path}/metadata.json`;
|
|
3695
|
+
const client = this.clients.get(fullPath);
|
|
3696
|
+
if (!client) {
|
|
3697
|
+
throw new Error(`Client "${name}" not found. Register it first.`);
|
|
3698
|
+
}
|
|
3699
|
+
return client.document.client_id;
|
|
3700
|
+
}
|
|
3701
|
+
/**
|
|
3702
|
+
* Register an invalid document for error testing.
|
|
3703
|
+
*
|
|
3704
|
+
* @param path - The path to serve the invalid document at
|
|
3705
|
+
* @param document - The invalid document content
|
|
3706
|
+
*/
|
|
3707
|
+
registerInvalidDocument(path, document) {
|
|
3708
|
+
if (!this._info) {
|
|
3709
|
+
throw new Error("Mock CIMD server is not running");
|
|
3710
|
+
}
|
|
3711
|
+
const fullPath = path.startsWith("/") ? path : `/${path}`;
|
|
3712
|
+
this.clients.set(fullPath, {
|
|
3713
|
+
path: fullPath,
|
|
3714
|
+
document
|
|
3715
|
+
});
|
|
3716
|
+
this.log(`Registered invalid document at ${fullPath}`);
|
|
3717
|
+
}
|
|
3718
|
+
/**
|
|
3719
|
+
* Register a custom error response for a path.
|
|
3720
|
+
*
|
|
3721
|
+
* @param path - The path to return the error at
|
|
3722
|
+
* @param statusCode - HTTP status code to return
|
|
3723
|
+
* @param body - Optional response body
|
|
3724
|
+
*/
|
|
3725
|
+
registerFetchError(path, statusCode, body) {
|
|
3726
|
+
const fullPath = path.startsWith("/") ? path : `/${path}`;
|
|
3727
|
+
this.customResponses.set(fullPath, { status: statusCode, body });
|
|
3728
|
+
this.log(`Registered error response at ${fullPath}: ${statusCode}`);
|
|
3729
|
+
}
|
|
3730
|
+
/**
|
|
3731
|
+
* Remove a registered client.
|
|
3732
|
+
*
|
|
3733
|
+
* @param name - The client name to remove
|
|
3734
|
+
*/
|
|
3735
|
+
removeClient(name) {
|
|
3736
|
+
const path = this.nameToPath(name);
|
|
3737
|
+
const fullPath = `/clients/${path}/metadata.json`;
|
|
3738
|
+
this.clients.delete(fullPath);
|
|
3739
|
+
this.log(`Removed client: ${name}`);
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* Clear all registered clients and custom responses.
|
|
3743
|
+
*/
|
|
3744
|
+
clear() {
|
|
3745
|
+
this.clients.clear();
|
|
3746
|
+
this.customResponses.clear();
|
|
3747
|
+
this.log("Cleared all clients and custom responses");
|
|
3748
|
+
}
|
|
3749
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3750
|
+
// PRIVATE
|
|
3751
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3752
|
+
handleRequest(req, res) {
|
|
3753
|
+
const url = req.url ?? "/";
|
|
3754
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
3755
|
+
this.log(`${method} ${url}`);
|
|
3756
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
3757
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
3758
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
|
|
3759
|
+
if (method === "OPTIONS") {
|
|
3760
|
+
res.writeHead(204);
|
|
3761
|
+
res.end();
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
if (method !== "GET") {
|
|
3765
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
3766
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
3767
|
+
return;
|
|
3768
|
+
}
|
|
3769
|
+
const customResponse = this.customResponses.get(url);
|
|
3770
|
+
if (customResponse) {
|
|
3771
|
+
res.writeHead(customResponse.status, { "Content-Type": "application/json" });
|
|
3772
|
+
res.end(customResponse.body ? JSON.stringify(customResponse.body) : "");
|
|
3773
|
+
this.log(`Returned custom error response: ${customResponse.status}`);
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
const client = this.clients.get(url);
|
|
3777
|
+
if (client) {
|
|
3778
|
+
res.writeHead(200, {
|
|
3779
|
+
"Content-Type": "application/json",
|
|
3780
|
+
"Cache-Control": "max-age=3600"
|
|
3781
|
+
});
|
|
3782
|
+
res.end(JSON.stringify(client.document));
|
|
3783
|
+
this.log(`Served client metadata: ${client.document.client_name}`);
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
3787
|
+
res.end(JSON.stringify({ error: "not_found", message: `No client at ${url}` }));
|
|
3788
|
+
this.log(`Client not found: ${url}`);
|
|
3789
|
+
}
|
|
3790
|
+
nameToPath(name) {
|
|
3791
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3792
|
+
}
|
|
3793
|
+
log(message) {
|
|
3794
|
+
if (this.options.debug) {
|
|
3795
|
+
console.log(`[MockCimdServer] ${message}`);
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
};
|
|
3799
|
+
|
|
3800
|
+
// libs/testing/src/server/test-server.ts
|
|
3801
|
+
import { spawn } from "child_process";
|
|
3802
|
+
|
|
3803
|
+
// libs/testing/src/errors/index.ts
|
|
3804
|
+
var TestClientError = class extends Error {
|
|
3805
|
+
constructor(message) {
|
|
3806
|
+
super(message);
|
|
3807
|
+
this.name = "TestClientError";
|
|
3808
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3809
|
+
}
|
|
3810
|
+
};
|
|
3811
|
+
var ConnectionError = class extends TestClientError {
|
|
3812
|
+
constructor(message, cause) {
|
|
3813
|
+
super(message);
|
|
3814
|
+
this.cause = cause;
|
|
3815
|
+
this.name = "ConnectionError";
|
|
3816
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3817
|
+
}
|
|
3818
|
+
};
|
|
3819
|
+
var TimeoutError = class extends TestClientError {
|
|
3820
|
+
constructor(message, timeoutMs) {
|
|
3821
|
+
super(message);
|
|
3822
|
+
this.timeoutMs = timeoutMs;
|
|
3823
|
+
this.name = "TimeoutError";
|
|
3824
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
var McpProtocolError = class extends TestClientError {
|
|
3828
|
+
constructor(message, code, data) {
|
|
3829
|
+
super(message);
|
|
3830
|
+
this.code = code;
|
|
3831
|
+
this.data = data;
|
|
3832
|
+
this.name = "McpProtocolError";
|
|
3833
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3834
|
+
}
|
|
3835
|
+
};
|
|
3836
|
+
var ServerStartError = class extends TestClientError {
|
|
3837
|
+
constructor(message, cause) {
|
|
3838
|
+
super(message);
|
|
3839
|
+
this.cause = cause;
|
|
3840
|
+
this.name = "ServerStartError";
|
|
3841
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3842
|
+
}
|
|
3843
|
+
};
|
|
3844
|
+
var AssertionError = class extends TestClientError {
|
|
3845
|
+
constructor(message) {
|
|
3846
|
+
super(message);
|
|
3847
|
+
this.name = "AssertionError";
|
|
3848
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3849
|
+
}
|
|
3850
|
+
};
|
|
3851
|
+
|
|
3852
|
+
// libs/testing/src/server/port-registry.ts
|
|
3853
|
+
import { createServer as createServer4 } from "net";
|
|
3854
|
+
var E2E_PORT_RANGES = {
|
|
3855
|
+
// Core E2E tests (50000-50099)
|
|
3856
|
+
"demo-e2e-public": { start: 5e4, size: 10 },
|
|
3857
|
+
"demo-e2e-cache": { start: 50010, size: 10 },
|
|
3858
|
+
"demo-e2e-config": { start: 50020, size: 10 },
|
|
3859
|
+
"demo-e2e-direct": { start: 50030, size: 10 },
|
|
3860
|
+
"demo-e2e-errors": { start: 50040, size: 10 },
|
|
3861
|
+
"demo-e2e-hooks": { start: 50050, size: 10 },
|
|
3862
|
+
"demo-e2e-multiapp": { start: 50060, size: 10 },
|
|
3863
|
+
"demo-e2e-notifications": { start: 50070, size: 10 },
|
|
3864
|
+
"demo-e2e-providers": { start: 50080, size: 10 },
|
|
3865
|
+
"demo-e2e-standalone": { start: 50090, size: 10 },
|
|
3866
|
+
// Auth E2E tests (50100-50199)
|
|
3867
|
+
"demo-e2e-orchestrated": { start: 50100, size: 10 },
|
|
3868
|
+
"demo-e2e-transparent": { start: 50110, size: 10 },
|
|
3869
|
+
"demo-e2e-cimd": { start: 50120, size: 10 },
|
|
3870
|
+
// Feature E2E tests (50200-50299)
|
|
3871
|
+
"demo-e2e-skills": { start: 50200, size: 10 },
|
|
3872
|
+
"demo-e2e-remote": { start: 50210, size: 10 },
|
|
3873
|
+
"demo-e2e-openapi": { start: 50220, size: 10 },
|
|
3874
|
+
"demo-e2e-ui": { start: 50230, size: 10 },
|
|
3875
|
+
"demo-e2e-codecall": { start: 50240, size: 10 },
|
|
3876
|
+
"demo-e2e-remember": { start: 50250, size: 10 },
|
|
3877
|
+
"demo-e2e-elicitation": { start: 50260, size: 10 },
|
|
3878
|
+
"demo-e2e-agents": { start: 50270, size: 10 },
|
|
3879
|
+
"demo-e2e-transport-recreation": { start: 50280, size: 10 },
|
|
3880
|
+
// Infrastructure E2E tests (50300-50399)
|
|
3881
|
+
"demo-e2e-redis": { start: 50300, size: 10 },
|
|
3882
|
+
"demo-e2e-serverless": { start: 50310, size: 10 },
|
|
3883
|
+
// Mock servers and utilities (50900-50999)
|
|
3884
|
+
"mock-oauth": { start: 50900, size: 10 },
|
|
3885
|
+
"mock-api": { start: 50910, size: 10 },
|
|
3886
|
+
"mock-cimd": { start: 50920, size: 10 },
|
|
3887
|
+
// Dynamic/unknown projects (51000+)
|
|
3888
|
+
default: { start: 51e3, size: 100 }
|
|
3889
|
+
};
|
|
3890
|
+
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3891
|
+
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
3892
|
+
function getPortRange(project) {
|
|
3893
|
+
const key = project;
|
|
3894
|
+
if (key in E2E_PORT_RANGES) {
|
|
3895
|
+
return E2E_PORT_RANGES[key];
|
|
3896
|
+
}
|
|
3897
|
+
return E2E_PORT_RANGES.default;
|
|
3898
|
+
}
|
|
3899
|
+
async function reservePort(project, preferredPort) {
|
|
3900
|
+
const range = getPortRange(project);
|
|
3901
|
+
if (preferredPort !== void 0) {
|
|
3902
|
+
const reservation = await tryReservePort(preferredPort, project);
|
|
3903
|
+
if (reservation) {
|
|
3904
|
+
return {
|
|
3905
|
+
port: preferredPort,
|
|
3906
|
+
release: async () => {
|
|
3907
|
+
await releasePort(preferredPort);
|
|
3908
|
+
}
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
3912
|
+
}
|
|
3913
|
+
let index = projectPortIndex.get(project) ?? 0;
|
|
3914
|
+
const maxAttempts = range.size;
|
|
3915
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3916
|
+
const port = range.start + index % range.size;
|
|
3917
|
+
index = (index + 1) % range.size;
|
|
3918
|
+
if (reservedPorts.has(port)) {
|
|
3919
|
+
continue;
|
|
3920
|
+
}
|
|
3921
|
+
const reservation = await tryReservePort(port, project);
|
|
3922
|
+
if (reservation) {
|
|
3923
|
+
projectPortIndex.set(project, index);
|
|
3924
|
+
return {
|
|
3925
|
+
port,
|
|
3926
|
+
release: async () => {
|
|
3927
|
+
await releasePort(port);
|
|
3928
|
+
}
|
|
3929
|
+
};
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
const dynamicPort = await findAvailablePortInRange(51e3, 52e3);
|
|
3933
|
+
if (dynamicPort) {
|
|
3934
|
+
const reservation = await tryReservePort(dynamicPort, project);
|
|
3935
|
+
if (reservation) {
|
|
3936
|
+
return {
|
|
3937
|
+
port: dynamicPort,
|
|
3938
|
+
release: async () => {
|
|
3939
|
+
await releasePort(dynamicPort);
|
|
3940
|
+
}
|
|
3941
|
+
};
|
|
3942
|
+
}
|
|
3943
|
+
}
|
|
3944
|
+
throw new Error(
|
|
3945
|
+
`[PortRegistry] Could not reserve a port for ${project}. Range: ${range.start}-${range.start + range.size - 1}. Currently reserved: ${Array.from(reservedPorts.keys()).join(", ")}`
|
|
3946
|
+
);
|
|
3947
|
+
}
|
|
3948
|
+
async function tryReservePort(port, project) {
|
|
3949
|
+
return new Promise((resolve) => {
|
|
3950
|
+
const server = createServer4();
|
|
3951
|
+
server.once("error", () => {
|
|
3952
|
+
resolve(false);
|
|
3953
|
+
});
|
|
3954
|
+
server.listen(port, "::", () => {
|
|
3955
|
+
reservedPorts.set(port, {
|
|
3956
|
+
port,
|
|
3957
|
+
project,
|
|
3958
|
+
holder: server,
|
|
3959
|
+
reservedAt: Date.now()
|
|
3960
|
+
});
|
|
3961
|
+
resolve(true);
|
|
3962
|
+
});
|
|
3963
|
+
});
|
|
3964
|
+
}
|
|
3965
|
+
async function releasePort(port) {
|
|
3966
|
+
const reservation = reservedPorts.get(port);
|
|
3967
|
+
if (!reservation) {
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
return new Promise((resolve) => {
|
|
3971
|
+
reservation.holder.close(() => {
|
|
3972
|
+
reservedPorts.delete(port);
|
|
3973
|
+
resolve();
|
|
3974
|
+
});
|
|
3975
|
+
});
|
|
3976
|
+
}
|
|
3977
|
+
async function findAvailablePortInRange(start, end) {
|
|
3978
|
+
for (let port = start; port < end; port++) {
|
|
3979
|
+
if (reservedPorts.has(port)) {
|
|
3980
|
+
continue;
|
|
3981
|
+
}
|
|
3982
|
+
const available = await isPortAvailable(port);
|
|
3983
|
+
if (available) {
|
|
3984
|
+
return port;
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
return null;
|
|
3988
|
+
}
|
|
3989
|
+
async function isPortAvailable(port) {
|
|
3990
|
+
return new Promise((resolve) => {
|
|
3991
|
+
const server = createServer4();
|
|
3992
|
+
server.once("error", () => {
|
|
3993
|
+
resolve(false);
|
|
3994
|
+
});
|
|
3995
|
+
server.listen(port, "::", () => {
|
|
3996
|
+
server.close(() => {
|
|
3997
|
+
resolve(true);
|
|
3998
|
+
});
|
|
3999
|
+
});
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
|
|
4003
|
+
// libs/testing/src/server/test-server.ts
|
|
4004
|
+
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
4005
|
+
var TestServer = class _TestServer {
|
|
4006
|
+
process = null;
|
|
4007
|
+
options;
|
|
4008
|
+
_info;
|
|
4009
|
+
logs = [];
|
|
4010
|
+
portRelease = null;
|
|
4011
|
+
constructor(options, port, portRelease) {
|
|
4012
|
+
this.options = {
|
|
4013
|
+
port,
|
|
4014
|
+
project: options.project,
|
|
4015
|
+
command: options.command ?? "",
|
|
4016
|
+
cwd: options.cwd ?? process.cwd(),
|
|
4017
|
+
env: options.env ?? {},
|
|
2602
4018
|
startupTimeout: options.startupTimeout ?? 3e4,
|
|
2603
4019
|
healthCheckPath: options.healthCheckPath ?? "/health",
|
|
2604
|
-
debug: options.debug ??
|
|
4020
|
+
debug: options.debug ?? DEBUG_SERVER
|
|
2605
4021
|
};
|
|
4022
|
+
this.portRelease = portRelease ?? null;
|
|
2606
4023
|
this._info = {
|
|
2607
4024
|
baseUrl: `http://localhost:${port}`,
|
|
2608
4025
|
port
|
|
@@ -2612,7 +4029,9 @@ var TestServer = class _TestServer {
|
|
|
2612
4029
|
* Start a test server with custom command
|
|
2613
4030
|
*/
|
|
2614
4031
|
static async start(options) {
|
|
2615
|
-
const
|
|
4032
|
+
const project = options.project ?? "default";
|
|
4033
|
+
const { port, release } = await reservePort(project, options.port);
|
|
4034
|
+
await release();
|
|
2616
4035
|
const server = new _TestServer(options, port);
|
|
2617
4036
|
try {
|
|
2618
4037
|
await server.startProcess();
|
|
@@ -2631,10 +4050,12 @@ var TestServer = class _TestServer {
|
|
|
2631
4050
|
`Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`
|
|
2632
4051
|
);
|
|
2633
4052
|
}
|
|
2634
|
-
const port
|
|
4053
|
+
const { port, release } = await reservePort(project, options.port);
|
|
4054
|
+
await release();
|
|
2635
4055
|
const serverOptions = {
|
|
2636
4056
|
...options,
|
|
2637
4057
|
port,
|
|
4058
|
+
project,
|
|
2638
4059
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2639
4060
|
cwd: options.cwd ?? process.cwd()
|
|
2640
4061
|
};
|
|
@@ -2792,22 +4213,54 @@ var TestServer = class _TestServer {
|
|
|
2792
4213
|
exitCode = code;
|
|
2793
4214
|
this.log(`Server process exited with code ${code}`);
|
|
2794
4215
|
});
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
4216
|
+
try {
|
|
4217
|
+
await this.waitForReadyWithExitDetection(() => {
|
|
4218
|
+
if (exitError) {
|
|
4219
|
+
return { exited: true, error: exitError };
|
|
4220
|
+
}
|
|
4221
|
+
if (processExited) {
|
|
4222
|
+
const allLogs = this.logs.join("\n");
|
|
4223
|
+
const errorLogs = this.logs.filter((l) => l.includes("[ERROR]") || l.toLowerCase().includes("error")).join("\n");
|
|
4224
|
+
return {
|
|
4225
|
+
exited: true,
|
|
4226
|
+
error: new ServerStartError(
|
|
4227
|
+
`Server process exited unexpectedly with code ${exitCode}.
|
|
2804
4228
|
|
|
2805
|
-
|
|
2806
|
-
${
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
4229
|
+
Command: ${this.options.command}
|
|
4230
|
+
CWD: ${this.options.cwd}
|
|
4231
|
+
Port: ${this.options.port}
|
|
4232
|
+
|
|
4233
|
+
=== Error Logs ===
|
|
4234
|
+
${errorLogs || "No error logs captured"}
|
|
4235
|
+
|
|
4236
|
+
=== Full Logs ===
|
|
4237
|
+
${allLogs || "No logs captured"}`
|
|
4238
|
+
)
|
|
4239
|
+
};
|
|
4240
|
+
}
|
|
4241
|
+
return { exited: false };
|
|
4242
|
+
});
|
|
4243
|
+
} catch (error) {
|
|
4244
|
+
this.printLogsOnFailure("Server startup failed");
|
|
4245
|
+
throw error;
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
/**
|
|
4249
|
+
* Print server logs on failure for debugging
|
|
4250
|
+
*/
|
|
4251
|
+
printLogsOnFailure(context) {
|
|
4252
|
+
const allLogs = this.logs.join("\n");
|
|
4253
|
+
if (allLogs) {
|
|
4254
|
+
console.error(`
|
|
4255
|
+
[TestServer] ${context}`);
|
|
4256
|
+
console.error(`[TestServer] Command: ${this.options.command}`);
|
|
4257
|
+
console.error(`[TestServer] Port: ${this.options.port}`);
|
|
4258
|
+
console.error(`[TestServer] CWD: ${this.options.cwd}`);
|
|
4259
|
+
console.error(`[TestServer] === Server Logs ===
|
|
4260
|
+
${allLogs}`);
|
|
4261
|
+
console.error(`[TestServer] === End Logs ===
|
|
4262
|
+
`);
|
|
4263
|
+
}
|
|
2811
4264
|
}
|
|
2812
4265
|
/**
|
|
2813
4266
|
* Wait for server to be ready, but also detect early process exit
|
|
@@ -2816,29 +4269,57 @@ ${recentLogs}`)
|
|
|
2816
4269
|
const timeoutMs = this.options.startupTimeout;
|
|
2817
4270
|
const deadline = Date.now() + timeoutMs;
|
|
2818
4271
|
const checkInterval = 100;
|
|
4272
|
+
let lastHealthCheckError = null;
|
|
4273
|
+
let healthCheckAttempts = 0;
|
|
4274
|
+
this.log(`Waiting for server to be ready (timeout: ${timeoutMs}ms)...`);
|
|
2819
4275
|
while (Date.now() < deadline) {
|
|
2820
4276
|
const exitStatus = checkExit();
|
|
2821
4277
|
if (exitStatus.exited) {
|
|
2822
|
-
throw exitStatus.error ?? new
|
|
4278
|
+
throw exitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
2823
4279
|
}
|
|
4280
|
+
healthCheckAttempts++;
|
|
2824
4281
|
try {
|
|
2825
|
-
const
|
|
4282
|
+
const healthUrl = `${this._info.baseUrl}${this.options.healthCheckPath}`;
|
|
4283
|
+
const response = await fetch(healthUrl, {
|
|
2826
4284
|
method: "GET",
|
|
2827
4285
|
signal: AbortSignal.timeout(1e3)
|
|
2828
4286
|
});
|
|
2829
4287
|
if (response.ok || response.status === 404) {
|
|
2830
|
-
this.log(
|
|
4288
|
+
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
2831
4289
|
return;
|
|
2832
4290
|
}
|
|
2833
|
-
|
|
4291
|
+
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
4292
|
+
} catch (err) {
|
|
4293
|
+
lastHealthCheckError = err instanceof Error ? err.message : String(err);
|
|
4294
|
+
}
|
|
4295
|
+
const elapsed = Date.now() - (deadline - timeoutMs);
|
|
4296
|
+
if (elapsed > 0 && elapsed % 5e3 < checkInterval) {
|
|
4297
|
+
this.log(
|
|
4298
|
+
`Still waiting for server... (${Math.round(elapsed / 1e3)}s elapsed, last error: ${lastHealthCheckError})`
|
|
4299
|
+
);
|
|
2834
4300
|
}
|
|
2835
4301
|
await sleep2(checkInterval);
|
|
2836
4302
|
}
|
|
2837
4303
|
const finalExitStatus = checkExit();
|
|
2838
4304
|
if (finalExitStatus.exited) {
|
|
2839
|
-
throw finalExitStatus.error ?? new
|
|
4305
|
+
throw finalExitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
2840
4306
|
}
|
|
2841
|
-
|
|
4307
|
+
const allLogs = this.logs.join("\n");
|
|
4308
|
+
throw new ServerStartError(
|
|
4309
|
+
`Server did not become ready within ${timeoutMs}ms.
|
|
4310
|
+
|
|
4311
|
+
Command: ${this.options.command}
|
|
4312
|
+
CWD: ${this.options.cwd}
|
|
4313
|
+
Port: ${this.options.port}
|
|
4314
|
+
Health check URL: ${this._info.baseUrl}${this.options.healthCheckPath}
|
|
4315
|
+
Health check attempts: ${healthCheckAttempts}
|
|
4316
|
+
Last health check error: ${lastHealthCheckError ?? "none"}
|
|
4317
|
+
|
|
4318
|
+
=== Server Logs ===
|
|
4319
|
+
${allLogs || "No logs captured"}
|
|
4320
|
+
|
|
4321
|
+
TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
4322
|
+
);
|
|
2842
4323
|
}
|
|
2843
4324
|
log(message) {
|
|
2844
4325
|
if (this.options.debug) {
|
|
@@ -2846,22 +4327,6 @@ ${recentLogs}`)
|
|
|
2846
4327
|
}
|
|
2847
4328
|
}
|
|
2848
4329
|
};
|
|
2849
|
-
async function findAvailablePort() {
|
|
2850
|
-
const { createServer: createServer3 } = await import("net");
|
|
2851
|
-
return new Promise((resolve, reject) => {
|
|
2852
|
-
const server = createServer3();
|
|
2853
|
-
server.listen(0, () => {
|
|
2854
|
-
const address = server.address();
|
|
2855
|
-
if (address && typeof address !== "string") {
|
|
2856
|
-
const port = address.port;
|
|
2857
|
-
server.close(() => resolve(port));
|
|
2858
|
-
} else {
|
|
2859
|
-
reject(new Error("Could not get port"));
|
|
2860
|
-
}
|
|
2861
|
-
});
|
|
2862
|
-
server.on("error", reject);
|
|
2863
|
-
});
|
|
2864
|
-
}
|
|
2865
4330
|
function sleep2(ms) {
|
|
2866
4331
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2867
4332
|
}
|
|
@@ -3031,55 +4496,6 @@ function hasMimeType(result, mimeType) {
|
|
|
3031
4496
|
return result.hasMimeType(mimeType);
|
|
3032
4497
|
}
|
|
3033
4498
|
|
|
3034
|
-
// libs/testing/src/errors/index.ts
|
|
3035
|
-
var TestClientError = class extends Error {
|
|
3036
|
-
constructor(message) {
|
|
3037
|
-
super(message);
|
|
3038
|
-
this.name = "TestClientError";
|
|
3039
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3040
|
-
}
|
|
3041
|
-
};
|
|
3042
|
-
var ConnectionError = class extends TestClientError {
|
|
3043
|
-
constructor(message, cause) {
|
|
3044
|
-
super(message);
|
|
3045
|
-
this.cause = cause;
|
|
3046
|
-
this.name = "ConnectionError";
|
|
3047
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3048
|
-
}
|
|
3049
|
-
};
|
|
3050
|
-
var TimeoutError = class extends TestClientError {
|
|
3051
|
-
constructor(message, timeoutMs) {
|
|
3052
|
-
super(message);
|
|
3053
|
-
this.timeoutMs = timeoutMs;
|
|
3054
|
-
this.name = "TimeoutError";
|
|
3055
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3056
|
-
}
|
|
3057
|
-
};
|
|
3058
|
-
var McpProtocolError = class extends TestClientError {
|
|
3059
|
-
constructor(message, code, data) {
|
|
3060
|
-
super(message);
|
|
3061
|
-
this.code = code;
|
|
3062
|
-
this.data = data;
|
|
3063
|
-
this.name = "McpProtocolError";
|
|
3064
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3065
|
-
}
|
|
3066
|
-
};
|
|
3067
|
-
var ServerStartError = class extends TestClientError {
|
|
3068
|
-
constructor(message, cause) {
|
|
3069
|
-
super(message);
|
|
3070
|
-
this.cause = cause;
|
|
3071
|
-
this.name = "ServerStartError";
|
|
3072
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3073
|
-
}
|
|
3074
|
-
};
|
|
3075
|
-
var AssertionError = class extends TestClientError {
|
|
3076
|
-
constructor(message) {
|
|
3077
|
-
super(message);
|
|
3078
|
-
this.name = "AssertionError";
|
|
3079
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3080
|
-
}
|
|
3081
|
-
};
|
|
3082
|
-
|
|
3083
4499
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3084
4500
|
var currentConfig = {};
|
|
3085
4501
|
var serverInstance = null;
|
|
@@ -3094,14 +4510,36 @@ async function initializeSharedResources() {
|
|
|
3094
4510
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
3095
4511
|
serverStartedByUs = false;
|
|
3096
4512
|
} else if (currentConfig.server) {
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
4513
|
+
const serverCommand = resolveServerCommand(currentConfig.server);
|
|
4514
|
+
const isDebug = currentConfig.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
4515
|
+
if (isDebug) {
|
|
4516
|
+
console.log(`[TestFixture] Starting server: ${serverCommand}`);
|
|
4517
|
+
}
|
|
4518
|
+
try {
|
|
4519
|
+
serverInstance = await TestServer.start({
|
|
4520
|
+
project: currentConfig.project,
|
|
4521
|
+
port: currentConfig.port,
|
|
4522
|
+
command: serverCommand,
|
|
4523
|
+
env: currentConfig.env,
|
|
4524
|
+
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
4525
|
+
debug: isDebug
|
|
4526
|
+
});
|
|
4527
|
+
serverStartedByUs = true;
|
|
4528
|
+
if (isDebug) {
|
|
4529
|
+
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
4530
|
+
}
|
|
4531
|
+
} catch (error) {
|
|
4532
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
4533
|
+
throw new Error(
|
|
4534
|
+
`Failed to start test server.
|
|
4535
|
+
|
|
4536
|
+
Server entry: ${currentConfig.server}
|
|
4537
|
+
Project: ${currentConfig.project ?? "default"}
|
|
4538
|
+
Command: ${serverCommand}
|
|
4539
|
+
|
|
4540
|
+
Error: ${errMsg}`
|
|
4541
|
+
);
|
|
4542
|
+
}
|
|
3105
4543
|
} else {
|
|
3106
4544
|
throw new Error(
|
|
3107
4545
|
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
@@ -3111,6 +4549,12 @@ async function initializeSharedResources() {
|
|
|
3111
4549
|
}
|
|
3112
4550
|
async function createTestFixtures() {
|
|
3113
4551
|
await initializeSharedResources();
|
|
4552
|
+
if (!serverInstance) {
|
|
4553
|
+
throw new Error("Server instance not initialized");
|
|
4554
|
+
}
|
|
4555
|
+
if (!tokenFactory) {
|
|
4556
|
+
throw new Error("Token factory not initialized");
|
|
4557
|
+
}
|
|
3114
4558
|
const clientInstance = await McpTestClient.create({
|
|
3115
4559
|
baseUrl: serverInstance.info.baseUrl,
|
|
3116
4560
|
transport: currentConfig.transport ?? "streamable-http",
|
|
@@ -3124,7 +4568,19 @@ async function createTestFixtures() {
|
|
|
3124
4568
|
server
|
|
3125
4569
|
};
|
|
3126
4570
|
}
|
|
3127
|
-
async function cleanupTestFixtures(fixtures,
|
|
4571
|
+
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
4572
|
+
if (testFailed && serverInstance) {
|
|
4573
|
+
const logs = serverInstance.getLogs();
|
|
4574
|
+
if (logs.length > 0) {
|
|
4575
|
+
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
4576
|
+
const recentLogs = logs.slice(-50);
|
|
4577
|
+
if (logs.length > 50) {
|
|
4578
|
+
console.error(`[TestFixture] (showing last 50 of ${logs.length} log entries)`);
|
|
4579
|
+
}
|
|
4580
|
+
console.error(recentLogs.join("\n"));
|
|
4581
|
+
console.error("[TestFixture] === End Server Logs ===\n");
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
3128
4584
|
if (fixtures.mcp.isConnected()) {
|
|
3129
4585
|
await fixtures.mcp.disconnect();
|
|
3130
4586
|
}
|
|
@@ -4799,10 +6255,1555 @@ var EXPECTED_GENERIC_TOOLS_LIST_META_KEYS = ["ui/resourceUri", "ui/mimeType", "u
|
|
|
4799
6255
|
var EXPECTED_GENERIC_TOOL_CALL_META_KEYS = ["ui/html", "ui/mimeType", "ui/type"];
|
|
4800
6256
|
var EXPECTED_FRONTMCP_TOOLS_LIST_META_KEYS = EXPECTED_GENERIC_TOOLS_LIST_META_KEYS;
|
|
4801
6257
|
var EXPECTED_FRONTMCP_TOOL_CALL_META_KEYS = EXPECTED_GENERIC_TOOL_CALL_META_KEYS;
|
|
6258
|
+
|
|
6259
|
+
// libs/testing/src/perf/metrics-collector.ts
|
|
6260
|
+
function isGcAvailable() {
|
|
6261
|
+
return typeof global.gc === "function";
|
|
6262
|
+
}
|
|
6263
|
+
function forceGc() {
|
|
6264
|
+
if (typeof global.gc === "function") {
|
|
6265
|
+
global.gc();
|
|
6266
|
+
}
|
|
6267
|
+
}
|
|
6268
|
+
async function forceFullGc(cycles = 3, delayMs = 10) {
|
|
6269
|
+
if (!isGcAvailable()) {
|
|
6270
|
+
return;
|
|
6271
|
+
}
|
|
6272
|
+
for (let i = 0; i < cycles; i++) {
|
|
6273
|
+
forceGc();
|
|
6274
|
+
if (i < cycles - 1 && delayMs > 0) {
|
|
6275
|
+
await sleep4(delayMs);
|
|
6276
|
+
}
|
|
6277
|
+
}
|
|
6278
|
+
}
|
|
6279
|
+
var MetricsCollector = class {
|
|
6280
|
+
cpuStartUsage = null;
|
|
6281
|
+
baseline = null;
|
|
6282
|
+
measurements = [];
|
|
6283
|
+
/**
|
|
6284
|
+
* Capture the baseline snapshot.
|
|
6285
|
+
* Forces GC to get a clean memory state.
|
|
6286
|
+
*/
|
|
6287
|
+
async captureBaseline(forceGcCycles = 3) {
|
|
6288
|
+
await forceFullGc(forceGcCycles);
|
|
6289
|
+
this.cpuStartUsage = process.cpuUsage();
|
|
6290
|
+
const snapshot = this.captureSnapshot("baseline");
|
|
6291
|
+
this.baseline = snapshot;
|
|
6292
|
+
return snapshot;
|
|
6293
|
+
}
|
|
6294
|
+
/**
|
|
6295
|
+
* Capture a snapshot of current memory and CPU state.
|
|
6296
|
+
*/
|
|
6297
|
+
captureSnapshot(label) {
|
|
6298
|
+
const memUsage = process.memoryUsage();
|
|
6299
|
+
const cpuUsage = this.cpuStartUsage ? process.cpuUsage(this.cpuStartUsage) : process.cpuUsage();
|
|
6300
|
+
const snapshot = {
|
|
6301
|
+
memory: {
|
|
6302
|
+
heapUsed: memUsage.heapUsed,
|
|
6303
|
+
heapTotal: memUsage.heapTotal,
|
|
6304
|
+
external: memUsage.external,
|
|
6305
|
+
rss: memUsage.rss,
|
|
6306
|
+
arrayBuffers: memUsage.arrayBuffers
|
|
6307
|
+
},
|
|
6308
|
+
cpu: {
|
|
6309
|
+
user: cpuUsage.user,
|
|
6310
|
+
system: cpuUsage.system,
|
|
6311
|
+
total: cpuUsage.user + cpuUsage.system
|
|
6312
|
+
},
|
|
6313
|
+
timestamp: Date.now(),
|
|
6314
|
+
label
|
|
6315
|
+
};
|
|
6316
|
+
if (label !== "baseline") {
|
|
6317
|
+
this.measurements.push(snapshot);
|
|
6318
|
+
}
|
|
6319
|
+
return snapshot;
|
|
6320
|
+
}
|
|
6321
|
+
/**
|
|
6322
|
+
* Start CPU tracking from this point.
|
|
6323
|
+
*/
|
|
6324
|
+
startCpuTracking() {
|
|
6325
|
+
this.cpuStartUsage = process.cpuUsage();
|
|
6326
|
+
}
|
|
6327
|
+
/**
|
|
6328
|
+
* Get CPU usage since tracking started.
|
|
6329
|
+
*/
|
|
6330
|
+
getCpuUsage() {
|
|
6331
|
+
const usage = this.cpuStartUsage ? process.cpuUsage(this.cpuStartUsage) : process.cpuUsage();
|
|
6332
|
+
return {
|
|
6333
|
+
user: usage.user,
|
|
6334
|
+
system: usage.system,
|
|
6335
|
+
total: usage.user + usage.system
|
|
6336
|
+
};
|
|
6337
|
+
}
|
|
6338
|
+
/**
|
|
6339
|
+
* Get current memory metrics.
|
|
6340
|
+
*/
|
|
6341
|
+
getMemoryMetrics() {
|
|
6342
|
+
const memUsage = process.memoryUsage();
|
|
6343
|
+
return {
|
|
6344
|
+
heapUsed: memUsage.heapUsed,
|
|
6345
|
+
heapTotal: memUsage.heapTotal,
|
|
6346
|
+
external: memUsage.external,
|
|
6347
|
+
rss: memUsage.rss,
|
|
6348
|
+
arrayBuffers: memUsage.arrayBuffers
|
|
6349
|
+
};
|
|
6350
|
+
}
|
|
6351
|
+
/**
|
|
6352
|
+
* Get the baseline snapshot if captured.
|
|
6353
|
+
*/
|
|
6354
|
+
getBaseline() {
|
|
6355
|
+
return this.baseline;
|
|
6356
|
+
}
|
|
6357
|
+
/**
|
|
6358
|
+
* Get all measurement snapshots.
|
|
6359
|
+
*/
|
|
6360
|
+
getMeasurements() {
|
|
6361
|
+
return [...this.measurements];
|
|
6362
|
+
}
|
|
6363
|
+
/**
|
|
6364
|
+
* Calculate memory delta from baseline.
|
|
6365
|
+
*/
|
|
6366
|
+
calculateMemoryDelta(current) {
|
|
6367
|
+
if (!this.baseline) {
|
|
6368
|
+
return null;
|
|
6369
|
+
}
|
|
6370
|
+
return {
|
|
6371
|
+
heapUsed: current.heapUsed - this.baseline.memory.heapUsed,
|
|
6372
|
+
heapTotal: current.heapTotal - this.baseline.memory.heapTotal,
|
|
6373
|
+
rss: current.rss - this.baseline.memory.rss
|
|
6374
|
+
};
|
|
6375
|
+
}
|
|
6376
|
+
/**
|
|
6377
|
+
* Reset the collector state.
|
|
6378
|
+
*/
|
|
6379
|
+
reset() {
|
|
6380
|
+
this.cpuStartUsage = null;
|
|
6381
|
+
this.baseline = null;
|
|
6382
|
+
this.measurements = [];
|
|
6383
|
+
}
|
|
6384
|
+
};
|
|
6385
|
+
function formatBytes(bytes) {
|
|
6386
|
+
const absBytes = Math.abs(bytes);
|
|
6387
|
+
const sign = bytes < 0 ? "-" : "";
|
|
6388
|
+
if (absBytes < 1024) {
|
|
6389
|
+
return `${sign}${absBytes} B`;
|
|
6390
|
+
}
|
|
6391
|
+
if (absBytes < 1024 * 1024) {
|
|
6392
|
+
return `${sign}${(absBytes / 1024).toFixed(2)} KB`;
|
|
6393
|
+
}
|
|
6394
|
+
if (absBytes < 1024 * 1024 * 1024) {
|
|
6395
|
+
return `${sign}${(absBytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
6396
|
+
}
|
|
6397
|
+
return `${sign}${(absBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
6398
|
+
}
|
|
6399
|
+
function formatMicroseconds(us) {
|
|
6400
|
+
if (us < 1e3) {
|
|
6401
|
+
return `${us.toFixed(2)} \xB5s`;
|
|
6402
|
+
}
|
|
6403
|
+
if (us < 1e6) {
|
|
6404
|
+
return `${(us / 1e3).toFixed(2)} ms`;
|
|
6405
|
+
}
|
|
6406
|
+
return `${(us / 1e6).toFixed(2)} s`;
|
|
6407
|
+
}
|
|
6408
|
+
function formatDuration(ms) {
|
|
6409
|
+
if (ms < 1e3) {
|
|
6410
|
+
return `${ms.toFixed(2)} ms`;
|
|
6411
|
+
}
|
|
6412
|
+
if (ms < 6e4) {
|
|
6413
|
+
return `${(ms / 1e3).toFixed(2)} s`;
|
|
6414
|
+
}
|
|
6415
|
+
return `${(ms / 6e4).toFixed(2)} min`;
|
|
6416
|
+
}
|
|
6417
|
+
function sleep4(ms) {
|
|
6418
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6419
|
+
}
|
|
6420
|
+
|
|
6421
|
+
// libs/testing/src/perf/leak-detector.ts
|
|
6422
|
+
var DEFAULT_OPTIONS = {
|
|
6423
|
+
iterations: 20,
|
|
6424
|
+
threshold: 1024 * 1024,
|
|
6425
|
+
// 1 MB
|
|
6426
|
+
warmupIterations: 3,
|
|
6427
|
+
forceGc: true,
|
|
6428
|
+
delayMs: 10,
|
|
6429
|
+
intervalSize: 10
|
|
6430
|
+
// Default interval size for measurements
|
|
6431
|
+
};
|
|
6432
|
+
var DEFAULT_PARALLEL_OPTIONS = {
|
|
6433
|
+
...DEFAULT_OPTIONS,
|
|
6434
|
+
workers: 5
|
|
6435
|
+
// Default number of parallel workers
|
|
6436
|
+
};
|
|
6437
|
+
function linearRegression(samples) {
|
|
6438
|
+
const n = samples.length;
|
|
6439
|
+
if (n < 2) {
|
|
6440
|
+
return { slope: 0, intercept: samples[0] ?? 0, rSquared: 0 };
|
|
6441
|
+
}
|
|
6442
|
+
let sumX = 0;
|
|
6443
|
+
let sumY = 0;
|
|
6444
|
+
for (let i = 0; i < n; i++) {
|
|
6445
|
+
sumX += i;
|
|
6446
|
+
sumY += samples[i];
|
|
6447
|
+
}
|
|
6448
|
+
const meanX = sumX / n;
|
|
6449
|
+
const meanY = sumY / n;
|
|
6450
|
+
let numerator = 0;
|
|
6451
|
+
let denominator = 0;
|
|
6452
|
+
for (let i = 0; i < n; i++) {
|
|
6453
|
+
const dx = i - meanX;
|
|
6454
|
+
const dy = samples[i] - meanY;
|
|
6455
|
+
numerator += dx * dy;
|
|
6456
|
+
denominator += dx * dx;
|
|
6457
|
+
}
|
|
6458
|
+
const slope = denominator !== 0 ? numerator / denominator : 0;
|
|
6459
|
+
const intercept = meanY - slope * meanX;
|
|
6460
|
+
let ssRes = 0;
|
|
6461
|
+
let ssTot = 0;
|
|
6462
|
+
for (let i = 0; i < n; i++) {
|
|
6463
|
+
const predicted = intercept + slope * i;
|
|
6464
|
+
const residual = samples[i] - predicted;
|
|
6465
|
+
ssRes += residual * residual;
|
|
6466
|
+
ssTot += (samples[i] - meanY) * (samples[i] - meanY);
|
|
6467
|
+
}
|
|
6468
|
+
const rSquared = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
|
|
6469
|
+
return { slope, intercept, rSquared };
|
|
6470
|
+
}
|
|
6471
|
+
var LeakDetector = class {
|
|
6472
|
+
/**
|
|
6473
|
+
* Run leak detection on an async operation.
|
|
6474
|
+
*
|
|
6475
|
+
* @param operation - The operation to test for leaks
|
|
6476
|
+
* @param options - Detection options
|
|
6477
|
+
* @returns Leak detection result
|
|
6478
|
+
*
|
|
6479
|
+
* @example
|
|
6480
|
+
* ```typescript
|
|
6481
|
+
* const detector = new LeakDetector();
|
|
6482
|
+
* const result = await detector.detectLeak(
|
|
6483
|
+
* async () => mcp.tools.call('my-tool', {}),
|
|
6484
|
+
* { iterations: 50, threshold: 5 * 1024 * 1024 }
|
|
6485
|
+
* );
|
|
6486
|
+
* expect(result.hasLeak).toBe(false);
|
|
6487
|
+
* ```
|
|
6488
|
+
*/
|
|
6489
|
+
async detectLeak(operation, options) {
|
|
6490
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
6491
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, delayMs, intervalSize } = opts;
|
|
6492
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
6493
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
6494
|
+
}
|
|
6495
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
6496
|
+
await operation();
|
|
6497
|
+
}
|
|
6498
|
+
if (forceGc2) {
|
|
6499
|
+
await forceFullGc();
|
|
6500
|
+
}
|
|
6501
|
+
const samples = [];
|
|
6502
|
+
const startTime = Date.now();
|
|
6503
|
+
for (let i = 0; i < iterations; i++) {
|
|
6504
|
+
await operation();
|
|
6505
|
+
if (forceGc2) {
|
|
6506
|
+
await forceFullGc(2, 5);
|
|
6507
|
+
}
|
|
6508
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
6509
|
+
if (delayMs > 0) {
|
|
6510
|
+
await sleep5(delayMs);
|
|
6511
|
+
}
|
|
6512
|
+
}
|
|
6513
|
+
const durationMs = Date.now() - startTime;
|
|
6514
|
+
return this.analyzeLeakPattern(samples, threshold, intervalSize, durationMs);
|
|
6515
|
+
}
|
|
6516
|
+
/**
|
|
6517
|
+
* Run leak detection on a sync operation.
|
|
6518
|
+
*/
|
|
6519
|
+
detectLeakSync(operation, options) {
|
|
6520
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
6521
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, intervalSize } = opts;
|
|
6522
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
6523
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
6524
|
+
}
|
|
6525
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
6526
|
+
operation();
|
|
6527
|
+
}
|
|
6528
|
+
if (forceGc2 && isGcAvailable() && global.gc) {
|
|
6529
|
+
global.gc();
|
|
6530
|
+
global.gc();
|
|
6531
|
+
global.gc();
|
|
6532
|
+
}
|
|
6533
|
+
const samples = [];
|
|
6534
|
+
for (let i = 0; i < iterations; i++) {
|
|
6535
|
+
operation();
|
|
6536
|
+
if (forceGc2 && isGcAvailable() && global.gc) {
|
|
6537
|
+
global.gc();
|
|
6538
|
+
global.gc();
|
|
6539
|
+
}
|
|
6540
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
6541
|
+
}
|
|
6542
|
+
return this.analyzeLeakPattern(samples, threshold, intervalSize);
|
|
6543
|
+
}
|
|
6544
|
+
/**
|
|
6545
|
+
* Run parallel leak detection using multiple clients.
|
|
6546
|
+
* Each worker gets its own client for true parallel HTTP requests.
|
|
6547
|
+
*
|
|
6548
|
+
* @param operationFactory - Factory that receives a client and worker index, returns an operation function
|
|
6549
|
+
* @param options - Detection options including worker count and client factory
|
|
6550
|
+
* @returns Combined leak detection result with per-worker statistics
|
|
6551
|
+
*
|
|
6552
|
+
* @example
|
|
6553
|
+
* ```typescript
|
|
6554
|
+
* const detector = new LeakDetector();
|
|
6555
|
+
* const result = await detector.detectLeakParallel(
|
|
6556
|
+
* (client, workerId) => async () => client.tools.call('my-tool', {}),
|
|
6557
|
+
* {
|
|
6558
|
+
* iterations: 1000,
|
|
6559
|
+
* workers: 5,
|
|
6560
|
+
* clientFactory: () => server.createClient(),
|
|
6561
|
+
* }
|
|
6562
|
+
* );
|
|
6563
|
+
* // 5 workers × ~80 req/s = ~400 req/s total
|
|
6564
|
+
* console.log(result.totalRequestsPerSecond);
|
|
6565
|
+
* ```
|
|
6566
|
+
*/
|
|
6567
|
+
async detectLeakParallel(operationFactory, options) {
|
|
6568
|
+
const opts = { ...DEFAULT_PARALLEL_OPTIONS, ...options };
|
|
6569
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, workers, intervalSize, clientFactory } = opts;
|
|
6570
|
+
const safeIntervalSize = Math.max(1, intervalSize);
|
|
6571
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
6572
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
6573
|
+
}
|
|
6574
|
+
const clients = [];
|
|
6575
|
+
let workerResults;
|
|
6576
|
+
let globalDurationMs;
|
|
6577
|
+
try {
|
|
6578
|
+
console.log(`[LeakDetector] Creating ${workers} clients sequentially...`);
|
|
6579
|
+
for (let i = 0; i < workers; i++) {
|
|
6580
|
+
console.log(`[LeakDetector] Creating client ${i + 1}/${workers}...`);
|
|
6581
|
+
const client = await clientFactory();
|
|
6582
|
+
clients.push(client);
|
|
6583
|
+
}
|
|
6584
|
+
console.log(`[LeakDetector] All ${workers} clients connected`);
|
|
6585
|
+
const operations = clients.map((client, workerId) => operationFactory(client, workerId));
|
|
6586
|
+
console.log(`[LeakDetector] Running ${warmupIterations} warmup iterations per worker...`);
|
|
6587
|
+
await Promise.all(
|
|
6588
|
+
operations.map(async (operation) => {
|
|
6589
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
6590
|
+
await operation();
|
|
6591
|
+
}
|
|
6592
|
+
})
|
|
6593
|
+
);
|
|
6594
|
+
if (forceGc2) {
|
|
6595
|
+
await forceFullGc();
|
|
6596
|
+
}
|
|
6597
|
+
console.log(`[LeakDetector] Starting parallel stress test: ${workers} workers \xD7 ${iterations} iterations`);
|
|
6598
|
+
const globalStartTime = Date.now();
|
|
6599
|
+
workerResults = await Promise.all(
|
|
6600
|
+
operations.map(async (operation, workerId) => {
|
|
6601
|
+
const samples = [];
|
|
6602
|
+
const workerStartTime = Date.now();
|
|
6603
|
+
for (let i = 0; i < iterations; i++) {
|
|
6604
|
+
await operation();
|
|
6605
|
+
if (forceGc2 && i > 0 && i % safeIntervalSize === 0) {
|
|
6606
|
+
await forceFullGc(1, 2);
|
|
6607
|
+
}
|
|
6608
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
6609
|
+
}
|
|
6610
|
+
const workerDurationMs = Date.now() - workerStartTime;
|
|
6611
|
+
const requestsPerSecond = iterations / workerDurationMs * 1e3;
|
|
6612
|
+
return {
|
|
6613
|
+
workerId,
|
|
6614
|
+
samples,
|
|
6615
|
+
durationMs: workerDurationMs,
|
|
6616
|
+
requestsPerSecond,
|
|
6617
|
+
iterationsCompleted: iterations
|
|
6618
|
+
};
|
|
6619
|
+
})
|
|
6620
|
+
);
|
|
6621
|
+
globalDurationMs = Date.now() - globalStartTime;
|
|
6622
|
+
} finally {
|
|
6623
|
+
await Promise.all(
|
|
6624
|
+
clients.map(async (client) => {
|
|
6625
|
+
if (client.disconnect) {
|
|
6626
|
+
await client.disconnect();
|
|
6627
|
+
}
|
|
6628
|
+
})
|
|
6629
|
+
);
|
|
6630
|
+
}
|
|
6631
|
+
const allSamples = workerResults.flatMap((w) => w.samples);
|
|
6632
|
+
const totalIterations = workers * iterations;
|
|
6633
|
+
const totalRequestsPerSecond = totalIterations / globalDurationMs * 1e3;
|
|
6634
|
+
const baseResult = this.analyzeLeakPattern(allSamples, threshold, intervalSize, globalDurationMs);
|
|
6635
|
+
const workerSummary = workerResults.map((w) => ` Worker ${w.workerId}: ${w.requestsPerSecond.toFixed(1)} req/s`).join("\n");
|
|
6636
|
+
const parallelMessage = `${baseResult.message}
|
|
6637
|
+
|
|
6638
|
+
Parallel execution summary:
|
|
6639
|
+
Workers: ${workers}
|
|
6640
|
+
Total iterations: ${totalIterations}
|
|
6641
|
+
Combined throughput: ${totalRequestsPerSecond.toFixed(1)} req/s
|
|
6642
|
+
Duration: ${(globalDurationMs / 1e3).toFixed(2)}s
|
|
6643
|
+
Per-worker throughput:
|
|
6644
|
+
${workerSummary}`;
|
|
6645
|
+
return {
|
|
6646
|
+
...baseResult,
|
|
6647
|
+
message: parallelMessage,
|
|
6648
|
+
workersUsed: workers,
|
|
6649
|
+
totalRequestsPerSecond,
|
|
6650
|
+
perWorkerStats: workerResults,
|
|
6651
|
+
totalIterations,
|
|
6652
|
+
durationMs: globalDurationMs,
|
|
6653
|
+
requestsPerSecond: totalRequestsPerSecond
|
|
6654
|
+
};
|
|
6655
|
+
}
|
|
6656
|
+
/**
|
|
6657
|
+
* Analyze heap samples for leak patterns using linear regression.
|
|
6658
|
+
*/
|
|
6659
|
+
analyzeLeakPattern(samples, threshold, intervalSize = 10, durationMs) {
|
|
6660
|
+
if (samples.length < 2) {
|
|
6661
|
+
return {
|
|
6662
|
+
hasLeak: false,
|
|
6663
|
+
leakSizePerIteration: 0,
|
|
6664
|
+
totalGrowth: 0,
|
|
6665
|
+
growthRate: 0,
|
|
6666
|
+
rSquared: 0,
|
|
6667
|
+
samples,
|
|
6668
|
+
message: "Insufficient samples for leak detection",
|
|
6669
|
+
intervals: [],
|
|
6670
|
+
graphData: [],
|
|
6671
|
+
durationMs,
|
|
6672
|
+
requestsPerSecond: 0
|
|
6673
|
+
};
|
|
6674
|
+
}
|
|
6675
|
+
const { slope, rSquared } = linearRegression(samples);
|
|
6676
|
+
const totalGrowth = samples[samples.length - 1] - samples[0];
|
|
6677
|
+
const growthRate = slope;
|
|
6678
|
+
const requestsPerSecond = durationMs && durationMs > 0 ? samples.length / durationMs * 1e3 : 0;
|
|
6679
|
+
const intervals = this.generateIntervals(samples, intervalSize);
|
|
6680
|
+
const graphData = this.generateGraphData(samples);
|
|
6681
|
+
const isSignificantGrowth = totalGrowth > threshold;
|
|
6682
|
+
const isLinearGrowth = rSquared > 0.7;
|
|
6683
|
+
const isHighGrowthRate = growthRate > threshold / samples.length;
|
|
6684
|
+
const hasLeak = isSignificantGrowth && (isLinearGrowth || isHighGrowthRate);
|
|
6685
|
+
const intervalSummary = intervals.map((i) => ` ${i.startIteration}-${i.endIteration}: ${i.deltaFormatted}`).join("\n");
|
|
6686
|
+
const perfStats = durationMs ? `
|
|
6687
|
+
Performance: ${samples.length} iterations in ${(durationMs / 1e3).toFixed(2)}s (${requestsPerSecond.toFixed(1)} req/s)` : "";
|
|
6688
|
+
let message;
|
|
6689
|
+
if (hasLeak) {
|
|
6690
|
+
message = `Memory leak detected: ${formatBytes(totalGrowth)} total growth, ${formatBytes(growthRate)}/iteration, R\xB2=${rSquared.toFixed(3)}
|
|
6691
|
+
Interval breakdown:
|
|
6692
|
+
${intervalSummary}${perfStats}`;
|
|
6693
|
+
} else if (isSignificantGrowth) {
|
|
6694
|
+
message = `Memory growth detected (${formatBytes(totalGrowth)}) but not linear (R\xB2=${rSquared.toFixed(3)}), may be normal allocation
|
|
6695
|
+
Interval breakdown:
|
|
6696
|
+
${intervalSummary}${perfStats}`;
|
|
6697
|
+
} else {
|
|
6698
|
+
message = `No leak detected: ${formatBytes(totalGrowth)} total, ${formatBytes(growthRate)}/iteration
|
|
6699
|
+
Interval breakdown:
|
|
6700
|
+
${intervalSummary}${perfStats}`;
|
|
6701
|
+
}
|
|
6702
|
+
return {
|
|
6703
|
+
hasLeak,
|
|
6704
|
+
leakSizePerIteration: hasLeak ? growthRate : 0,
|
|
6705
|
+
totalGrowth,
|
|
6706
|
+
growthRate,
|
|
6707
|
+
rSquared,
|
|
6708
|
+
samples,
|
|
6709
|
+
message,
|
|
6710
|
+
intervals,
|
|
6711
|
+
graphData,
|
|
6712
|
+
durationMs,
|
|
6713
|
+
requestsPerSecond
|
|
6714
|
+
};
|
|
6715
|
+
}
|
|
6716
|
+
/**
|
|
6717
|
+
* Generate interval-based measurements for detailed analysis.
|
|
6718
|
+
*/
|
|
6719
|
+
generateIntervals(samples, intervalSize) {
|
|
6720
|
+
const intervals = [];
|
|
6721
|
+
const safeIntervalSize = intervalSize <= 0 ? 1 : intervalSize;
|
|
6722
|
+
const numIntervals = Math.ceil(samples.length / safeIntervalSize);
|
|
6723
|
+
for (let i = 0; i < numIntervals; i++) {
|
|
6724
|
+
const startIdx = i * safeIntervalSize;
|
|
6725
|
+
const endIdx = Math.min((i + 1) * safeIntervalSize - 1, samples.length - 1);
|
|
6726
|
+
if (startIdx >= samples.length) break;
|
|
6727
|
+
const heapAtStart = samples[startIdx];
|
|
6728
|
+
const heapAtEnd = samples[endIdx];
|
|
6729
|
+
const delta = heapAtEnd - heapAtStart;
|
|
6730
|
+
const iterationsInInterval = endIdx - startIdx + 1;
|
|
6731
|
+
const growthRatePerIteration = iterationsInInterval > 1 ? delta / (iterationsInInterval - 1) : 0;
|
|
6732
|
+
intervals.push({
|
|
6733
|
+
startIteration: startIdx,
|
|
6734
|
+
endIteration: endIdx,
|
|
6735
|
+
heapAtStart,
|
|
6736
|
+
heapAtEnd,
|
|
6737
|
+
delta,
|
|
6738
|
+
deltaFormatted: formatBytes(delta),
|
|
6739
|
+
growthRatePerIteration
|
|
6740
|
+
});
|
|
6741
|
+
}
|
|
6742
|
+
return intervals;
|
|
6743
|
+
}
|
|
6744
|
+
/**
|
|
6745
|
+
* Generate graph data points for visualization.
|
|
6746
|
+
*/
|
|
6747
|
+
generateGraphData(samples) {
|
|
6748
|
+
if (samples.length === 0) return [];
|
|
6749
|
+
const baselineHeap = samples[0];
|
|
6750
|
+
return samples.map((heapUsed, iteration) => ({
|
|
6751
|
+
iteration,
|
|
6752
|
+
heapUsed,
|
|
6753
|
+
heapUsedFormatted: formatBytes(heapUsed),
|
|
6754
|
+
cumulativeDelta: heapUsed - baselineHeap,
|
|
6755
|
+
cumulativeDeltaFormatted: formatBytes(heapUsed - baselineHeap)
|
|
6756
|
+
}));
|
|
6757
|
+
}
|
|
6758
|
+
};
|
|
6759
|
+
function sleep5(ms) {
|
|
6760
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6761
|
+
}
|
|
6762
|
+
async function assertNoLeak(operation, options) {
|
|
6763
|
+
const detector = new LeakDetector();
|
|
6764
|
+
const result = await detector.detectLeak(operation, options);
|
|
6765
|
+
if (result.hasLeak) {
|
|
6766
|
+
throw new Error(`Memory leak detected: ${result.message}`);
|
|
6767
|
+
}
|
|
6768
|
+
return result;
|
|
6769
|
+
}
|
|
6770
|
+
function createLeakDetector() {
|
|
6771
|
+
return new LeakDetector();
|
|
6772
|
+
}
|
|
6773
|
+
|
|
6774
|
+
// libs/testing/src/perf/perf-fixtures.ts
|
|
6775
|
+
function createPerfFixtures(testName, project) {
|
|
6776
|
+
return new PerfFixturesImpl(testName, project);
|
|
6777
|
+
}
|
|
6778
|
+
var PerfFixturesImpl = class {
|
|
6779
|
+
constructor(testName, project) {
|
|
6780
|
+
this.testName = testName;
|
|
6781
|
+
this.project = project;
|
|
6782
|
+
this.collector = new MetricsCollector();
|
|
6783
|
+
this.leakDetector = new LeakDetector();
|
|
6784
|
+
}
|
|
6785
|
+
collector;
|
|
6786
|
+
leakDetector;
|
|
6787
|
+
baselineSnapshot = null;
|
|
6788
|
+
startTime = 0;
|
|
6789
|
+
issues = [];
|
|
6790
|
+
leakResults = [];
|
|
6791
|
+
/**
|
|
6792
|
+
* Capture baseline snapshot with GC.
|
|
6793
|
+
*/
|
|
6794
|
+
async baseline() {
|
|
6795
|
+
this.startTime = Date.now();
|
|
6796
|
+
this.baselineSnapshot = await this.collector.captureBaseline();
|
|
6797
|
+
return this.baselineSnapshot;
|
|
6798
|
+
}
|
|
6799
|
+
/**
|
|
6800
|
+
* Capture a measurement snapshot.
|
|
6801
|
+
*/
|
|
6802
|
+
measure(label) {
|
|
6803
|
+
return this.collector.captureSnapshot(label);
|
|
6804
|
+
}
|
|
6805
|
+
/**
|
|
6806
|
+
* Run leak detection on an operation.
|
|
6807
|
+
*/
|
|
6808
|
+
async checkLeak(operation, options) {
|
|
6809
|
+
const result = await this.leakDetector.detectLeak(operation, options);
|
|
6810
|
+
this.leakResults.push(result);
|
|
6811
|
+
if (result.hasLeak) {
|
|
6812
|
+
this.issues.push({
|
|
6813
|
+
type: "memory-leak",
|
|
6814
|
+
severity: "error",
|
|
6815
|
+
message: result.message,
|
|
6816
|
+
metric: "heapUsed",
|
|
6817
|
+
actual: result.totalGrowth,
|
|
6818
|
+
expected: options?.threshold ?? 1024 * 1024
|
|
6819
|
+
});
|
|
6820
|
+
}
|
|
6821
|
+
return result;
|
|
6822
|
+
}
|
|
6823
|
+
/**
|
|
6824
|
+
* Run parallel leak detection using multiple clients.
|
|
6825
|
+
* Each worker gets its own client for true parallel HTTP requests.
|
|
6826
|
+
*
|
|
6827
|
+
* @param operationFactory - Factory that receives a client and worker index, returns an operation function
|
|
6828
|
+
* @param options - Detection options including worker count and client factory
|
|
6829
|
+
* @returns Combined leak detection result with per-worker statistics
|
|
6830
|
+
*
|
|
6831
|
+
* @example
|
|
6832
|
+
* ```typescript
|
|
6833
|
+
* const result = await perf.checkLeakParallel(
|
|
6834
|
+
* (client, workerId) => async () => {
|
|
6835
|
+
* await client.tools.call('loadSkills', { skillIds: ['review-pr'] });
|
|
6836
|
+
* },
|
|
6837
|
+
* {
|
|
6838
|
+
* iterations: 1000,
|
|
6839
|
+
* workers: 5,
|
|
6840
|
+
* clientFactory: () => server.createClient(),
|
|
6841
|
+
* }
|
|
6842
|
+
* );
|
|
6843
|
+
* // 5 workers × ~80 req/s = ~400 req/s total
|
|
6844
|
+
* expect(result.totalRequestsPerSecond).toBeGreaterThan(300);
|
|
6845
|
+
* ```
|
|
6846
|
+
*/
|
|
6847
|
+
async checkLeakParallel(operationFactory, options) {
|
|
6848
|
+
const result = await this.leakDetector.detectLeakParallel(operationFactory, options);
|
|
6849
|
+
this.leakResults.push(result);
|
|
6850
|
+
if (result.hasLeak) {
|
|
6851
|
+
this.issues.push({
|
|
6852
|
+
type: "memory-leak",
|
|
6853
|
+
severity: "error",
|
|
6854
|
+
message: result.message,
|
|
6855
|
+
metric: "heapUsed",
|
|
6856
|
+
actual: result.totalGrowth,
|
|
6857
|
+
expected: options?.threshold ?? 1024 * 1024
|
|
6858
|
+
});
|
|
6859
|
+
}
|
|
6860
|
+
return result;
|
|
6861
|
+
}
|
|
6862
|
+
/**
|
|
6863
|
+
* Assert that metrics are within thresholds.
|
|
6864
|
+
*/
|
|
6865
|
+
assertThresholds(thresholds) {
|
|
6866
|
+
const finalSnapshot = this.collector.captureSnapshot("final");
|
|
6867
|
+
const baseline = this.collector.getBaseline();
|
|
6868
|
+
if (!baseline) {
|
|
6869
|
+
throw new Error("Cannot assert thresholds without baseline. Call baseline() first.");
|
|
6870
|
+
}
|
|
6871
|
+
const duration = Date.now() - this.startTime;
|
|
6872
|
+
const memoryDelta = this.collector.calculateMemoryDelta(finalSnapshot.memory);
|
|
6873
|
+
if (thresholds.maxHeapDelta !== void 0 && memoryDelta) {
|
|
6874
|
+
if (memoryDelta.heapUsed > thresholds.maxHeapDelta) {
|
|
6875
|
+
const issue = {
|
|
6876
|
+
type: "threshold-exceeded",
|
|
6877
|
+
severity: "error",
|
|
6878
|
+
message: `Heap delta ${formatBytes(memoryDelta.heapUsed)} exceeds threshold ${formatBytes(thresholds.maxHeapDelta)}`,
|
|
6879
|
+
metric: "heapUsed",
|
|
6880
|
+
actual: memoryDelta.heapUsed,
|
|
6881
|
+
expected: thresholds.maxHeapDelta
|
|
6882
|
+
};
|
|
6883
|
+
this.issues.push(issue);
|
|
6884
|
+
throw new Error(issue.message);
|
|
6885
|
+
}
|
|
6886
|
+
}
|
|
6887
|
+
if (thresholds.maxDurationMs !== void 0) {
|
|
6888
|
+
if (duration > thresholds.maxDurationMs) {
|
|
6889
|
+
const issue = {
|
|
6890
|
+
type: "threshold-exceeded",
|
|
6891
|
+
severity: "error",
|
|
6892
|
+
message: `Duration ${formatDuration(duration)} exceeds threshold ${formatDuration(thresholds.maxDurationMs)}`,
|
|
6893
|
+
metric: "durationMs",
|
|
6894
|
+
actual: duration,
|
|
6895
|
+
expected: thresholds.maxDurationMs
|
|
6896
|
+
};
|
|
6897
|
+
this.issues.push(issue);
|
|
6898
|
+
throw new Error(issue.message);
|
|
6899
|
+
}
|
|
6900
|
+
}
|
|
6901
|
+
if (thresholds.maxCpuTime !== void 0) {
|
|
6902
|
+
const cpuUsage = this.collector.getCpuUsage();
|
|
6903
|
+
if (cpuUsage.total > thresholds.maxCpuTime) {
|
|
6904
|
+
const issue = {
|
|
6905
|
+
type: "threshold-exceeded",
|
|
6906
|
+
severity: "error",
|
|
6907
|
+
message: `CPU time ${cpuUsage.total}\xB5s exceeds threshold ${thresholds.maxCpuTime}\xB5s`,
|
|
6908
|
+
metric: "cpuTime",
|
|
6909
|
+
actual: cpuUsage.total,
|
|
6910
|
+
expected: thresholds.maxCpuTime
|
|
6911
|
+
};
|
|
6912
|
+
this.issues.push(issue);
|
|
6913
|
+
throw new Error(issue.message);
|
|
6914
|
+
}
|
|
6915
|
+
}
|
|
6916
|
+
if (thresholds.maxRssDelta !== void 0 && memoryDelta) {
|
|
6917
|
+
if (memoryDelta.rss > thresholds.maxRssDelta) {
|
|
6918
|
+
const issue = {
|
|
6919
|
+
type: "threshold-exceeded",
|
|
6920
|
+
severity: "error",
|
|
6921
|
+
message: `RSS delta ${formatBytes(memoryDelta.rss)} exceeds threshold ${formatBytes(thresholds.maxRssDelta)}`,
|
|
6922
|
+
metric: "rss",
|
|
6923
|
+
actual: memoryDelta.rss,
|
|
6924
|
+
expected: thresholds.maxRssDelta
|
|
6925
|
+
};
|
|
6926
|
+
this.issues.push(issue);
|
|
6927
|
+
throw new Error(issue.message);
|
|
6928
|
+
}
|
|
6929
|
+
}
|
|
6930
|
+
}
|
|
6931
|
+
/**
|
|
6932
|
+
* Get all measurements so far.
|
|
6933
|
+
*/
|
|
6934
|
+
getMeasurements() {
|
|
6935
|
+
return this.collector.getMeasurements();
|
|
6936
|
+
}
|
|
6937
|
+
/**
|
|
6938
|
+
* Get the current test name.
|
|
6939
|
+
*/
|
|
6940
|
+
getTestName() {
|
|
6941
|
+
return this.testName;
|
|
6942
|
+
}
|
|
6943
|
+
/**
|
|
6944
|
+
* Get the project name.
|
|
6945
|
+
*/
|
|
6946
|
+
getProject() {
|
|
6947
|
+
return this.project;
|
|
6948
|
+
}
|
|
6949
|
+
/**
|
|
6950
|
+
* Get all detected issues.
|
|
6951
|
+
*/
|
|
6952
|
+
getIssues() {
|
|
6953
|
+
return [...this.issues];
|
|
6954
|
+
}
|
|
6955
|
+
/**
|
|
6956
|
+
* Build a complete PerfMeasurement for this test.
|
|
6957
|
+
*/
|
|
6958
|
+
buildMeasurement() {
|
|
6959
|
+
const endTime = Date.now();
|
|
6960
|
+
const finalSnapshot = this.collector.captureSnapshot("final");
|
|
6961
|
+
const baseline = this.collector.getBaseline();
|
|
6962
|
+
const memoryDelta = baseline ? this.collector.calculateMemoryDelta(finalSnapshot.memory) : void 0;
|
|
6963
|
+
return {
|
|
6964
|
+
name: this.testName,
|
|
6965
|
+
project: this.project,
|
|
6966
|
+
baseline: baseline ?? finalSnapshot,
|
|
6967
|
+
measurements: this.collector.getMeasurements(),
|
|
6968
|
+
final: finalSnapshot,
|
|
6969
|
+
timing: {
|
|
6970
|
+
startTime: this.startTime || endTime,
|
|
6971
|
+
endTime,
|
|
6972
|
+
durationMs: endTime - (this.startTime || endTime)
|
|
6973
|
+
},
|
|
6974
|
+
memoryDelta: memoryDelta ?? void 0,
|
|
6975
|
+
issues: this.getIssues(),
|
|
6976
|
+
leakDetectionResults: this.leakResults.length > 0 ? this.leakResults : void 0
|
|
6977
|
+
};
|
|
6978
|
+
}
|
|
6979
|
+
/**
|
|
6980
|
+
* Reset the fixture state.
|
|
6981
|
+
*/
|
|
6982
|
+
reset() {
|
|
6983
|
+
this.collector.reset();
|
|
6984
|
+
this.baselineSnapshot = null;
|
|
6985
|
+
this.startTime = 0;
|
|
6986
|
+
this.issues = [];
|
|
6987
|
+
this.leakResults = [];
|
|
6988
|
+
}
|
|
6989
|
+
};
|
|
6990
|
+
var MEASUREMENTS_KEY = "__FRONTMCP_PERF_MEASUREMENTS__";
|
|
6991
|
+
function getGlobalMeasurementsArray() {
|
|
6992
|
+
if (!globalThis[MEASUREMENTS_KEY]) {
|
|
6993
|
+
globalThis[MEASUREMENTS_KEY] = [];
|
|
6994
|
+
}
|
|
6995
|
+
return globalThis[MEASUREMENTS_KEY];
|
|
6996
|
+
}
|
|
6997
|
+
function addGlobalMeasurement(measurement) {
|
|
6998
|
+
getGlobalMeasurementsArray().push(measurement);
|
|
6999
|
+
}
|
|
7000
|
+
function getGlobalMeasurements() {
|
|
7001
|
+
return [...getGlobalMeasurementsArray()];
|
|
7002
|
+
}
|
|
7003
|
+
function clearGlobalMeasurements() {
|
|
7004
|
+
const arr = getGlobalMeasurementsArray();
|
|
7005
|
+
arr.length = 0;
|
|
7006
|
+
}
|
|
7007
|
+
|
|
7008
|
+
// libs/testing/src/perf/perf-test.ts
|
|
7009
|
+
var currentConfig2 = {};
|
|
7010
|
+
var serverInstance2 = null;
|
|
7011
|
+
var tokenFactory2 = null;
|
|
7012
|
+
var serverStartedByUs2 = false;
|
|
7013
|
+
async function initializeSharedResources2() {
|
|
7014
|
+
if (!tokenFactory2) {
|
|
7015
|
+
tokenFactory2 = new TestTokenFactory();
|
|
7016
|
+
}
|
|
7017
|
+
if (!serverInstance2) {
|
|
7018
|
+
if (currentConfig2.baseUrl) {
|
|
7019
|
+
serverInstance2 = TestServer.connect(currentConfig2.baseUrl);
|
|
7020
|
+
serverStartedByUs2 = false;
|
|
7021
|
+
} else if (currentConfig2.server) {
|
|
7022
|
+
const serverCommand = resolveServerCommand2(currentConfig2.server);
|
|
7023
|
+
const isDebug = process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
7024
|
+
if (isDebug) {
|
|
7025
|
+
console.log(`[PerfTest] Starting server: ${serverCommand}`);
|
|
7026
|
+
}
|
|
7027
|
+
try {
|
|
7028
|
+
serverInstance2 = await TestServer.start({
|
|
7029
|
+
project: currentConfig2.project,
|
|
7030
|
+
port: currentConfig2.port,
|
|
7031
|
+
command: serverCommand,
|
|
7032
|
+
env: currentConfig2.env,
|
|
7033
|
+
startupTimeout: currentConfig2.startupTimeout ?? 3e4,
|
|
7034
|
+
debug: isDebug
|
|
7035
|
+
});
|
|
7036
|
+
serverStartedByUs2 = true;
|
|
7037
|
+
if (isDebug) {
|
|
7038
|
+
console.log(`[PerfTest] Server started at ${serverInstance2.info.baseUrl}`);
|
|
7039
|
+
}
|
|
7040
|
+
} catch (error) {
|
|
7041
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
7042
|
+
throw new Error(
|
|
7043
|
+
`Failed to start test server.
|
|
7044
|
+
|
|
7045
|
+
Server entry: ${currentConfig2.server}
|
|
7046
|
+
Project: ${currentConfig2.project ?? "default"}
|
|
7047
|
+
Command: ${serverCommand}
|
|
7048
|
+
|
|
7049
|
+
Error: ${errMsg}`
|
|
7050
|
+
);
|
|
7051
|
+
}
|
|
7052
|
+
} else {
|
|
7053
|
+
throw new Error('perfTest.use() requires either "server" (entry file path) or "baseUrl" option');
|
|
7054
|
+
}
|
|
7055
|
+
}
|
|
7056
|
+
}
|
|
7057
|
+
async function createTestFixtures2(testName) {
|
|
7058
|
+
await initializeSharedResources2();
|
|
7059
|
+
if (!serverInstance2) {
|
|
7060
|
+
throw new Error("Server instance not initialized");
|
|
7061
|
+
}
|
|
7062
|
+
if (!tokenFactory2) {
|
|
7063
|
+
throw new Error("Token factory not initialized");
|
|
7064
|
+
}
|
|
7065
|
+
const clientInstance = await McpTestClient.create({
|
|
7066
|
+
baseUrl: serverInstance2.info.baseUrl,
|
|
7067
|
+
transport: currentConfig2.transport ?? "streamable-http",
|
|
7068
|
+
publicMode: currentConfig2.publicMode
|
|
7069
|
+
}).buildAndConnect();
|
|
7070
|
+
const auth = createAuthFixture2(tokenFactory2);
|
|
7071
|
+
const server = createServerFixture2(serverInstance2);
|
|
7072
|
+
const perfImpl = createPerfFixtures(testName, currentConfig2.project ?? "unknown");
|
|
7073
|
+
if (currentConfig2.forceGcOnBaseline !== false) {
|
|
7074
|
+
await perfImpl.baseline();
|
|
7075
|
+
}
|
|
7076
|
+
return {
|
|
7077
|
+
fixtures: {
|
|
7078
|
+
mcp: clientInstance,
|
|
7079
|
+
auth,
|
|
7080
|
+
server,
|
|
7081
|
+
perf: perfImpl
|
|
7082
|
+
},
|
|
7083
|
+
perfImpl
|
|
7084
|
+
};
|
|
7085
|
+
}
|
|
7086
|
+
async function cleanupTestFixtures2(fixtures, perfImpl, testFailed = false) {
|
|
7087
|
+
const measurement = perfImpl.buildMeasurement();
|
|
7088
|
+
addGlobalMeasurement(measurement);
|
|
7089
|
+
if (testFailed && serverInstance2) {
|
|
7090
|
+
const logs = serverInstance2.getLogs();
|
|
7091
|
+
if (logs.length > 0) {
|
|
7092
|
+
console.error("\n[PerfTest] === Server Logs (test failed) ===");
|
|
7093
|
+
const recentLogs = logs.slice(-50);
|
|
7094
|
+
if (logs.length > 50) {
|
|
7095
|
+
console.error(`[PerfTest] (showing last 50 of ${logs.length} log entries)`);
|
|
7096
|
+
}
|
|
7097
|
+
console.error(recentLogs.join("\n"));
|
|
7098
|
+
console.error("[PerfTest] === End Server Logs ===\n");
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
7101
|
+
if (fixtures.mcp.isConnected()) {
|
|
7102
|
+
await fixtures.mcp.disconnect();
|
|
7103
|
+
}
|
|
7104
|
+
}
|
|
7105
|
+
async function cleanupSharedResources2() {
|
|
7106
|
+
if (serverInstance2 && serverStartedByUs2) {
|
|
7107
|
+
await serverInstance2.stop();
|
|
7108
|
+
}
|
|
7109
|
+
serverInstance2 = null;
|
|
7110
|
+
tokenFactory2 = null;
|
|
7111
|
+
serverStartedByUs2 = false;
|
|
7112
|
+
}
|
|
7113
|
+
function createAuthFixture2(factory) {
|
|
7114
|
+
const users = {
|
|
7115
|
+
admin: {
|
|
7116
|
+
sub: "admin-001",
|
|
7117
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
7118
|
+
email: "admin@test.local",
|
|
7119
|
+
name: "Test Admin"
|
|
7120
|
+
},
|
|
7121
|
+
user: {
|
|
7122
|
+
sub: "user-001",
|
|
7123
|
+
scopes: ["read", "write"],
|
|
7124
|
+
email: "user@test.local",
|
|
7125
|
+
name: "Test User"
|
|
7126
|
+
},
|
|
7127
|
+
readOnly: {
|
|
7128
|
+
sub: "readonly-001",
|
|
7129
|
+
scopes: ["read"],
|
|
7130
|
+
email: "readonly@test.local",
|
|
7131
|
+
name: "Read Only User"
|
|
7132
|
+
}
|
|
7133
|
+
};
|
|
7134
|
+
return {
|
|
7135
|
+
createToken: (opts) => factory.createTestToken({
|
|
7136
|
+
sub: opts.sub,
|
|
7137
|
+
scopes: opts.scopes,
|
|
7138
|
+
claims: {
|
|
7139
|
+
email: opts.email,
|
|
7140
|
+
name: opts.name,
|
|
7141
|
+
...opts.claims
|
|
7142
|
+
},
|
|
7143
|
+
exp: opts.expiresIn
|
|
7144
|
+
}),
|
|
7145
|
+
createExpiredToken: (opts) => factory.createExpiredToken(opts),
|
|
7146
|
+
createInvalidToken: (opts) => factory.createTokenWithInvalidSignature(opts),
|
|
7147
|
+
users: {
|
|
7148
|
+
admin: users["admin"],
|
|
7149
|
+
user: users["user"],
|
|
7150
|
+
readOnly: users["readOnly"]
|
|
7151
|
+
},
|
|
7152
|
+
getJwks: () => factory.getPublicJwks(),
|
|
7153
|
+
getIssuer: () => factory.getIssuer(),
|
|
7154
|
+
getAudience: () => factory.getAudience()
|
|
7155
|
+
};
|
|
7156
|
+
}
|
|
7157
|
+
function createServerFixture2(server) {
|
|
7158
|
+
return {
|
|
7159
|
+
info: server.info,
|
|
7160
|
+
createClient: async (opts) => {
|
|
7161
|
+
return McpTestClient.create({
|
|
7162
|
+
baseUrl: server.info.baseUrl,
|
|
7163
|
+
transport: opts?.transport ?? "streamable-http",
|
|
7164
|
+
auth: opts?.token ? { token: opts.token } : void 0,
|
|
7165
|
+
clientInfo: opts?.clientInfo,
|
|
7166
|
+
publicMode: currentConfig2.publicMode
|
|
7167
|
+
}).buildAndConnect();
|
|
7168
|
+
},
|
|
7169
|
+
createClientBuilder: () => {
|
|
7170
|
+
return new McpTestClientBuilder({
|
|
7171
|
+
baseUrl: server.info.baseUrl,
|
|
7172
|
+
publicMode: currentConfig2.publicMode
|
|
7173
|
+
});
|
|
7174
|
+
},
|
|
7175
|
+
restart: () => server.restart(),
|
|
7176
|
+
getLogs: () => server.getLogs(),
|
|
7177
|
+
clearLogs: () => server.clearLogs()
|
|
7178
|
+
};
|
|
7179
|
+
}
|
|
7180
|
+
function resolveServerCommand2(server) {
|
|
7181
|
+
if (server.includes(" ")) {
|
|
7182
|
+
return server;
|
|
7183
|
+
}
|
|
7184
|
+
return `npx tsx ${server}`;
|
|
7185
|
+
}
|
|
7186
|
+
function perfTestWithFixtures(name, fn) {
|
|
7187
|
+
it(name, async () => {
|
|
7188
|
+
const { fixtures, perfImpl } = await createTestFixtures2(name);
|
|
7189
|
+
let testFailed = false;
|
|
7190
|
+
try {
|
|
7191
|
+
await fn(fixtures);
|
|
7192
|
+
} catch (error) {
|
|
7193
|
+
testFailed = true;
|
|
7194
|
+
throw error;
|
|
7195
|
+
} finally {
|
|
7196
|
+
await cleanupTestFixtures2(fixtures, perfImpl, testFailed);
|
|
7197
|
+
}
|
|
7198
|
+
});
|
|
7199
|
+
}
|
|
7200
|
+
function use2(config) {
|
|
7201
|
+
currentConfig2 = { ...currentConfig2, ...config };
|
|
7202
|
+
afterAll(async () => {
|
|
7203
|
+
await cleanupSharedResources2();
|
|
7204
|
+
});
|
|
7205
|
+
}
|
|
7206
|
+
function skip2(name, fn) {
|
|
7207
|
+
it.skip(name, async () => {
|
|
7208
|
+
const { fixtures, perfImpl } = await createTestFixtures2(name);
|
|
7209
|
+
let testFailed = false;
|
|
7210
|
+
try {
|
|
7211
|
+
await fn(fixtures);
|
|
7212
|
+
} catch (error) {
|
|
7213
|
+
testFailed = true;
|
|
7214
|
+
throw error;
|
|
7215
|
+
} finally {
|
|
7216
|
+
await cleanupTestFixtures2(fixtures, perfImpl, testFailed);
|
|
7217
|
+
}
|
|
7218
|
+
});
|
|
7219
|
+
}
|
|
7220
|
+
function only2(name, fn) {
|
|
7221
|
+
it.only(name, async () => {
|
|
7222
|
+
const { fixtures, perfImpl } = await createTestFixtures2(name);
|
|
7223
|
+
let testFailed = false;
|
|
7224
|
+
try {
|
|
7225
|
+
await fn(fixtures);
|
|
7226
|
+
} catch (error) {
|
|
7227
|
+
testFailed = true;
|
|
7228
|
+
throw error;
|
|
7229
|
+
} finally {
|
|
7230
|
+
await cleanupTestFixtures2(fixtures, perfImpl, testFailed);
|
|
7231
|
+
}
|
|
7232
|
+
});
|
|
7233
|
+
}
|
|
7234
|
+
function todo2(name) {
|
|
7235
|
+
it.todo(name);
|
|
7236
|
+
}
|
|
7237
|
+
var perfTest = perfTestWithFixtures;
|
|
7238
|
+
perfTest.use = use2;
|
|
7239
|
+
perfTest.describe = describe;
|
|
7240
|
+
perfTest.beforeAll = beforeAll;
|
|
7241
|
+
perfTest.beforeEach = beforeEach;
|
|
7242
|
+
perfTest.afterEach = afterEach;
|
|
7243
|
+
perfTest.afterAll = afterAll;
|
|
7244
|
+
perfTest.skip = skip2;
|
|
7245
|
+
perfTest.only = only2;
|
|
7246
|
+
perfTest.todo = todo2;
|
|
7247
|
+
|
|
7248
|
+
// libs/testing/src/perf/baseline-store.ts
|
|
7249
|
+
var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
|
|
7250
|
+
var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
|
|
7251
|
+
var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
|
|
7252
|
+
var BaselineStore = class {
|
|
7253
|
+
baseline = null;
|
|
7254
|
+
baselinePath;
|
|
7255
|
+
constructor(baselinePath = DEFAULT_BASELINE_PATH) {
|
|
7256
|
+
this.baselinePath = baselinePath;
|
|
7257
|
+
}
|
|
7258
|
+
/**
|
|
7259
|
+
* Load baseline from local file.
|
|
7260
|
+
*/
|
|
7261
|
+
async load() {
|
|
7262
|
+
try {
|
|
7263
|
+
const { readFile, fileExists } = await import("@frontmcp/utils");
|
|
7264
|
+
if (!await fileExists(this.baselinePath)) {
|
|
7265
|
+
return null;
|
|
7266
|
+
}
|
|
7267
|
+
const content = await readFile(this.baselinePath);
|
|
7268
|
+
this.baseline = JSON.parse(content);
|
|
7269
|
+
return this.baseline;
|
|
7270
|
+
} catch {
|
|
7271
|
+
return null;
|
|
7272
|
+
}
|
|
7273
|
+
}
|
|
7274
|
+
/**
|
|
7275
|
+
* Save baseline to local file.
|
|
7276
|
+
*/
|
|
7277
|
+
async save(baseline) {
|
|
7278
|
+
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
7279
|
+
const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
|
|
7280
|
+
await ensureDir(dir);
|
|
7281
|
+
await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
7282
|
+
this.baseline = baseline;
|
|
7283
|
+
}
|
|
7284
|
+
/**
|
|
7285
|
+
* Get a test baseline by ID.
|
|
7286
|
+
*/
|
|
7287
|
+
getTestBaseline(testId) {
|
|
7288
|
+
if (!this.baseline) {
|
|
7289
|
+
return null;
|
|
7290
|
+
}
|
|
7291
|
+
return this.baseline.tests[testId] ?? null;
|
|
7292
|
+
}
|
|
7293
|
+
/**
|
|
7294
|
+
* Check if baseline is loaded.
|
|
7295
|
+
*/
|
|
7296
|
+
isLoaded() {
|
|
7297
|
+
return this.baseline !== null;
|
|
7298
|
+
}
|
|
7299
|
+
/**
|
|
7300
|
+
* Get the loaded baseline.
|
|
7301
|
+
*/
|
|
7302
|
+
getBaseline() {
|
|
7303
|
+
return this.baseline;
|
|
7304
|
+
}
|
|
7305
|
+
/**
|
|
7306
|
+
* Create a baseline from measurements.
|
|
7307
|
+
*/
|
|
7308
|
+
static createFromMeasurements(measurements, release, commitHash) {
|
|
7309
|
+
const tests = {};
|
|
7310
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
7311
|
+
for (const m of measurements) {
|
|
7312
|
+
const key = `${m.project}::${m.name}`;
|
|
7313
|
+
if (!grouped.has(key)) {
|
|
7314
|
+
grouped.set(key, []);
|
|
7315
|
+
}
|
|
7316
|
+
const group = grouped.get(key);
|
|
7317
|
+
if (group) {
|
|
7318
|
+
group.push(m);
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
for (const [key, testMeasurements] of grouped) {
|
|
7322
|
+
const [project] = key.split("::");
|
|
7323
|
+
const heapSamples = testMeasurements.map((m) => m.final?.memory.heapUsed ?? 0);
|
|
7324
|
+
const durationSamples = testMeasurements.map((m) => m.timing.durationMs);
|
|
7325
|
+
const cpuSamples = testMeasurements.map((m) => m.final?.cpu.total ?? 0);
|
|
7326
|
+
tests[key] = {
|
|
7327
|
+
testId: key,
|
|
7328
|
+
project,
|
|
7329
|
+
heapUsed: calculateMetricBaseline(heapSamples),
|
|
7330
|
+
durationMs: calculateMetricBaseline(durationSamples),
|
|
7331
|
+
cpuTime: calculateMetricBaseline(cpuSamples),
|
|
7332
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7333
|
+
commitHash
|
|
7334
|
+
};
|
|
7335
|
+
}
|
|
7336
|
+
return {
|
|
7337
|
+
release,
|
|
7338
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7339
|
+
commitHash,
|
|
7340
|
+
tests
|
|
7341
|
+
};
|
|
7342
|
+
}
|
|
7343
|
+
};
|
|
7344
|
+
function parseBaselineFromComment(commentBody) {
|
|
7345
|
+
const startIdx = commentBody.indexOf(BASELINE_START_MARKER);
|
|
7346
|
+
const endIdx = commentBody.indexOf(BASELINE_END_MARKER);
|
|
7347
|
+
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
7348
|
+
return null;
|
|
7349
|
+
}
|
|
7350
|
+
const content = commentBody.substring(startIdx + BASELINE_START_MARKER.length, endIdx);
|
|
7351
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
|
|
7352
|
+
if (!jsonMatch) {
|
|
7353
|
+
return null;
|
|
7354
|
+
}
|
|
7355
|
+
try {
|
|
7356
|
+
return JSON.parse(jsonMatch[1]);
|
|
7357
|
+
} catch {
|
|
7358
|
+
return null;
|
|
7359
|
+
}
|
|
7360
|
+
}
|
|
7361
|
+
function formatBaselineAsComment(baseline) {
|
|
7362
|
+
const json = JSON.stringify(baseline, null, 2);
|
|
7363
|
+
return `## Performance Baseline
|
|
7364
|
+
|
|
7365
|
+
This comment contains the performance baseline for release ${baseline.release}.
|
|
7366
|
+
|
|
7367
|
+
${BASELINE_START_MARKER}
|
|
7368
|
+
\`\`\`json
|
|
7369
|
+
${json}
|
|
7370
|
+
\`\`\`
|
|
7371
|
+
${BASELINE_END_MARKER}
|
|
7372
|
+
|
|
7373
|
+
Generated at: ${baseline.timestamp}
|
|
7374
|
+
${baseline.commitHash ? `Commit: ${baseline.commitHash}` : ""}
|
|
7375
|
+
`;
|
|
7376
|
+
}
|
|
7377
|
+
function calculateMetricBaseline(samples) {
|
|
7378
|
+
if (samples.length === 0) {
|
|
7379
|
+
return {
|
|
7380
|
+
mean: 0,
|
|
7381
|
+
stdDev: 0,
|
|
7382
|
+
min: 0,
|
|
7383
|
+
max: 0,
|
|
7384
|
+
p95: 0,
|
|
7385
|
+
sampleCount: 0
|
|
7386
|
+
};
|
|
7387
|
+
}
|
|
7388
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
7389
|
+
const n = sorted.length;
|
|
7390
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
7391
|
+
const mean = sum / n;
|
|
7392
|
+
const squaredDiffs = sorted.map((x) => Math.pow(x - mean, 2));
|
|
7393
|
+
const variance = squaredDiffs.reduce((a, b) => a + b, 0) / n;
|
|
7394
|
+
const stdDev = Math.sqrt(variance);
|
|
7395
|
+
const min = sorted[0];
|
|
7396
|
+
const max = sorted[n - 1];
|
|
7397
|
+
const p95Index = Math.ceil(n * 0.95) - 1;
|
|
7398
|
+
const p95 = sorted[Math.min(p95Index, n - 1)];
|
|
7399
|
+
return {
|
|
7400
|
+
mean,
|
|
7401
|
+
stdDev,
|
|
7402
|
+
min,
|
|
7403
|
+
max,
|
|
7404
|
+
p95,
|
|
7405
|
+
sampleCount: n
|
|
7406
|
+
};
|
|
7407
|
+
}
|
|
7408
|
+
var globalBaselineStore = null;
|
|
7409
|
+
function getBaselineStore(baselinePath) {
|
|
7410
|
+
if (!globalBaselineStore || baselinePath) {
|
|
7411
|
+
globalBaselineStore = new BaselineStore(baselinePath);
|
|
7412
|
+
}
|
|
7413
|
+
return globalBaselineStore;
|
|
7414
|
+
}
|
|
7415
|
+
|
|
7416
|
+
// libs/testing/src/perf/regression-detector.ts
|
|
7417
|
+
var DEFAULT_CONFIG = {
|
|
7418
|
+
warningThresholdPercent: 10,
|
|
7419
|
+
errorThresholdPercent: 25,
|
|
7420
|
+
minAbsoluteChange: 1024
|
|
7421
|
+
// 1KB minimum to avoid noise
|
|
7422
|
+
};
|
|
7423
|
+
var RegressionDetector = class {
|
|
7424
|
+
config;
|
|
7425
|
+
constructor(config) {
|
|
7426
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
7427
|
+
}
|
|
7428
|
+
/**
|
|
7429
|
+
* Detect regressions in a measurement compared to baseline.
|
|
7430
|
+
*/
|
|
7431
|
+
detectRegression(measurement, baseline) {
|
|
7432
|
+
const testId = `${measurement.project}::${measurement.name}`;
|
|
7433
|
+
const metrics = [];
|
|
7434
|
+
const currentHeap = measurement.final?.memory.heapUsed ?? 0;
|
|
7435
|
+
const heapRegression = this.checkMetric("heapUsed", baseline.heapUsed.mean, currentHeap, formatBytes);
|
|
7436
|
+
metrics.push(heapRegression);
|
|
7437
|
+
const durationRegression = this.checkMetric(
|
|
7438
|
+
"durationMs",
|
|
7439
|
+
baseline.durationMs.mean,
|
|
7440
|
+
measurement.timing.durationMs,
|
|
7441
|
+
formatDuration
|
|
7442
|
+
);
|
|
7443
|
+
metrics.push(durationRegression);
|
|
7444
|
+
const currentCpu = measurement.final?.cpu.total ?? 0;
|
|
7445
|
+
const cpuRegression = this.checkMetric("cpuTime", baseline.cpuTime.mean, currentCpu, formatMicroseconds);
|
|
7446
|
+
metrics.push(cpuRegression);
|
|
7447
|
+
const hasRegression = metrics.some((m) => m.status === "regression");
|
|
7448
|
+
const hasWarning = metrics.some((m) => m.status === "warning");
|
|
7449
|
+
const status = hasRegression ? "regression" : hasWarning ? "warning" : "ok";
|
|
7450
|
+
const message = this.buildMessage(testId, metrics, status);
|
|
7451
|
+
return {
|
|
7452
|
+
testId,
|
|
7453
|
+
status,
|
|
7454
|
+
metrics,
|
|
7455
|
+
message
|
|
7456
|
+
};
|
|
7457
|
+
}
|
|
7458
|
+
/**
|
|
7459
|
+
* Detect regressions for multiple measurements.
|
|
7460
|
+
*/
|
|
7461
|
+
detectRegressions(measurements, baselines) {
|
|
7462
|
+
const results = [];
|
|
7463
|
+
for (const measurement of measurements) {
|
|
7464
|
+
const testId = `${measurement.project}::${measurement.name}`;
|
|
7465
|
+
const baseline = baselines.tests[testId];
|
|
7466
|
+
if (baseline) {
|
|
7467
|
+
results.push(this.detectRegression(measurement, baseline));
|
|
7468
|
+
}
|
|
7469
|
+
}
|
|
7470
|
+
return results;
|
|
7471
|
+
}
|
|
7472
|
+
/**
|
|
7473
|
+
* Check a single metric for regression.
|
|
7474
|
+
*/
|
|
7475
|
+
checkMetric(name, baseline, current, _formatter) {
|
|
7476
|
+
const absoluteChange = current - baseline;
|
|
7477
|
+
const changePercent = baseline > 0 ? absoluteChange / baseline * 100 : 0;
|
|
7478
|
+
let status = "ok";
|
|
7479
|
+
if (Math.abs(absoluteChange) > this.config.minAbsoluteChange) {
|
|
7480
|
+
if (changePercent >= this.config.errorThresholdPercent) {
|
|
7481
|
+
status = "regression";
|
|
7482
|
+
} else if (changePercent >= this.config.warningThresholdPercent) {
|
|
7483
|
+
status = "warning";
|
|
7484
|
+
}
|
|
7485
|
+
}
|
|
7486
|
+
return {
|
|
7487
|
+
metric: name,
|
|
7488
|
+
baseline,
|
|
7489
|
+
current,
|
|
7490
|
+
changePercent,
|
|
7491
|
+
absoluteChange,
|
|
7492
|
+
status
|
|
7493
|
+
};
|
|
7494
|
+
}
|
|
7495
|
+
/**
|
|
7496
|
+
* Build a human-readable message for regression result.
|
|
7497
|
+
*/
|
|
7498
|
+
buildMessage(testId, metrics, status) {
|
|
7499
|
+
if (status === "ok") {
|
|
7500
|
+
return `${testId}: All metrics within acceptable range`;
|
|
7501
|
+
}
|
|
7502
|
+
const issues = metrics.filter((m) => m.status !== "ok").map((m) => {
|
|
7503
|
+
const direction = m.absoluteChange > 0 ? "+" : "";
|
|
7504
|
+
return `${m.metric}: ${direction}${m.changePercent.toFixed(1)}%`;
|
|
7505
|
+
});
|
|
7506
|
+
const statusText = status === "regression" ? "REGRESSION" : "WARNING";
|
|
7507
|
+
return `${testId}: ${statusText} - ${issues.join(", ")}`;
|
|
7508
|
+
}
|
|
7509
|
+
};
|
|
7510
|
+
function summarizeRegressions(results) {
|
|
7511
|
+
const total = results.length;
|
|
7512
|
+
const ok = results.filter((r) => r.status === "ok").length;
|
|
7513
|
+
const warnings = results.filter((r) => r.status === "warning").length;
|
|
7514
|
+
const regressions = results.filter((r) => r.status === "regression").length;
|
|
7515
|
+
let summary;
|
|
7516
|
+
if (regressions > 0) {
|
|
7517
|
+
summary = `${regressions} regression(s) detected out of ${total} tests`;
|
|
7518
|
+
} else if (warnings > 0) {
|
|
7519
|
+
summary = `${warnings} warning(s) detected out of ${total} tests`;
|
|
7520
|
+
} else {
|
|
7521
|
+
summary = `All ${total} tests within acceptable range`;
|
|
7522
|
+
}
|
|
7523
|
+
return { total, ok, warnings, regressions, summary };
|
|
7524
|
+
}
|
|
7525
|
+
var globalDetector = null;
|
|
7526
|
+
function getRegressionDetector(config) {
|
|
7527
|
+
if (!globalDetector || config) {
|
|
7528
|
+
globalDetector = new RegressionDetector(config);
|
|
7529
|
+
}
|
|
7530
|
+
return globalDetector;
|
|
7531
|
+
}
|
|
7532
|
+
|
|
7533
|
+
// libs/testing/src/perf/report-generator.ts
|
|
7534
|
+
var ReportGenerator = class {
|
|
7535
|
+
detector;
|
|
7536
|
+
constructor() {
|
|
7537
|
+
this.detector = new RegressionDetector();
|
|
7538
|
+
}
|
|
7539
|
+
/**
|
|
7540
|
+
* Generate a complete performance report.
|
|
7541
|
+
*/
|
|
7542
|
+
generateReport(measurements, baseline, gitInfo) {
|
|
7543
|
+
const projectGroups = /* @__PURE__ */ new Map();
|
|
7544
|
+
for (const m of measurements) {
|
|
7545
|
+
if (!projectGroups.has(m.project)) {
|
|
7546
|
+
projectGroups.set(m.project, []);
|
|
7547
|
+
}
|
|
7548
|
+
const group = projectGroups.get(m.project);
|
|
7549
|
+
if (group) {
|
|
7550
|
+
group.push(m);
|
|
7551
|
+
}
|
|
7552
|
+
}
|
|
7553
|
+
const projects = [];
|
|
7554
|
+
for (const [projectName, projectMeasurements] of projectGroups) {
|
|
7555
|
+
const summary = this.calculateSummary(projectMeasurements);
|
|
7556
|
+
const regressions = baseline ? this.detector.detectRegressions(projectMeasurements, baseline) : void 0;
|
|
7557
|
+
projects.push({
|
|
7558
|
+
project: projectName,
|
|
7559
|
+
summary,
|
|
7560
|
+
measurements: projectMeasurements,
|
|
7561
|
+
regressions
|
|
7562
|
+
});
|
|
7563
|
+
}
|
|
7564
|
+
const overallSummary = this.calculateSummary(measurements);
|
|
7565
|
+
return {
|
|
7566
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7567
|
+
commitHash: gitInfo?.commitHash,
|
|
7568
|
+
branch: gitInfo?.branch,
|
|
7569
|
+
summary: overallSummary,
|
|
7570
|
+
projects,
|
|
7571
|
+
baseline: baseline ? { release: baseline.release, timestamp: baseline.timestamp } : void 0
|
|
7572
|
+
};
|
|
7573
|
+
}
|
|
7574
|
+
/**
|
|
7575
|
+
* Generate Markdown report for PR comments.
|
|
7576
|
+
*/
|
|
7577
|
+
generateMarkdownReport(report) {
|
|
7578
|
+
const lines = [];
|
|
7579
|
+
lines.push("## Performance Test Results");
|
|
7580
|
+
lines.push("");
|
|
7581
|
+
const statusEmoji = this.getStatusEmoji(report.summary);
|
|
7582
|
+
lines.push(`**Status:** ${statusEmoji} ${this.getSummaryText(report.summary)}`);
|
|
7583
|
+
lines.push("");
|
|
7584
|
+
lines.push("### Summary");
|
|
7585
|
+
lines.push("");
|
|
7586
|
+
lines.push("| Metric | Value |");
|
|
7587
|
+
lines.push("|--------|-------|");
|
|
7588
|
+
lines.push(`| Total Tests | ${report.summary.totalTests} |`);
|
|
7589
|
+
lines.push(`| Passed | ${report.summary.passedTests} |`);
|
|
7590
|
+
lines.push(`| Warnings | ${report.summary.warningTests} |`);
|
|
7591
|
+
lines.push(`| Failed | ${report.summary.failedTests} |`);
|
|
7592
|
+
lines.push(`| Memory Leaks | ${report.summary.leakTests} |`);
|
|
7593
|
+
lines.push("");
|
|
7594
|
+
if (report.baseline) {
|
|
7595
|
+
lines.push(`**Baseline:** ${report.baseline.release} (${report.baseline.timestamp})`);
|
|
7596
|
+
lines.push("");
|
|
7597
|
+
}
|
|
7598
|
+
lines.push("### Project Breakdown");
|
|
7599
|
+
lines.push("");
|
|
7600
|
+
for (const project of report.projects) {
|
|
7601
|
+
lines.push(`#### ${project.project}`);
|
|
7602
|
+
lines.push("");
|
|
7603
|
+
lines.push(this.generateProjectTable(project));
|
|
7604
|
+
lines.push("");
|
|
7605
|
+
const leakTests = project.measurements.filter((m) => m.leakDetectionResults && m.leakDetectionResults.length > 0);
|
|
7606
|
+
if (leakTests.length > 0) {
|
|
7607
|
+
const parallelTests = leakTests.filter((m) => m.leakDetectionResults?.some((r) => this.isParallelResult(r)));
|
|
7608
|
+
if (parallelTests.length > 0) {
|
|
7609
|
+
lines.push("**Parallel Stress Test Results:**");
|
|
7610
|
+
lines.push("");
|
|
7611
|
+
lines.push("| Test | Workers | Iterations | Duration | Total req/s |");
|
|
7612
|
+
lines.push("|------|---------|------------|----------|-------------|");
|
|
7613
|
+
for (const m of parallelTests) {
|
|
7614
|
+
for (const result of m.leakDetectionResults || []) {
|
|
7615
|
+
if (this.isParallelResult(result)) {
|
|
7616
|
+
const parallelResult = result;
|
|
7617
|
+
const durationStr = parallelResult.durationMs ? `${(parallelResult.durationMs / 1e3).toFixed(2)}s` : "N/A";
|
|
7618
|
+
lines.push(
|
|
7619
|
+
`| ${m.name} | ${parallelResult.workersUsed} | ${parallelResult.totalIterations} | ${durationStr} | ${parallelResult.totalRequestsPerSecond.toFixed(1)} |`
|
|
7620
|
+
);
|
|
7621
|
+
}
|
|
7622
|
+
}
|
|
7623
|
+
}
|
|
7624
|
+
lines.push("");
|
|
7625
|
+
}
|
|
7626
|
+
lines.push("**Memory Interval Analysis:**");
|
|
7627
|
+
lines.push("");
|
|
7628
|
+
for (const m of leakTests) {
|
|
7629
|
+
for (const result of m.leakDetectionResults || []) {
|
|
7630
|
+
if (result.intervals && result.intervals.length > 0) {
|
|
7631
|
+
lines.push(`*${m.name}:*`);
|
|
7632
|
+
lines.push("");
|
|
7633
|
+
if (this.isParallelResult(result)) {
|
|
7634
|
+
const parallelResult = result;
|
|
7635
|
+
lines.push("| Worker | req/s | Iterations |");
|
|
7636
|
+
lines.push("|--------|-------|------------|");
|
|
7637
|
+
for (const worker of parallelResult.perWorkerStats) {
|
|
7638
|
+
lines.push(
|
|
7639
|
+
`| ${worker.workerId} | ${worker.requestsPerSecond.toFixed(1)} | ${worker.iterationsCompleted} |`
|
|
7640
|
+
);
|
|
7641
|
+
}
|
|
7642
|
+
lines.push("");
|
|
7643
|
+
}
|
|
7644
|
+
lines.push("| Interval | Heap Start | Heap End | Delta | Rate/iter |");
|
|
7645
|
+
lines.push("|----------|------------|----------|-------|-----------|");
|
|
7646
|
+
for (const interval of result.intervals) {
|
|
7647
|
+
lines.push(
|
|
7648
|
+
`| ${interval.startIteration}-${interval.endIteration} | ${formatBytes(interval.heapAtStart)} | ${formatBytes(interval.heapAtEnd)} | ${interval.deltaFormatted} | ${formatBytes(interval.growthRatePerIteration)}/iter |`
|
|
7649
|
+
);
|
|
7650
|
+
}
|
|
7651
|
+
lines.push("");
|
|
7652
|
+
const durationStr = result.durationMs ? `${(result.durationMs / 1e3).toFixed(2)}s` : "N/A";
|
|
7653
|
+
const rpsStr = result.requestsPerSecond ? `${result.requestsPerSecond.toFixed(1)} req/s` : "N/A";
|
|
7654
|
+
lines.push(
|
|
7655
|
+
`Total: ${formatBytes(result.totalGrowth)}, R\xB2=${result.rSquared.toFixed(3)} | ${result.samples.length} iterations in ${durationStr} (${rpsStr})`
|
|
7656
|
+
);
|
|
7657
|
+
lines.push("");
|
|
7658
|
+
}
|
|
7659
|
+
}
|
|
7660
|
+
}
|
|
7661
|
+
}
|
|
7662
|
+
if (project.regressions && project.regressions.length > 0) {
|
|
7663
|
+
const regressionsWithIssues = project.regressions.filter((r) => r.status !== "ok");
|
|
7664
|
+
if (regressionsWithIssues.length > 0) {
|
|
7665
|
+
lines.push("**Regressions:**");
|
|
7666
|
+
for (const r of regressionsWithIssues) {
|
|
7667
|
+
const emoji = r.status === "regression" ? "\u274C" : "\u26A0\uFE0F";
|
|
7668
|
+
lines.push(`- ${emoji} ${r.message}`);
|
|
7669
|
+
}
|
|
7670
|
+
lines.push("");
|
|
7671
|
+
}
|
|
7672
|
+
}
|
|
7673
|
+
}
|
|
7674
|
+
lines.push("---");
|
|
7675
|
+
lines.push(`Generated at: ${report.timestamp}`);
|
|
7676
|
+
if (report.commitHash) {
|
|
7677
|
+
lines.push(`Commit: \`${report.commitHash.substring(0, 8)}\``);
|
|
7678
|
+
}
|
|
7679
|
+
if (report.branch) {
|
|
7680
|
+
lines.push(`Branch: \`${report.branch}\``);
|
|
7681
|
+
}
|
|
7682
|
+
return lines.join("\n");
|
|
7683
|
+
}
|
|
7684
|
+
/**
|
|
7685
|
+
* Generate JSON report.
|
|
7686
|
+
*/
|
|
7687
|
+
generateJsonReport(report) {
|
|
7688
|
+
return JSON.stringify(report, null, 2);
|
|
7689
|
+
}
|
|
7690
|
+
/**
|
|
7691
|
+
* Calculate summary statistics for measurements.
|
|
7692
|
+
*/
|
|
7693
|
+
calculateSummary(measurements) {
|
|
7694
|
+
let passedTests = 0;
|
|
7695
|
+
let warningTests = 0;
|
|
7696
|
+
let failedTests = 0;
|
|
7697
|
+
let leakTests = 0;
|
|
7698
|
+
for (const m of measurements) {
|
|
7699
|
+
const hasError = m.issues.some((i) => i.severity === "error");
|
|
7700
|
+
const hasWarning = m.issues.some((i) => i.severity === "warning");
|
|
7701
|
+
const hasLeak = m.issues.some((i) => i.type === "memory-leak");
|
|
7702
|
+
if (hasLeak) {
|
|
7703
|
+
leakTests++;
|
|
7704
|
+
}
|
|
7705
|
+
if (hasError) {
|
|
7706
|
+
failedTests++;
|
|
7707
|
+
} else if (hasWarning) {
|
|
7708
|
+
warningTests++;
|
|
7709
|
+
} else {
|
|
7710
|
+
passedTests++;
|
|
7711
|
+
}
|
|
7712
|
+
}
|
|
7713
|
+
return {
|
|
7714
|
+
totalTests: measurements.length,
|
|
7715
|
+
passedTests,
|
|
7716
|
+
warningTests,
|
|
7717
|
+
failedTests,
|
|
7718
|
+
leakTests
|
|
7719
|
+
};
|
|
7720
|
+
}
|
|
7721
|
+
/**
|
|
7722
|
+
* Get status emoji based on summary.
|
|
7723
|
+
*/
|
|
7724
|
+
getStatusEmoji(summary) {
|
|
7725
|
+
if (summary.failedTests > 0 || summary.leakTests > 0) {
|
|
7726
|
+
return "X";
|
|
7727
|
+
}
|
|
7728
|
+
if (summary.warningTests > 0) {
|
|
7729
|
+
return "!";
|
|
7730
|
+
}
|
|
7731
|
+
return "OK";
|
|
7732
|
+
}
|
|
7733
|
+
/**
|
|
7734
|
+
* Get summary text.
|
|
7735
|
+
*/
|
|
7736
|
+
getSummaryText(summary) {
|
|
7737
|
+
if (summary.failedTests > 0) {
|
|
7738
|
+
return `${summary.failedTests} test(s) failed`;
|
|
7739
|
+
}
|
|
7740
|
+
if (summary.leakTests > 0) {
|
|
7741
|
+
return `${summary.leakTests} memory leak(s) detected`;
|
|
7742
|
+
}
|
|
7743
|
+
if (summary.warningTests > 0) {
|
|
7744
|
+
return `${summary.warningTests} warning(s)`;
|
|
7745
|
+
}
|
|
7746
|
+
return "All tests passed";
|
|
7747
|
+
}
|
|
7748
|
+
/**
|
|
7749
|
+
* Generate markdown table for project measurements.
|
|
7750
|
+
*/
|
|
7751
|
+
generateProjectTable(project) {
|
|
7752
|
+
const lines = [];
|
|
7753
|
+
lines.push("| Test | Duration | Heap Delta | CPU Time | Status |");
|
|
7754
|
+
lines.push("|------|----------|------------|----------|--------|");
|
|
7755
|
+
for (const m of project.measurements) {
|
|
7756
|
+
const status = this.getTestStatus(m);
|
|
7757
|
+
const heapDelta = m.memoryDelta ? formatBytes(m.memoryDelta.heapUsed) : "N/A";
|
|
7758
|
+
const cpuTime = m.final?.cpu.total ? formatMicroseconds(m.final.cpu.total) : "N/A";
|
|
7759
|
+
lines.push(`| ${m.name} | ${formatDuration(m.timing.durationMs)} | ${heapDelta} | ${cpuTime} | ${status} |`);
|
|
7760
|
+
}
|
|
7761
|
+
return lines.join("\n");
|
|
7762
|
+
}
|
|
7763
|
+
/**
|
|
7764
|
+
* Get test status indicator.
|
|
7765
|
+
*/
|
|
7766
|
+
getTestStatus(m) {
|
|
7767
|
+
const hasError = m.issues.some((i) => i.severity === "error");
|
|
7768
|
+
const hasLeak = m.issues.some((i) => i.type === "memory-leak");
|
|
7769
|
+
const hasWarning = m.issues.some((i) => i.severity === "warning");
|
|
7770
|
+
if (hasLeak) {
|
|
7771
|
+
return "LEAK";
|
|
7772
|
+
}
|
|
7773
|
+
if (hasError) {
|
|
7774
|
+
return "FAIL";
|
|
7775
|
+
}
|
|
7776
|
+
if (hasWarning) {
|
|
7777
|
+
return "WARN";
|
|
7778
|
+
}
|
|
7779
|
+
return "OK";
|
|
7780
|
+
}
|
|
7781
|
+
/**
|
|
7782
|
+
* Check if a leak detection result is a parallel result.
|
|
7783
|
+
*/
|
|
7784
|
+
isParallelResult(result) {
|
|
7785
|
+
return typeof result === "object" && result !== null && "workersUsed" in result && "perWorkerStats" in result && "totalRequestsPerSecond" in result;
|
|
7786
|
+
}
|
|
7787
|
+
};
|
|
7788
|
+
async function saveReports(measurements, outputDir, baseline, gitInfo) {
|
|
7789
|
+
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
7790
|
+
await ensureDir(outputDir);
|
|
7791
|
+
const generator = new ReportGenerator();
|
|
7792
|
+
const report = generator.generateReport(measurements, baseline, gitInfo);
|
|
7793
|
+
const jsonPath = `${outputDir}/report.json`;
|
|
7794
|
+
const markdownPath = `${outputDir}/report.md`;
|
|
7795
|
+
await writeFile(jsonPath, generator.generateJsonReport(report));
|
|
7796
|
+
await writeFile(markdownPath, generator.generateMarkdownReport(report));
|
|
7797
|
+
return { jsonPath, markdownPath };
|
|
7798
|
+
}
|
|
7799
|
+
function createReportGenerator() {
|
|
7800
|
+
return new ReportGenerator();
|
|
7801
|
+
}
|
|
4802
7802
|
export {
|
|
4803
7803
|
AssertionError,
|
|
4804
7804
|
AuthHeaders,
|
|
4805
7805
|
BASIC_UI_TOOL_CONFIG,
|
|
7806
|
+
BaselineStore,
|
|
4806
7807
|
ConnectionError,
|
|
4807
7808
|
DefaultInterceptorChain,
|
|
4808
7809
|
DefaultMockRegistry,
|
|
@@ -4813,13 +7814,19 @@ export {
|
|
|
4813
7814
|
EXPECTED_OPENAI_TOOLS_LIST_META_KEYS,
|
|
4814
7815
|
EXPECTED_OPENAI_TOOL_CALL_META_KEYS,
|
|
4815
7816
|
FULL_UI_TOOL_CONFIG,
|
|
7817
|
+
LeakDetector,
|
|
4816
7818
|
McpAssertions,
|
|
4817
7819
|
McpProtocolError,
|
|
4818
7820
|
McpTestClient,
|
|
4819
7821
|
McpTestClientBuilder,
|
|
7822
|
+
MetricsCollector,
|
|
4820
7823
|
MockAPIServer,
|
|
7824
|
+
MockCimdServer,
|
|
4821
7825
|
MockOAuthServer,
|
|
4822
7826
|
PLATFORM_DETECTION_PATTERNS,
|
|
7827
|
+
PerfFixturesImpl,
|
|
7828
|
+
RegressionDetector,
|
|
7829
|
+
ReportGenerator,
|
|
4823
7830
|
ServerStartError,
|
|
4824
7831
|
StreamableHttpTransport,
|
|
4825
7832
|
TestClientError,
|
|
@@ -4828,24 +7835,38 @@ export {
|
|
|
4828
7835
|
TestUsers,
|
|
4829
7836
|
TimeoutError,
|
|
4830
7837
|
UIAssertions,
|
|
7838
|
+
assertNoLeak,
|
|
4831
7839
|
basicUIToolInputSchema,
|
|
4832
7840
|
basicUIToolOutputSchema,
|
|
4833
7841
|
buildUserAgent,
|
|
7842
|
+
clearGlobalMeasurements,
|
|
4834
7843
|
containsPrompt,
|
|
4835
7844
|
containsResource,
|
|
4836
7845
|
containsResourceTemplate,
|
|
4837
7846
|
containsTool,
|
|
7847
|
+
createLeakDetector,
|
|
7848
|
+
createPerfFixtures,
|
|
7849
|
+
createReportGenerator,
|
|
4838
7850
|
createTestUser,
|
|
4839
7851
|
expect,
|
|
7852
|
+
forceFullGc,
|
|
7853
|
+
forceGc,
|
|
7854
|
+
formatBaselineAsComment,
|
|
7855
|
+
formatBytes,
|
|
7856
|
+
formatDuration,
|
|
7857
|
+
formatMicroseconds,
|
|
4840
7858
|
fullUIToolInputSchema,
|
|
4841
7859
|
fullUIToolOutputSchema,
|
|
4842
7860
|
generateBasicUIToolOutput,
|
|
4843
7861
|
generateFullUIToolOutput,
|
|
7862
|
+
getBaselineStore,
|
|
4844
7863
|
getForbiddenMetaPrefixes,
|
|
7864
|
+
getGlobalMeasurements,
|
|
4845
7865
|
getPlatformClientInfo,
|
|
4846
7866
|
getPlatformMetaNamespace,
|
|
4847
7867
|
getPlatformMimeType,
|
|
4848
7868
|
getPlatformUserAgent,
|
|
7869
|
+
getRegressionDetector,
|
|
4849
7870
|
getToolCallMetaPrefixes,
|
|
4850
7871
|
getToolsListMetaPrefixes,
|
|
4851
7872
|
hasMimeType,
|
|
@@ -4855,11 +7876,16 @@ export {
|
|
|
4855
7876
|
interceptors,
|
|
4856
7877
|
isError,
|
|
4857
7878
|
isExtAppsPlatform,
|
|
7879
|
+
isGcAvailable,
|
|
4858
7880
|
isOpenAIPlatform,
|
|
4859
7881
|
isSuccessful,
|
|
4860
7882
|
isUiPlatform,
|
|
4861
7883
|
mcpMatchers,
|
|
4862
7884
|
mockResponse,
|
|
7885
|
+
parseBaselineFromComment,
|
|
7886
|
+
perfTest,
|
|
7887
|
+
saveReports,
|
|
7888
|
+
summarizeRegressions,
|
|
4863
7889
|
test,
|
|
4864
7890
|
uiMatchers
|
|
4865
7891
|
};
|