@frontmcp/testing 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth/index.d.ts +2 -0
- package/auth/index.d.ts.map +1 -1
- package/auth/mock-cimd-server.d.ts +174 -0
- package/auth/mock-cimd-server.d.ts.map +1 -0
- package/auth/mock-oauth-server.d.ts +136 -6
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts.map +1 -1
- package/client/index.d.ts +1 -1
- package/client/index.d.ts.map +1 -1
- package/client/mcp-test-client.builder.d.ts +12 -0
- package/client/mcp-test-client.builder.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +48 -2
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +60 -0
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +661 -83
- package/esm/index.mjs +3245 -219
- package/esm/package.json +5 -4
- package/esm/perf/index.mjs +4334 -0
- package/esm/perf/perf-setup.mjs +31 -0
- package/fixtures/fixture-types.d.ts +10 -1
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +661 -93
- package/fixtures/test-fixture.d.ts +1 -1
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.d.ts +5 -1
- package/index.d.ts.map +1 -1
- package/index.js +3271 -219
- package/interceptor/interceptor-chain.d.ts +1 -0
- package/interceptor/interceptor-chain.d.ts.map +1 -1
- package/package.json +5 -4
- package/perf/baseline-store.d.ts +67 -0
- package/perf/baseline-store.d.ts.map +1 -0
- package/perf/index.d.ts +44 -0
- package/perf/index.d.ts.map +1 -0
- package/perf/index.js +4404 -0
- package/perf/jest-perf-reporter.d.ts +6 -0
- package/perf/jest-perf-reporter.d.ts.map +1 -0
- package/perf/leak-detector.d.ts +81 -0
- package/perf/leak-detector.d.ts.map +1 -0
- package/perf/metrics-collector.d.ts +83 -0
- package/perf/metrics-collector.d.ts.map +1 -0
- package/perf/perf-fixtures.d.ts +107 -0
- package/perf/perf-fixtures.d.ts.map +1 -0
- package/perf/perf-setup.d.ts +9 -0
- package/perf/perf-setup.d.ts.map +1 -0
- package/perf/perf-setup.js +50 -0
- package/perf/perf-test.d.ts +69 -0
- package/perf/perf-test.d.ts.map +1 -0
- package/perf/regression-detector.d.ts +55 -0
- package/perf/regression-detector.d.ts.map +1 -0
- package/perf/report-generator.d.ts +66 -0
- package/perf/report-generator.d.ts.map +1 -0
- package/perf/types.d.ts +439 -0
- package/perf/types.d.ts.map +1 -0
- package/platform/platform-client-info.d.ts +18 -0
- package/platform/platform-client-info.d.ts.map +1 -1
- package/server/index.d.ts +2 -0
- package/server/index.d.ts.map +1 -1
- package/server/port-registry.d.ts +179 -0
- package/server/port-registry.d.ts.map +1 -0
- package/server/test-server.d.ts +9 -5
- package/server/test-server.d.ts.map +1 -1
- package/transport/streamable-http.transport.d.ts +26 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +9 -1
- package/transport/transport.interface.d.ts.map +1 -1
package/index.js
CHANGED
|
@@ -33,6 +33,7 @@ __export(index_exports, {
|
|
|
33
33
|
AssertionError: () => AssertionError,
|
|
34
34
|
AuthHeaders: () => AuthHeaders,
|
|
35
35
|
BASIC_UI_TOOL_CONFIG: () => BASIC_UI_TOOL_CONFIG,
|
|
36
|
+
BaselineStore: () => BaselineStore,
|
|
36
37
|
ConnectionError: () => ConnectionError,
|
|
37
38
|
DefaultInterceptorChain: () => DefaultInterceptorChain,
|
|
38
39
|
DefaultMockRegistry: () => DefaultMockRegistry,
|
|
@@ -43,13 +44,19 @@ __export(index_exports, {
|
|
|
43
44
|
EXPECTED_OPENAI_TOOLS_LIST_META_KEYS: () => EXPECTED_OPENAI_TOOLS_LIST_META_KEYS,
|
|
44
45
|
EXPECTED_OPENAI_TOOL_CALL_META_KEYS: () => EXPECTED_OPENAI_TOOL_CALL_META_KEYS,
|
|
45
46
|
FULL_UI_TOOL_CONFIG: () => FULL_UI_TOOL_CONFIG,
|
|
47
|
+
LeakDetector: () => LeakDetector,
|
|
46
48
|
McpAssertions: () => McpAssertions,
|
|
47
49
|
McpProtocolError: () => McpProtocolError,
|
|
48
50
|
McpTestClient: () => McpTestClient,
|
|
49
51
|
McpTestClientBuilder: () => McpTestClientBuilder,
|
|
52
|
+
MetricsCollector: () => MetricsCollector,
|
|
50
53
|
MockAPIServer: () => MockAPIServer,
|
|
54
|
+
MockCimdServer: () => MockCimdServer,
|
|
51
55
|
MockOAuthServer: () => MockOAuthServer,
|
|
52
56
|
PLATFORM_DETECTION_PATTERNS: () => PLATFORM_DETECTION_PATTERNS,
|
|
57
|
+
PerfFixturesImpl: () => PerfFixturesImpl,
|
|
58
|
+
RegressionDetector: () => RegressionDetector,
|
|
59
|
+
ReportGenerator: () => ReportGenerator,
|
|
53
60
|
ServerStartError: () => ServerStartError,
|
|
54
61
|
StreamableHttpTransport: () => StreamableHttpTransport,
|
|
55
62
|
TestClientError: () => TestClientError,
|
|
@@ -58,24 +65,38 @@ __export(index_exports, {
|
|
|
58
65
|
TestUsers: () => TestUsers,
|
|
59
66
|
TimeoutError: () => TimeoutError,
|
|
60
67
|
UIAssertions: () => UIAssertions,
|
|
68
|
+
assertNoLeak: () => assertNoLeak,
|
|
61
69
|
basicUIToolInputSchema: () => basicUIToolInputSchema,
|
|
62
70
|
basicUIToolOutputSchema: () => basicUIToolOutputSchema,
|
|
63
71
|
buildUserAgent: () => buildUserAgent,
|
|
72
|
+
clearGlobalMeasurements: () => clearGlobalMeasurements,
|
|
64
73
|
containsPrompt: () => containsPrompt,
|
|
65
74
|
containsResource: () => containsResource,
|
|
66
75
|
containsResourceTemplate: () => containsResourceTemplate,
|
|
67
76
|
containsTool: () => containsTool,
|
|
77
|
+
createLeakDetector: () => createLeakDetector,
|
|
78
|
+
createPerfFixtures: () => createPerfFixtures,
|
|
79
|
+
createReportGenerator: () => createReportGenerator,
|
|
68
80
|
createTestUser: () => createTestUser,
|
|
69
81
|
expect: () => expect,
|
|
82
|
+
forceFullGc: () => forceFullGc,
|
|
83
|
+
forceGc: () => forceGc,
|
|
84
|
+
formatBaselineAsComment: () => formatBaselineAsComment,
|
|
85
|
+
formatBytes: () => formatBytes,
|
|
86
|
+
formatDuration: () => formatDuration,
|
|
87
|
+
formatMicroseconds: () => formatMicroseconds,
|
|
70
88
|
fullUIToolInputSchema: () => fullUIToolInputSchema,
|
|
71
89
|
fullUIToolOutputSchema: () => fullUIToolOutputSchema,
|
|
72
90
|
generateBasicUIToolOutput: () => generateBasicUIToolOutput,
|
|
73
91
|
generateFullUIToolOutput: () => generateFullUIToolOutput,
|
|
92
|
+
getBaselineStore: () => getBaselineStore,
|
|
74
93
|
getForbiddenMetaPrefixes: () => getForbiddenMetaPrefixes,
|
|
94
|
+
getGlobalMeasurements: () => getGlobalMeasurements,
|
|
75
95
|
getPlatformClientInfo: () => getPlatformClientInfo,
|
|
76
96
|
getPlatformMetaNamespace: () => getPlatformMetaNamespace,
|
|
77
97
|
getPlatformMimeType: () => getPlatformMimeType,
|
|
78
98
|
getPlatformUserAgent: () => getPlatformUserAgent,
|
|
99
|
+
getRegressionDetector: () => getRegressionDetector,
|
|
79
100
|
getToolCallMetaPrefixes: () => getToolCallMetaPrefixes,
|
|
80
101
|
getToolsListMetaPrefixes: () => getToolsListMetaPrefixes,
|
|
81
102
|
hasMimeType: () => hasMimeType,
|
|
@@ -85,11 +106,16 @@ __export(index_exports, {
|
|
|
85
106
|
interceptors: () => interceptors,
|
|
86
107
|
isError: () => isError,
|
|
87
108
|
isExtAppsPlatform: () => isExtAppsPlatform,
|
|
109
|
+
isGcAvailable: () => isGcAvailable,
|
|
88
110
|
isOpenAIPlatform: () => isOpenAIPlatform,
|
|
89
111
|
isSuccessful: () => isSuccessful,
|
|
90
112
|
isUiPlatform: () => isUiPlatform,
|
|
91
113
|
mcpMatchers: () => mcpMatchers,
|
|
92
114
|
mockResponse: () => mockResponse,
|
|
115
|
+
parseBaselineFromComment: () => parseBaselineFromComment,
|
|
116
|
+
perfTest: () => perfTest,
|
|
117
|
+
saveReports: () => saveReports,
|
|
118
|
+
summarizeRegressions: () => summarizeRegressions,
|
|
93
119
|
test: () => test,
|
|
94
120
|
uiMatchers: () => uiMatchers
|
|
95
121
|
});
|
|
@@ -168,7 +194,12 @@ var PLATFORM_DETECTION_PATTERNS = {
|
|
|
168
194
|
};
|
|
169
195
|
function getPlatformCapabilities(platform) {
|
|
170
196
|
const baseCapabilities = {
|
|
171
|
-
sampling: {}
|
|
197
|
+
sampling: {},
|
|
198
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
199
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
200
|
+
elicitation: {
|
|
201
|
+
form: {}
|
|
202
|
+
}
|
|
172
203
|
};
|
|
173
204
|
if (platform === "ext-apps") {
|
|
174
205
|
return {
|
|
@@ -306,6 +337,21 @@ var McpTestClientBuilder = class {
|
|
|
306
337
|
this.config.capabilities = capabilities;
|
|
307
338
|
return this;
|
|
308
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Set query parameters to append to the connection URL.
|
|
342
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
343
|
+
*
|
|
344
|
+
* @example
|
|
345
|
+
* ```typescript
|
|
346
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
347
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
348
|
+
* .buildAndConnect();
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
withQueryParams(params) {
|
|
352
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
353
|
+
return this;
|
|
354
|
+
}
|
|
309
355
|
/**
|
|
310
356
|
* Build the McpTestClient instance (does not connect)
|
|
311
357
|
*/
|
|
@@ -334,6 +380,7 @@ var StreamableHttpTransport = class {
|
|
|
334
380
|
lastRequestHeaders = {};
|
|
335
381
|
interceptors;
|
|
336
382
|
publicMode;
|
|
383
|
+
elicitationHandler;
|
|
337
384
|
constructor(config) {
|
|
338
385
|
this.config = {
|
|
339
386
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
@@ -348,6 +395,7 @@ var StreamableHttpTransport = class {
|
|
|
348
395
|
this.authToken = config.auth?.token;
|
|
349
396
|
this.interceptors = config.interceptors;
|
|
350
397
|
this.publicMode = config.publicMode ?? false;
|
|
398
|
+
this.elicitationHandler = config.elicitationHandler;
|
|
351
399
|
}
|
|
352
400
|
async connect() {
|
|
353
401
|
this.state = "connecting";
|
|
@@ -440,7 +488,6 @@ var StreamableHttpTransport = class {
|
|
|
440
488
|
body: JSON.stringify(message),
|
|
441
489
|
signal: controller.signal
|
|
442
490
|
});
|
|
443
|
-
clearTimeout(timeoutId);
|
|
444
491
|
const newSessionId = response.headers.get("mcp-session-id");
|
|
445
492
|
if (newSessionId) {
|
|
446
493
|
this.sessionId = newSessionId;
|
|
@@ -460,28 +507,26 @@ var StreamableHttpTransport = class {
|
|
|
460
507
|
};
|
|
461
508
|
} else {
|
|
462
509
|
const contentType = response.headers.get("content-type") ?? "";
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (!text.trim()) {
|
|
466
|
-
jsonResponse = {
|
|
467
|
-
jsonrpc: "2.0",
|
|
468
|
-
id: message.id ?? null,
|
|
469
|
-
result: void 0
|
|
470
|
-
};
|
|
471
|
-
} else if (contentType.includes("text/event-stream")) {
|
|
472
|
-
const { response: sseResponse, sseSessionId } = this.parseSSEResponseWithSession(text, message.id);
|
|
473
|
-
jsonResponse = sseResponse;
|
|
474
|
-
if (sseSessionId && !this.sessionId) {
|
|
475
|
-
this.sessionId = sseSessionId;
|
|
476
|
-
this.log("Session ID from SSE:", this.sessionId);
|
|
477
|
-
}
|
|
510
|
+
if (contentType.includes("text/event-stream")) {
|
|
511
|
+
jsonResponse = await this.handleSSEResponseWithElicitation(response, message);
|
|
478
512
|
} else {
|
|
479
|
-
|
|
513
|
+
const text = await response.text();
|
|
514
|
+
this.log("Response:", text);
|
|
515
|
+
if (!text.trim()) {
|
|
516
|
+
jsonResponse = {
|
|
517
|
+
jsonrpc: "2.0",
|
|
518
|
+
id: message.id ?? null,
|
|
519
|
+
result: void 0
|
|
520
|
+
};
|
|
521
|
+
} else {
|
|
522
|
+
jsonResponse = JSON.parse(text);
|
|
523
|
+
}
|
|
480
524
|
}
|
|
481
525
|
}
|
|
482
526
|
if (this.interceptors) {
|
|
483
527
|
jsonResponse = await this.interceptors.processResponse(message, jsonResponse, Date.now() - startTime);
|
|
484
528
|
}
|
|
529
|
+
clearTimeout(timeoutId);
|
|
485
530
|
return jsonResponse;
|
|
486
531
|
} catch (error) {
|
|
487
532
|
clearTimeout(timeoutId);
|
|
@@ -596,6 +641,9 @@ var StreamableHttpTransport = class {
|
|
|
596
641
|
getInterceptors() {
|
|
597
642
|
return this.interceptors;
|
|
598
643
|
}
|
|
644
|
+
setElicitationHandler(handler) {
|
|
645
|
+
this.elicitationHandler = handler;
|
|
646
|
+
}
|
|
599
647
|
getConnectionCount() {
|
|
600
648
|
return this.connectionCount;
|
|
601
649
|
}
|
|
@@ -624,6 +672,215 @@ var StreamableHttpTransport = class {
|
|
|
624
672
|
// ═══════════════════════════════════════════════════════════════════
|
|
625
673
|
// PRIVATE HELPERS
|
|
626
674
|
// ═══════════════════════════════════════════════════════════════════
|
|
675
|
+
/**
|
|
676
|
+
* Handle SSE response with elicitation support.
|
|
677
|
+
*
|
|
678
|
+
* Streams the SSE response, detects elicitation/create requests, and handles them
|
|
679
|
+
* by calling the registered handler and sending the response back to the server.
|
|
680
|
+
*/
|
|
681
|
+
async handleSSEResponseWithElicitation(response, originalRequest) {
|
|
682
|
+
this.log("handleSSEResponseWithElicitation: starting", { requestId: originalRequest.id });
|
|
683
|
+
const reader = response.body?.getReader();
|
|
684
|
+
if (!reader) {
|
|
685
|
+
this.log("handleSSEResponseWithElicitation: no response body");
|
|
686
|
+
return {
|
|
687
|
+
jsonrpc: "2.0",
|
|
688
|
+
id: originalRequest.id ?? null,
|
|
689
|
+
error: { code: -32e3, message: "No response body" }
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const decoder = new TextDecoder();
|
|
693
|
+
let buffer = "";
|
|
694
|
+
let finalResponse = null;
|
|
695
|
+
let sseSessionId;
|
|
696
|
+
try {
|
|
697
|
+
let readCount = 0;
|
|
698
|
+
while (true) {
|
|
699
|
+
readCount++;
|
|
700
|
+
this.log(`handleSSEResponseWithElicitation: reading chunk ${readCount}`);
|
|
701
|
+
const { done, value } = await reader.read();
|
|
702
|
+
this.log(`handleSSEResponseWithElicitation: read result`, { done, valueLength: value?.length });
|
|
703
|
+
if (done) {
|
|
704
|
+
if (buffer.trim()) {
|
|
705
|
+
const parsed = this.parseSSEEvents(buffer, originalRequest.id);
|
|
706
|
+
for (const event of parsed.events) {
|
|
707
|
+
const handled = await this.handleSSEEvent(event);
|
|
708
|
+
if (handled.isFinal) {
|
|
709
|
+
finalResponse = handled.response;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (parsed.sessionId && !sseSessionId) {
|
|
713
|
+
sseSessionId = parsed.sessionId;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
buffer += decoder.decode(value, { stream: true });
|
|
719
|
+
const eventEndPattern = /\n\n/g;
|
|
720
|
+
let lastEventEnd = 0;
|
|
721
|
+
let match;
|
|
722
|
+
while ((match = eventEndPattern.exec(buffer)) !== null) {
|
|
723
|
+
const eventText = buffer.slice(lastEventEnd, match.index);
|
|
724
|
+
lastEventEnd = match.index + 2;
|
|
725
|
+
if (eventText.trim()) {
|
|
726
|
+
const parsed = this.parseSSEEvents(eventText, originalRequest.id);
|
|
727
|
+
for (const event of parsed.events) {
|
|
728
|
+
const handled = await this.handleSSEEvent(event);
|
|
729
|
+
if (handled.isFinal) {
|
|
730
|
+
finalResponse = handled.response;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (parsed.sessionId && !sseSessionId) {
|
|
734
|
+
sseSessionId = parsed.sessionId;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
buffer = buffer.slice(lastEventEnd);
|
|
739
|
+
}
|
|
740
|
+
} finally {
|
|
741
|
+
reader.releaseLock();
|
|
742
|
+
}
|
|
743
|
+
if (sseSessionId && !this.sessionId) {
|
|
744
|
+
this.sessionId = sseSessionId;
|
|
745
|
+
this.log("Session ID from SSE:", this.sessionId);
|
|
746
|
+
}
|
|
747
|
+
if (finalResponse) {
|
|
748
|
+
return finalResponse;
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
jsonrpc: "2.0",
|
|
752
|
+
id: originalRequest.id ?? null,
|
|
753
|
+
error: { code: -32e3, message: "No final response received in SSE stream" }
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Parse SSE event text into structured events
|
|
758
|
+
*/
|
|
759
|
+
parseSSEEvents(text, _requestId) {
|
|
760
|
+
const lines = text.split("\n");
|
|
761
|
+
const events = [];
|
|
762
|
+
let currentEvent = { type: "message", data: [] };
|
|
763
|
+
let sessionId;
|
|
764
|
+
for (const line of lines) {
|
|
765
|
+
if (line.startsWith("event: ")) {
|
|
766
|
+
currentEvent.type = line.slice(7);
|
|
767
|
+
} else if (line.startsWith("data: ")) {
|
|
768
|
+
currentEvent.data.push(line.slice(6));
|
|
769
|
+
} else if (line === "data:") {
|
|
770
|
+
currentEvent.data.push("");
|
|
771
|
+
} else if (line.startsWith("id: ")) {
|
|
772
|
+
const idValue = line.slice(4);
|
|
773
|
+
currentEvent.id = idValue;
|
|
774
|
+
const colonIndex = idValue.lastIndexOf(":");
|
|
775
|
+
if (colonIndex > 0) {
|
|
776
|
+
sessionId = idValue.substring(0, colonIndex);
|
|
777
|
+
} else {
|
|
778
|
+
sessionId = idValue;
|
|
779
|
+
}
|
|
780
|
+
} else if (line === "" && currentEvent.data.length > 0) {
|
|
781
|
+
events.push({
|
|
782
|
+
type: currentEvent.type,
|
|
783
|
+
data: currentEvent.data.join("\n"),
|
|
784
|
+
id: currentEvent.id
|
|
785
|
+
});
|
|
786
|
+
currentEvent = { type: "message", data: [] };
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (currentEvent.data.length > 0) {
|
|
790
|
+
events.push({
|
|
791
|
+
type: currentEvent.type,
|
|
792
|
+
data: currentEvent.data.join("\n"),
|
|
793
|
+
id: currentEvent.id
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
return { events, sessionId };
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Handle a single SSE event, including elicitation requests
|
|
800
|
+
*/
|
|
801
|
+
async handleSSEEvent(event) {
|
|
802
|
+
this.log("SSE Event:", { type: event.type, data: event.data.slice(0, 200) });
|
|
803
|
+
try {
|
|
804
|
+
const parsed = JSON.parse(event.data);
|
|
805
|
+
if ("method" in parsed && parsed.method === "elicitation/create") {
|
|
806
|
+
await this.handleElicitationRequest(parsed);
|
|
807
|
+
return {
|
|
808
|
+
isFinal: false,
|
|
809
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
if ("result" in parsed || "error" in parsed) {
|
|
813
|
+
return { isFinal: true, response: parsed };
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
isFinal: false,
|
|
817
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
818
|
+
};
|
|
819
|
+
} catch {
|
|
820
|
+
this.log("Failed to parse SSE event data:", event.data);
|
|
821
|
+
return {
|
|
822
|
+
isFinal: false,
|
|
823
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Handle an elicitation/create request from the server
|
|
829
|
+
*/
|
|
830
|
+
async handleElicitationRequest(request) {
|
|
831
|
+
const params = request.params;
|
|
832
|
+
this.log("Elicitation request received:", {
|
|
833
|
+
mode: params?.mode,
|
|
834
|
+
message: params?.message?.slice(0, 100)
|
|
835
|
+
});
|
|
836
|
+
const requestId = request.id;
|
|
837
|
+
if (requestId === void 0 || requestId === null) {
|
|
838
|
+
this.log("Elicitation request has no ID, cannot respond");
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (!this.elicitationHandler) {
|
|
842
|
+
this.log("No elicitation handler registered, sending error");
|
|
843
|
+
await this.sendElicitationResponse(requestId, {
|
|
844
|
+
action: "decline"
|
|
845
|
+
});
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
try {
|
|
849
|
+
const response = await this.elicitationHandler(params);
|
|
850
|
+
this.log("Elicitation handler response:", response);
|
|
851
|
+
await this.sendElicitationResponse(requestId, response);
|
|
852
|
+
} catch (error) {
|
|
853
|
+
this.log("Elicitation handler error:", error);
|
|
854
|
+
await this.sendElicitationResponse(requestId, {
|
|
855
|
+
action: "cancel"
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Send an elicitation response back to the server
|
|
861
|
+
*/
|
|
862
|
+
async sendElicitationResponse(requestId, response) {
|
|
863
|
+
const headers = this.buildHeaders();
|
|
864
|
+
const url = `${this.config.baseUrl}/`;
|
|
865
|
+
const rpcResponse = {
|
|
866
|
+
jsonrpc: "2.0",
|
|
867
|
+
id: requestId,
|
|
868
|
+
result: response
|
|
869
|
+
};
|
|
870
|
+
this.log("Sending elicitation response:", rpcResponse);
|
|
871
|
+
try {
|
|
872
|
+
const fetchResponse = await fetch(url, {
|
|
873
|
+
method: "POST",
|
|
874
|
+
headers,
|
|
875
|
+
body: JSON.stringify(rpcResponse)
|
|
876
|
+
});
|
|
877
|
+
if (!fetchResponse.ok) {
|
|
878
|
+
this.log(`Elicitation response HTTP error: ${fetchResponse.status}`);
|
|
879
|
+
}
|
|
880
|
+
} catch (error) {
|
|
881
|
+
this.log("Failed to send elicitation response:", error);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
627
884
|
buildHeaders() {
|
|
628
885
|
const headers = {
|
|
629
886
|
"Content-Type": "application/json",
|
|
@@ -1055,7 +1312,7 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1055
1312
|
version: "0.4.0"
|
|
1056
1313
|
};
|
|
1057
1314
|
var McpTestClient = class {
|
|
1058
|
-
// Platform and
|
|
1315
|
+
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1059
1316
|
config;
|
|
1060
1317
|
transport = null;
|
|
1061
1318
|
initResult = null;
|
|
@@ -1071,6 +1328,8 @@ var McpTestClient = class {
|
|
|
1071
1328
|
_progressUpdates = [];
|
|
1072
1329
|
// Interceptor chain
|
|
1073
1330
|
_interceptors;
|
|
1331
|
+
// Elicitation handler for server→client elicit requests
|
|
1332
|
+
_elicitationHandler;
|
|
1074
1333
|
// ═══════════════════════════════════════════════════════════════════
|
|
1075
1334
|
// CONSTRUCTOR & FACTORY
|
|
1076
1335
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -1085,7 +1344,8 @@ var McpTestClient = class {
|
|
|
1085
1344
|
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
1086
1345
|
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
1087
1346
|
platform: config.platform,
|
|
1088
|
-
capabilities: config.capabilities
|
|
1347
|
+
capabilities: config.capabilities,
|
|
1348
|
+
queryParams: config.queryParams
|
|
1089
1349
|
};
|
|
1090
1350
|
if (config.auth?.token) {
|
|
1091
1351
|
this._authState = {
|
|
@@ -1318,9 +1578,9 @@ var McpTestClient = class {
|
|
|
1318
1578
|
* Send any JSON-RPC request
|
|
1319
1579
|
*/
|
|
1320
1580
|
request: async (message) => {
|
|
1321
|
-
this.
|
|
1581
|
+
const transport = this.getConnectedTransport();
|
|
1322
1582
|
const start = Date.now();
|
|
1323
|
-
const response = await
|
|
1583
|
+
const response = await transport.request(message);
|
|
1324
1584
|
this.traceRequest(message.method, message.params, message.id, response, Date.now() - start);
|
|
1325
1585
|
return response;
|
|
1326
1586
|
},
|
|
@@ -1328,15 +1588,15 @@ var McpTestClient = class {
|
|
|
1328
1588
|
* Send a notification (no response expected)
|
|
1329
1589
|
*/
|
|
1330
1590
|
notify: async (message) => {
|
|
1331
|
-
this.
|
|
1332
|
-
await
|
|
1591
|
+
const transport = this.getConnectedTransport();
|
|
1592
|
+
await transport.notify(message);
|
|
1333
1593
|
},
|
|
1334
1594
|
/**
|
|
1335
1595
|
* Send raw string data (for error testing)
|
|
1336
1596
|
*/
|
|
1337
1597
|
sendRaw: async (data) => {
|
|
1338
|
-
this.
|
|
1339
|
-
return
|
|
1598
|
+
const transport = this.getConnectedTransport();
|
|
1599
|
+
return transport.sendRaw(data);
|
|
1340
1600
|
}
|
|
1341
1601
|
};
|
|
1342
1602
|
get lastRequestId() {
|
|
@@ -1392,6 +1652,63 @@ var McpTestClient = class {
|
|
|
1392
1652
|
}
|
|
1393
1653
|
};
|
|
1394
1654
|
// ═══════════════════════════════════════════════════════════════════
|
|
1655
|
+
// ELICITATION
|
|
1656
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1657
|
+
/**
|
|
1658
|
+
* Register a handler for elicitation requests from the server.
|
|
1659
|
+
*
|
|
1660
|
+
* When a tool calls `this.elicit()` during execution, the server sends an
|
|
1661
|
+
* `elicitation/create` request to the client. This handler is called to
|
|
1662
|
+
* provide the response that would normally come from user interaction.
|
|
1663
|
+
*
|
|
1664
|
+
* @param handler - Function that receives the elicitation request and returns a response
|
|
1665
|
+
*
|
|
1666
|
+
* @example
|
|
1667
|
+
* ```typescript
|
|
1668
|
+
* // Simple acceptance
|
|
1669
|
+
* mcp.onElicitation(async () => ({
|
|
1670
|
+
* action: 'accept',
|
|
1671
|
+
* content: { confirmed: true }
|
|
1672
|
+
* }));
|
|
1673
|
+
*
|
|
1674
|
+
* // Conditional response based on request
|
|
1675
|
+
* mcp.onElicitation(async (request) => {
|
|
1676
|
+
* if (request.message.includes('delete')) {
|
|
1677
|
+
* return { action: 'decline' };
|
|
1678
|
+
* }
|
|
1679
|
+
* return { action: 'accept', content: { approved: true } };
|
|
1680
|
+
* });
|
|
1681
|
+
*
|
|
1682
|
+
* // Multi-step wizard
|
|
1683
|
+
* let step = 0;
|
|
1684
|
+
* mcp.onElicitation(async () => {
|
|
1685
|
+
* step++;
|
|
1686
|
+
* if (step === 1) return { action: 'accept', content: { name: 'Alice' } };
|
|
1687
|
+
* return { action: 'accept', content: { color: 'blue' } };
|
|
1688
|
+
* });
|
|
1689
|
+
* ```
|
|
1690
|
+
*/
|
|
1691
|
+
onElicitation(handler) {
|
|
1692
|
+
this._elicitationHandler = handler;
|
|
1693
|
+
if (this.transport?.setElicitationHandler) {
|
|
1694
|
+
this.transport.setElicitationHandler(handler);
|
|
1695
|
+
}
|
|
1696
|
+
this.log("debug", "Elicitation handler registered");
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Clear the elicitation handler.
|
|
1700
|
+
*
|
|
1701
|
+
* After calling this, elicitation requests from the server will not be
|
|
1702
|
+
* handled automatically. This can be used to test timeout scenarios.
|
|
1703
|
+
*/
|
|
1704
|
+
clearElicitationHandler() {
|
|
1705
|
+
this._elicitationHandler = void 0;
|
|
1706
|
+
if (this.transport?.setElicitationHandler) {
|
|
1707
|
+
this.transport.setElicitationHandler(void 0);
|
|
1708
|
+
}
|
|
1709
|
+
this.log("debug", "Elicitation handler cleared");
|
|
1710
|
+
}
|
|
1711
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1395
1712
|
// LOGGING & DEBUGGING
|
|
1396
1713
|
// ═══════════════════════════════════════════════════════════════════
|
|
1397
1714
|
logs = {
|
|
@@ -1616,7 +1933,10 @@ var McpTestClient = class {
|
|
|
1616
1933
|
// ═══════════════════════════════════════════════════════════════════
|
|
1617
1934
|
async initialize() {
|
|
1618
1935
|
const capabilities = this.config.capabilities ?? {
|
|
1619
|
-
sampling: {}
|
|
1936
|
+
sampling: {},
|
|
1937
|
+
elicitation: {
|
|
1938
|
+
form: {}
|
|
1939
|
+
}
|
|
1620
1940
|
};
|
|
1621
1941
|
return this.request("initialize", {
|
|
1622
1942
|
protocolVersion: this.config.protocolVersion,
|
|
@@ -1655,16 +1975,25 @@ var McpTestClient = class {
|
|
|
1655
1975
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1656
1976
|
// ═══════════════════════════════════════════════════════════════════
|
|
1657
1977
|
createTransport() {
|
|
1978
|
+
let baseUrl = this.config.baseUrl;
|
|
1979
|
+
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
1980
|
+
const url = new URL(baseUrl);
|
|
1981
|
+
Object.entries(this.config.queryParams).forEach(([key, value]) => {
|
|
1982
|
+
url.searchParams.set(key, String(value));
|
|
1983
|
+
});
|
|
1984
|
+
baseUrl = url.toString();
|
|
1985
|
+
}
|
|
1658
1986
|
switch (this.config.transport) {
|
|
1659
1987
|
case "streamable-http":
|
|
1660
1988
|
return new StreamableHttpTransport({
|
|
1661
|
-
baseUrl
|
|
1989
|
+
baseUrl,
|
|
1662
1990
|
timeout: this.config.timeout,
|
|
1663
1991
|
auth: this.config.auth,
|
|
1664
1992
|
publicMode: this.config.publicMode,
|
|
1665
1993
|
debug: this.config.debug,
|
|
1666
1994
|
interceptors: this._interceptors,
|
|
1667
|
-
clientInfo: this.config.clientInfo
|
|
1995
|
+
clientInfo: this.config.clientInfo,
|
|
1996
|
+
elicitationHandler: this._elicitationHandler
|
|
1668
1997
|
});
|
|
1669
1998
|
case "sse":
|
|
1670
1999
|
throw new Error("SSE transport not yet implemented");
|
|
@@ -1673,12 +2002,12 @@ var McpTestClient = class {
|
|
|
1673
2002
|
}
|
|
1674
2003
|
}
|
|
1675
2004
|
async request(method, params) {
|
|
1676
|
-
this.
|
|
2005
|
+
const transport = this.getConnectedTransport();
|
|
1677
2006
|
const id = ++this.requestIdCounter;
|
|
1678
2007
|
this._lastRequestId = id;
|
|
1679
2008
|
const start = Date.now();
|
|
1680
2009
|
try {
|
|
1681
|
-
const response = await
|
|
2010
|
+
const response = await transport.request({
|
|
1682
2011
|
jsonrpc: "2.0",
|
|
1683
2012
|
id,
|
|
1684
2013
|
method,
|
|
@@ -1717,10 +2046,14 @@ var McpTestClient = class {
|
|
|
1717
2046
|
};
|
|
1718
2047
|
}
|
|
1719
2048
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
2049
|
+
/**
|
|
2050
|
+
* Get the transport, throwing if not connected.
|
|
2051
|
+
*/
|
|
2052
|
+
getConnectedTransport() {
|
|
2053
|
+
if (!this.transport || !this.transport.isConnected()) {
|
|
1722
2054
|
throw new Error("Not connected to MCP server. Call connect() first.");
|
|
1723
2055
|
}
|
|
2056
|
+
return this.transport;
|
|
1724
2057
|
}
|
|
1725
2058
|
updateSessionActivity() {
|
|
1726
2059
|
if (this._sessionInfo) {
|
|
@@ -1973,6 +2306,9 @@ var TestTokenFactory = class {
|
|
|
1973
2306
|
scope: options.scopes?.join(" "),
|
|
1974
2307
|
...options.claims
|
|
1975
2308
|
};
|
|
2309
|
+
if (!this.privateKey) {
|
|
2310
|
+
throw new Error("Private key not initialized");
|
|
2311
|
+
}
|
|
1976
2312
|
const token = await new import_jose.SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
1977
2313
|
return token;
|
|
1978
2314
|
}
|
|
@@ -2032,6 +2368,9 @@ var TestTokenFactory = class {
|
|
|
2032
2368
|
exp: now - 3600
|
|
2033
2369
|
// Expired 1 hour ago
|
|
2034
2370
|
};
|
|
2371
|
+
if (!this.privateKey) {
|
|
2372
|
+
throw new Error("Private key not initialized");
|
|
2373
|
+
}
|
|
2035
2374
|
const token = await new import_jose.SignJWT(payload).setProtectedHeader({ alg: "RS256", kid: this.keyId }).sign(this.privateKey);
|
|
2036
2375
|
return token;
|
|
2037
2376
|
}
|
|
@@ -2058,6 +2397,9 @@ var TestTokenFactory = class {
|
|
|
2058
2397
|
*/
|
|
2059
2398
|
async getPublicJwks() {
|
|
2060
2399
|
await this.ensureKeys();
|
|
2400
|
+
if (!this.jwk) {
|
|
2401
|
+
throw new Error("JWK not initialized");
|
|
2402
|
+
}
|
|
2061
2403
|
return {
|
|
2062
2404
|
keys: [this.jwk]
|
|
2063
2405
|
};
|
|
@@ -2210,15 +2552,66 @@ function createTestUser(overrides) {
|
|
|
2210
2552
|
|
|
2211
2553
|
// libs/testing/src/auth/mock-oauth-server.ts
|
|
2212
2554
|
var import_http = require("http");
|
|
2555
|
+
var _randomBytes;
|
|
2556
|
+
var _sha256Base64url;
|
|
2557
|
+
var _base64urlEncode;
|
|
2558
|
+
async function loadCryptoUtils() {
|
|
2559
|
+
if (!_randomBytes) {
|
|
2560
|
+
const utils = await import("@frontmcp/utils");
|
|
2561
|
+
_randomBytes = utils.randomBytes;
|
|
2562
|
+
_sha256Base64url = utils.sha256Base64url;
|
|
2563
|
+
_base64urlEncode = utils.base64urlEncode;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2213
2566
|
var MockOAuthServer = class {
|
|
2214
2567
|
tokenFactory;
|
|
2215
2568
|
options;
|
|
2216
2569
|
server = null;
|
|
2217
2570
|
_info = null;
|
|
2218
2571
|
connections = /* @__PURE__ */ new Set();
|
|
2219
|
-
|
|
2220
|
-
|
|
2572
|
+
/** Authorization code storage (code -> record) */
|
|
2573
|
+
authCodes = /* @__PURE__ */ new Map();
|
|
2574
|
+
/** Refresh token storage (token -> record) */
|
|
2575
|
+
refreshTokens = /* @__PURE__ */ new Map();
|
|
2576
|
+
/** Access token TTL in seconds */
|
|
2577
|
+
accessTokenTtlSeconds;
|
|
2578
|
+
/** Refresh token TTL in seconds */
|
|
2579
|
+
refreshTokenTtlSeconds;
|
|
2580
|
+
constructor(tokenFactory3, options = {}) {
|
|
2581
|
+
this.tokenFactory = tokenFactory3;
|
|
2221
2582
|
this.options = options;
|
|
2583
|
+
this.accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? 3600;
|
|
2584
|
+
this.refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? 30 * 24 * 3600;
|
|
2585
|
+
}
|
|
2586
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2587
|
+
// CONFIGURATION METHODS
|
|
2588
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2589
|
+
/**
|
|
2590
|
+
* Set auto-approve mode for authorization requests
|
|
2591
|
+
*/
|
|
2592
|
+
setAutoApprove(enabled) {
|
|
2593
|
+
this.options.autoApprove = enabled;
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Set the test user returned on authorization
|
|
2597
|
+
*/
|
|
2598
|
+
setTestUser(user) {
|
|
2599
|
+
this.options.testUser = user;
|
|
2600
|
+
}
|
|
2601
|
+
/**
|
|
2602
|
+
* Add a valid redirect URI
|
|
2603
|
+
*/
|
|
2604
|
+
addValidRedirectUri(uri) {
|
|
2605
|
+
const uris = this.options.validRedirectUris ?? [];
|
|
2606
|
+
uris.push(uri);
|
|
2607
|
+
this.options.validRedirectUris = uris;
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* Clear all stored authorization codes and refresh tokens
|
|
2611
|
+
*/
|
|
2612
|
+
clearStoredTokens() {
|
|
2613
|
+
this.authCodes.clear();
|
|
2614
|
+
this.refreshTokens.clear();
|
|
2222
2615
|
}
|
|
2223
2616
|
/**
|
|
2224
2617
|
* Start the mock OAuth server
|
|
@@ -2302,8 +2695,9 @@ var MockOAuthServer = class {
|
|
|
2302
2695
|
// PRIVATE
|
|
2303
2696
|
// ═══════════════════════════════════════════════════════════════════
|
|
2304
2697
|
async handleRequest(req, res) {
|
|
2305
|
-
const
|
|
2306
|
-
|
|
2698
|
+
const fullUrl = req.url ?? "/";
|
|
2699
|
+
const [urlPath, queryString] = fullUrl.split("?");
|
|
2700
|
+
this.log(`${req.method} ${urlPath}`);
|
|
2307
2701
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
2308
2702
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
2309
2703
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
@@ -2313,20 +2707,28 @@ var MockOAuthServer = class {
|
|
|
2313
2707
|
return;
|
|
2314
2708
|
}
|
|
2315
2709
|
try {
|
|
2316
|
-
if (
|
|
2710
|
+
if (urlPath === "/.well-known/jwks.json" || urlPath === "/.well-known/jwks") {
|
|
2317
2711
|
await this.handleJwks(req, res);
|
|
2318
|
-
} else if (
|
|
2712
|
+
} else if (urlPath === "/.well-known/openid-configuration") {
|
|
2319
2713
|
await this.handleOidcConfig(req, res);
|
|
2320
|
-
} else if (
|
|
2714
|
+
} else if (urlPath === "/.well-known/oauth-authorization-server") {
|
|
2321
2715
|
await this.handleOAuthMetadata(req, res);
|
|
2322
|
-
} else if (
|
|
2716
|
+
} else if (urlPath === "/oauth/authorize") {
|
|
2717
|
+
await this.handleAuthorizeEndpoint(req, res, queryString);
|
|
2718
|
+
} else if (urlPath === "/oauth/token") {
|
|
2323
2719
|
await this.handleTokenEndpoint(req, res);
|
|
2720
|
+
} else if (urlPath === "/userinfo") {
|
|
2721
|
+
await this.handleUserInfoEndpoint(req, res);
|
|
2722
|
+
} else if (urlPath === "/oauth/authorize/submit" && req.method === "POST") {
|
|
2723
|
+
await this.handleAuthorizeSubmit(req, res);
|
|
2324
2724
|
} else {
|
|
2325
2725
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2326
2726
|
res.end(JSON.stringify({ error: "not_found", error_description: "Endpoint not found" }));
|
|
2327
2727
|
}
|
|
2328
2728
|
} catch (error) {
|
|
2329
|
-
|
|
2729
|
+
const errorMsg = error instanceof Error ? `${error.message}
|
|
2730
|
+
${error.stack}` : String(error);
|
|
2731
|
+
this.logError(`Error handling request to ${urlPath}: ${errorMsg}`);
|
|
2330
2732
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2331
2733
|
res.end(JSON.stringify({ error: "server_error", error_description: "Internal server error" }));
|
|
2332
2734
|
}
|
|
@@ -2344,13 +2746,13 @@ var MockOAuthServer = class {
|
|
|
2344
2746
|
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
2345
2747
|
token_endpoint: `${issuer}/oauth/token`,
|
|
2346
2748
|
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
2347
|
-
response_types_supported: ["code"
|
|
2749
|
+
response_types_supported: ["code"],
|
|
2348
2750
|
subject_types_supported: ["public"],
|
|
2349
2751
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
2350
2752
|
scopes_supported: ["openid", "profile", "email"],
|
|
2351
2753
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
2352
2754
|
claims_supported: ["sub", "iss", "aud", "exp", "iat", "email", "name"],
|
|
2353
|
-
grant_types_supported: ["authorization_code", "refresh_token", "
|
|
2755
|
+
grant_types_supported: ["authorization_code", "refresh_token", "anonymous"]
|
|
2354
2756
|
};
|
|
2355
2757
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2356
2758
|
res.end(JSON.stringify(config));
|
|
@@ -2363,8 +2765,8 @@ var MockOAuthServer = class {
|
|
|
2363
2765
|
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
2364
2766
|
token_endpoint: `${issuer}/oauth/token`,
|
|
2365
2767
|
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
2366
|
-
response_types_supported: ["code"
|
|
2367
|
-
grant_types_supported: ["authorization_code", "refresh_token", "
|
|
2768
|
+
response_types_supported: ["code"],
|
|
2769
|
+
grant_types_supported: ["authorization_code", "refresh_token", "anonymous"],
|
|
2368
2770
|
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
2369
2771
|
scopes_supported: ["openid", "profile", "email", "anonymous"]
|
|
2370
2772
|
};
|
|
@@ -2372,9 +2774,112 @@ var MockOAuthServer = class {
|
|
|
2372
2774
|
res.end(JSON.stringify(metadata));
|
|
2373
2775
|
this.log("Served OAuth metadata");
|
|
2374
2776
|
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Handle authorization endpoint (GET /oauth/authorize)
|
|
2779
|
+
* Supports auto-approve mode for E2E testing
|
|
2780
|
+
*/
|
|
2781
|
+
async handleAuthorizeEndpoint(_req, res, queryString) {
|
|
2782
|
+
const params = new URLSearchParams(queryString ?? "");
|
|
2783
|
+
const clientId = params.get("client_id");
|
|
2784
|
+
const redirectUri = params.get("redirect_uri");
|
|
2785
|
+
const responseType = params.get("response_type");
|
|
2786
|
+
const state = params.get("state") ?? void 0;
|
|
2787
|
+
const scope = params.get("scope") ?? "openid";
|
|
2788
|
+
const codeChallenge = params.get("code_challenge") ?? void 0;
|
|
2789
|
+
const codeChallengeMethod = params.get("code_challenge_method") ?? void 0;
|
|
2790
|
+
if (!clientId || !redirectUri || !responseType) {
|
|
2791
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2792
|
+
res.end(
|
|
2793
|
+
JSON.stringify({
|
|
2794
|
+
error: "invalid_request",
|
|
2795
|
+
error_description: "Missing required parameters: client_id, redirect_uri, response_type"
|
|
2796
|
+
})
|
|
2797
|
+
);
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
if (responseType !== "code") {
|
|
2801
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2802
|
+
res.end(
|
|
2803
|
+
JSON.stringify({
|
|
2804
|
+
error: "unsupported_response_type",
|
|
2805
|
+
error_description: "Only response_type=code is supported"
|
|
2806
|
+
})
|
|
2807
|
+
);
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
if (!this.isValidRedirectUri(redirectUri)) {
|
|
2811
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2812
|
+
res.end(
|
|
2813
|
+
JSON.stringify({
|
|
2814
|
+
error: "invalid_request",
|
|
2815
|
+
error_description: "Invalid redirect_uri"
|
|
2816
|
+
})
|
|
2817
|
+
);
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
if (this.options.clientId && clientId !== this.options.clientId) {
|
|
2821
|
+
this.redirectWithError(res, redirectUri, "unauthorized_client", "Invalid client_id", state);
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
if (this.options.autoApprove) {
|
|
2825
|
+
const testUser = this.options.testUser;
|
|
2826
|
+
if (!testUser) {
|
|
2827
|
+
this.logError("autoApprove is enabled but no testUser configured. Set testUser in MockOAuthServerOptions.");
|
|
2828
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2829
|
+
res.end(
|
|
2830
|
+
JSON.stringify({
|
|
2831
|
+
error: "server_error",
|
|
2832
|
+
error_description: "autoApprove is enabled but no testUser configured"
|
|
2833
|
+
})
|
|
2834
|
+
);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
const code = this.generateCode();
|
|
2838
|
+
const scopes = scope.split(" ").filter(Boolean);
|
|
2839
|
+
this.authCodes.set(code, {
|
|
2840
|
+
code,
|
|
2841
|
+
clientId,
|
|
2842
|
+
redirectUri,
|
|
2843
|
+
codeChallenge,
|
|
2844
|
+
codeChallengeMethod,
|
|
2845
|
+
scopes,
|
|
2846
|
+
user: testUser,
|
|
2847
|
+
state,
|
|
2848
|
+
expiresAt: Date.now() + 5 * 60 * 1e3,
|
|
2849
|
+
// 5 minutes
|
|
2850
|
+
used: false
|
|
2851
|
+
});
|
|
2852
|
+
const callbackUrl = new URL(redirectUri);
|
|
2853
|
+
callbackUrl.searchParams.set("code", code);
|
|
2854
|
+
if (state) {
|
|
2855
|
+
callbackUrl.searchParams.set("state", state);
|
|
2856
|
+
}
|
|
2857
|
+
this.log(`Auto-approved auth request, redirecting with code to ${callbackUrl.origin}${callbackUrl.pathname}`);
|
|
2858
|
+
res.writeHead(302, { Location: callbackUrl.toString() });
|
|
2859
|
+
res.end();
|
|
2860
|
+
return;
|
|
2861
|
+
}
|
|
2862
|
+
const html = this.renderLoginPage(clientId, redirectUri, scope, state, codeChallenge, codeChallengeMethod);
|
|
2863
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
2864
|
+
res.end(html);
|
|
2865
|
+
}
|
|
2375
2866
|
async handleTokenEndpoint(req, res) {
|
|
2376
2867
|
const body = await this.readBody(req);
|
|
2377
2868
|
const params = new URLSearchParams(body);
|
|
2869
|
+
const authHeader = req.headers["authorization"];
|
|
2870
|
+
let clientSecret = params.get("client_secret") ?? void 0;
|
|
2871
|
+
if (!clientSecret && authHeader?.startsWith("Basic ")) {
|
|
2872
|
+
const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf8");
|
|
2873
|
+
const colonIndex = decoded.indexOf(":");
|
|
2874
|
+
if (colonIndex >= 0) {
|
|
2875
|
+
clientSecret = decoded.slice(colonIndex + 1);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
if (this.options.clientSecret && clientSecret !== this.options.clientSecret) {
|
|
2879
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
2880
|
+
res.end(JSON.stringify({ error: "invalid_client", error_description: "Invalid client_secret" }));
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2378
2883
|
const grantType = params.get("grant_type");
|
|
2379
2884
|
if (grantType === "anonymous") {
|
|
2380
2885
|
const token = await this.tokenFactory.createAnonymousToken();
|
|
@@ -2383,84 +2888,596 @@ var MockOAuthServer = class {
|
|
|
2383
2888
|
JSON.stringify({
|
|
2384
2889
|
access_token: token,
|
|
2385
2890
|
token_type: "Bearer",
|
|
2386
|
-
expires_in:
|
|
2891
|
+
expires_in: this.accessTokenTtlSeconds
|
|
2387
2892
|
})
|
|
2388
2893
|
);
|
|
2389
2894
|
this.log("Issued anonymous token");
|
|
2895
|
+
} else if (grantType === "authorization_code") {
|
|
2896
|
+
await this.handleAuthorizationCodeGrant(params, res);
|
|
2897
|
+
} else if (grantType === "refresh_token") {
|
|
2898
|
+
await this.handleRefreshTokenGrant(params, res);
|
|
2390
2899
|
} else {
|
|
2391
2900
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2392
2901
|
res.end(
|
|
2393
2902
|
JSON.stringify({
|
|
2394
2903
|
error: "unsupported_grant_type",
|
|
2395
|
-
error_description:
|
|
2904
|
+
error_description: `Unsupported grant_type: ${grantType}`
|
|
2396
2905
|
})
|
|
2397
2906
|
);
|
|
2398
2907
|
}
|
|
2399
2908
|
}
|
|
2400
|
-
readBody(req) {
|
|
2401
|
-
return new Promise((resolve, reject) => {
|
|
2402
|
-
const chunks = [];
|
|
2403
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
2404
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
2405
|
-
req.on("error", reject);
|
|
2406
|
-
});
|
|
2407
|
-
}
|
|
2408
|
-
log(message) {
|
|
2409
|
-
if (this.options.debug) {
|
|
2410
|
-
console.log(`[MockOAuthServer] ${message}`);
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
};
|
|
2414
|
-
|
|
2415
|
-
// libs/testing/src/auth/mock-api-server.ts
|
|
2416
|
-
var import_http2 = require("http");
|
|
2417
|
-
var MockAPIServer = class {
|
|
2418
|
-
options;
|
|
2419
|
-
server = null;
|
|
2420
|
-
_info = null;
|
|
2421
|
-
routes;
|
|
2422
|
-
constructor(options) {
|
|
2423
|
-
this.options = options;
|
|
2424
|
-
this.routes = options.routes ?? [];
|
|
2425
|
-
for (const route of this.routes) {
|
|
2426
|
-
this.validateRoute(route);
|
|
2427
|
-
}
|
|
2428
|
-
}
|
|
2429
2909
|
/**
|
|
2430
|
-
*
|
|
2910
|
+
* Handle authorization_code grant type
|
|
2431
2911
|
*/
|
|
2432
|
-
async
|
|
2433
|
-
|
|
2434
|
-
|
|
2912
|
+
async handleAuthorizationCodeGrant(params, res) {
|
|
2913
|
+
const code = params.get("code");
|
|
2914
|
+
const redirectUri = params.get("redirect_uri");
|
|
2915
|
+
const clientId = params.get("client_id");
|
|
2916
|
+
const codeVerifier = params.get("code_verifier");
|
|
2917
|
+
if (!code || !redirectUri || !clientId) {
|
|
2918
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2919
|
+
res.end(
|
|
2920
|
+
JSON.stringify({
|
|
2921
|
+
error: "invalid_request",
|
|
2922
|
+
error_description: "Missing required parameters: code, redirect_uri, client_id"
|
|
2923
|
+
})
|
|
2924
|
+
);
|
|
2925
|
+
return;
|
|
2435
2926
|
}
|
|
2436
|
-
const
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2927
|
+
const codeRecord = this.authCodes.get(code);
|
|
2928
|
+
if (!codeRecord) {
|
|
2929
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2930
|
+
res.end(
|
|
2931
|
+
JSON.stringify({
|
|
2932
|
+
error: "invalid_grant",
|
|
2933
|
+
error_description: "Authorization code not found or expired"
|
|
2934
|
+
})
|
|
2935
|
+
);
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
if (codeRecord.used) {
|
|
2939
|
+
this.authCodes.delete(code);
|
|
2940
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2941
|
+
res.end(
|
|
2942
|
+
JSON.stringify({
|
|
2943
|
+
error: "invalid_grant",
|
|
2944
|
+
error_description: "Authorization code has already been used"
|
|
2945
|
+
})
|
|
2946
|
+
);
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
if (codeRecord.expiresAt < Date.now()) {
|
|
2950
|
+
this.authCodes.delete(code);
|
|
2951
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2952
|
+
res.end(
|
|
2953
|
+
JSON.stringify({
|
|
2954
|
+
error: "invalid_grant",
|
|
2955
|
+
error_description: "Authorization code has expired"
|
|
2956
|
+
})
|
|
2957
|
+
);
|
|
2958
|
+
return;
|
|
2959
|
+
}
|
|
2960
|
+
if (codeRecord.clientId !== clientId) {
|
|
2961
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2962
|
+
res.end(
|
|
2963
|
+
JSON.stringify({
|
|
2964
|
+
error: "invalid_grant",
|
|
2965
|
+
error_description: "client_id mismatch"
|
|
2966
|
+
})
|
|
2967
|
+
);
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
if (codeRecord.redirectUri !== redirectUri) {
|
|
2971
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2972
|
+
res.end(
|
|
2973
|
+
JSON.stringify({
|
|
2974
|
+
error: "invalid_grant",
|
|
2975
|
+
error_description: "redirect_uri mismatch"
|
|
2976
|
+
})
|
|
2977
|
+
);
|
|
2978
|
+
return;
|
|
2979
|
+
}
|
|
2980
|
+
if (codeRecord.codeChallenge) {
|
|
2981
|
+
if (!codeVerifier) {
|
|
2982
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2983
|
+
res.end(
|
|
2984
|
+
JSON.stringify({
|
|
2985
|
+
error: "invalid_grant",
|
|
2986
|
+
error_description: "code_verifier required"
|
|
2987
|
+
})
|
|
2988
|
+
);
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
const method = codeRecord.codeChallengeMethod ?? "plain";
|
|
2992
|
+
if (method !== "S256" && method !== "plain") {
|
|
2993
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2994
|
+
res.end(
|
|
2995
|
+
JSON.stringify({
|
|
2996
|
+
error: "invalid_grant",
|
|
2997
|
+
error_description: `Unsupported code_challenge_method: ${method}`
|
|
2998
|
+
})
|
|
2999
|
+
);
|
|
3000
|
+
return;
|
|
3001
|
+
}
|
|
3002
|
+
const expectedChallenge = await this.computeCodeChallengeAsync(codeVerifier, method);
|
|
3003
|
+
if (expectedChallenge !== codeRecord.codeChallenge) {
|
|
3004
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3005
|
+
res.end(
|
|
3006
|
+
JSON.stringify({
|
|
3007
|
+
error: "invalid_grant",
|
|
3008
|
+
error_description: "PKCE verification failed"
|
|
3009
|
+
})
|
|
3010
|
+
);
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
codeRecord.used = true;
|
|
3015
|
+
const accessToken = await this.tokenFactory.createTestToken({
|
|
3016
|
+
sub: codeRecord.user.sub,
|
|
3017
|
+
claims: {
|
|
3018
|
+
email: codeRecord.user.email,
|
|
3019
|
+
name: codeRecord.user.name,
|
|
3020
|
+
...codeRecord.user.claims ?? {}
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
3023
|
+
const idToken = await this.tokenFactory.createTestToken({
|
|
3024
|
+
sub: codeRecord.user.sub,
|
|
3025
|
+
claims: {
|
|
3026
|
+
email: codeRecord.user.email,
|
|
3027
|
+
name: codeRecord.user.name,
|
|
3028
|
+
...codeRecord.user.claims ?? {}
|
|
3029
|
+
}
|
|
3030
|
+
});
|
|
3031
|
+
const refreshToken = this.generateCode();
|
|
3032
|
+
this.refreshTokens.set(refreshToken, {
|
|
3033
|
+
token: refreshToken,
|
|
3034
|
+
clientId,
|
|
3035
|
+
user: codeRecord.user,
|
|
3036
|
+
scopes: codeRecord.scopes,
|
|
3037
|
+
expiresAt: Date.now() + this.refreshTokenTtlSeconds * 1e3
|
|
3038
|
+
});
|
|
3039
|
+
this.log(`Issued tokens for user: ${codeRecord.user.sub}`);
|
|
3040
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3041
|
+
res.end(
|
|
3042
|
+
JSON.stringify({
|
|
3043
|
+
access_token: accessToken,
|
|
3044
|
+
token_type: "Bearer",
|
|
3045
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
3046
|
+
refresh_token: refreshToken,
|
|
3047
|
+
id_token: idToken,
|
|
3048
|
+
scope: codeRecord.scopes.join(" ")
|
|
3049
|
+
})
|
|
3050
|
+
);
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Handle refresh_token grant type
|
|
3054
|
+
*/
|
|
3055
|
+
async handleRefreshTokenGrant(params, res) {
|
|
3056
|
+
const refreshToken = params.get("refresh_token");
|
|
3057
|
+
const clientId = params.get("client_id");
|
|
3058
|
+
if (!refreshToken || !clientId) {
|
|
3059
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3060
|
+
res.end(
|
|
3061
|
+
JSON.stringify({
|
|
3062
|
+
error: "invalid_request",
|
|
3063
|
+
error_description: "Missing required parameters: refresh_token, client_id"
|
|
3064
|
+
})
|
|
3065
|
+
);
|
|
3066
|
+
return;
|
|
3067
|
+
}
|
|
3068
|
+
const tokenRecord = this.refreshTokens.get(refreshToken);
|
|
3069
|
+
if (!tokenRecord) {
|
|
3070
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3071
|
+
res.end(
|
|
3072
|
+
JSON.stringify({
|
|
3073
|
+
error: "invalid_grant",
|
|
3074
|
+
error_description: "Refresh token not found or expired"
|
|
3075
|
+
})
|
|
3076
|
+
);
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
if (tokenRecord.expiresAt < Date.now()) {
|
|
3080
|
+
this.refreshTokens.delete(refreshToken);
|
|
3081
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3082
|
+
res.end(
|
|
3083
|
+
JSON.stringify({
|
|
3084
|
+
error: "invalid_grant",
|
|
3085
|
+
error_description: "Refresh token has expired"
|
|
3086
|
+
})
|
|
3087
|
+
);
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
if (tokenRecord.clientId !== clientId) {
|
|
3091
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3092
|
+
res.end(
|
|
3093
|
+
JSON.stringify({
|
|
3094
|
+
error: "invalid_grant",
|
|
3095
|
+
error_description: "client_id mismatch"
|
|
3096
|
+
})
|
|
3097
|
+
);
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
const accessToken = await this.tokenFactory.createTestToken({
|
|
3101
|
+
sub: tokenRecord.user.sub,
|
|
3102
|
+
claims: {
|
|
3103
|
+
email: tokenRecord.user.email,
|
|
3104
|
+
name: tokenRecord.user.name,
|
|
3105
|
+
...tokenRecord.user.claims ?? {}
|
|
3106
|
+
}
|
|
3107
|
+
});
|
|
3108
|
+
this.refreshTokens.delete(refreshToken);
|
|
3109
|
+
const newRefreshToken = this.generateCode();
|
|
3110
|
+
this.refreshTokens.set(newRefreshToken, {
|
|
3111
|
+
...tokenRecord,
|
|
3112
|
+
token: newRefreshToken,
|
|
3113
|
+
expiresAt: Date.now() + this.refreshTokenTtlSeconds * 1e3
|
|
3114
|
+
});
|
|
3115
|
+
this.log(`Refreshed tokens for user: ${tokenRecord.user.sub}`);
|
|
3116
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3117
|
+
res.end(
|
|
3118
|
+
JSON.stringify({
|
|
3119
|
+
access_token: accessToken,
|
|
3120
|
+
token_type: "Bearer",
|
|
3121
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
3122
|
+
refresh_token: newRefreshToken,
|
|
3123
|
+
scope: tokenRecord.scopes.join(" ")
|
|
3124
|
+
})
|
|
3125
|
+
);
|
|
3126
|
+
}
|
|
3127
|
+
/**
|
|
3128
|
+
* Handle userinfo endpoint (GET /userinfo)
|
|
3129
|
+
*/
|
|
3130
|
+
async handleUserInfoEndpoint(req, res) {
|
|
3131
|
+
const authHeader = req.headers["authorization"];
|
|
3132
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
3133
|
+
res.writeHead(401, { "WWW-Authenticate": 'Bearer error="invalid_token"' });
|
|
3134
|
+
res.end(JSON.stringify({ error: "invalid_token", error_description: "Missing or invalid Authorization header" }));
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
const testUser = this.options.testUser;
|
|
3138
|
+
if (!testUser) {
|
|
3139
|
+
this.logError("UserInfo endpoint called but no testUser configured. Set testUser in MockOAuthServerOptions.");
|
|
3140
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3141
|
+
res.end(JSON.stringify({ error: "server_error", error_description: "No test user configured" }));
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3145
|
+
res.end(
|
|
3146
|
+
JSON.stringify({
|
|
3147
|
+
sub: testUser.sub,
|
|
3148
|
+
email: testUser.email,
|
|
3149
|
+
name: testUser.name,
|
|
3150
|
+
picture: testUser.picture,
|
|
3151
|
+
...testUser.claims ?? {}
|
|
3152
|
+
})
|
|
3153
|
+
);
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Handle authorize form submission (POST /oauth/authorize/submit)
|
|
3157
|
+
* Processes the manual login form for non-autoApprove testing
|
|
3158
|
+
*/
|
|
3159
|
+
async handleAuthorizeSubmit(req, res) {
|
|
3160
|
+
const body = await this.readBody(req);
|
|
3161
|
+
const params = new URLSearchParams(body);
|
|
3162
|
+
const clientId = params.get("client_id");
|
|
3163
|
+
const redirectUri = params.get("redirect_uri");
|
|
3164
|
+
const scope = params.get("scope") ?? "openid";
|
|
3165
|
+
const state = params.get("state") ?? void 0;
|
|
3166
|
+
const codeChallenge = params.get("code_challenge") ?? void 0;
|
|
3167
|
+
const codeChallengeMethod = params.get("code_challenge_method") ?? void 0;
|
|
3168
|
+
const action = params.get("action");
|
|
3169
|
+
const sub = params.get("sub");
|
|
3170
|
+
const email = params.get("email") ?? void 0;
|
|
3171
|
+
const name = params.get("name") ?? void 0;
|
|
3172
|
+
if (!clientId || !redirectUri || !sub) {
|
|
3173
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3174
|
+
res.end(
|
|
3175
|
+
JSON.stringify({
|
|
3176
|
+
error: "invalid_request",
|
|
3177
|
+
error_description: "Missing required fields"
|
|
3178
|
+
})
|
|
3179
|
+
);
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
if (!this.isValidRedirectUri(redirectUri)) {
|
|
3183
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3184
|
+
res.end(
|
|
3185
|
+
JSON.stringify({
|
|
3186
|
+
error: "invalid_request",
|
|
3187
|
+
error_description: "Invalid redirect_uri"
|
|
3188
|
+
})
|
|
3189
|
+
);
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
if (this.options.clientId && clientId !== this.options.clientId) {
|
|
3193
|
+
this.redirectWithError(res, redirectUri, "unauthorized_client", "Invalid client_id", state);
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
if (action === "deny") {
|
|
3197
|
+
this.redirectWithError(res, redirectUri, "access_denied", "User denied the authorization request", state);
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
const testUser = {
|
|
3201
|
+
sub,
|
|
3202
|
+
email,
|
|
3203
|
+
name
|
|
3204
|
+
};
|
|
3205
|
+
const code = this.generateCode();
|
|
3206
|
+
const scopes = scope.split(" ").filter(Boolean);
|
|
3207
|
+
this.authCodes.set(code, {
|
|
3208
|
+
code,
|
|
3209
|
+
clientId,
|
|
3210
|
+
redirectUri,
|
|
3211
|
+
codeChallenge,
|
|
3212
|
+
codeChallengeMethod,
|
|
3213
|
+
scopes,
|
|
3214
|
+
user: testUser,
|
|
3215
|
+
state,
|
|
3216
|
+
expiresAt: Date.now() + 5 * 60 * 1e3,
|
|
3217
|
+
// 5 minutes
|
|
3218
|
+
used: false
|
|
3219
|
+
});
|
|
3220
|
+
const callbackUrl = new URL(redirectUri);
|
|
3221
|
+
callbackUrl.searchParams.set("code", code);
|
|
3222
|
+
if (state) {
|
|
3223
|
+
callbackUrl.searchParams.set("state", state);
|
|
3224
|
+
}
|
|
3225
|
+
this.log(
|
|
3226
|
+
`Manual auth approved for user: ${sub}, redirecting with code to ${callbackUrl.origin}${callbackUrl.pathname}`
|
|
3227
|
+
);
|
|
3228
|
+
res.writeHead(302, { Location: callbackUrl.toString() });
|
|
3229
|
+
res.end();
|
|
3230
|
+
}
|
|
3231
|
+
readBody(req) {
|
|
3232
|
+
return new Promise((resolve, reject) => {
|
|
3233
|
+
const chunks = [];
|
|
3234
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3235
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
3236
|
+
req.on("error", reject);
|
|
3237
|
+
});
|
|
3238
|
+
}
|
|
3239
|
+
log(message) {
|
|
3240
|
+
const shouldLog = this.options.debug || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3241
|
+
if (shouldLog) {
|
|
3242
|
+
console.log(`[MockOAuthServer] ${message}`);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
logError(message) {
|
|
3246
|
+
console.error(`[MockOAuthServer ERROR] ${message}`);
|
|
3247
|
+
}
|
|
3248
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3249
|
+
// HELPER METHODS
|
|
3250
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3251
|
+
/**
|
|
3252
|
+
* Validate redirect URI against configured valid URIs
|
|
3253
|
+
* Supports wildcards for port (e.g., http://localhost:*)
|
|
3254
|
+
*/
|
|
3255
|
+
isValidRedirectUri(redirectUri) {
|
|
3256
|
+
const validUris = this.options.validRedirectUris;
|
|
3257
|
+
if (!validUris || validUris.length === 0) {
|
|
3258
|
+
return true;
|
|
3259
|
+
}
|
|
3260
|
+
if (redirectUri.length > 2048) {
|
|
3261
|
+
return false;
|
|
3262
|
+
}
|
|
3263
|
+
for (const pattern of validUris) {
|
|
3264
|
+
if (pattern === redirectUri) {
|
|
3265
|
+
return true;
|
|
3266
|
+
}
|
|
3267
|
+
if (pattern.includes("*")) {
|
|
3268
|
+
if (this.matchWildcardPattern(pattern, redirectUri)) {
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
return false;
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* Safe string-based wildcard matching (O(n) complexity)
|
|
3277
|
+
* Avoids regex to prevent ReDoS vulnerabilities
|
|
3278
|
+
*/
|
|
3279
|
+
matchWildcardPattern(pattern, input) {
|
|
3280
|
+
const parts = pattern.split("*");
|
|
3281
|
+
let remaining = input;
|
|
3282
|
+
if (!remaining.startsWith(parts[0])) {
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3285
|
+
remaining = remaining.slice(parts[0].length);
|
|
3286
|
+
for (let i = 1; i < parts.length; i++) {
|
|
3287
|
+
const part = parts[i];
|
|
3288
|
+
if (i === parts.length - 1) {
|
|
3289
|
+
if (!remaining.endsWith(part)) {
|
|
3290
|
+
return false;
|
|
3291
|
+
}
|
|
3292
|
+
} else {
|
|
3293
|
+
const idx = remaining.indexOf(part);
|
|
3294
|
+
if (idx === -1) {
|
|
3295
|
+
return false;
|
|
3296
|
+
}
|
|
3297
|
+
remaining = remaining.slice(idx + part.length);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
return true;
|
|
3301
|
+
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Redirect with OAuth error
|
|
3304
|
+
*/
|
|
3305
|
+
redirectWithError(res, redirectUri, error, errorDescription, state) {
|
|
3306
|
+
const url = new URL(redirectUri);
|
|
3307
|
+
url.searchParams.set("error", error);
|
|
3308
|
+
url.searchParams.set("error_description", errorDescription);
|
|
3309
|
+
if (state) {
|
|
3310
|
+
url.searchParams.set("state", state);
|
|
3311
|
+
}
|
|
3312
|
+
res.writeHead(302, { Location: url.toString() });
|
|
3313
|
+
res.end();
|
|
3314
|
+
}
|
|
3315
|
+
/**
|
|
3316
|
+
* Generate a random authorization code
|
|
3317
|
+
*/
|
|
3318
|
+
async generateCodeAsync() {
|
|
3319
|
+
await loadCryptoUtils();
|
|
3320
|
+
return _base64urlEncode(_randomBytes(32));
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3323
|
+
* Generate a random authorization code (sync wrapper for compatibility)
|
|
3324
|
+
*/
|
|
3325
|
+
generateCode() {
|
|
3326
|
+
const bytes = new Uint8Array(32);
|
|
3327
|
+
crypto.getRandomValues(bytes);
|
|
3328
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
3329
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
3330
|
+
}
|
|
3331
|
+
/**
|
|
3332
|
+
* Compute PKCE code challenge from verifier
|
|
3333
|
+
*/
|
|
3334
|
+
async computeCodeChallengeAsync(verifier, method) {
|
|
3335
|
+
if (method === "S256") {
|
|
3336
|
+
await loadCryptoUtils();
|
|
3337
|
+
return _sha256Base64url(verifier);
|
|
3338
|
+
}
|
|
3339
|
+
return verifier;
|
|
3340
|
+
}
|
|
3341
|
+
/**
|
|
3342
|
+
* Render a simple login page for manual testing
|
|
3343
|
+
*/
|
|
3344
|
+
renderLoginPage(clientId, redirectUri, scope, state, codeChallenge, codeChallengeMethod) {
|
|
3345
|
+
const issuer = this._info?.issuer ?? "http://localhost";
|
|
3346
|
+
return `<!DOCTYPE html>
|
|
3347
|
+
<html lang="en">
|
|
3348
|
+
<head>
|
|
3349
|
+
<meta charset="UTF-8">
|
|
3350
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
3351
|
+
<title>Mock OAuth Login</title>
|
|
3352
|
+
<style>
|
|
3353
|
+
body {
|
|
3354
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
3355
|
+
background: #f5f5f5;
|
|
3356
|
+
min-height: 100vh;
|
|
3357
|
+
display: flex;
|
|
3358
|
+
align-items: center;
|
|
3359
|
+
justify-content: center;
|
|
3360
|
+
padding: 20px;
|
|
3361
|
+
}
|
|
3362
|
+
.container {
|
|
3363
|
+
background: white;
|
|
3364
|
+
padding: 40px;
|
|
3365
|
+
border-radius: 12px;
|
|
3366
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
3367
|
+
max-width: 400px;
|
|
3368
|
+
width: 100%;
|
|
3369
|
+
}
|
|
3370
|
+
h1 { margin-top: 0; color: #333; }
|
|
3371
|
+
.info { color: #666; font-size: 14px; margin-bottom: 20px; }
|
|
3372
|
+
.field { margin-bottom: 15px; }
|
|
3373
|
+
label { display: block; margin-bottom: 5px; font-weight: 500; }
|
|
3374
|
+
input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
|
|
3375
|
+
button {
|
|
3376
|
+
width: 100%;
|
|
3377
|
+
padding: 12px;
|
|
3378
|
+
background: #667eea;
|
|
3379
|
+
color: white;
|
|
3380
|
+
border: none;
|
|
3381
|
+
border-radius: 6px;
|
|
3382
|
+
cursor: pointer;
|
|
3383
|
+
font-size: 16px;
|
|
3384
|
+
}
|
|
3385
|
+
button:hover { background: #5a6fd6; }
|
|
3386
|
+
.deny { background: #e53e3e; margin-top: 10px; }
|
|
3387
|
+
.deny:hover { background: #c53030; }
|
|
3388
|
+
</style>
|
|
3389
|
+
</head>
|
|
3390
|
+
<body>
|
|
3391
|
+
<div class="container">
|
|
3392
|
+
<h1>Mock OAuth Login</h1>
|
|
3393
|
+
<p class="info">
|
|
3394
|
+
<strong>Client:</strong> ${this.escapeHtml(clientId)}<br>
|
|
3395
|
+
<strong>Scopes:</strong> ${this.escapeHtml(scope)}<br>
|
|
3396
|
+
<strong>Issuer:</strong> ${this.escapeHtml(issuer)}
|
|
3397
|
+
</p>
|
|
3398
|
+
<form method="POST" action="/oauth/authorize/submit">
|
|
3399
|
+
<input type="hidden" name="client_id" value="${this.escapeHtml(clientId)}">
|
|
3400
|
+
<input type="hidden" name="redirect_uri" value="${this.escapeHtml(redirectUri)}">
|
|
3401
|
+
<input type="hidden" name="scope" value="${this.escapeHtml(scope)}">
|
|
3402
|
+
${state ? `<input type="hidden" name="state" value="${this.escapeHtml(state)}">` : ""}
|
|
3403
|
+
${codeChallenge ? `<input type="hidden" name="code_challenge" value="${this.escapeHtml(codeChallenge)}">` : ""}
|
|
3404
|
+
${codeChallengeMethod ? `<input type="hidden" name="code_challenge_method" value="${this.escapeHtml(codeChallengeMethod)}">` : ""}
|
|
3405
|
+
<div class="field">
|
|
3406
|
+
<label for="sub">User ID (sub)</label>
|
|
3407
|
+
<input type="text" id="sub" name="sub" value="test-user-123" required>
|
|
3408
|
+
</div>
|
|
3409
|
+
<div class="field">
|
|
3410
|
+
<label for="email">Email</label>
|
|
3411
|
+
<input type="email" id="email" name="email" value="test@example.com">
|
|
3412
|
+
</div>
|
|
3413
|
+
<div class="field">
|
|
3414
|
+
<label for="name">Name</label>
|
|
3415
|
+
<input type="text" id="name" name="name" value="Test User">
|
|
3416
|
+
</div>
|
|
3417
|
+
<button type="submit" name="action" value="approve">Approve</button>
|
|
3418
|
+
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
|
3419
|
+
</form>
|
|
3420
|
+
</div>
|
|
3421
|
+
</body>
|
|
3422
|
+
</html>`;
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Escape HTML to prevent XSS
|
|
3426
|
+
*/
|
|
3427
|
+
escapeHtml(text) {
|
|
3428
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3429
|
+
}
|
|
3430
|
+
};
|
|
3431
|
+
|
|
3432
|
+
// libs/testing/src/auth/mock-api-server.ts
|
|
3433
|
+
var import_http2 = require("http");
|
|
3434
|
+
var MockAPIServer = class {
|
|
3435
|
+
options;
|
|
3436
|
+
server = null;
|
|
3437
|
+
_info = null;
|
|
3438
|
+
routes;
|
|
3439
|
+
constructor(options) {
|
|
3440
|
+
this.options = options;
|
|
3441
|
+
this.routes = options.routes ?? [];
|
|
3442
|
+
for (const route of this.routes) {
|
|
3443
|
+
this.validateRoute(route);
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
/**
|
|
3447
|
+
* Start the mock API server
|
|
3448
|
+
*/
|
|
3449
|
+
async start() {
|
|
3450
|
+
if (this.server) {
|
|
3451
|
+
throw new Error("Mock API server is already running");
|
|
3452
|
+
}
|
|
3453
|
+
const port = this.options.port ?? 0;
|
|
3454
|
+
return new Promise((resolve, reject) => {
|
|
3455
|
+
const server = (0, import_http2.createServer)(this.handleRequest.bind(this));
|
|
3456
|
+
this.server = server;
|
|
3457
|
+
server.on("error", (err) => {
|
|
3458
|
+
this.log(`Server error: ${err.message}`);
|
|
3459
|
+
reject(err);
|
|
3460
|
+
});
|
|
3461
|
+
server.listen(port, () => {
|
|
3462
|
+
const address = server.address();
|
|
3463
|
+
if (!address || typeof address === "string") {
|
|
3464
|
+
reject(new Error("Failed to get server address"));
|
|
3465
|
+
return;
|
|
3466
|
+
}
|
|
3467
|
+
const actualPort = address.port;
|
|
3468
|
+
this._info = {
|
|
3469
|
+
baseUrl: `http://localhost:${actualPort}`,
|
|
3470
|
+
port: actualPort,
|
|
3471
|
+
specUrl: `http://localhost:${actualPort}/openapi.json`
|
|
3472
|
+
};
|
|
3473
|
+
this.log(`Mock API server started at ${this._info.baseUrl}`);
|
|
3474
|
+
resolve(this._info);
|
|
3475
|
+
});
|
|
3476
|
+
});
|
|
3477
|
+
}
|
|
3478
|
+
/**
|
|
3479
|
+
* Stop the mock API server
|
|
3480
|
+
*/
|
|
2464
3481
|
async stop() {
|
|
2465
3482
|
const server = this.server;
|
|
2466
3483
|
if (!server) {
|
|
@@ -2683,23 +3700,449 @@ var MockAPIServer = class {
|
|
|
2683
3700
|
}
|
|
2684
3701
|
};
|
|
2685
3702
|
|
|
2686
|
-
// libs/testing/src/
|
|
2687
|
-
var
|
|
2688
|
-
var
|
|
2689
|
-
process = null;
|
|
3703
|
+
// libs/testing/src/auth/mock-cimd-server.ts
|
|
3704
|
+
var import_http3 = require("http");
|
|
3705
|
+
var MockCimdServer = class {
|
|
2690
3706
|
options;
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
3707
|
+
server = null;
|
|
3708
|
+
_info = null;
|
|
3709
|
+
/** Map of path -> client */
|
|
3710
|
+
clients = /* @__PURE__ */ new Map();
|
|
3711
|
+
/** Map of path -> custom response (for error testing) */
|
|
3712
|
+
customResponses = /* @__PURE__ */ new Map();
|
|
3713
|
+
constructor(options = {}) {
|
|
3714
|
+
this.options = options;
|
|
3715
|
+
}
|
|
3716
|
+
/**
|
|
3717
|
+
* Start the mock CIMD server.
|
|
3718
|
+
*/
|
|
3719
|
+
async start() {
|
|
3720
|
+
if (this.server) {
|
|
3721
|
+
throw new Error("Mock CIMD server is already running");
|
|
3722
|
+
}
|
|
3723
|
+
const port = this.options.port ?? 0;
|
|
3724
|
+
return new Promise((resolve, reject) => {
|
|
3725
|
+
const server = (0, import_http3.createServer)(this.handleRequest.bind(this));
|
|
3726
|
+
this.server = server;
|
|
3727
|
+
server.on("error", (err) => {
|
|
3728
|
+
this.log(`Server error: ${err.message}`);
|
|
3729
|
+
reject(err);
|
|
3730
|
+
});
|
|
3731
|
+
server.listen(port, () => {
|
|
3732
|
+
const address = server.address();
|
|
3733
|
+
if (!address || typeof address === "string") {
|
|
3734
|
+
reject(new Error("Failed to get server address"));
|
|
3735
|
+
return;
|
|
3736
|
+
}
|
|
3737
|
+
const actualPort = address.port;
|
|
3738
|
+
this._info = {
|
|
3739
|
+
baseUrl: `http://localhost:${actualPort}`,
|
|
3740
|
+
port: actualPort
|
|
3741
|
+
};
|
|
3742
|
+
this.log(`Mock CIMD server started at ${this._info.baseUrl}`);
|
|
3743
|
+
resolve(this._info);
|
|
3744
|
+
});
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
/**
|
|
3748
|
+
* Stop the mock CIMD server.
|
|
3749
|
+
*/
|
|
3750
|
+
async stop() {
|
|
3751
|
+
const server = this.server;
|
|
3752
|
+
if (!server) {
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
return new Promise((resolve, reject) => {
|
|
3756
|
+
server.close((err) => {
|
|
3757
|
+
if (err) {
|
|
3758
|
+
reject(err);
|
|
3759
|
+
} else {
|
|
3760
|
+
this.server = null;
|
|
3761
|
+
this._info = null;
|
|
3762
|
+
this.log("Mock CIMD server stopped");
|
|
3763
|
+
resolve();
|
|
3764
|
+
}
|
|
3765
|
+
});
|
|
3766
|
+
});
|
|
3767
|
+
}
|
|
3768
|
+
/**
|
|
3769
|
+
* Get server info.
|
|
3770
|
+
*/
|
|
3771
|
+
get info() {
|
|
3772
|
+
if (!this._info) {
|
|
3773
|
+
throw new Error("Mock CIMD server is not running");
|
|
3774
|
+
}
|
|
3775
|
+
return this._info;
|
|
3776
|
+
}
|
|
3777
|
+
/**
|
|
3778
|
+
* Register a client and return its client_id URL.
|
|
3779
|
+
*
|
|
3780
|
+
* @param options - Client configuration
|
|
3781
|
+
* @returns The client_id URL to use in OAuth flows
|
|
3782
|
+
*/
|
|
3783
|
+
registerClient(options) {
|
|
3784
|
+
if (!this._info) {
|
|
3785
|
+
throw new Error("Mock CIMD server is not running. Call start() first.");
|
|
3786
|
+
}
|
|
3787
|
+
const path = options.path ?? this.nameToPath(options.name);
|
|
3788
|
+
const fullPath = `/clients/${path}/metadata.json`;
|
|
3789
|
+
const clientId = `${this._info.baseUrl}${fullPath}`;
|
|
3790
|
+
const document = {
|
|
3791
|
+
client_id: clientId,
|
|
3792
|
+
client_name: options.name,
|
|
3793
|
+
redirect_uris: options.redirectUris ?? ["http://localhost:3000/callback"],
|
|
3794
|
+
token_endpoint_auth_method: options.tokenEndpointAuthMethod ?? "none",
|
|
3795
|
+
grant_types: options.grantTypes ?? ["authorization_code"],
|
|
3796
|
+
response_types: options.responseTypes ?? ["code"],
|
|
3797
|
+
...options.clientUri && { client_uri: options.clientUri },
|
|
3798
|
+
...options.logoUri && { logo_uri: options.logoUri },
|
|
3799
|
+
...options.scope && { scope: options.scope },
|
|
3800
|
+
...options.contacts && { contacts: options.contacts }
|
|
3801
|
+
};
|
|
3802
|
+
this.clients.set(fullPath, { path: fullPath, document });
|
|
3803
|
+
this.log(`Registered client: ${options.name} at ${fullPath}`);
|
|
3804
|
+
return clientId;
|
|
3805
|
+
}
|
|
3806
|
+
/**
|
|
3807
|
+
* Get the client_id URL for a previously registered client.
|
|
3808
|
+
*
|
|
3809
|
+
* @param name - The client name used during registration
|
|
3810
|
+
* @returns The client_id URL
|
|
3811
|
+
*/
|
|
3812
|
+
getClientId(name) {
|
|
3813
|
+
if (!this._info) {
|
|
3814
|
+
throw new Error("Mock CIMD server is not running");
|
|
3815
|
+
}
|
|
3816
|
+
const path = this.nameToPath(name);
|
|
3817
|
+
const fullPath = `/clients/${path}/metadata.json`;
|
|
3818
|
+
const client = this.clients.get(fullPath);
|
|
3819
|
+
if (!client) {
|
|
3820
|
+
throw new Error(`Client "${name}" not found. Register it first.`);
|
|
3821
|
+
}
|
|
3822
|
+
return client.document.client_id;
|
|
3823
|
+
}
|
|
3824
|
+
/**
|
|
3825
|
+
* Register an invalid document for error testing.
|
|
3826
|
+
*
|
|
3827
|
+
* @param path - The path to serve the invalid document at
|
|
3828
|
+
* @param document - The invalid document content
|
|
3829
|
+
*/
|
|
3830
|
+
registerInvalidDocument(path, document) {
|
|
3831
|
+
if (!this._info) {
|
|
3832
|
+
throw new Error("Mock CIMD server is not running");
|
|
3833
|
+
}
|
|
3834
|
+
const fullPath = path.startsWith("/") ? path : `/${path}`;
|
|
3835
|
+
this.clients.set(fullPath, {
|
|
3836
|
+
path: fullPath,
|
|
3837
|
+
document
|
|
3838
|
+
});
|
|
3839
|
+
this.log(`Registered invalid document at ${fullPath}`);
|
|
3840
|
+
}
|
|
3841
|
+
/**
|
|
3842
|
+
* Register a custom error response for a path.
|
|
3843
|
+
*
|
|
3844
|
+
* @param path - The path to return the error at
|
|
3845
|
+
* @param statusCode - HTTP status code to return
|
|
3846
|
+
* @param body - Optional response body
|
|
3847
|
+
*/
|
|
3848
|
+
registerFetchError(path, statusCode, body) {
|
|
3849
|
+
const fullPath = path.startsWith("/") ? path : `/${path}`;
|
|
3850
|
+
this.customResponses.set(fullPath, { status: statusCode, body });
|
|
3851
|
+
this.log(`Registered error response at ${fullPath}: ${statusCode}`);
|
|
3852
|
+
}
|
|
3853
|
+
/**
|
|
3854
|
+
* Remove a registered client.
|
|
3855
|
+
*
|
|
3856
|
+
* @param name - The client name to remove
|
|
3857
|
+
*/
|
|
3858
|
+
removeClient(name) {
|
|
3859
|
+
const path = this.nameToPath(name);
|
|
3860
|
+
const fullPath = `/clients/${path}/metadata.json`;
|
|
3861
|
+
this.clients.delete(fullPath);
|
|
3862
|
+
this.log(`Removed client: ${name}`);
|
|
3863
|
+
}
|
|
3864
|
+
/**
|
|
3865
|
+
* Clear all registered clients and custom responses.
|
|
3866
|
+
*/
|
|
3867
|
+
clear() {
|
|
3868
|
+
this.clients.clear();
|
|
3869
|
+
this.customResponses.clear();
|
|
3870
|
+
this.log("Cleared all clients and custom responses");
|
|
3871
|
+
}
|
|
3872
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3873
|
+
// PRIVATE
|
|
3874
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
3875
|
+
handleRequest(req, res) {
|
|
3876
|
+
const url = req.url ?? "/";
|
|
3877
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
3878
|
+
this.log(`${method} ${url}`);
|
|
3879
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
3880
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
3881
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
|
|
3882
|
+
if (method === "OPTIONS") {
|
|
3883
|
+
res.writeHead(204);
|
|
3884
|
+
res.end();
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
if (method !== "GET") {
|
|
3888
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
3889
|
+
res.end(JSON.stringify({ error: "method_not_allowed" }));
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
const customResponse = this.customResponses.get(url);
|
|
3893
|
+
if (customResponse) {
|
|
3894
|
+
res.writeHead(customResponse.status, { "Content-Type": "application/json" });
|
|
3895
|
+
res.end(customResponse.body ? JSON.stringify(customResponse.body) : "");
|
|
3896
|
+
this.log(`Returned custom error response: ${customResponse.status}`);
|
|
3897
|
+
return;
|
|
3898
|
+
}
|
|
3899
|
+
const client = this.clients.get(url);
|
|
3900
|
+
if (client) {
|
|
3901
|
+
res.writeHead(200, {
|
|
3902
|
+
"Content-Type": "application/json",
|
|
3903
|
+
"Cache-Control": "max-age=3600"
|
|
3904
|
+
});
|
|
3905
|
+
res.end(JSON.stringify(client.document));
|
|
3906
|
+
this.log(`Served client metadata: ${client.document.client_name}`);
|
|
3907
|
+
return;
|
|
3908
|
+
}
|
|
3909
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
3910
|
+
res.end(JSON.stringify({ error: "not_found", message: `No client at ${url}` }));
|
|
3911
|
+
this.log(`Client not found: ${url}`);
|
|
3912
|
+
}
|
|
3913
|
+
nameToPath(name) {
|
|
3914
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3915
|
+
}
|
|
3916
|
+
log(message) {
|
|
3917
|
+
if (this.options.debug) {
|
|
3918
|
+
console.log(`[MockCimdServer] ${message}`);
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
};
|
|
3922
|
+
|
|
3923
|
+
// libs/testing/src/server/test-server.ts
|
|
3924
|
+
var import_child_process = require("child_process");
|
|
3925
|
+
|
|
3926
|
+
// libs/testing/src/errors/index.ts
|
|
3927
|
+
var TestClientError = class extends Error {
|
|
3928
|
+
constructor(message) {
|
|
3929
|
+
super(message);
|
|
3930
|
+
this.name = "TestClientError";
|
|
3931
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3932
|
+
}
|
|
3933
|
+
};
|
|
3934
|
+
var ConnectionError = class extends TestClientError {
|
|
3935
|
+
constructor(message, cause) {
|
|
3936
|
+
super(message);
|
|
3937
|
+
this.cause = cause;
|
|
3938
|
+
this.name = "ConnectionError";
|
|
3939
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3940
|
+
}
|
|
3941
|
+
};
|
|
3942
|
+
var TimeoutError = class extends TestClientError {
|
|
3943
|
+
constructor(message, timeoutMs) {
|
|
3944
|
+
super(message);
|
|
3945
|
+
this.timeoutMs = timeoutMs;
|
|
3946
|
+
this.name = "TimeoutError";
|
|
3947
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3948
|
+
}
|
|
3949
|
+
};
|
|
3950
|
+
var McpProtocolError = class extends TestClientError {
|
|
3951
|
+
constructor(message, code, data) {
|
|
3952
|
+
super(message);
|
|
3953
|
+
this.code = code;
|
|
3954
|
+
this.data = data;
|
|
3955
|
+
this.name = "McpProtocolError";
|
|
3956
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3957
|
+
}
|
|
3958
|
+
};
|
|
3959
|
+
var ServerStartError = class extends TestClientError {
|
|
3960
|
+
constructor(message, cause) {
|
|
3961
|
+
super(message);
|
|
3962
|
+
this.cause = cause;
|
|
3963
|
+
this.name = "ServerStartError";
|
|
3964
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3965
|
+
}
|
|
3966
|
+
};
|
|
3967
|
+
var AssertionError = class extends TestClientError {
|
|
3968
|
+
constructor(message) {
|
|
3969
|
+
super(message);
|
|
3970
|
+
this.name = "AssertionError";
|
|
3971
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
3972
|
+
}
|
|
3973
|
+
};
|
|
3974
|
+
|
|
3975
|
+
// libs/testing/src/server/port-registry.ts
|
|
3976
|
+
var import_net = require("net");
|
|
3977
|
+
var E2E_PORT_RANGES = {
|
|
3978
|
+
// Core E2E tests (50000-50099)
|
|
3979
|
+
"demo-e2e-public": { start: 5e4, size: 10 },
|
|
3980
|
+
"demo-e2e-cache": { start: 50010, size: 10 },
|
|
3981
|
+
"demo-e2e-config": { start: 50020, size: 10 },
|
|
3982
|
+
"demo-e2e-direct": { start: 50030, size: 10 },
|
|
3983
|
+
"demo-e2e-errors": { start: 50040, size: 10 },
|
|
3984
|
+
"demo-e2e-hooks": { start: 50050, size: 10 },
|
|
3985
|
+
"demo-e2e-multiapp": { start: 50060, size: 10 },
|
|
3986
|
+
"demo-e2e-notifications": { start: 50070, size: 10 },
|
|
3987
|
+
"demo-e2e-providers": { start: 50080, size: 10 },
|
|
3988
|
+
"demo-e2e-standalone": { start: 50090, size: 10 },
|
|
3989
|
+
// Auth E2E tests (50100-50199)
|
|
3990
|
+
"demo-e2e-orchestrated": { start: 50100, size: 10 },
|
|
3991
|
+
"demo-e2e-transparent": { start: 50110, size: 10 },
|
|
3992
|
+
"demo-e2e-cimd": { start: 50120, size: 10 },
|
|
3993
|
+
// Feature E2E tests (50200-50299)
|
|
3994
|
+
"demo-e2e-skills": { start: 50200, size: 10 },
|
|
3995
|
+
"demo-e2e-remote": { start: 50210, size: 10 },
|
|
3996
|
+
"demo-e2e-openapi": { start: 50220, size: 10 },
|
|
3997
|
+
"demo-e2e-ui": { start: 50230, size: 10 },
|
|
3998
|
+
"demo-e2e-codecall": { start: 50240, size: 10 },
|
|
3999
|
+
"demo-e2e-remember": { start: 50250, size: 10 },
|
|
4000
|
+
"demo-e2e-elicitation": { start: 50260, size: 10 },
|
|
4001
|
+
"demo-e2e-agents": { start: 50270, size: 10 },
|
|
4002
|
+
"demo-e2e-transport-recreation": { start: 50280, size: 10 },
|
|
4003
|
+
// Infrastructure E2E tests (50300-50399)
|
|
4004
|
+
"demo-e2e-redis": { start: 50300, size: 10 },
|
|
4005
|
+
"demo-e2e-serverless": { start: 50310, size: 10 },
|
|
4006
|
+
// Mock servers and utilities (50900-50999)
|
|
4007
|
+
"mock-oauth": { start: 50900, size: 10 },
|
|
4008
|
+
"mock-api": { start: 50910, size: 10 },
|
|
4009
|
+
"mock-cimd": { start: 50920, size: 10 },
|
|
4010
|
+
// Dynamic/unknown projects (51000+)
|
|
4011
|
+
default: { start: 51e3, size: 100 }
|
|
4012
|
+
};
|
|
4013
|
+
var reservedPorts = /* @__PURE__ */ new Map();
|
|
4014
|
+
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
4015
|
+
function getPortRange(project) {
|
|
4016
|
+
const key = project;
|
|
4017
|
+
if (key in E2E_PORT_RANGES) {
|
|
4018
|
+
return E2E_PORT_RANGES[key];
|
|
4019
|
+
}
|
|
4020
|
+
return E2E_PORT_RANGES.default;
|
|
4021
|
+
}
|
|
4022
|
+
async function reservePort(project, preferredPort) {
|
|
4023
|
+
const range = getPortRange(project);
|
|
4024
|
+
if (preferredPort !== void 0) {
|
|
4025
|
+
const reservation = await tryReservePort(preferredPort, project);
|
|
4026
|
+
if (reservation) {
|
|
4027
|
+
return {
|
|
4028
|
+
port: preferredPort,
|
|
4029
|
+
release: async () => {
|
|
4030
|
+
await releasePort(preferredPort);
|
|
4031
|
+
}
|
|
4032
|
+
};
|
|
4033
|
+
}
|
|
4034
|
+
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
4035
|
+
}
|
|
4036
|
+
let index = projectPortIndex.get(project) ?? 0;
|
|
4037
|
+
const maxAttempts = range.size;
|
|
4038
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
4039
|
+
const port = range.start + index % range.size;
|
|
4040
|
+
index = (index + 1) % range.size;
|
|
4041
|
+
if (reservedPorts.has(port)) {
|
|
4042
|
+
continue;
|
|
4043
|
+
}
|
|
4044
|
+
const reservation = await tryReservePort(port, project);
|
|
4045
|
+
if (reservation) {
|
|
4046
|
+
projectPortIndex.set(project, index);
|
|
4047
|
+
return {
|
|
4048
|
+
port,
|
|
4049
|
+
release: async () => {
|
|
4050
|
+
await releasePort(port);
|
|
4051
|
+
}
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
const dynamicPort = await findAvailablePortInRange(51e3, 52e3);
|
|
4056
|
+
if (dynamicPort) {
|
|
4057
|
+
const reservation = await tryReservePort(dynamicPort, project);
|
|
4058
|
+
if (reservation) {
|
|
4059
|
+
return {
|
|
4060
|
+
port: dynamicPort,
|
|
4061
|
+
release: async () => {
|
|
4062
|
+
await releasePort(dynamicPort);
|
|
4063
|
+
}
|
|
4064
|
+
};
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
throw new Error(
|
|
4068
|
+
`[PortRegistry] Could not reserve a port for ${project}. Range: ${range.start}-${range.start + range.size - 1}. Currently reserved: ${Array.from(reservedPorts.keys()).join(", ")}`
|
|
4069
|
+
);
|
|
4070
|
+
}
|
|
4071
|
+
async function tryReservePort(port, project) {
|
|
4072
|
+
return new Promise((resolve) => {
|
|
4073
|
+
const server = (0, import_net.createServer)();
|
|
4074
|
+
server.once("error", () => {
|
|
4075
|
+
resolve(false);
|
|
4076
|
+
});
|
|
4077
|
+
server.listen(port, "::", () => {
|
|
4078
|
+
reservedPorts.set(port, {
|
|
4079
|
+
port,
|
|
4080
|
+
project,
|
|
4081
|
+
holder: server,
|
|
4082
|
+
reservedAt: Date.now()
|
|
4083
|
+
});
|
|
4084
|
+
resolve(true);
|
|
4085
|
+
});
|
|
4086
|
+
});
|
|
4087
|
+
}
|
|
4088
|
+
async function releasePort(port) {
|
|
4089
|
+
const reservation = reservedPorts.get(port);
|
|
4090
|
+
if (!reservation) {
|
|
4091
|
+
return;
|
|
4092
|
+
}
|
|
4093
|
+
return new Promise((resolve) => {
|
|
4094
|
+
reservation.holder.close(() => {
|
|
4095
|
+
reservedPorts.delete(port);
|
|
4096
|
+
resolve();
|
|
4097
|
+
});
|
|
4098
|
+
});
|
|
4099
|
+
}
|
|
4100
|
+
async function findAvailablePortInRange(start, end) {
|
|
4101
|
+
for (let port = start; port < end; port++) {
|
|
4102
|
+
if (reservedPorts.has(port)) {
|
|
4103
|
+
continue;
|
|
4104
|
+
}
|
|
4105
|
+
const available = await isPortAvailable(port);
|
|
4106
|
+
if (available) {
|
|
4107
|
+
return port;
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
return null;
|
|
4111
|
+
}
|
|
4112
|
+
async function isPortAvailable(port) {
|
|
4113
|
+
return new Promise((resolve) => {
|
|
4114
|
+
const server = (0, import_net.createServer)();
|
|
4115
|
+
server.once("error", () => {
|
|
4116
|
+
resolve(false);
|
|
4117
|
+
});
|
|
4118
|
+
server.listen(port, "::", () => {
|
|
4119
|
+
server.close(() => {
|
|
4120
|
+
resolve(true);
|
|
4121
|
+
});
|
|
4122
|
+
});
|
|
4123
|
+
});
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
// libs/testing/src/server/test-server.ts
|
|
4127
|
+
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
4128
|
+
var TestServer = class _TestServer {
|
|
4129
|
+
process = null;
|
|
4130
|
+
options;
|
|
4131
|
+
_info;
|
|
4132
|
+
logs = [];
|
|
4133
|
+
portRelease = null;
|
|
4134
|
+
constructor(options, port, portRelease) {
|
|
4135
|
+
this.options = {
|
|
4136
|
+
port,
|
|
4137
|
+
project: options.project,
|
|
4138
|
+
command: options.command ?? "",
|
|
4139
|
+
cwd: options.cwd ?? process.cwd(),
|
|
4140
|
+
env: options.env ?? {},
|
|
2699
4141
|
startupTimeout: options.startupTimeout ?? 3e4,
|
|
2700
4142
|
healthCheckPath: options.healthCheckPath ?? "/health",
|
|
2701
|
-
debug: options.debug ??
|
|
4143
|
+
debug: options.debug ?? DEBUG_SERVER
|
|
2702
4144
|
};
|
|
4145
|
+
this.portRelease = portRelease ?? null;
|
|
2703
4146
|
this._info = {
|
|
2704
4147
|
baseUrl: `http://localhost:${port}`,
|
|
2705
4148
|
port
|
|
@@ -2709,7 +4152,9 @@ var TestServer = class _TestServer {
|
|
|
2709
4152
|
* Start a test server with custom command
|
|
2710
4153
|
*/
|
|
2711
4154
|
static async start(options) {
|
|
2712
|
-
const
|
|
4155
|
+
const project = options.project ?? "default";
|
|
4156
|
+
const { port, release } = await reservePort(project, options.port);
|
|
4157
|
+
await release();
|
|
2713
4158
|
const server = new _TestServer(options, port);
|
|
2714
4159
|
try {
|
|
2715
4160
|
await server.startProcess();
|
|
@@ -2728,10 +4173,12 @@ var TestServer = class _TestServer {
|
|
|
2728
4173
|
`Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`
|
|
2729
4174
|
);
|
|
2730
4175
|
}
|
|
2731
|
-
const port
|
|
4176
|
+
const { port, release } = await reservePort(project, options.port);
|
|
4177
|
+
await release();
|
|
2732
4178
|
const serverOptions = {
|
|
2733
4179
|
...options,
|
|
2734
4180
|
port,
|
|
4181
|
+
project,
|
|
2735
4182
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2736
4183
|
cwd: options.cwd ?? process.cwd()
|
|
2737
4184
|
};
|
|
@@ -2889,22 +4336,54 @@ var TestServer = class _TestServer {
|
|
|
2889
4336
|
exitCode = code;
|
|
2890
4337
|
this.log(`Server process exited with code ${code}`);
|
|
2891
4338
|
});
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
4339
|
+
try {
|
|
4340
|
+
await this.waitForReadyWithExitDetection(() => {
|
|
4341
|
+
if (exitError) {
|
|
4342
|
+
return { exited: true, error: exitError };
|
|
4343
|
+
}
|
|
4344
|
+
if (processExited) {
|
|
4345
|
+
const allLogs = this.logs.join("\n");
|
|
4346
|
+
const errorLogs = this.logs.filter((l) => l.includes("[ERROR]") || l.toLowerCase().includes("error")).join("\n");
|
|
4347
|
+
return {
|
|
4348
|
+
exited: true,
|
|
4349
|
+
error: new ServerStartError(
|
|
4350
|
+
`Server process exited unexpectedly with code ${exitCode}.
|
|
2901
4351
|
|
|
2902
|
-
|
|
2903
|
-
${
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
4352
|
+
Command: ${this.options.command}
|
|
4353
|
+
CWD: ${this.options.cwd}
|
|
4354
|
+
Port: ${this.options.port}
|
|
4355
|
+
|
|
4356
|
+
=== Error Logs ===
|
|
4357
|
+
${errorLogs || "No error logs captured"}
|
|
4358
|
+
|
|
4359
|
+
=== Full Logs ===
|
|
4360
|
+
${allLogs || "No logs captured"}`
|
|
4361
|
+
)
|
|
4362
|
+
};
|
|
4363
|
+
}
|
|
4364
|
+
return { exited: false };
|
|
4365
|
+
});
|
|
4366
|
+
} catch (error) {
|
|
4367
|
+
this.printLogsOnFailure("Server startup failed");
|
|
4368
|
+
throw error;
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
/**
|
|
4372
|
+
* Print server logs on failure for debugging
|
|
4373
|
+
*/
|
|
4374
|
+
printLogsOnFailure(context) {
|
|
4375
|
+
const allLogs = this.logs.join("\n");
|
|
4376
|
+
if (allLogs) {
|
|
4377
|
+
console.error(`
|
|
4378
|
+
[TestServer] ${context}`);
|
|
4379
|
+
console.error(`[TestServer] Command: ${this.options.command}`);
|
|
4380
|
+
console.error(`[TestServer] Port: ${this.options.port}`);
|
|
4381
|
+
console.error(`[TestServer] CWD: ${this.options.cwd}`);
|
|
4382
|
+
console.error(`[TestServer] === Server Logs ===
|
|
4383
|
+
${allLogs}`);
|
|
4384
|
+
console.error(`[TestServer] === End Logs ===
|
|
4385
|
+
`);
|
|
4386
|
+
}
|
|
2908
4387
|
}
|
|
2909
4388
|
/**
|
|
2910
4389
|
* Wait for server to be ready, but also detect early process exit
|
|
@@ -2913,29 +4392,57 @@ ${recentLogs}`)
|
|
|
2913
4392
|
const timeoutMs = this.options.startupTimeout;
|
|
2914
4393
|
const deadline = Date.now() + timeoutMs;
|
|
2915
4394
|
const checkInterval = 100;
|
|
4395
|
+
let lastHealthCheckError = null;
|
|
4396
|
+
let healthCheckAttempts = 0;
|
|
4397
|
+
this.log(`Waiting for server to be ready (timeout: ${timeoutMs}ms)...`);
|
|
2916
4398
|
while (Date.now() < deadline) {
|
|
2917
4399
|
const exitStatus = checkExit();
|
|
2918
4400
|
if (exitStatus.exited) {
|
|
2919
|
-
throw exitStatus.error ?? new
|
|
4401
|
+
throw exitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
2920
4402
|
}
|
|
4403
|
+
healthCheckAttempts++;
|
|
2921
4404
|
try {
|
|
2922
|
-
const
|
|
4405
|
+
const healthUrl = `${this._info.baseUrl}${this.options.healthCheckPath}`;
|
|
4406
|
+
const response = await fetch(healthUrl, {
|
|
2923
4407
|
method: "GET",
|
|
2924
4408
|
signal: AbortSignal.timeout(1e3)
|
|
2925
4409
|
});
|
|
2926
4410
|
if (response.ok || response.status === 404) {
|
|
2927
|
-
this.log(
|
|
4411
|
+
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
2928
4412
|
return;
|
|
2929
4413
|
}
|
|
2930
|
-
|
|
4414
|
+
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
4415
|
+
} catch (err) {
|
|
4416
|
+
lastHealthCheckError = err instanceof Error ? err.message : String(err);
|
|
4417
|
+
}
|
|
4418
|
+
const elapsed = Date.now() - (deadline - timeoutMs);
|
|
4419
|
+
if (elapsed > 0 && elapsed % 5e3 < checkInterval) {
|
|
4420
|
+
this.log(
|
|
4421
|
+
`Still waiting for server... (${Math.round(elapsed / 1e3)}s elapsed, last error: ${lastHealthCheckError})`
|
|
4422
|
+
);
|
|
2931
4423
|
}
|
|
2932
4424
|
await sleep2(checkInterval);
|
|
2933
4425
|
}
|
|
2934
4426
|
const finalExitStatus = checkExit();
|
|
2935
4427
|
if (finalExitStatus.exited) {
|
|
2936
|
-
throw finalExitStatus.error ?? new
|
|
4428
|
+
throw finalExitStatus.error ?? new ServerStartError("Server process exited unexpectedly");
|
|
2937
4429
|
}
|
|
2938
|
-
|
|
4430
|
+
const allLogs = this.logs.join("\n");
|
|
4431
|
+
throw new ServerStartError(
|
|
4432
|
+
`Server did not become ready within ${timeoutMs}ms.
|
|
4433
|
+
|
|
4434
|
+
Command: ${this.options.command}
|
|
4435
|
+
CWD: ${this.options.cwd}
|
|
4436
|
+
Port: ${this.options.port}
|
|
4437
|
+
Health check URL: ${this._info.baseUrl}${this.options.healthCheckPath}
|
|
4438
|
+
Health check attempts: ${healthCheckAttempts}
|
|
4439
|
+
Last health check error: ${lastHealthCheckError ?? "none"}
|
|
4440
|
+
|
|
4441
|
+
=== Server Logs ===
|
|
4442
|
+
${allLogs || "No logs captured"}
|
|
4443
|
+
|
|
4444
|
+
TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
4445
|
+
);
|
|
2939
4446
|
}
|
|
2940
4447
|
log(message) {
|
|
2941
4448
|
if (this.options.debug) {
|
|
@@ -2943,22 +4450,6 @@ ${recentLogs}`)
|
|
|
2943
4450
|
}
|
|
2944
4451
|
}
|
|
2945
4452
|
};
|
|
2946
|
-
async function findAvailablePort() {
|
|
2947
|
-
const { createServer: createServer3 } = await import("net");
|
|
2948
|
-
return new Promise((resolve, reject) => {
|
|
2949
|
-
const server = createServer3();
|
|
2950
|
-
server.listen(0, () => {
|
|
2951
|
-
const address = server.address();
|
|
2952
|
-
if (address && typeof address !== "string") {
|
|
2953
|
-
const port = address.port;
|
|
2954
|
-
server.close(() => resolve(port));
|
|
2955
|
-
} else {
|
|
2956
|
-
reject(new Error("Could not get port"));
|
|
2957
|
-
}
|
|
2958
|
-
});
|
|
2959
|
-
server.on("error", reject);
|
|
2960
|
-
});
|
|
2961
|
-
}
|
|
2962
4453
|
function sleep2(ms) {
|
|
2963
4454
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2964
4455
|
}
|
|
@@ -3128,55 +4619,6 @@ function hasMimeType(result, mimeType) {
|
|
|
3128
4619
|
return result.hasMimeType(mimeType);
|
|
3129
4620
|
}
|
|
3130
4621
|
|
|
3131
|
-
// libs/testing/src/errors/index.ts
|
|
3132
|
-
var TestClientError = class extends Error {
|
|
3133
|
-
constructor(message) {
|
|
3134
|
-
super(message);
|
|
3135
|
-
this.name = "TestClientError";
|
|
3136
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3137
|
-
}
|
|
3138
|
-
};
|
|
3139
|
-
var ConnectionError = class extends TestClientError {
|
|
3140
|
-
constructor(message, cause) {
|
|
3141
|
-
super(message);
|
|
3142
|
-
this.cause = cause;
|
|
3143
|
-
this.name = "ConnectionError";
|
|
3144
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3145
|
-
}
|
|
3146
|
-
};
|
|
3147
|
-
var TimeoutError = class extends TestClientError {
|
|
3148
|
-
constructor(message, timeoutMs) {
|
|
3149
|
-
super(message);
|
|
3150
|
-
this.timeoutMs = timeoutMs;
|
|
3151
|
-
this.name = "TimeoutError";
|
|
3152
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3153
|
-
}
|
|
3154
|
-
};
|
|
3155
|
-
var McpProtocolError = class extends TestClientError {
|
|
3156
|
-
constructor(message, code, data) {
|
|
3157
|
-
super(message);
|
|
3158
|
-
this.code = code;
|
|
3159
|
-
this.data = data;
|
|
3160
|
-
this.name = "McpProtocolError";
|
|
3161
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3162
|
-
}
|
|
3163
|
-
};
|
|
3164
|
-
var ServerStartError = class extends TestClientError {
|
|
3165
|
-
constructor(message, cause) {
|
|
3166
|
-
super(message);
|
|
3167
|
-
this.cause = cause;
|
|
3168
|
-
this.name = "ServerStartError";
|
|
3169
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3170
|
-
}
|
|
3171
|
-
};
|
|
3172
|
-
var AssertionError = class extends TestClientError {
|
|
3173
|
-
constructor(message) {
|
|
3174
|
-
super(message);
|
|
3175
|
-
this.name = "AssertionError";
|
|
3176
|
-
Object.setPrototypeOf(this, new.target.prototype);
|
|
3177
|
-
}
|
|
3178
|
-
};
|
|
3179
|
-
|
|
3180
4622
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3181
4623
|
var currentConfig = {};
|
|
3182
4624
|
var serverInstance = null;
|
|
@@ -3191,14 +4633,36 @@ async function initializeSharedResources() {
|
|
|
3191
4633
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
3192
4634
|
serverStartedByUs = false;
|
|
3193
4635
|
} else if (currentConfig.server) {
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
4636
|
+
const serverCommand = resolveServerCommand(currentConfig.server);
|
|
4637
|
+
const isDebug = currentConfig.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
4638
|
+
if (isDebug) {
|
|
4639
|
+
console.log(`[TestFixture] Starting server: ${serverCommand}`);
|
|
4640
|
+
}
|
|
4641
|
+
try {
|
|
4642
|
+
serverInstance = await TestServer.start({
|
|
4643
|
+
project: currentConfig.project,
|
|
4644
|
+
port: currentConfig.port,
|
|
4645
|
+
command: serverCommand,
|
|
4646
|
+
env: currentConfig.env,
|
|
4647
|
+
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
4648
|
+
debug: isDebug
|
|
4649
|
+
});
|
|
4650
|
+
serverStartedByUs = true;
|
|
4651
|
+
if (isDebug) {
|
|
4652
|
+
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
4653
|
+
}
|
|
4654
|
+
} catch (error) {
|
|
4655
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
4656
|
+
throw new Error(
|
|
4657
|
+
`Failed to start test server.
|
|
4658
|
+
|
|
4659
|
+
Server entry: ${currentConfig.server}
|
|
4660
|
+
Project: ${currentConfig.project ?? "default"}
|
|
4661
|
+
Command: ${serverCommand}
|
|
4662
|
+
|
|
4663
|
+
Error: ${errMsg}`
|
|
4664
|
+
);
|
|
4665
|
+
}
|
|
3202
4666
|
} else {
|
|
3203
4667
|
throw new Error(
|
|
3204
4668
|
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
@@ -3208,6 +4672,12 @@ async function initializeSharedResources() {
|
|
|
3208
4672
|
}
|
|
3209
4673
|
async function createTestFixtures() {
|
|
3210
4674
|
await initializeSharedResources();
|
|
4675
|
+
if (!serverInstance) {
|
|
4676
|
+
throw new Error("Server instance not initialized");
|
|
4677
|
+
}
|
|
4678
|
+
if (!tokenFactory) {
|
|
4679
|
+
throw new Error("Token factory not initialized");
|
|
4680
|
+
}
|
|
3211
4681
|
const clientInstance = await McpTestClient.create({
|
|
3212
4682
|
baseUrl: serverInstance.info.baseUrl,
|
|
3213
4683
|
transport: currentConfig.transport ?? "streamable-http",
|
|
@@ -3221,7 +4691,19 @@ async function createTestFixtures() {
|
|
|
3221
4691
|
server
|
|
3222
4692
|
};
|
|
3223
4693
|
}
|
|
3224
|
-
async function cleanupTestFixtures(fixtures,
|
|
4694
|
+
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
4695
|
+
if (testFailed && serverInstance) {
|
|
4696
|
+
const logs = serverInstance.getLogs();
|
|
4697
|
+
if (logs.length > 0) {
|
|
4698
|
+
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
4699
|
+
const recentLogs = logs.slice(-50);
|
|
4700
|
+
if (logs.length > 50) {
|
|
4701
|
+
console.error(`[TestFixture] (showing last 50 of ${logs.length} log entries)`);
|
|
4702
|
+
}
|
|
4703
|
+
console.error(recentLogs.join("\n"));
|
|
4704
|
+
console.error("[TestFixture] === End Server Logs ===\n");
|
|
4705
|
+
}
|
|
4706
|
+
}
|
|
3225
4707
|
if (fixtures.mcp.isConnected()) {
|
|
3226
4708
|
await fixtures.mcp.disconnect();
|
|
3227
4709
|
}
|
|
@@ -4896,11 +6378,1556 @@ var EXPECTED_GENERIC_TOOLS_LIST_META_KEYS = ["ui/resourceUri", "ui/mimeType", "u
|
|
|
4896
6378
|
var EXPECTED_GENERIC_TOOL_CALL_META_KEYS = ["ui/html", "ui/mimeType", "ui/type"];
|
|
4897
6379
|
var EXPECTED_FRONTMCP_TOOLS_LIST_META_KEYS = EXPECTED_GENERIC_TOOLS_LIST_META_KEYS;
|
|
4898
6380
|
var EXPECTED_FRONTMCP_TOOL_CALL_META_KEYS = EXPECTED_GENERIC_TOOL_CALL_META_KEYS;
|
|
6381
|
+
|
|
6382
|
+
// libs/testing/src/perf/metrics-collector.ts
|
|
6383
|
+
function isGcAvailable() {
|
|
6384
|
+
return typeof global.gc === "function";
|
|
6385
|
+
}
|
|
6386
|
+
function forceGc() {
|
|
6387
|
+
if (typeof global.gc === "function") {
|
|
6388
|
+
global.gc();
|
|
6389
|
+
}
|
|
6390
|
+
}
|
|
6391
|
+
async function forceFullGc(cycles = 3, delayMs = 10) {
|
|
6392
|
+
if (!isGcAvailable()) {
|
|
6393
|
+
return;
|
|
6394
|
+
}
|
|
6395
|
+
for (let i = 0; i < cycles; i++) {
|
|
6396
|
+
forceGc();
|
|
6397
|
+
if (i < cycles - 1 && delayMs > 0) {
|
|
6398
|
+
await sleep4(delayMs);
|
|
6399
|
+
}
|
|
6400
|
+
}
|
|
6401
|
+
}
|
|
6402
|
+
var MetricsCollector = class {
|
|
6403
|
+
cpuStartUsage = null;
|
|
6404
|
+
baseline = null;
|
|
6405
|
+
measurements = [];
|
|
6406
|
+
/**
|
|
6407
|
+
* Capture the baseline snapshot.
|
|
6408
|
+
* Forces GC to get a clean memory state.
|
|
6409
|
+
*/
|
|
6410
|
+
async captureBaseline(forceGcCycles = 3) {
|
|
6411
|
+
await forceFullGc(forceGcCycles);
|
|
6412
|
+
this.cpuStartUsage = process.cpuUsage();
|
|
6413
|
+
const snapshot = this.captureSnapshot("baseline");
|
|
6414
|
+
this.baseline = snapshot;
|
|
6415
|
+
return snapshot;
|
|
6416
|
+
}
|
|
6417
|
+
/**
|
|
6418
|
+
* Capture a snapshot of current memory and CPU state.
|
|
6419
|
+
*/
|
|
6420
|
+
captureSnapshot(label) {
|
|
6421
|
+
const memUsage = process.memoryUsage();
|
|
6422
|
+
const cpuUsage = this.cpuStartUsage ? process.cpuUsage(this.cpuStartUsage) : process.cpuUsage();
|
|
6423
|
+
const snapshot = {
|
|
6424
|
+
memory: {
|
|
6425
|
+
heapUsed: memUsage.heapUsed,
|
|
6426
|
+
heapTotal: memUsage.heapTotal,
|
|
6427
|
+
external: memUsage.external,
|
|
6428
|
+
rss: memUsage.rss,
|
|
6429
|
+
arrayBuffers: memUsage.arrayBuffers
|
|
6430
|
+
},
|
|
6431
|
+
cpu: {
|
|
6432
|
+
user: cpuUsage.user,
|
|
6433
|
+
system: cpuUsage.system,
|
|
6434
|
+
total: cpuUsage.user + cpuUsage.system
|
|
6435
|
+
},
|
|
6436
|
+
timestamp: Date.now(),
|
|
6437
|
+
label
|
|
6438
|
+
};
|
|
6439
|
+
if (label !== "baseline") {
|
|
6440
|
+
this.measurements.push(snapshot);
|
|
6441
|
+
}
|
|
6442
|
+
return snapshot;
|
|
6443
|
+
}
|
|
6444
|
+
/**
|
|
6445
|
+
* Start CPU tracking from this point.
|
|
6446
|
+
*/
|
|
6447
|
+
startCpuTracking() {
|
|
6448
|
+
this.cpuStartUsage = process.cpuUsage();
|
|
6449
|
+
}
|
|
6450
|
+
/**
|
|
6451
|
+
* Get CPU usage since tracking started.
|
|
6452
|
+
*/
|
|
6453
|
+
getCpuUsage() {
|
|
6454
|
+
const usage = this.cpuStartUsage ? process.cpuUsage(this.cpuStartUsage) : process.cpuUsage();
|
|
6455
|
+
return {
|
|
6456
|
+
user: usage.user,
|
|
6457
|
+
system: usage.system,
|
|
6458
|
+
total: usage.user + usage.system
|
|
6459
|
+
};
|
|
6460
|
+
}
|
|
6461
|
+
/**
|
|
6462
|
+
* Get current memory metrics.
|
|
6463
|
+
*/
|
|
6464
|
+
getMemoryMetrics() {
|
|
6465
|
+
const memUsage = process.memoryUsage();
|
|
6466
|
+
return {
|
|
6467
|
+
heapUsed: memUsage.heapUsed,
|
|
6468
|
+
heapTotal: memUsage.heapTotal,
|
|
6469
|
+
external: memUsage.external,
|
|
6470
|
+
rss: memUsage.rss,
|
|
6471
|
+
arrayBuffers: memUsage.arrayBuffers
|
|
6472
|
+
};
|
|
6473
|
+
}
|
|
6474
|
+
/**
|
|
6475
|
+
* Get the baseline snapshot if captured.
|
|
6476
|
+
*/
|
|
6477
|
+
getBaseline() {
|
|
6478
|
+
return this.baseline;
|
|
6479
|
+
}
|
|
6480
|
+
/**
|
|
6481
|
+
* Get all measurement snapshots.
|
|
6482
|
+
*/
|
|
6483
|
+
getMeasurements() {
|
|
6484
|
+
return [...this.measurements];
|
|
6485
|
+
}
|
|
6486
|
+
/**
|
|
6487
|
+
* Calculate memory delta from baseline.
|
|
6488
|
+
*/
|
|
6489
|
+
calculateMemoryDelta(current) {
|
|
6490
|
+
if (!this.baseline) {
|
|
6491
|
+
return null;
|
|
6492
|
+
}
|
|
6493
|
+
return {
|
|
6494
|
+
heapUsed: current.heapUsed - this.baseline.memory.heapUsed,
|
|
6495
|
+
heapTotal: current.heapTotal - this.baseline.memory.heapTotal,
|
|
6496
|
+
rss: current.rss - this.baseline.memory.rss
|
|
6497
|
+
};
|
|
6498
|
+
}
|
|
6499
|
+
/**
|
|
6500
|
+
* Reset the collector state.
|
|
6501
|
+
*/
|
|
6502
|
+
reset() {
|
|
6503
|
+
this.cpuStartUsage = null;
|
|
6504
|
+
this.baseline = null;
|
|
6505
|
+
this.measurements = [];
|
|
6506
|
+
}
|
|
6507
|
+
};
|
|
6508
|
+
function formatBytes(bytes) {
|
|
6509
|
+
const absBytes = Math.abs(bytes);
|
|
6510
|
+
const sign = bytes < 0 ? "-" : "";
|
|
6511
|
+
if (absBytes < 1024) {
|
|
6512
|
+
return `${sign}${absBytes} B`;
|
|
6513
|
+
}
|
|
6514
|
+
if (absBytes < 1024 * 1024) {
|
|
6515
|
+
return `${sign}${(absBytes / 1024).toFixed(2)} KB`;
|
|
6516
|
+
}
|
|
6517
|
+
if (absBytes < 1024 * 1024 * 1024) {
|
|
6518
|
+
return `${sign}${(absBytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
6519
|
+
}
|
|
6520
|
+
return `${sign}${(absBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
6521
|
+
}
|
|
6522
|
+
function formatMicroseconds(us) {
|
|
6523
|
+
if (us < 1e3) {
|
|
6524
|
+
return `${us.toFixed(2)} \xB5s`;
|
|
6525
|
+
}
|
|
6526
|
+
if (us < 1e6) {
|
|
6527
|
+
return `${(us / 1e3).toFixed(2)} ms`;
|
|
6528
|
+
}
|
|
6529
|
+
return `${(us / 1e6).toFixed(2)} s`;
|
|
6530
|
+
}
|
|
6531
|
+
function formatDuration(ms) {
|
|
6532
|
+
if (ms < 1e3) {
|
|
6533
|
+
return `${ms.toFixed(2)} ms`;
|
|
6534
|
+
}
|
|
6535
|
+
if (ms < 6e4) {
|
|
6536
|
+
return `${(ms / 1e3).toFixed(2)} s`;
|
|
6537
|
+
}
|
|
6538
|
+
return `${(ms / 6e4).toFixed(2)} min`;
|
|
6539
|
+
}
|
|
6540
|
+
function sleep4(ms) {
|
|
6541
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6542
|
+
}
|
|
6543
|
+
|
|
6544
|
+
// libs/testing/src/perf/leak-detector.ts
|
|
6545
|
+
var DEFAULT_OPTIONS = {
|
|
6546
|
+
iterations: 20,
|
|
6547
|
+
threshold: 1024 * 1024,
|
|
6548
|
+
// 1 MB
|
|
6549
|
+
warmupIterations: 3,
|
|
6550
|
+
forceGc: true,
|
|
6551
|
+
delayMs: 10,
|
|
6552
|
+
intervalSize: 10
|
|
6553
|
+
// Default interval size for measurements
|
|
6554
|
+
};
|
|
6555
|
+
var DEFAULT_PARALLEL_OPTIONS = {
|
|
6556
|
+
...DEFAULT_OPTIONS,
|
|
6557
|
+
workers: 5
|
|
6558
|
+
// Default number of parallel workers
|
|
6559
|
+
};
|
|
6560
|
+
function linearRegression(samples) {
|
|
6561
|
+
const n = samples.length;
|
|
6562
|
+
if (n < 2) {
|
|
6563
|
+
return { slope: 0, intercept: samples[0] ?? 0, rSquared: 0 };
|
|
6564
|
+
}
|
|
6565
|
+
let sumX = 0;
|
|
6566
|
+
let sumY = 0;
|
|
6567
|
+
for (let i = 0; i < n; i++) {
|
|
6568
|
+
sumX += i;
|
|
6569
|
+
sumY += samples[i];
|
|
6570
|
+
}
|
|
6571
|
+
const meanX = sumX / n;
|
|
6572
|
+
const meanY = sumY / n;
|
|
6573
|
+
let numerator = 0;
|
|
6574
|
+
let denominator = 0;
|
|
6575
|
+
for (let i = 0; i < n; i++) {
|
|
6576
|
+
const dx = i - meanX;
|
|
6577
|
+
const dy = samples[i] - meanY;
|
|
6578
|
+
numerator += dx * dy;
|
|
6579
|
+
denominator += dx * dx;
|
|
6580
|
+
}
|
|
6581
|
+
const slope = denominator !== 0 ? numerator / denominator : 0;
|
|
6582
|
+
const intercept = meanY - slope * meanX;
|
|
6583
|
+
let ssRes = 0;
|
|
6584
|
+
let ssTot = 0;
|
|
6585
|
+
for (let i = 0; i < n; i++) {
|
|
6586
|
+
const predicted = intercept + slope * i;
|
|
6587
|
+
const residual = samples[i] - predicted;
|
|
6588
|
+
ssRes += residual * residual;
|
|
6589
|
+
ssTot += (samples[i] - meanY) * (samples[i] - meanY);
|
|
6590
|
+
}
|
|
6591
|
+
const rSquared = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
|
|
6592
|
+
return { slope, intercept, rSquared };
|
|
6593
|
+
}
|
|
6594
|
+
var LeakDetector = class {
|
|
6595
|
+
/**
|
|
6596
|
+
* Run leak detection on an async operation.
|
|
6597
|
+
*
|
|
6598
|
+
* @param operation - The operation to test for leaks
|
|
6599
|
+
* @param options - Detection options
|
|
6600
|
+
* @returns Leak detection result
|
|
6601
|
+
*
|
|
6602
|
+
* @example
|
|
6603
|
+
* ```typescript
|
|
6604
|
+
* const detector = new LeakDetector();
|
|
6605
|
+
* const result = await detector.detectLeak(
|
|
6606
|
+
* async () => mcp.tools.call('my-tool', {}),
|
|
6607
|
+
* { iterations: 50, threshold: 5 * 1024 * 1024 }
|
|
6608
|
+
* );
|
|
6609
|
+
* expect(result.hasLeak).toBe(false);
|
|
6610
|
+
* ```
|
|
6611
|
+
*/
|
|
6612
|
+
async detectLeak(operation, options) {
|
|
6613
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
6614
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, delayMs, intervalSize } = opts;
|
|
6615
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
6616
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
6617
|
+
}
|
|
6618
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
6619
|
+
await operation();
|
|
6620
|
+
}
|
|
6621
|
+
if (forceGc2) {
|
|
6622
|
+
await forceFullGc();
|
|
6623
|
+
}
|
|
6624
|
+
const samples = [];
|
|
6625
|
+
const startTime = Date.now();
|
|
6626
|
+
for (let i = 0; i < iterations; i++) {
|
|
6627
|
+
await operation();
|
|
6628
|
+
if (forceGc2) {
|
|
6629
|
+
await forceFullGc(2, 5);
|
|
6630
|
+
}
|
|
6631
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
6632
|
+
if (delayMs > 0) {
|
|
6633
|
+
await sleep5(delayMs);
|
|
6634
|
+
}
|
|
6635
|
+
}
|
|
6636
|
+
const durationMs = Date.now() - startTime;
|
|
6637
|
+
return this.analyzeLeakPattern(samples, threshold, intervalSize, durationMs);
|
|
6638
|
+
}
|
|
6639
|
+
/**
|
|
6640
|
+
* Run leak detection on a sync operation.
|
|
6641
|
+
*/
|
|
6642
|
+
detectLeakSync(operation, options) {
|
|
6643
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
6644
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, intervalSize } = opts;
|
|
6645
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
6646
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
6647
|
+
}
|
|
6648
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
6649
|
+
operation();
|
|
6650
|
+
}
|
|
6651
|
+
if (forceGc2 && isGcAvailable() && global.gc) {
|
|
6652
|
+
global.gc();
|
|
6653
|
+
global.gc();
|
|
6654
|
+
global.gc();
|
|
6655
|
+
}
|
|
6656
|
+
const samples = [];
|
|
6657
|
+
for (let i = 0; i < iterations; i++) {
|
|
6658
|
+
operation();
|
|
6659
|
+
if (forceGc2 && isGcAvailable() && global.gc) {
|
|
6660
|
+
global.gc();
|
|
6661
|
+
global.gc();
|
|
6662
|
+
}
|
|
6663
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
6664
|
+
}
|
|
6665
|
+
return this.analyzeLeakPattern(samples, threshold, intervalSize);
|
|
6666
|
+
}
|
|
6667
|
+
/**
|
|
6668
|
+
* Run parallel leak detection using multiple clients.
|
|
6669
|
+
* Each worker gets its own client for true parallel HTTP requests.
|
|
6670
|
+
*
|
|
6671
|
+
* @param operationFactory - Factory that receives a client and worker index, returns an operation function
|
|
6672
|
+
* @param options - Detection options including worker count and client factory
|
|
6673
|
+
* @returns Combined leak detection result with per-worker statistics
|
|
6674
|
+
*
|
|
6675
|
+
* @example
|
|
6676
|
+
* ```typescript
|
|
6677
|
+
* const detector = new LeakDetector();
|
|
6678
|
+
* const result = await detector.detectLeakParallel(
|
|
6679
|
+
* (client, workerId) => async () => client.tools.call('my-tool', {}),
|
|
6680
|
+
* {
|
|
6681
|
+
* iterations: 1000,
|
|
6682
|
+
* workers: 5,
|
|
6683
|
+
* clientFactory: () => server.createClient(),
|
|
6684
|
+
* }
|
|
6685
|
+
* );
|
|
6686
|
+
* // 5 workers × ~80 req/s = ~400 req/s total
|
|
6687
|
+
* console.log(result.totalRequestsPerSecond);
|
|
6688
|
+
* ```
|
|
6689
|
+
*/
|
|
6690
|
+
async detectLeakParallel(operationFactory, options) {
|
|
6691
|
+
const opts = { ...DEFAULT_PARALLEL_OPTIONS, ...options };
|
|
6692
|
+
const { iterations, threshold, warmupIterations, forceGc: forceGc2, workers, intervalSize, clientFactory } = opts;
|
|
6693
|
+
const safeIntervalSize = Math.max(1, intervalSize);
|
|
6694
|
+
if (forceGc2 && !isGcAvailable()) {
|
|
6695
|
+
console.warn("[LeakDetector] Manual GC not available. Run Node.js with --expose-gc for accurate results.");
|
|
6696
|
+
}
|
|
6697
|
+
const clients = [];
|
|
6698
|
+
let workerResults;
|
|
6699
|
+
let globalDurationMs;
|
|
6700
|
+
try {
|
|
6701
|
+
console.log(`[LeakDetector] Creating ${workers} clients sequentially...`);
|
|
6702
|
+
for (let i = 0; i < workers; i++) {
|
|
6703
|
+
console.log(`[LeakDetector] Creating client ${i + 1}/${workers}...`);
|
|
6704
|
+
const client = await clientFactory();
|
|
6705
|
+
clients.push(client);
|
|
6706
|
+
}
|
|
6707
|
+
console.log(`[LeakDetector] All ${workers} clients connected`);
|
|
6708
|
+
const operations = clients.map((client, workerId) => operationFactory(client, workerId));
|
|
6709
|
+
console.log(`[LeakDetector] Running ${warmupIterations} warmup iterations per worker...`);
|
|
6710
|
+
await Promise.all(
|
|
6711
|
+
operations.map(async (operation) => {
|
|
6712
|
+
for (let i = 0; i < warmupIterations; i++) {
|
|
6713
|
+
await operation();
|
|
6714
|
+
}
|
|
6715
|
+
})
|
|
6716
|
+
);
|
|
6717
|
+
if (forceGc2) {
|
|
6718
|
+
await forceFullGc();
|
|
6719
|
+
}
|
|
6720
|
+
console.log(`[LeakDetector] Starting parallel stress test: ${workers} workers \xD7 ${iterations} iterations`);
|
|
6721
|
+
const globalStartTime = Date.now();
|
|
6722
|
+
workerResults = await Promise.all(
|
|
6723
|
+
operations.map(async (operation, workerId) => {
|
|
6724
|
+
const samples = [];
|
|
6725
|
+
const workerStartTime = Date.now();
|
|
6726
|
+
for (let i = 0; i < iterations; i++) {
|
|
6727
|
+
await operation();
|
|
6728
|
+
if (forceGc2 && i > 0 && i % safeIntervalSize === 0) {
|
|
6729
|
+
await forceFullGc(1, 2);
|
|
6730
|
+
}
|
|
6731
|
+
samples.push(process.memoryUsage().heapUsed);
|
|
6732
|
+
}
|
|
6733
|
+
const workerDurationMs = Date.now() - workerStartTime;
|
|
6734
|
+
const requestsPerSecond = iterations / workerDurationMs * 1e3;
|
|
6735
|
+
return {
|
|
6736
|
+
workerId,
|
|
6737
|
+
samples,
|
|
6738
|
+
durationMs: workerDurationMs,
|
|
6739
|
+
requestsPerSecond,
|
|
6740
|
+
iterationsCompleted: iterations
|
|
6741
|
+
};
|
|
6742
|
+
})
|
|
6743
|
+
);
|
|
6744
|
+
globalDurationMs = Date.now() - globalStartTime;
|
|
6745
|
+
} finally {
|
|
6746
|
+
await Promise.all(
|
|
6747
|
+
clients.map(async (client) => {
|
|
6748
|
+
if (client.disconnect) {
|
|
6749
|
+
await client.disconnect();
|
|
6750
|
+
}
|
|
6751
|
+
})
|
|
6752
|
+
);
|
|
6753
|
+
}
|
|
6754
|
+
const allSamples = workerResults.flatMap((w) => w.samples);
|
|
6755
|
+
const totalIterations = workers * iterations;
|
|
6756
|
+
const totalRequestsPerSecond = totalIterations / globalDurationMs * 1e3;
|
|
6757
|
+
const baseResult = this.analyzeLeakPattern(allSamples, threshold, intervalSize, globalDurationMs);
|
|
6758
|
+
const workerSummary = workerResults.map((w) => ` Worker ${w.workerId}: ${w.requestsPerSecond.toFixed(1)} req/s`).join("\n");
|
|
6759
|
+
const parallelMessage = `${baseResult.message}
|
|
6760
|
+
|
|
6761
|
+
Parallel execution summary:
|
|
6762
|
+
Workers: ${workers}
|
|
6763
|
+
Total iterations: ${totalIterations}
|
|
6764
|
+
Combined throughput: ${totalRequestsPerSecond.toFixed(1)} req/s
|
|
6765
|
+
Duration: ${(globalDurationMs / 1e3).toFixed(2)}s
|
|
6766
|
+
Per-worker throughput:
|
|
6767
|
+
${workerSummary}`;
|
|
6768
|
+
return {
|
|
6769
|
+
...baseResult,
|
|
6770
|
+
message: parallelMessage,
|
|
6771
|
+
workersUsed: workers,
|
|
6772
|
+
totalRequestsPerSecond,
|
|
6773
|
+
perWorkerStats: workerResults,
|
|
6774
|
+
totalIterations,
|
|
6775
|
+
durationMs: globalDurationMs,
|
|
6776
|
+
requestsPerSecond: totalRequestsPerSecond
|
|
6777
|
+
};
|
|
6778
|
+
}
|
|
6779
|
+
/**
|
|
6780
|
+
* Analyze heap samples for leak patterns using linear regression.
|
|
6781
|
+
*/
|
|
6782
|
+
analyzeLeakPattern(samples, threshold, intervalSize = 10, durationMs) {
|
|
6783
|
+
if (samples.length < 2) {
|
|
6784
|
+
return {
|
|
6785
|
+
hasLeak: false,
|
|
6786
|
+
leakSizePerIteration: 0,
|
|
6787
|
+
totalGrowth: 0,
|
|
6788
|
+
growthRate: 0,
|
|
6789
|
+
rSquared: 0,
|
|
6790
|
+
samples,
|
|
6791
|
+
message: "Insufficient samples for leak detection",
|
|
6792
|
+
intervals: [],
|
|
6793
|
+
graphData: [],
|
|
6794
|
+
durationMs,
|
|
6795
|
+
requestsPerSecond: 0
|
|
6796
|
+
};
|
|
6797
|
+
}
|
|
6798
|
+
const { slope, rSquared } = linearRegression(samples);
|
|
6799
|
+
const totalGrowth = samples[samples.length - 1] - samples[0];
|
|
6800
|
+
const growthRate = slope;
|
|
6801
|
+
const requestsPerSecond = durationMs && durationMs > 0 ? samples.length / durationMs * 1e3 : 0;
|
|
6802
|
+
const intervals = this.generateIntervals(samples, intervalSize);
|
|
6803
|
+
const graphData = this.generateGraphData(samples);
|
|
6804
|
+
const isSignificantGrowth = totalGrowth > threshold;
|
|
6805
|
+
const isLinearGrowth = rSquared > 0.7;
|
|
6806
|
+
const isHighGrowthRate = growthRate > threshold / samples.length;
|
|
6807
|
+
const hasLeak = isSignificantGrowth && (isLinearGrowth || isHighGrowthRate);
|
|
6808
|
+
const intervalSummary = intervals.map((i) => ` ${i.startIteration}-${i.endIteration}: ${i.deltaFormatted}`).join("\n");
|
|
6809
|
+
const perfStats = durationMs ? `
|
|
6810
|
+
Performance: ${samples.length} iterations in ${(durationMs / 1e3).toFixed(2)}s (${requestsPerSecond.toFixed(1)} req/s)` : "";
|
|
6811
|
+
let message;
|
|
6812
|
+
if (hasLeak) {
|
|
6813
|
+
message = `Memory leak detected: ${formatBytes(totalGrowth)} total growth, ${formatBytes(growthRate)}/iteration, R\xB2=${rSquared.toFixed(3)}
|
|
6814
|
+
Interval breakdown:
|
|
6815
|
+
${intervalSummary}${perfStats}`;
|
|
6816
|
+
} else if (isSignificantGrowth) {
|
|
6817
|
+
message = `Memory growth detected (${formatBytes(totalGrowth)}) but not linear (R\xB2=${rSquared.toFixed(3)}), may be normal allocation
|
|
6818
|
+
Interval breakdown:
|
|
6819
|
+
${intervalSummary}${perfStats}`;
|
|
6820
|
+
} else {
|
|
6821
|
+
message = `No leak detected: ${formatBytes(totalGrowth)} total, ${formatBytes(growthRate)}/iteration
|
|
6822
|
+
Interval breakdown:
|
|
6823
|
+
${intervalSummary}${perfStats}`;
|
|
6824
|
+
}
|
|
6825
|
+
return {
|
|
6826
|
+
hasLeak,
|
|
6827
|
+
leakSizePerIteration: hasLeak ? growthRate : 0,
|
|
6828
|
+
totalGrowth,
|
|
6829
|
+
growthRate,
|
|
6830
|
+
rSquared,
|
|
6831
|
+
samples,
|
|
6832
|
+
message,
|
|
6833
|
+
intervals,
|
|
6834
|
+
graphData,
|
|
6835
|
+
durationMs,
|
|
6836
|
+
requestsPerSecond
|
|
6837
|
+
};
|
|
6838
|
+
}
|
|
6839
|
+
/**
|
|
6840
|
+
* Generate interval-based measurements for detailed analysis.
|
|
6841
|
+
*/
|
|
6842
|
+
generateIntervals(samples, intervalSize) {
|
|
6843
|
+
const intervals = [];
|
|
6844
|
+
const safeIntervalSize = intervalSize <= 0 ? 1 : intervalSize;
|
|
6845
|
+
const numIntervals = Math.ceil(samples.length / safeIntervalSize);
|
|
6846
|
+
for (let i = 0; i < numIntervals; i++) {
|
|
6847
|
+
const startIdx = i * safeIntervalSize;
|
|
6848
|
+
const endIdx = Math.min((i + 1) * safeIntervalSize - 1, samples.length - 1);
|
|
6849
|
+
if (startIdx >= samples.length) break;
|
|
6850
|
+
const heapAtStart = samples[startIdx];
|
|
6851
|
+
const heapAtEnd = samples[endIdx];
|
|
6852
|
+
const delta = heapAtEnd - heapAtStart;
|
|
6853
|
+
const iterationsInInterval = endIdx - startIdx + 1;
|
|
6854
|
+
const growthRatePerIteration = iterationsInInterval > 1 ? delta / (iterationsInInterval - 1) : 0;
|
|
6855
|
+
intervals.push({
|
|
6856
|
+
startIteration: startIdx,
|
|
6857
|
+
endIteration: endIdx,
|
|
6858
|
+
heapAtStart,
|
|
6859
|
+
heapAtEnd,
|
|
6860
|
+
delta,
|
|
6861
|
+
deltaFormatted: formatBytes(delta),
|
|
6862
|
+
growthRatePerIteration
|
|
6863
|
+
});
|
|
6864
|
+
}
|
|
6865
|
+
return intervals;
|
|
6866
|
+
}
|
|
6867
|
+
/**
|
|
6868
|
+
* Generate graph data points for visualization.
|
|
6869
|
+
*/
|
|
6870
|
+
generateGraphData(samples) {
|
|
6871
|
+
if (samples.length === 0) return [];
|
|
6872
|
+
const baselineHeap = samples[0];
|
|
6873
|
+
return samples.map((heapUsed, iteration) => ({
|
|
6874
|
+
iteration,
|
|
6875
|
+
heapUsed,
|
|
6876
|
+
heapUsedFormatted: formatBytes(heapUsed),
|
|
6877
|
+
cumulativeDelta: heapUsed - baselineHeap,
|
|
6878
|
+
cumulativeDeltaFormatted: formatBytes(heapUsed - baselineHeap)
|
|
6879
|
+
}));
|
|
6880
|
+
}
|
|
6881
|
+
};
|
|
6882
|
+
function sleep5(ms) {
|
|
6883
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6884
|
+
}
|
|
6885
|
+
async function assertNoLeak(operation, options) {
|
|
6886
|
+
const detector = new LeakDetector();
|
|
6887
|
+
const result = await detector.detectLeak(operation, options);
|
|
6888
|
+
if (result.hasLeak) {
|
|
6889
|
+
throw new Error(`Memory leak detected: ${result.message}`);
|
|
6890
|
+
}
|
|
6891
|
+
return result;
|
|
6892
|
+
}
|
|
6893
|
+
function createLeakDetector() {
|
|
6894
|
+
return new LeakDetector();
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
// libs/testing/src/perf/perf-fixtures.ts
|
|
6898
|
+
function createPerfFixtures(testName, project) {
|
|
6899
|
+
return new PerfFixturesImpl(testName, project);
|
|
6900
|
+
}
|
|
6901
|
+
var PerfFixturesImpl = class {
|
|
6902
|
+
constructor(testName, project) {
|
|
6903
|
+
this.testName = testName;
|
|
6904
|
+
this.project = project;
|
|
6905
|
+
this.collector = new MetricsCollector();
|
|
6906
|
+
this.leakDetector = new LeakDetector();
|
|
6907
|
+
}
|
|
6908
|
+
collector;
|
|
6909
|
+
leakDetector;
|
|
6910
|
+
baselineSnapshot = null;
|
|
6911
|
+
startTime = 0;
|
|
6912
|
+
issues = [];
|
|
6913
|
+
leakResults = [];
|
|
6914
|
+
/**
|
|
6915
|
+
* Capture baseline snapshot with GC.
|
|
6916
|
+
*/
|
|
6917
|
+
async baseline() {
|
|
6918
|
+
this.startTime = Date.now();
|
|
6919
|
+
this.baselineSnapshot = await this.collector.captureBaseline();
|
|
6920
|
+
return this.baselineSnapshot;
|
|
6921
|
+
}
|
|
6922
|
+
/**
|
|
6923
|
+
* Capture a measurement snapshot.
|
|
6924
|
+
*/
|
|
6925
|
+
measure(label) {
|
|
6926
|
+
return this.collector.captureSnapshot(label);
|
|
6927
|
+
}
|
|
6928
|
+
/**
|
|
6929
|
+
* Run leak detection on an operation.
|
|
6930
|
+
*/
|
|
6931
|
+
async checkLeak(operation, options) {
|
|
6932
|
+
const result = await this.leakDetector.detectLeak(operation, options);
|
|
6933
|
+
this.leakResults.push(result);
|
|
6934
|
+
if (result.hasLeak) {
|
|
6935
|
+
this.issues.push({
|
|
6936
|
+
type: "memory-leak",
|
|
6937
|
+
severity: "error",
|
|
6938
|
+
message: result.message,
|
|
6939
|
+
metric: "heapUsed",
|
|
6940
|
+
actual: result.totalGrowth,
|
|
6941
|
+
expected: options?.threshold ?? 1024 * 1024
|
|
6942
|
+
});
|
|
6943
|
+
}
|
|
6944
|
+
return result;
|
|
6945
|
+
}
|
|
6946
|
+
/**
|
|
6947
|
+
* Run parallel leak detection using multiple clients.
|
|
6948
|
+
* Each worker gets its own client for true parallel HTTP requests.
|
|
6949
|
+
*
|
|
6950
|
+
* @param operationFactory - Factory that receives a client and worker index, returns an operation function
|
|
6951
|
+
* @param options - Detection options including worker count and client factory
|
|
6952
|
+
* @returns Combined leak detection result with per-worker statistics
|
|
6953
|
+
*
|
|
6954
|
+
* @example
|
|
6955
|
+
* ```typescript
|
|
6956
|
+
* const result = await perf.checkLeakParallel(
|
|
6957
|
+
* (client, workerId) => async () => {
|
|
6958
|
+
* await client.tools.call('loadSkills', { skillIds: ['review-pr'] });
|
|
6959
|
+
* },
|
|
6960
|
+
* {
|
|
6961
|
+
* iterations: 1000,
|
|
6962
|
+
* workers: 5,
|
|
6963
|
+
* clientFactory: () => server.createClient(),
|
|
6964
|
+
* }
|
|
6965
|
+
* );
|
|
6966
|
+
* // 5 workers × ~80 req/s = ~400 req/s total
|
|
6967
|
+
* expect(result.totalRequestsPerSecond).toBeGreaterThan(300);
|
|
6968
|
+
* ```
|
|
6969
|
+
*/
|
|
6970
|
+
async checkLeakParallel(operationFactory, options) {
|
|
6971
|
+
const result = await this.leakDetector.detectLeakParallel(operationFactory, options);
|
|
6972
|
+
this.leakResults.push(result);
|
|
6973
|
+
if (result.hasLeak) {
|
|
6974
|
+
this.issues.push({
|
|
6975
|
+
type: "memory-leak",
|
|
6976
|
+
severity: "error",
|
|
6977
|
+
message: result.message,
|
|
6978
|
+
metric: "heapUsed",
|
|
6979
|
+
actual: result.totalGrowth,
|
|
6980
|
+
expected: options?.threshold ?? 1024 * 1024
|
|
6981
|
+
});
|
|
6982
|
+
}
|
|
6983
|
+
return result;
|
|
6984
|
+
}
|
|
6985
|
+
/**
|
|
6986
|
+
* Assert that metrics are within thresholds.
|
|
6987
|
+
*/
|
|
6988
|
+
assertThresholds(thresholds) {
|
|
6989
|
+
const finalSnapshot = this.collector.captureSnapshot("final");
|
|
6990
|
+
const baseline = this.collector.getBaseline();
|
|
6991
|
+
if (!baseline) {
|
|
6992
|
+
throw new Error("Cannot assert thresholds without baseline. Call baseline() first.");
|
|
6993
|
+
}
|
|
6994
|
+
const duration = Date.now() - this.startTime;
|
|
6995
|
+
const memoryDelta = this.collector.calculateMemoryDelta(finalSnapshot.memory);
|
|
6996
|
+
if (thresholds.maxHeapDelta !== void 0 && memoryDelta) {
|
|
6997
|
+
if (memoryDelta.heapUsed > thresholds.maxHeapDelta) {
|
|
6998
|
+
const issue = {
|
|
6999
|
+
type: "threshold-exceeded",
|
|
7000
|
+
severity: "error",
|
|
7001
|
+
message: `Heap delta ${formatBytes(memoryDelta.heapUsed)} exceeds threshold ${formatBytes(thresholds.maxHeapDelta)}`,
|
|
7002
|
+
metric: "heapUsed",
|
|
7003
|
+
actual: memoryDelta.heapUsed,
|
|
7004
|
+
expected: thresholds.maxHeapDelta
|
|
7005
|
+
};
|
|
7006
|
+
this.issues.push(issue);
|
|
7007
|
+
throw new Error(issue.message);
|
|
7008
|
+
}
|
|
7009
|
+
}
|
|
7010
|
+
if (thresholds.maxDurationMs !== void 0) {
|
|
7011
|
+
if (duration > thresholds.maxDurationMs) {
|
|
7012
|
+
const issue = {
|
|
7013
|
+
type: "threshold-exceeded",
|
|
7014
|
+
severity: "error",
|
|
7015
|
+
message: `Duration ${formatDuration(duration)} exceeds threshold ${formatDuration(thresholds.maxDurationMs)}`,
|
|
7016
|
+
metric: "durationMs",
|
|
7017
|
+
actual: duration,
|
|
7018
|
+
expected: thresholds.maxDurationMs
|
|
7019
|
+
};
|
|
7020
|
+
this.issues.push(issue);
|
|
7021
|
+
throw new Error(issue.message);
|
|
7022
|
+
}
|
|
7023
|
+
}
|
|
7024
|
+
if (thresholds.maxCpuTime !== void 0) {
|
|
7025
|
+
const cpuUsage = this.collector.getCpuUsage();
|
|
7026
|
+
if (cpuUsage.total > thresholds.maxCpuTime) {
|
|
7027
|
+
const issue = {
|
|
7028
|
+
type: "threshold-exceeded",
|
|
7029
|
+
severity: "error",
|
|
7030
|
+
message: `CPU time ${cpuUsage.total}\xB5s exceeds threshold ${thresholds.maxCpuTime}\xB5s`,
|
|
7031
|
+
metric: "cpuTime",
|
|
7032
|
+
actual: cpuUsage.total,
|
|
7033
|
+
expected: thresholds.maxCpuTime
|
|
7034
|
+
};
|
|
7035
|
+
this.issues.push(issue);
|
|
7036
|
+
throw new Error(issue.message);
|
|
7037
|
+
}
|
|
7038
|
+
}
|
|
7039
|
+
if (thresholds.maxRssDelta !== void 0 && memoryDelta) {
|
|
7040
|
+
if (memoryDelta.rss > thresholds.maxRssDelta) {
|
|
7041
|
+
const issue = {
|
|
7042
|
+
type: "threshold-exceeded",
|
|
7043
|
+
severity: "error",
|
|
7044
|
+
message: `RSS delta ${formatBytes(memoryDelta.rss)} exceeds threshold ${formatBytes(thresholds.maxRssDelta)}`,
|
|
7045
|
+
metric: "rss",
|
|
7046
|
+
actual: memoryDelta.rss,
|
|
7047
|
+
expected: thresholds.maxRssDelta
|
|
7048
|
+
};
|
|
7049
|
+
this.issues.push(issue);
|
|
7050
|
+
throw new Error(issue.message);
|
|
7051
|
+
}
|
|
7052
|
+
}
|
|
7053
|
+
}
|
|
7054
|
+
/**
|
|
7055
|
+
* Get all measurements so far.
|
|
7056
|
+
*/
|
|
7057
|
+
getMeasurements() {
|
|
7058
|
+
return this.collector.getMeasurements();
|
|
7059
|
+
}
|
|
7060
|
+
/**
|
|
7061
|
+
* Get the current test name.
|
|
7062
|
+
*/
|
|
7063
|
+
getTestName() {
|
|
7064
|
+
return this.testName;
|
|
7065
|
+
}
|
|
7066
|
+
/**
|
|
7067
|
+
* Get the project name.
|
|
7068
|
+
*/
|
|
7069
|
+
getProject() {
|
|
7070
|
+
return this.project;
|
|
7071
|
+
}
|
|
7072
|
+
/**
|
|
7073
|
+
* Get all detected issues.
|
|
7074
|
+
*/
|
|
7075
|
+
getIssues() {
|
|
7076
|
+
return [...this.issues];
|
|
7077
|
+
}
|
|
7078
|
+
/**
|
|
7079
|
+
* Build a complete PerfMeasurement for this test.
|
|
7080
|
+
*/
|
|
7081
|
+
buildMeasurement() {
|
|
7082
|
+
const endTime = Date.now();
|
|
7083
|
+
const finalSnapshot = this.collector.captureSnapshot("final");
|
|
7084
|
+
const baseline = this.collector.getBaseline();
|
|
7085
|
+
const memoryDelta = baseline ? this.collector.calculateMemoryDelta(finalSnapshot.memory) : void 0;
|
|
7086
|
+
return {
|
|
7087
|
+
name: this.testName,
|
|
7088
|
+
project: this.project,
|
|
7089
|
+
baseline: baseline ?? finalSnapshot,
|
|
7090
|
+
measurements: this.collector.getMeasurements(),
|
|
7091
|
+
final: finalSnapshot,
|
|
7092
|
+
timing: {
|
|
7093
|
+
startTime: this.startTime || endTime,
|
|
7094
|
+
endTime,
|
|
7095
|
+
durationMs: endTime - (this.startTime || endTime)
|
|
7096
|
+
},
|
|
7097
|
+
memoryDelta: memoryDelta ?? void 0,
|
|
7098
|
+
issues: this.getIssues(),
|
|
7099
|
+
leakDetectionResults: this.leakResults.length > 0 ? this.leakResults : void 0
|
|
7100
|
+
};
|
|
7101
|
+
}
|
|
7102
|
+
/**
|
|
7103
|
+
* Reset the fixture state.
|
|
7104
|
+
*/
|
|
7105
|
+
reset() {
|
|
7106
|
+
this.collector.reset();
|
|
7107
|
+
this.baselineSnapshot = null;
|
|
7108
|
+
this.startTime = 0;
|
|
7109
|
+
this.issues = [];
|
|
7110
|
+
this.leakResults = [];
|
|
7111
|
+
}
|
|
7112
|
+
};
|
|
7113
|
+
var MEASUREMENTS_KEY = "__FRONTMCP_PERF_MEASUREMENTS__";
|
|
7114
|
+
function getGlobalMeasurementsArray() {
|
|
7115
|
+
if (!globalThis[MEASUREMENTS_KEY]) {
|
|
7116
|
+
globalThis[MEASUREMENTS_KEY] = [];
|
|
7117
|
+
}
|
|
7118
|
+
return globalThis[MEASUREMENTS_KEY];
|
|
7119
|
+
}
|
|
7120
|
+
function addGlobalMeasurement(measurement) {
|
|
7121
|
+
getGlobalMeasurementsArray().push(measurement);
|
|
7122
|
+
}
|
|
7123
|
+
function getGlobalMeasurements() {
|
|
7124
|
+
return [...getGlobalMeasurementsArray()];
|
|
7125
|
+
}
|
|
7126
|
+
function clearGlobalMeasurements() {
|
|
7127
|
+
const arr = getGlobalMeasurementsArray();
|
|
7128
|
+
arr.length = 0;
|
|
7129
|
+
}
|
|
7130
|
+
|
|
7131
|
+
// libs/testing/src/perf/perf-test.ts
|
|
7132
|
+
var currentConfig2 = {};
|
|
7133
|
+
var serverInstance2 = null;
|
|
7134
|
+
var tokenFactory2 = null;
|
|
7135
|
+
var serverStartedByUs2 = false;
|
|
7136
|
+
async function initializeSharedResources2() {
|
|
7137
|
+
if (!tokenFactory2) {
|
|
7138
|
+
tokenFactory2 = new TestTokenFactory();
|
|
7139
|
+
}
|
|
7140
|
+
if (!serverInstance2) {
|
|
7141
|
+
if (currentConfig2.baseUrl) {
|
|
7142
|
+
serverInstance2 = TestServer.connect(currentConfig2.baseUrl);
|
|
7143
|
+
serverStartedByUs2 = false;
|
|
7144
|
+
} else if (currentConfig2.server) {
|
|
7145
|
+
const serverCommand = resolveServerCommand2(currentConfig2.server);
|
|
7146
|
+
const isDebug = process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
7147
|
+
if (isDebug) {
|
|
7148
|
+
console.log(`[PerfTest] Starting server: ${serverCommand}`);
|
|
7149
|
+
}
|
|
7150
|
+
try {
|
|
7151
|
+
serverInstance2 = await TestServer.start({
|
|
7152
|
+
project: currentConfig2.project,
|
|
7153
|
+
port: currentConfig2.port,
|
|
7154
|
+
command: serverCommand,
|
|
7155
|
+
env: currentConfig2.env,
|
|
7156
|
+
startupTimeout: currentConfig2.startupTimeout ?? 3e4,
|
|
7157
|
+
debug: isDebug
|
|
7158
|
+
});
|
|
7159
|
+
serverStartedByUs2 = true;
|
|
7160
|
+
if (isDebug) {
|
|
7161
|
+
console.log(`[PerfTest] Server started at ${serverInstance2.info.baseUrl}`);
|
|
7162
|
+
}
|
|
7163
|
+
} catch (error) {
|
|
7164
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
7165
|
+
throw new Error(
|
|
7166
|
+
`Failed to start test server.
|
|
7167
|
+
|
|
7168
|
+
Server entry: ${currentConfig2.server}
|
|
7169
|
+
Project: ${currentConfig2.project ?? "default"}
|
|
7170
|
+
Command: ${serverCommand}
|
|
7171
|
+
|
|
7172
|
+
Error: ${errMsg}`
|
|
7173
|
+
);
|
|
7174
|
+
}
|
|
7175
|
+
} else {
|
|
7176
|
+
throw new Error('perfTest.use() requires either "server" (entry file path) or "baseUrl" option');
|
|
7177
|
+
}
|
|
7178
|
+
}
|
|
7179
|
+
}
|
|
7180
|
+
async function createTestFixtures2(testName) {
|
|
7181
|
+
await initializeSharedResources2();
|
|
7182
|
+
if (!serverInstance2) {
|
|
7183
|
+
throw new Error("Server instance not initialized");
|
|
7184
|
+
}
|
|
7185
|
+
if (!tokenFactory2) {
|
|
7186
|
+
throw new Error("Token factory not initialized");
|
|
7187
|
+
}
|
|
7188
|
+
const clientInstance = await McpTestClient.create({
|
|
7189
|
+
baseUrl: serverInstance2.info.baseUrl,
|
|
7190
|
+
transport: currentConfig2.transport ?? "streamable-http",
|
|
7191
|
+
publicMode: currentConfig2.publicMode
|
|
7192
|
+
}).buildAndConnect();
|
|
7193
|
+
const auth = createAuthFixture2(tokenFactory2);
|
|
7194
|
+
const server = createServerFixture2(serverInstance2);
|
|
7195
|
+
const perfImpl = createPerfFixtures(testName, currentConfig2.project ?? "unknown");
|
|
7196
|
+
if (currentConfig2.forceGcOnBaseline !== false) {
|
|
7197
|
+
await perfImpl.baseline();
|
|
7198
|
+
}
|
|
7199
|
+
return {
|
|
7200
|
+
fixtures: {
|
|
7201
|
+
mcp: clientInstance,
|
|
7202
|
+
auth,
|
|
7203
|
+
server,
|
|
7204
|
+
perf: perfImpl
|
|
7205
|
+
},
|
|
7206
|
+
perfImpl
|
|
7207
|
+
};
|
|
7208
|
+
}
|
|
7209
|
+
async function cleanupTestFixtures2(fixtures, perfImpl, testFailed = false) {
|
|
7210
|
+
const measurement = perfImpl.buildMeasurement();
|
|
7211
|
+
addGlobalMeasurement(measurement);
|
|
7212
|
+
if (testFailed && serverInstance2) {
|
|
7213
|
+
const logs = serverInstance2.getLogs();
|
|
7214
|
+
if (logs.length > 0) {
|
|
7215
|
+
console.error("\n[PerfTest] === Server Logs (test failed) ===");
|
|
7216
|
+
const recentLogs = logs.slice(-50);
|
|
7217
|
+
if (logs.length > 50) {
|
|
7218
|
+
console.error(`[PerfTest] (showing last 50 of ${logs.length} log entries)`);
|
|
7219
|
+
}
|
|
7220
|
+
console.error(recentLogs.join("\n"));
|
|
7221
|
+
console.error("[PerfTest] === End Server Logs ===\n");
|
|
7222
|
+
}
|
|
7223
|
+
}
|
|
7224
|
+
if (fixtures.mcp.isConnected()) {
|
|
7225
|
+
await fixtures.mcp.disconnect();
|
|
7226
|
+
}
|
|
7227
|
+
}
|
|
7228
|
+
async function cleanupSharedResources2() {
|
|
7229
|
+
if (serverInstance2 && serverStartedByUs2) {
|
|
7230
|
+
await serverInstance2.stop();
|
|
7231
|
+
}
|
|
7232
|
+
serverInstance2 = null;
|
|
7233
|
+
tokenFactory2 = null;
|
|
7234
|
+
serverStartedByUs2 = false;
|
|
7235
|
+
}
|
|
7236
|
+
function createAuthFixture2(factory) {
|
|
7237
|
+
const users = {
|
|
7238
|
+
admin: {
|
|
7239
|
+
sub: "admin-001",
|
|
7240
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
7241
|
+
email: "admin@test.local",
|
|
7242
|
+
name: "Test Admin"
|
|
7243
|
+
},
|
|
7244
|
+
user: {
|
|
7245
|
+
sub: "user-001",
|
|
7246
|
+
scopes: ["read", "write"],
|
|
7247
|
+
email: "user@test.local",
|
|
7248
|
+
name: "Test User"
|
|
7249
|
+
},
|
|
7250
|
+
readOnly: {
|
|
7251
|
+
sub: "readonly-001",
|
|
7252
|
+
scopes: ["read"],
|
|
7253
|
+
email: "readonly@test.local",
|
|
7254
|
+
name: "Read Only User"
|
|
7255
|
+
}
|
|
7256
|
+
};
|
|
7257
|
+
return {
|
|
7258
|
+
createToken: (opts) => factory.createTestToken({
|
|
7259
|
+
sub: opts.sub,
|
|
7260
|
+
scopes: opts.scopes,
|
|
7261
|
+
claims: {
|
|
7262
|
+
email: opts.email,
|
|
7263
|
+
name: opts.name,
|
|
7264
|
+
...opts.claims
|
|
7265
|
+
},
|
|
7266
|
+
exp: opts.expiresIn
|
|
7267
|
+
}),
|
|
7268
|
+
createExpiredToken: (opts) => factory.createExpiredToken(opts),
|
|
7269
|
+
createInvalidToken: (opts) => factory.createTokenWithInvalidSignature(opts),
|
|
7270
|
+
users: {
|
|
7271
|
+
admin: users["admin"],
|
|
7272
|
+
user: users["user"],
|
|
7273
|
+
readOnly: users["readOnly"]
|
|
7274
|
+
},
|
|
7275
|
+
getJwks: () => factory.getPublicJwks(),
|
|
7276
|
+
getIssuer: () => factory.getIssuer(),
|
|
7277
|
+
getAudience: () => factory.getAudience()
|
|
7278
|
+
};
|
|
7279
|
+
}
|
|
7280
|
+
function createServerFixture2(server) {
|
|
7281
|
+
return {
|
|
7282
|
+
info: server.info,
|
|
7283
|
+
createClient: async (opts) => {
|
|
7284
|
+
return McpTestClient.create({
|
|
7285
|
+
baseUrl: server.info.baseUrl,
|
|
7286
|
+
transport: opts?.transport ?? "streamable-http",
|
|
7287
|
+
auth: opts?.token ? { token: opts.token } : void 0,
|
|
7288
|
+
clientInfo: opts?.clientInfo,
|
|
7289
|
+
publicMode: currentConfig2.publicMode
|
|
7290
|
+
}).buildAndConnect();
|
|
7291
|
+
},
|
|
7292
|
+
createClientBuilder: () => {
|
|
7293
|
+
return new McpTestClientBuilder({
|
|
7294
|
+
baseUrl: server.info.baseUrl,
|
|
7295
|
+
publicMode: currentConfig2.publicMode
|
|
7296
|
+
});
|
|
7297
|
+
},
|
|
7298
|
+
restart: () => server.restart(),
|
|
7299
|
+
getLogs: () => server.getLogs(),
|
|
7300
|
+
clearLogs: () => server.clearLogs()
|
|
7301
|
+
};
|
|
7302
|
+
}
|
|
7303
|
+
function resolveServerCommand2(server) {
|
|
7304
|
+
if (server.includes(" ")) {
|
|
7305
|
+
return server;
|
|
7306
|
+
}
|
|
7307
|
+
return `npx tsx ${server}`;
|
|
7308
|
+
}
|
|
7309
|
+
function perfTestWithFixtures(name, fn) {
|
|
7310
|
+
it(name, async () => {
|
|
7311
|
+
const { fixtures, perfImpl } = await createTestFixtures2(name);
|
|
7312
|
+
let testFailed = false;
|
|
7313
|
+
try {
|
|
7314
|
+
await fn(fixtures);
|
|
7315
|
+
} catch (error) {
|
|
7316
|
+
testFailed = true;
|
|
7317
|
+
throw error;
|
|
7318
|
+
} finally {
|
|
7319
|
+
await cleanupTestFixtures2(fixtures, perfImpl, testFailed);
|
|
7320
|
+
}
|
|
7321
|
+
});
|
|
7322
|
+
}
|
|
7323
|
+
function use2(config) {
|
|
7324
|
+
currentConfig2 = { ...currentConfig2, ...config };
|
|
7325
|
+
afterAll(async () => {
|
|
7326
|
+
await cleanupSharedResources2();
|
|
7327
|
+
});
|
|
7328
|
+
}
|
|
7329
|
+
function skip2(name, fn) {
|
|
7330
|
+
it.skip(name, async () => {
|
|
7331
|
+
const { fixtures, perfImpl } = await createTestFixtures2(name);
|
|
7332
|
+
let testFailed = false;
|
|
7333
|
+
try {
|
|
7334
|
+
await fn(fixtures);
|
|
7335
|
+
} catch (error) {
|
|
7336
|
+
testFailed = true;
|
|
7337
|
+
throw error;
|
|
7338
|
+
} finally {
|
|
7339
|
+
await cleanupTestFixtures2(fixtures, perfImpl, testFailed);
|
|
7340
|
+
}
|
|
7341
|
+
});
|
|
7342
|
+
}
|
|
7343
|
+
function only2(name, fn) {
|
|
7344
|
+
it.only(name, async () => {
|
|
7345
|
+
const { fixtures, perfImpl } = await createTestFixtures2(name);
|
|
7346
|
+
let testFailed = false;
|
|
7347
|
+
try {
|
|
7348
|
+
await fn(fixtures);
|
|
7349
|
+
} catch (error) {
|
|
7350
|
+
testFailed = true;
|
|
7351
|
+
throw error;
|
|
7352
|
+
} finally {
|
|
7353
|
+
await cleanupTestFixtures2(fixtures, perfImpl, testFailed);
|
|
7354
|
+
}
|
|
7355
|
+
});
|
|
7356
|
+
}
|
|
7357
|
+
function todo2(name) {
|
|
7358
|
+
it.todo(name);
|
|
7359
|
+
}
|
|
7360
|
+
var perfTest = perfTestWithFixtures;
|
|
7361
|
+
perfTest.use = use2;
|
|
7362
|
+
perfTest.describe = describe;
|
|
7363
|
+
perfTest.beforeAll = beforeAll;
|
|
7364
|
+
perfTest.beforeEach = beforeEach;
|
|
7365
|
+
perfTest.afterEach = afterEach;
|
|
7366
|
+
perfTest.afterAll = afterAll;
|
|
7367
|
+
perfTest.skip = skip2;
|
|
7368
|
+
perfTest.only = only2;
|
|
7369
|
+
perfTest.todo = todo2;
|
|
7370
|
+
|
|
7371
|
+
// libs/testing/src/perf/baseline-store.ts
|
|
7372
|
+
var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
|
|
7373
|
+
var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
|
|
7374
|
+
var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
|
|
7375
|
+
var BaselineStore = class {
|
|
7376
|
+
baseline = null;
|
|
7377
|
+
baselinePath;
|
|
7378
|
+
constructor(baselinePath = DEFAULT_BASELINE_PATH) {
|
|
7379
|
+
this.baselinePath = baselinePath;
|
|
7380
|
+
}
|
|
7381
|
+
/**
|
|
7382
|
+
* Load baseline from local file.
|
|
7383
|
+
*/
|
|
7384
|
+
async load() {
|
|
7385
|
+
try {
|
|
7386
|
+
const { readFile, fileExists } = await import("@frontmcp/utils");
|
|
7387
|
+
if (!await fileExists(this.baselinePath)) {
|
|
7388
|
+
return null;
|
|
7389
|
+
}
|
|
7390
|
+
const content = await readFile(this.baselinePath);
|
|
7391
|
+
this.baseline = JSON.parse(content);
|
|
7392
|
+
return this.baseline;
|
|
7393
|
+
} catch {
|
|
7394
|
+
return null;
|
|
7395
|
+
}
|
|
7396
|
+
}
|
|
7397
|
+
/**
|
|
7398
|
+
* Save baseline to local file.
|
|
7399
|
+
*/
|
|
7400
|
+
async save(baseline) {
|
|
7401
|
+
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
7402
|
+
const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
|
|
7403
|
+
await ensureDir(dir);
|
|
7404
|
+
await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
7405
|
+
this.baseline = baseline;
|
|
7406
|
+
}
|
|
7407
|
+
/**
|
|
7408
|
+
* Get a test baseline by ID.
|
|
7409
|
+
*/
|
|
7410
|
+
getTestBaseline(testId) {
|
|
7411
|
+
if (!this.baseline) {
|
|
7412
|
+
return null;
|
|
7413
|
+
}
|
|
7414
|
+
return this.baseline.tests[testId] ?? null;
|
|
7415
|
+
}
|
|
7416
|
+
/**
|
|
7417
|
+
* Check if baseline is loaded.
|
|
7418
|
+
*/
|
|
7419
|
+
isLoaded() {
|
|
7420
|
+
return this.baseline !== null;
|
|
7421
|
+
}
|
|
7422
|
+
/**
|
|
7423
|
+
* Get the loaded baseline.
|
|
7424
|
+
*/
|
|
7425
|
+
getBaseline() {
|
|
7426
|
+
return this.baseline;
|
|
7427
|
+
}
|
|
7428
|
+
/**
|
|
7429
|
+
* Create a baseline from measurements.
|
|
7430
|
+
*/
|
|
7431
|
+
static createFromMeasurements(measurements, release, commitHash) {
|
|
7432
|
+
const tests = {};
|
|
7433
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
7434
|
+
for (const m of measurements) {
|
|
7435
|
+
const key = `${m.project}::${m.name}`;
|
|
7436
|
+
if (!grouped.has(key)) {
|
|
7437
|
+
grouped.set(key, []);
|
|
7438
|
+
}
|
|
7439
|
+
const group = grouped.get(key);
|
|
7440
|
+
if (group) {
|
|
7441
|
+
group.push(m);
|
|
7442
|
+
}
|
|
7443
|
+
}
|
|
7444
|
+
for (const [key, testMeasurements] of grouped) {
|
|
7445
|
+
const [project] = key.split("::");
|
|
7446
|
+
const heapSamples = testMeasurements.map((m) => m.final?.memory.heapUsed ?? 0);
|
|
7447
|
+
const durationSamples = testMeasurements.map((m) => m.timing.durationMs);
|
|
7448
|
+
const cpuSamples = testMeasurements.map((m) => m.final?.cpu.total ?? 0);
|
|
7449
|
+
tests[key] = {
|
|
7450
|
+
testId: key,
|
|
7451
|
+
project,
|
|
7452
|
+
heapUsed: calculateMetricBaseline(heapSamples),
|
|
7453
|
+
durationMs: calculateMetricBaseline(durationSamples),
|
|
7454
|
+
cpuTime: calculateMetricBaseline(cpuSamples),
|
|
7455
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7456
|
+
commitHash
|
|
7457
|
+
};
|
|
7458
|
+
}
|
|
7459
|
+
return {
|
|
7460
|
+
release,
|
|
7461
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7462
|
+
commitHash,
|
|
7463
|
+
tests
|
|
7464
|
+
};
|
|
7465
|
+
}
|
|
7466
|
+
};
|
|
7467
|
+
function parseBaselineFromComment(commentBody) {
|
|
7468
|
+
const startIdx = commentBody.indexOf(BASELINE_START_MARKER);
|
|
7469
|
+
const endIdx = commentBody.indexOf(BASELINE_END_MARKER);
|
|
7470
|
+
if (startIdx === -1 || endIdx === -1 || startIdx >= endIdx) {
|
|
7471
|
+
return null;
|
|
7472
|
+
}
|
|
7473
|
+
const content = commentBody.substring(startIdx + BASELINE_START_MARKER.length, endIdx);
|
|
7474
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
|
|
7475
|
+
if (!jsonMatch) {
|
|
7476
|
+
return null;
|
|
7477
|
+
}
|
|
7478
|
+
try {
|
|
7479
|
+
return JSON.parse(jsonMatch[1]);
|
|
7480
|
+
} catch {
|
|
7481
|
+
return null;
|
|
7482
|
+
}
|
|
7483
|
+
}
|
|
7484
|
+
function formatBaselineAsComment(baseline) {
|
|
7485
|
+
const json = JSON.stringify(baseline, null, 2);
|
|
7486
|
+
return `## Performance Baseline
|
|
7487
|
+
|
|
7488
|
+
This comment contains the performance baseline for release ${baseline.release}.
|
|
7489
|
+
|
|
7490
|
+
${BASELINE_START_MARKER}
|
|
7491
|
+
\`\`\`json
|
|
7492
|
+
${json}
|
|
7493
|
+
\`\`\`
|
|
7494
|
+
${BASELINE_END_MARKER}
|
|
7495
|
+
|
|
7496
|
+
Generated at: ${baseline.timestamp}
|
|
7497
|
+
${baseline.commitHash ? `Commit: ${baseline.commitHash}` : ""}
|
|
7498
|
+
`;
|
|
7499
|
+
}
|
|
7500
|
+
function calculateMetricBaseline(samples) {
|
|
7501
|
+
if (samples.length === 0) {
|
|
7502
|
+
return {
|
|
7503
|
+
mean: 0,
|
|
7504
|
+
stdDev: 0,
|
|
7505
|
+
min: 0,
|
|
7506
|
+
max: 0,
|
|
7507
|
+
p95: 0,
|
|
7508
|
+
sampleCount: 0
|
|
7509
|
+
};
|
|
7510
|
+
}
|
|
7511
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
7512
|
+
const n = sorted.length;
|
|
7513
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
7514
|
+
const mean = sum / n;
|
|
7515
|
+
const squaredDiffs = sorted.map((x) => Math.pow(x - mean, 2));
|
|
7516
|
+
const variance = squaredDiffs.reduce((a, b) => a + b, 0) / n;
|
|
7517
|
+
const stdDev = Math.sqrt(variance);
|
|
7518
|
+
const min = sorted[0];
|
|
7519
|
+
const max = sorted[n - 1];
|
|
7520
|
+
const p95Index = Math.ceil(n * 0.95) - 1;
|
|
7521
|
+
const p95 = sorted[Math.min(p95Index, n - 1)];
|
|
7522
|
+
return {
|
|
7523
|
+
mean,
|
|
7524
|
+
stdDev,
|
|
7525
|
+
min,
|
|
7526
|
+
max,
|
|
7527
|
+
p95,
|
|
7528
|
+
sampleCount: n
|
|
7529
|
+
};
|
|
7530
|
+
}
|
|
7531
|
+
var globalBaselineStore = null;
|
|
7532
|
+
function getBaselineStore(baselinePath) {
|
|
7533
|
+
if (!globalBaselineStore || baselinePath) {
|
|
7534
|
+
globalBaselineStore = new BaselineStore(baselinePath);
|
|
7535
|
+
}
|
|
7536
|
+
return globalBaselineStore;
|
|
7537
|
+
}
|
|
7538
|
+
|
|
7539
|
+
// libs/testing/src/perf/regression-detector.ts
|
|
7540
|
+
var DEFAULT_CONFIG = {
|
|
7541
|
+
warningThresholdPercent: 10,
|
|
7542
|
+
errorThresholdPercent: 25,
|
|
7543
|
+
minAbsoluteChange: 1024
|
|
7544
|
+
// 1KB minimum to avoid noise
|
|
7545
|
+
};
|
|
7546
|
+
var RegressionDetector = class {
|
|
7547
|
+
config;
|
|
7548
|
+
constructor(config) {
|
|
7549
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
7550
|
+
}
|
|
7551
|
+
/**
|
|
7552
|
+
* Detect regressions in a measurement compared to baseline.
|
|
7553
|
+
*/
|
|
7554
|
+
detectRegression(measurement, baseline) {
|
|
7555
|
+
const testId = `${measurement.project}::${measurement.name}`;
|
|
7556
|
+
const metrics = [];
|
|
7557
|
+
const currentHeap = measurement.final?.memory.heapUsed ?? 0;
|
|
7558
|
+
const heapRegression = this.checkMetric("heapUsed", baseline.heapUsed.mean, currentHeap, formatBytes);
|
|
7559
|
+
metrics.push(heapRegression);
|
|
7560
|
+
const durationRegression = this.checkMetric(
|
|
7561
|
+
"durationMs",
|
|
7562
|
+
baseline.durationMs.mean,
|
|
7563
|
+
measurement.timing.durationMs,
|
|
7564
|
+
formatDuration
|
|
7565
|
+
);
|
|
7566
|
+
metrics.push(durationRegression);
|
|
7567
|
+
const currentCpu = measurement.final?.cpu.total ?? 0;
|
|
7568
|
+
const cpuRegression = this.checkMetric("cpuTime", baseline.cpuTime.mean, currentCpu, formatMicroseconds);
|
|
7569
|
+
metrics.push(cpuRegression);
|
|
7570
|
+
const hasRegression = metrics.some((m) => m.status === "regression");
|
|
7571
|
+
const hasWarning = metrics.some((m) => m.status === "warning");
|
|
7572
|
+
const status = hasRegression ? "regression" : hasWarning ? "warning" : "ok";
|
|
7573
|
+
const message = this.buildMessage(testId, metrics, status);
|
|
7574
|
+
return {
|
|
7575
|
+
testId,
|
|
7576
|
+
status,
|
|
7577
|
+
metrics,
|
|
7578
|
+
message
|
|
7579
|
+
};
|
|
7580
|
+
}
|
|
7581
|
+
/**
|
|
7582
|
+
* Detect regressions for multiple measurements.
|
|
7583
|
+
*/
|
|
7584
|
+
detectRegressions(measurements, baselines) {
|
|
7585
|
+
const results = [];
|
|
7586
|
+
for (const measurement of measurements) {
|
|
7587
|
+
const testId = `${measurement.project}::${measurement.name}`;
|
|
7588
|
+
const baseline = baselines.tests[testId];
|
|
7589
|
+
if (baseline) {
|
|
7590
|
+
results.push(this.detectRegression(measurement, baseline));
|
|
7591
|
+
}
|
|
7592
|
+
}
|
|
7593
|
+
return results;
|
|
7594
|
+
}
|
|
7595
|
+
/**
|
|
7596
|
+
* Check a single metric for regression.
|
|
7597
|
+
*/
|
|
7598
|
+
checkMetric(name, baseline, current, _formatter) {
|
|
7599
|
+
const absoluteChange = current - baseline;
|
|
7600
|
+
const changePercent = baseline > 0 ? absoluteChange / baseline * 100 : 0;
|
|
7601
|
+
let status = "ok";
|
|
7602
|
+
if (Math.abs(absoluteChange) > this.config.minAbsoluteChange) {
|
|
7603
|
+
if (changePercent >= this.config.errorThresholdPercent) {
|
|
7604
|
+
status = "regression";
|
|
7605
|
+
} else if (changePercent >= this.config.warningThresholdPercent) {
|
|
7606
|
+
status = "warning";
|
|
7607
|
+
}
|
|
7608
|
+
}
|
|
7609
|
+
return {
|
|
7610
|
+
metric: name,
|
|
7611
|
+
baseline,
|
|
7612
|
+
current,
|
|
7613
|
+
changePercent,
|
|
7614
|
+
absoluteChange,
|
|
7615
|
+
status
|
|
7616
|
+
};
|
|
7617
|
+
}
|
|
7618
|
+
/**
|
|
7619
|
+
* Build a human-readable message for regression result.
|
|
7620
|
+
*/
|
|
7621
|
+
buildMessage(testId, metrics, status) {
|
|
7622
|
+
if (status === "ok") {
|
|
7623
|
+
return `${testId}: All metrics within acceptable range`;
|
|
7624
|
+
}
|
|
7625
|
+
const issues = metrics.filter((m) => m.status !== "ok").map((m) => {
|
|
7626
|
+
const direction = m.absoluteChange > 0 ? "+" : "";
|
|
7627
|
+
return `${m.metric}: ${direction}${m.changePercent.toFixed(1)}%`;
|
|
7628
|
+
});
|
|
7629
|
+
const statusText = status === "regression" ? "REGRESSION" : "WARNING";
|
|
7630
|
+
return `${testId}: ${statusText} - ${issues.join(", ")}`;
|
|
7631
|
+
}
|
|
7632
|
+
};
|
|
7633
|
+
function summarizeRegressions(results) {
|
|
7634
|
+
const total = results.length;
|
|
7635
|
+
const ok = results.filter((r) => r.status === "ok").length;
|
|
7636
|
+
const warnings = results.filter((r) => r.status === "warning").length;
|
|
7637
|
+
const regressions = results.filter((r) => r.status === "regression").length;
|
|
7638
|
+
let summary;
|
|
7639
|
+
if (regressions > 0) {
|
|
7640
|
+
summary = `${regressions} regression(s) detected out of ${total} tests`;
|
|
7641
|
+
} else if (warnings > 0) {
|
|
7642
|
+
summary = `${warnings} warning(s) detected out of ${total} tests`;
|
|
7643
|
+
} else {
|
|
7644
|
+
summary = `All ${total} tests within acceptable range`;
|
|
7645
|
+
}
|
|
7646
|
+
return { total, ok, warnings, regressions, summary };
|
|
7647
|
+
}
|
|
7648
|
+
var globalDetector = null;
|
|
7649
|
+
function getRegressionDetector(config) {
|
|
7650
|
+
if (!globalDetector || config) {
|
|
7651
|
+
globalDetector = new RegressionDetector(config);
|
|
7652
|
+
}
|
|
7653
|
+
return globalDetector;
|
|
7654
|
+
}
|
|
7655
|
+
|
|
7656
|
+
// libs/testing/src/perf/report-generator.ts
|
|
7657
|
+
var ReportGenerator = class {
|
|
7658
|
+
detector;
|
|
7659
|
+
constructor() {
|
|
7660
|
+
this.detector = new RegressionDetector();
|
|
7661
|
+
}
|
|
7662
|
+
/**
|
|
7663
|
+
* Generate a complete performance report.
|
|
7664
|
+
*/
|
|
7665
|
+
generateReport(measurements, baseline, gitInfo) {
|
|
7666
|
+
const projectGroups = /* @__PURE__ */ new Map();
|
|
7667
|
+
for (const m of measurements) {
|
|
7668
|
+
if (!projectGroups.has(m.project)) {
|
|
7669
|
+
projectGroups.set(m.project, []);
|
|
7670
|
+
}
|
|
7671
|
+
const group = projectGroups.get(m.project);
|
|
7672
|
+
if (group) {
|
|
7673
|
+
group.push(m);
|
|
7674
|
+
}
|
|
7675
|
+
}
|
|
7676
|
+
const projects = [];
|
|
7677
|
+
for (const [projectName, projectMeasurements] of projectGroups) {
|
|
7678
|
+
const summary = this.calculateSummary(projectMeasurements);
|
|
7679
|
+
const regressions = baseline ? this.detector.detectRegressions(projectMeasurements, baseline) : void 0;
|
|
7680
|
+
projects.push({
|
|
7681
|
+
project: projectName,
|
|
7682
|
+
summary,
|
|
7683
|
+
measurements: projectMeasurements,
|
|
7684
|
+
regressions
|
|
7685
|
+
});
|
|
7686
|
+
}
|
|
7687
|
+
const overallSummary = this.calculateSummary(measurements);
|
|
7688
|
+
return {
|
|
7689
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7690
|
+
commitHash: gitInfo?.commitHash,
|
|
7691
|
+
branch: gitInfo?.branch,
|
|
7692
|
+
summary: overallSummary,
|
|
7693
|
+
projects,
|
|
7694
|
+
baseline: baseline ? { release: baseline.release, timestamp: baseline.timestamp } : void 0
|
|
7695
|
+
};
|
|
7696
|
+
}
|
|
7697
|
+
/**
|
|
7698
|
+
* Generate Markdown report for PR comments.
|
|
7699
|
+
*/
|
|
7700
|
+
generateMarkdownReport(report) {
|
|
7701
|
+
const lines = [];
|
|
7702
|
+
lines.push("## Performance Test Results");
|
|
7703
|
+
lines.push("");
|
|
7704
|
+
const statusEmoji = this.getStatusEmoji(report.summary);
|
|
7705
|
+
lines.push(`**Status:** ${statusEmoji} ${this.getSummaryText(report.summary)}`);
|
|
7706
|
+
lines.push("");
|
|
7707
|
+
lines.push("### Summary");
|
|
7708
|
+
lines.push("");
|
|
7709
|
+
lines.push("| Metric | Value |");
|
|
7710
|
+
lines.push("|--------|-------|");
|
|
7711
|
+
lines.push(`| Total Tests | ${report.summary.totalTests} |`);
|
|
7712
|
+
lines.push(`| Passed | ${report.summary.passedTests} |`);
|
|
7713
|
+
lines.push(`| Warnings | ${report.summary.warningTests} |`);
|
|
7714
|
+
lines.push(`| Failed | ${report.summary.failedTests} |`);
|
|
7715
|
+
lines.push(`| Memory Leaks | ${report.summary.leakTests} |`);
|
|
7716
|
+
lines.push("");
|
|
7717
|
+
if (report.baseline) {
|
|
7718
|
+
lines.push(`**Baseline:** ${report.baseline.release} (${report.baseline.timestamp})`);
|
|
7719
|
+
lines.push("");
|
|
7720
|
+
}
|
|
7721
|
+
lines.push("### Project Breakdown");
|
|
7722
|
+
lines.push("");
|
|
7723
|
+
for (const project of report.projects) {
|
|
7724
|
+
lines.push(`#### ${project.project}`);
|
|
7725
|
+
lines.push("");
|
|
7726
|
+
lines.push(this.generateProjectTable(project));
|
|
7727
|
+
lines.push("");
|
|
7728
|
+
const leakTests = project.measurements.filter((m) => m.leakDetectionResults && m.leakDetectionResults.length > 0);
|
|
7729
|
+
if (leakTests.length > 0) {
|
|
7730
|
+
const parallelTests = leakTests.filter((m) => m.leakDetectionResults?.some((r) => this.isParallelResult(r)));
|
|
7731
|
+
if (parallelTests.length > 0) {
|
|
7732
|
+
lines.push("**Parallel Stress Test Results:**");
|
|
7733
|
+
lines.push("");
|
|
7734
|
+
lines.push("| Test | Workers | Iterations | Duration | Total req/s |");
|
|
7735
|
+
lines.push("|------|---------|------------|----------|-------------|");
|
|
7736
|
+
for (const m of parallelTests) {
|
|
7737
|
+
for (const result of m.leakDetectionResults || []) {
|
|
7738
|
+
if (this.isParallelResult(result)) {
|
|
7739
|
+
const parallelResult = result;
|
|
7740
|
+
const durationStr = parallelResult.durationMs ? `${(parallelResult.durationMs / 1e3).toFixed(2)}s` : "N/A";
|
|
7741
|
+
lines.push(
|
|
7742
|
+
`| ${m.name} | ${parallelResult.workersUsed} | ${parallelResult.totalIterations} | ${durationStr} | ${parallelResult.totalRequestsPerSecond.toFixed(1)} |`
|
|
7743
|
+
);
|
|
7744
|
+
}
|
|
7745
|
+
}
|
|
7746
|
+
}
|
|
7747
|
+
lines.push("");
|
|
7748
|
+
}
|
|
7749
|
+
lines.push("**Memory Interval Analysis:**");
|
|
7750
|
+
lines.push("");
|
|
7751
|
+
for (const m of leakTests) {
|
|
7752
|
+
for (const result of m.leakDetectionResults || []) {
|
|
7753
|
+
if (result.intervals && result.intervals.length > 0) {
|
|
7754
|
+
lines.push(`*${m.name}:*`);
|
|
7755
|
+
lines.push("");
|
|
7756
|
+
if (this.isParallelResult(result)) {
|
|
7757
|
+
const parallelResult = result;
|
|
7758
|
+
lines.push("| Worker | req/s | Iterations |");
|
|
7759
|
+
lines.push("|--------|-------|------------|");
|
|
7760
|
+
for (const worker of parallelResult.perWorkerStats) {
|
|
7761
|
+
lines.push(
|
|
7762
|
+
`| ${worker.workerId} | ${worker.requestsPerSecond.toFixed(1)} | ${worker.iterationsCompleted} |`
|
|
7763
|
+
);
|
|
7764
|
+
}
|
|
7765
|
+
lines.push("");
|
|
7766
|
+
}
|
|
7767
|
+
lines.push("| Interval | Heap Start | Heap End | Delta | Rate/iter |");
|
|
7768
|
+
lines.push("|----------|------------|----------|-------|-----------|");
|
|
7769
|
+
for (const interval of result.intervals) {
|
|
7770
|
+
lines.push(
|
|
7771
|
+
`| ${interval.startIteration}-${interval.endIteration} | ${formatBytes(interval.heapAtStart)} | ${formatBytes(interval.heapAtEnd)} | ${interval.deltaFormatted} | ${formatBytes(interval.growthRatePerIteration)}/iter |`
|
|
7772
|
+
);
|
|
7773
|
+
}
|
|
7774
|
+
lines.push("");
|
|
7775
|
+
const durationStr = result.durationMs ? `${(result.durationMs / 1e3).toFixed(2)}s` : "N/A";
|
|
7776
|
+
const rpsStr = result.requestsPerSecond ? `${result.requestsPerSecond.toFixed(1)} req/s` : "N/A";
|
|
7777
|
+
lines.push(
|
|
7778
|
+
`Total: ${formatBytes(result.totalGrowth)}, R\xB2=${result.rSquared.toFixed(3)} | ${result.samples.length} iterations in ${durationStr} (${rpsStr})`
|
|
7779
|
+
);
|
|
7780
|
+
lines.push("");
|
|
7781
|
+
}
|
|
7782
|
+
}
|
|
7783
|
+
}
|
|
7784
|
+
}
|
|
7785
|
+
if (project.regressions && project.regressions.length > 0) {
|
|
7786
|
+
const regressionsWithIssues = project.regressions.filter((r) => r.status !== "ok");
|
|
7787
|
+
if (regressionsWithIssues.length > 0) {
|
|
7788
|
+
lines.push("**Regressions:**");
|
|
7789
|
+
for (const r of regressionsWithIssues) {
|
|
7790
|
+
const emoji = r.status === "regression" ? "\u274C" : "\u26A0\uFE0F";
|
|
7791
|
+
lines.push(`- ${emoji} ${r.message}`);
|
|
7792
|
+
}
|
|
7793
|
+
lines.push("");
|
|
7794
|
+
}
|
|
7795
|
+
}
|
|
7796
|
+
}
|
|
7797
|
+
lines.push("---");
|
|
7798
|
+
lines.push(`Generated at: ${report.timestamp}`);
|
|
7799
|
+
if (report.commitHash) {
|
|
7800
|
+
lines.push(`Commit: \`${report.commitHash.substring(0, 8)}\``);
|
|
7801
|
+
}
|
|
7802
|
+
if (report.branch) {
|
|
7803
|
+
lines.push(`Branch: \`${report.branch}\``);
|
|
7804
|
+
}
|
|
7805
|
+
return lines.join("\n");
|
|
7806
|
+
}
|
|
7807
|
+
/**
|
|
7808
|
+
* Generate JSON report.
|
|
7809
|
+
*/
|
|
7810
|
+
generateJsonReport(report) {
|
|
7811
|
+
return JSON.stringify(report, null, 2);
|
|
7812
|
+
}
|
|
7813
|
+
/**
|
|
7814
|
+
* Calculate summary statistics for measurements.
|
|
7815
|
+
*/
|
|
7816
|
+
calculateSummary(measurements) {
|
|
7817
|
+
let passedTests = 0;
|
|
7818
|
+
let warningTests = 0;
|
|
7819
|
+
let failedTests = 0;
|
|
7820
|
+
let leakTests = 0;
|
|
7821
|
+
for (const m of measurements) {
|
|
7822
|
+
const hasError = m.issues.some((i) => i.severity === "error");
|
|
7823
|
+
const hasWarning = m.issues.some((i) => i.severity === "warning");
|
|
7824
|
+
const hasLeak = m.issues.some((i) => i.type === "memory-leak");
|
|
7825
|
+
if (hasLeak) {
|
|
7826
|
+
leakTests++;
|
|
7827
|
+
}
|
|
7828
|
+
if (hasError) {
|
|
7829
|
+
failedTests++;
|
|
7830
|
+
} else if (hasWarning) {
|
|
7831
|
+
warningTests++;
|
|
7832
|
+
} else {
|
|
7833
|
+
passedTests++;
|
|
7834
|
+
}
|
|
7835
|
+
}
|
|
7836
|
+
return {
|
|
7837
|
+
totalTests: measurements.length,
|
|
7838
|
+
passedTests,
|
|
7839
|
+
warningTests,
|
|
7840
|
+
failedTests,
|
|
7841
|
+
leakTests
|
|
7842
|
+
};
|
|
7843
|
+
}
|
|
7844
|
+
/**
|
|
7845
|
+
* Get status emoji based on summary.
|
|
7846
|
+
*/
|
|
7847
|
+
getStatusEmoji(summary) {
|
|
7848
|
+
if (summary.failedTests > 0 || summary.leakTests > 0) {
|
|
7849
|
+
return "X";
|
|
7850
|
+
}
|
|
7851
|
+
if (summary.warningTests > 0) {
|
|
7852
|
+
return "!";
|
|
7853
|
+
}
|
|
7854
|
+
return "OK";
|
|
7855
|
+
}
|
|
7856
|
+
/**
|
|
7857
|
+
* Get summary text.
|
|
7858
|
+
*/
|
|
7859
|
+
getSummaryText(summary) {
|
|
7860
|
+
if (summary.failedTests > 0) {
|
|
7861
|
+
return `${summary.failedTests} test(s) failed`;
|
|
7862
|
+
}
|
|
7863
|
+
if (summary.leakTests > 0) {
|
|
7864
|
+
return `${summary.leakTests} memory leak(s) detected`;
|
|
7865
|
+
}
|
|
7866
|
+
if (summary.warningTests > 0) {
|
|
7867
|
+
return `${summary.warningTests} warning(s)`;
|
|
7868
|
+
}
|
|
7869
|
+
return "All tests passed";
|
|
7870
|
+
}
|
|
7871
|
+
/**
|
|
7872
|
+
* Generate markdown table for project measurements.
|
|
7873
|
+
*/
|
|
7874
|
+
generateProjectTable(project) {
|
|
7875
|
+
const lines = [];
|
|
7876
|
+
lines.push("| Test | Duration | Heap Delta | CPU Time | Status |");
|
|
7877
|
+
lines.push("|------|----------|------------|----------|--------|");
|
|
7878
|
+
for (const m of project.measurements) {
|
|
7879
|
+
const status = this.getTestStatus(m);
|
|
7880
|
+
const heapDelta = m.memoryDelta ? formatBytes(m.memoryDelta.heapUsed) : "N/A";
|
|
7881
|
+
const cpuTime = m.final?.cpu.total ? formatMicroseconds(m.final.cpu.total) : "N/A";
|
|
7882
|
+
lines.push(`| ${m.name} | ${formatDuration(m.timing.durationMs)} | ${heapDelta} | ${cpuTime} | ${status} |`);
|
|
7883
|
+
}
|
|
7884
|
+
return lines.join("\n");
|
|
7885
|
+
}
|
|
7886
|
+
/**
|
|
7887
|
+
* Get test status indicator.
|
|
7888
|
+
*/
|
|
7889
|
+
getTestStatus(m) {
|
|
7890
|
+
const hasError = m.issues.some((i) => i.severity === "error");
|
|
7891
|
+
const hasLeak = m.issues.some((i) => i.type === "memory-leak");
|
|
7892
|
+
const hasWarning = m.issues.some((i) => i.severity === "warning");
|
|
7893
|
+
if (hasLeak) {
|
|
7894
|
+
return "LEAK";
|
|
7895
|
+
}
|
|
7896
|
+
if (hasError) {
|
|
7897
|
+
return "FAIL";
|
|
7898
|
+
}
|
|
7899
|
+
if (hasWarning) {
|
|
7900
|
+
return "WARN";
|
|
7901
|
+
}
|
|
7902
|
+
return "OK";
|
|
7903
|
+
}
|
|
7904
|
+
/**
|
|
7905
|
+
* Check if a leak detection result is a parallel result.
|
|
7906
|
+
*/
|
|
7907
|
+
isParallelResult(result) {
|
|
7908
|
+
return typeof result === "object" && result !== null && "workersUsed" in result && "perWorkerStats" in result && "totalRequestsPerSecond" in result;
|
|
7909
|
+
}
|
|
7910
|
+
};
|
|
7911
|
+
async function saveReports(measurements, outputDir, baseline, gitInfo) {
|
|
7912
|
+
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
7913
|
+
await ensureDir(outputDir);
|
|
7914
|
+
const generator = new ReportGenerator();
|
|
7915
|
+
const report = generator.generateReport(measurements, baseline, gitInfo);
|
|
7916
|
+
const jsonPath = `${outputDir}/report.json`;
|
|
7917
|
+
const markdownPath = `${outputDir}/report.md`;
|
|
7918
|
+
await writeFile(jsonPath, generator.generateJsonReport(report));
|
|
7919
|
+
await writeFile(markdownPath, generator.generateMarkdownReport(report));
|
|
7920
|
+
return { jsonPath, markdownPath };
|
|
7921
|
+
}
|
|
7922
|
+
function createReportGenerator() {
|
|
7923
|
+
return new ReportGenerator();
|
|
7924
|
+
}
|
|
4899
7925
|
// Annotate the CommonJS export names for ESM import in node:
|
|
4900
7926
|
0 && (module.exports = {
|
|
4901
7927
|
AssertionError,
|
|
4902
7928
|
AuthHeaders,
|
|
4903
7929
|
BASIC_UI_TOOL_CONFIG,
|
|
7930
|
+
BaselineStore,
|
|
4904
7931
|
ConnectionError,
|
|
4905
7932
|
DefaultInterceptorChain,
|
|
4906
7933
|
DefaultMockRegistry,
|
|
@@ -4911,13 +7938,19 @@ var EXPECTED_FRONTMCP_TOOL_CALL_META_KEYS = EXPECTED_GENERIC_TOOL_CALL_META_KEYS
|
|
|
4911
7938
|
EXPECTED_OPENAI_TOOLS_LIST_META_KEYS,
|
|
4912
7939
|
EXPECTED_OPENAI_TOOL_CALL_META_KEYS,
|
|
4913
7940
|
FULL_UI_TOOL_CONFIG,
|
|
7941
|
+
LeakDetector,
|
|
4914
7942
|
McpAssertions,
|
|
4915
7943
|
McpProtocolError,
|
|
4916
7944
|
McpTestClient,
|
|
4917
7945
|
McpTestClientBuilder,
|
|
7946
|
+
MetricsCollector,
|
|
4918
7947
|
MockAPIServer,
|
|
7948
|
+
MockCimdServer,
|
|
4919
7949
|
MockOAuthServer,
|
|
4920
7950
|
PLATFORM_DETECTION_PATTERNS,
|
|
7951
|
+
PerfFixturesImpl,
|
|
7952
|
+
RegressionDetector,
|
|
7953
|
+
ReportGenerator,
|
|
4921
7954
|
ServerStartError,
|
|
4922
7955
|
StreamableHttpTransport,
|
|
4923
7956
|
TestClientError,
|
|
@@ -4926,24 +7959,38 @@ var EXPECTED_FRONTMCP_TOOL_CALL_META_KEYS = EXPECTED_GENERIC_TOOL_CALL_META_KEYS
|
|
|
4926
7959
|
TestUsers,
|
|
4927
7960
|
TimeoutError,
|
|
4928
7961
|
UIAssertions,
|
|
7962
|
+
assertNoLeak,
|
|
4929
7963
|
basicUIToolInputSchema,
|
|
4930
7964
|
basicUIToolOutputSchema,
|
|
4931
7965
|
buildUserAgent,
|
|
7966
|
+
clearGlobalMeasurements,
|
|
4932
7967
|
containsPrompt,
|
|
4933
7968
|
containsResource,
|
|
4934
7969
|
containsResourceTemplate,
|
|
4935
7970
|
containsTool,
|
|
7971
|
+
createLeakDetector,
|
|
7972
|
+
createPerfFixtures,
|
|
7973
|
+
createReportGenerator,
|
|
4936
7974
|
createTestUser,
|
|
4937
7975
|
expect,
|
|
7976
|
+
forceFullGc,
|
|
7977
|
+
forceGc,
|
|
7978
|
+
formatBaselineAsComment,
|
|
7979
|
+
formatBytes,
|
|
7980
|
+
formatDuration,
|
|
7981
|
+
formatMicroseconds,
|
|
4938
7982
|
fullUIToolInputSchema,
|
|
4939
7983
|
fullUIToolOutputSchema,
|
|
4940
7984
|
generateBasicUIToolOutput,
|
|
4941
7985
|
generateFullUIToolOutput,
|
|
7986
|
+
getBaselineStore,
|
|
4942
7987
|
getForbiddenMetaPrefixes,
|
|
7988
|
+
getGlobalMeasurements,
|
|
4943
7989
|
getPlatformClientInfo,
|
|
4944
7990
|
getPlatformMetaNamespace,
|
|
4945
7991
|
getPlatformMimeType,
|
|
4946
7992
|
getPlatformUserAgent,
|
|
7993
|
+
getRegressionDetector,
|
|
4947
7994
|
getToolCallMetaPrefixes,
|
|
4948
7995
|
getToolsListMetaPrefixes,
|
|
4949
7996
|
hasMimeType,
|
|
@@ -4953,11 +8000,16 @@ var EXPECTED_FRONTMCP_TOOL_CALL_META_KEYS = EXPECTED_GENERIC_TOOL_CALL_META_KEYS
|
|
|
4953
8000
|
interceptors,
|
|
4954
8001
|
isError,
|
|
4955
8002
|
isExtAppsPlatform,
|
|
8003
|
+
isGcAvailable,
|
|
4956
8004
|
isOpenAIPlatform,
|
|
4957
8005
|
isSuccessful,
|
|
4958
8006
|
isUiPlatform,
|
|
4959
8007
|
mcpMatchers,
|
|
4960
8008
|
mockResponse,
|
|
8009
|
+
parseBaselineFromComment,
|
|
8010
|
+
perfTest,
|
|
8011
|
+
saveReports,
|
|
8012
|
+
summarizeRegressions,
|
|
4961
8013
|
test,
|
|
4962
8014
|
uiMatchers
|
|
4963
8015
|
});
|