@frontmcp/testing 1.8.6 → 1.9.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/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts +2 -2
- package/auth/token-factory.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +6 -1
- package/client/mcp-test-client.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +993 -237
- package/esm/index.mjs +1151 -360
- package/esm/matchers/index.mjs +3 -0
- package/esm/package.json +8 -6
- package/esm/perf/index.mjs +874 -280
- package/esm/perf/perf-setup.mjs +3 -0
- package/esm/playwright/index.mjs +3 -0
- package/esm/setup.mjs +3 -0
- package/expect.d.ts +12 -11
- package/expect.d.ts.map +1 -1
- package/fixtures/fixture-types.d.ts +41 -10
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.d.ts +1 -1
- package/fixtures/index.d.ts.map +1 -1
- package/fixtures/index.js +989 -229
- package/fixtures/test-fixture.d.ts +6 -5
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/http-mock/http-mock.d.ts +5 -3
- package/http-mock/http-mock.d.ts.map +1 -1
- package/http-mock/http-mock.types.d.ts +17 -6
- package/http-mock/http-mock.types.d.ts.map +1 -1
- package/http-mock/index.d.ts +1 -1
- package/http-mock/index.d.ts.map +1 -1
- package/index.d.ts +3 -2
- package/index.d.ts.map +1 -1
- package/index.js +1157 -361
- package/package.json +8 -6
- package/perf/baseline-store.d.ts +1 -1
- package/perf/baseline-store.d.ts.map +1 -1
- package/perf/index.js +874 -293
- package/perf/perf-test.d.ts.map +1 -1
- package/perf/report-generator.d.ts +1 -1
- package/perf/report-generator.d.ts.map +1 -1
- package/server/port-registry.d.ts +7 -1
- package/server/port-registry.d.ts.map +1 -1
- package/server/test-server.d.ts +10 -1
- package/server/test-server.d.ts.map +1 -1
- package/transport/error-utils.d.ts +15 -0
- package/transport/error-utils.d.ts.map +1 -0
- package/transport/index.d.ts +1 -0
- package/transport/index.d.ts.map +1 -1
- package/transport/sse.transport.d.ts +80 -0
- package/transport/sse.transport.d.ts.map +1 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
package/fixtures/index.js
CHANGED
|
@@ -28,9 +28,6 @@ __export(fixtures_exports, {
|
|
|
28
28
|
});
|
|
29
29
|
module.exports = __toCommonJS(fixtures_exports);
|
|
30
30
|
|
|
31
|
-
// libs/testing/src/fixtures/test-fixture.ts
|
|
32
|
-
var import_globals = require("@jest/globals");
|
|
33
|
-
|
|
34
31
|
// libs/testing/src/auth/token-factory.ts
|
|
35
32
|
var import_jose = require("jose");
|
|
36
33
|
var TestTokenFactory = class {
|
|
@@ -89,14 +86,15 @@ var TestTokenFactory = class {
|
|
|
89
86
|
*/
|
|
90
87
|
async createTestToken(options) {
|
|
91
88
|
await this.ensureKeys();
|
|
92
|
-
const
|
|
89
|
+
const nowMs = Date.now();
|
|
90
|
+
const iat = Math.floor(nowMs / 1e3);
|
|
93
91
|
const exp = options.exp ?? 3600;
|
|
94
92
|
const payload = {
|
|
95
93
|
iss: options.iss ?? this.issuer,
|
|
96
94
|
sub: options.sub,
|
|
97
95
|
aud: options.aud ?? this.audience,
|
|
98
|
-
iat
|
|
99
|
-
exp:
|
|
96
|
+
iat,
|
|
97
|
+
exp: Math.ceil(nowMs / 1e3) + exp,
|
|
100
98
|
scope: options.scopes?.join(" "),
|
|
101
99
|
...options.claims
|
|
102
100
|
};
|
|
@@ -121,10 +119,10 @@ var TestTokenFactory = class {
|
|
|
121
119
|
/**
|
|
122
120
|
* Create a regular user token
|
|
123
121
|
*/
|
|
124
|
-
async createUserToken(sub = "user-001",
|
|
122
|
+
async createUserToken(sub = "user-001", scopes2 = ["read", "write"]) {
|
|
125
123
|
return this.createTestToken({
|
|
126
124
|
sub,
|
|
127
|
-
scopes,
|
|
125
|
+
scopes: scopes2,
|
|
128
126
|
claims: {
|
|
129
127
|
email: "user@test.local",
|
|
130
128
|
name: "Test User",
|
|
@@ -135,10 +133,11 @@ var TestTokenFactory = class {
|
|
|
135
133
|
/**
|
|
136
134
|
* Create an anonymous user token
|
|
137
135
|
*/
|
|
138
|
-
async createAnonymousToken() {
|
|
136
|
+
async createAnonymousToken(expiresIn) {
|
|
139
137
|
return this.createTestToken({
|
|
140
138
|
sub: `anon:${Date.now()}`,
|
|
141
139
|
scopes: ["anonymous"],
|
|
140
|
+
exp: expiresIn,
|
|
142
141
|
claims: {
|
|
143
142
|
name: "Anonymous",
|
|
144
143
|
role: "anonymous"
|
|
@@ -487,8 +486,479 @@ function sleep(ms) {
|
|
|
487
486
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
488
487
|
}
|
|
489
488
|
|
|
490
|
-
// libs/testing/src/transport/
|
|
489
|
+
// libs/testing/src/transport/error-utils.ts
|
|
490
|
+
function errorName(err) {
|
|
491
|
+
if (typeof err !== "object" || err === null) return void 0;
|
|
492
|
+
const name = err.name;
|
|
493
|
+
return typeof name === "string" ? name : void 0;
|
|
494
|
+
}
|
|
495
|
+
function isAbortError(err) {
|
|
496
|
+
return errorName(err) === "AbortError";
|
|
497
|
+
}
|
|
498
|
+
function errorMessage(err, fallback = "Unknown error") {
|
|
499
|
+
if (typeof err === "string" && err) return err;
|
|
500
|
+
if (typeof err === "object" && err !== null) {
|
|
501
|
+
const message = err.message;
|
|
502
|
+
if (typeof message === "string" && message) return message;
|
|
503
|
+
}
|
|
504
|
+
return fallback;
|
|
505
|
+
}
|
|
506
|
+
var interceptedErrors = /* @__PURE__ */ new WeakSet();
|
|
507
|
+
function markInterceptedError(err) {
|
|
508
|
+
interceptedErrors.add(err);
|
|
509
|
+
return err;
|
|
510
|
+
}
|
|
511
|
+
function isInterceptedError(err) {
|
|
512
|
+
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// libs/testing/src/transport/sse.transport.ts
|
|
491
516
|
var DEFAULT_TIMEOUT = 3e4;
|
|
517
|
+
var SseTransport = class {
|
|
518
|
+
baseUrl;
|
|
519
|
+
entryPath;
|
|
520
|
+
timeout;
|
|
521
|
+
authHeaders;
|
|
522
|
+
publicMode;
|
|
523
|
+
debug;
|
|
524
|
+
clientInfo;
|
|
525
|
+
notificationHandler;
|
|
526
|
+
interceptors;
|
|
527
|
+
elicitationHandler;
|
|
528
|
+
state = "disconnected";
|
|
529
|
+
authToken;
|
|
530
|
+
sessionId;
|
|
531
|
+
messageEndpoint;
|
|
532
|
+
stream;
|
|
533
|
+
pending = /* @__PURE__ */ new Map();
|
|
534
|
+
connectionCount = 0;
|
|
535
|
+
reconnectCount = 0;
|
|
536
|
+
lastRequestHeaders = {};
|
|
537
|
+
constructor(config) {
|
|
538
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
539
|
+
this.entryPath = config.entryPath;
|
|
540
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
541
|
+
this.authHeaders = config.auth?.headers ?? {};
|
|
542
|
+
this.authToken = config.auth?.token;
|
|
543
|
+
this.publicMode = config.publicMode ?? false;
|
|
544
|
+
this.debug = config.debug ?? false;
|
|
545
|
+
this.clientInfo = config.clientInfo;
|
|
546
|
+
this.notificationHandler = config.notificationHandler;
|
|
547
|
+
this.interceptors = config.interceptors;
|
|
548
|
+
this.elicitationHandler = config.elicitationHandler;
|
|
549
|
+
}
|
|
550
|
+
async connect() {
|
|
551
|
+
this.state = "connecting";
|
|
552
|
+
this.connectionCount++;
|
|
553
|
+
try {
|
|
554
|
+
if (!this.publicMode && !this.authToken) {
|
|
555
|
+
await this.requestAnonymousToken();
|
|
556
|
+
}
|
|
557
|
+
await this.openStream();
|
|
558
|
+
this.state = "connected";
|
|
559
|
+
this.log(`Connected; messages go to ${this.messageEndpoint}`);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.state = "error";
|
|
562
|
+
this.stream?.abort();
|
|
563
|
+
this.stream = void 0;
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async request(message) {
|
|
568
|
+
this.ensureConnected();
|
|
569
|
+
const startTime = Date.now();
|
|
570
|
+
if (this.interceptors) {
|
|
571
|
+
const intercepted = await this.interceptors.processRequest(message, {
|
|
572
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
573
|
+
transport: "sse",
|
|
574
|
+
sessionId: this.sessionId
|
|
575
|
+
});
|
|
576
|
+
switch (intercepted.type) {
|
|
577
|
+
case "mock":
|
|
578
|
+
return await this.interceptors.processResponse(
|
|
579
|
+
message,
|
|
580
|
+
intercepted.response,
|
|
581
|
+
Date.now() - startTime
|
|
582
|
+
);
|
|
583
|
+
case "error":
|
|
584
|
+
throw markInterceptedError(intercepted.error);
|
|
585
|
+
case "continue":
|
|
586
|
+
message = intercepted.request;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
let response;
|
|
591
|
+
if (message.id === void 0) {
|
|
592
|
+
await this.post(JSON.stringify(message));
|
|
593
|
+
response = { jsonrpc: "2.0", id: null, result: void 0 };
|
|
594
|
+
} else {
|
|
595
|
+
response = await this.postAndAwait(message.id, JSON.stringify(message));
|
|
596
|
+
}
|
|
597
|
+
if (this.interceptors) {
|
|
598
|
+
response = await this.interceptors.processResponse(message, response, Date.now() - startTime);
|
|
599
|
+
}
|
|
600
|
+
return response;
|
|
601
|
+
}
|
|
602
|
+
async notify(message) {
|
|
603
|
+
this.ensureConnected();
|
|
604
|
+
const result = await this.post(JSON.stringify(message));
|
|
605
|
+
if (!result.ok) this.log(`HTTP ${result.status} on notification: ${result.body}`);
|
|
606
|
+
}
|
|
607
|
+
async sendRaw(data) {
|
|
608
|
+
this.ensureConnected();
|
|
609
|
+
let id;
|
|
610
|
+
try {
|
|
611
|
+
const parsed = JSON.parse(data);
|
|
612
|
+
if (typeof parsed.id === "string" || typeof parsed.id === "number") id = parsed.id;
|
|
613
|
+
} catch {
|
|
614
|
+
}
|
|
615
|
+
if (id !== void 0) return this.postAndAwait(id, data);
|
|
616
|
+
const result = await this.post(data);
|
|
617
|
+
if (!result.ok) {
|
|
618
|
+
return { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error", data: result.body } };
|
|
619
|
+
}
|
|
620
|
+
return { jsonrpc: "2.0", id: null, result: void 0 };
|
|
621
|
+
}
|
|
622
|
+
async close() {
|
|
623
|
+
this.closeStream("Transport closed");
|
|
624
|
+
this.state = "disconnected";
|
|
625
|
+
this.sessionId = void 0;
|
|
626
|
+
this.messageEndpoint = void 0;
|
|
627
|
+
this.log("SSE transport closed");
|
|
628
|
+
}
|
|
629
|
+
isConnected() {
|
|
630
|
+
return this.state === "connected";
|
|
631
|
+
}
|
|
632
|
+
getState() {
|
|
633
|
+
return this.state;
|
|
634
|
+
}
|
|
635
|
+
getSessionId() {
|
|
636
|
+
return this.sessionId;
|
|
637
|
+
}
|
|
638
|
+
setAuthToken(token) {
|
|
639
|
+
this.authToken = token;
|
|
640
|
+
}
|
|
641
|
+
setTimeout(ms) {
|
|
642
|
+
this.timeout = ms;
|
|
643
|
+
}
|
|
644
|
+
getMessageEndpoint() {
|
|
645
|
+
return this.messageEndpoint;
|
|
646
|
+
}
|
|
647
|
+
getConnectionCount() {
|
|
648
|
+
return this.connectionCount;
|
|
649
|
+
}
|
|
650
|
+
getReconnectCount() {
|
|
651
|
+
return this.reconnectCount;
|
|
652
|
+
}
|
|
653
|
+
getLastRequestHeaders() {
|
|
654
|
+
return { ...this.lastRequestHeaders };
|
|
655
|
+
}
|
|
656
|
+
setInterceptors(interceptors2) {
|
|
657
|
+
this.interceptors = interceptors2;
|
|
658
|
+
}
|
|
659
|
+
getInterceptors() {
|
|
660
|
+
return this.interceptors;
|
|
661
|
+
}
|
|
662
|
+
setElicitationHandler(handler) {
|
|
663
|
+
this.elicitationHandler = handler;
|
|
664
|
+
}
|
|
665
|
+
async simulateDisconnect() {
|
|
666
|
+
this.closeStream("Simulated disconnect");
|
|
667
|
+
this.state = "disconnected";
|
|
668
|
+
this.sessionId = void 0;
|
|
669
|
+
this.messageEndpoint = void 0;
|
|
670
|
+
}
|
|
671
|
+
async waitForReconnect(timeoutMs) {
|
|
672
|
+
this.reconnectCount++;
|
|
673
|
+
let timer;
|
|
674
|
+
try {
|
|
675
|
+
await Promise.race([
|
|
676
|
+
this.connect(),
|
|
677
|
+
new Promise((_, reject) => {
|
|
678
|
+
timer = setTimeout(() => reject(new Error("Timeout waiting for reconnection")), timeoutMs);
|
|
679
|
+
})
|
|
680
|
+
]);
|
|
681
|
+
} finally {
|
|
682
|
+
clearTimeout(timer);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
686
|
+
// STREAM
|
|
687
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
688
|
+
/** Open `GET <entry>/sse` and wait for the `endpoint` event. */
|
|
689
|
+
async openStream() {
|
|
690
|
+
const controller = new AbortController();
|
|
691
|
+
this.stream = controller;
|
|
692
|
+
const url = this.sseUrl();
|
|
693
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
694
|
+
this.log(`GET ${url}`);
|
|
695
|
+
const connectTimer = setTimeout(() => controller.abort(), this.timeout);
|
|
696
|
+
let response;
|
|
697
|
+
try {
|
|
698
|
+
response = await fetch(url, {
|
|
699
|
+
method: "GET",
|
|
700
|
+
headers: { ...headers, Accept: "text/event-stream" },
|
|
701
|
+
signal: controller.signal
|
|
702
|
+
});
|
|
703
|
+
} catch (error) {
|
|
704
|
+
clearTimeout(connectTimer);
|
|
705
|
+
throw new Error(
|
|
706
|
+
isAbortError(error) ? `SSE connection to ${url} timed out after ${this.timeout}ms` : `SSE connection to ${url} failed: ${errorMessage(error)}`,
|
|
707
|
+
{ cause: error }
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
711
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
712
|
+
clearTimeout(connectTimer);
|
|
713
|
+
const body = await response.text().catch(() => "");
|
|
714
|
+
throw new Error(
|
|
715
|
+
`SSE connection to ${url} failed: HTTP ${response.status} ${response.statusText}${body ? ` \u2014 ${body}` : ""}. The legacy HTTP+SSE transport needs a server that serves it (@FrontMcp({ transport: { protocol: { legacy: true } } })).`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
const endpoint = new Promise((resolve, reject) => {
|
|
719
|
+
const reader = response.body?.getReader();
|
|
720
|
+
if (!reader) {
|
|
721
|
+
reject(new Error(`SSE connection to ${url} has no body`));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
void this.readStream(controller, reader, resolve, reject);
|
|
725
|
+
});
|
|
726
|
+
try {
|
|
727
|
+
const data = await endpoint;
|
|
728
|
+
const messageUrl = new URL(data, url);
|
|
729
|
+
this.messageEndpoint = messageUrl.toString();
|
|
730
|
+
this.sessionId = messageUrl.searchParams.get("sessionId") ?? void 0;
|
|
731
|
+
} finally {
|
|
732
|
+
clearTimeout(connectTimer);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async readStream(controller, reader, onEndpoint, onEndpointError) {
|
|
736
|
+
const decoder = new TextDecoder();
|
|
737
|
+
let buffer = "";
|
|
738
|
+
let endpointSeen = false;
|
|
739
|
+
try {
|
|
740
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
741
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
742
|
+
buffer = remainder;
|
|
743
|
+
for (const text of completeEvents) {
|
|
744
|
+
const event = parseSSEEvent(text);
|
|
745
|
+
if (!event) continue;
|
|
746
|
+
if (event.type === "endpoint") {
|
|
747
|
+
if (!endpointSeen) {
|
|
748
|
+
endpointSeen = true;
|
|
749
|
+
onEndpoint(event.data);
|
|
750
|
+
}
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
await this.handleMessage(event.data);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
} catch (error) {
|
|
757
|
+
if (!controller.signal.aborted) this.log("SSE stream failed:", error);
|
|
758
|
+
} finally {
|
|
759
|
+
reader.releaseLock();
|
|
760
|
+
}
|
|
761
|
+
if (!endpointSeen) {
|
|
762
|
+
onEndpointError(
|
|
763
|
+
new Error(
|
|
764
|
+
controller.signal.aborted ? `SSE stream at ${this.sseUrl()} sent no endpoint event within ${this.timeout}ms` : `SSE stream at ${this.sseUrl()} ended before the server sent its endpoint`
|
|
765
|
+
)
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
if (this.stream === controller) {
|
|
769
|
+
this.stream = void 0;
|
|
770
|
+
this.rejectPending("SSE stream closed by the server");
|
|
771
|
+
if (this.state === "connected") this.state = "disconnected";
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
async handleMessage(data) {
|
|
775
|
+
let message;
|
|
776
|
+
try {
|
|
777
|
+
message = JSON.parse(data);
|
|
778
|
+
} catch {
|
|
779
|
+
this.log("Ignoring SSE event that is not JSON:", data);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
if (!message || typeof message !== "object") return;
|
|
783
|
+
if (typeof message.method === "string") {
|
|
784
|
+
const request = message;
|
|
785
|
+
if (request.id === void 0 || request.id === null) {
|
|
786
|
+
this.notificationHandler?.(request);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
await this.answerServerRequest(request);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (message.id !== void 0 && message.id !== null) {
|
|
793
|
+
const pending = this.pending.get(message.id);
|
|
794
|
+
if (pending) {
|
|
795
|
+
clearTimeout(pending.timer);
|
|
796
|
+
this.pending.delete(message.id);
|
|
797
|
+
pending.resolve(message);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/** Server→client requests: elicitation goes to the handler, `ping` is answered, the rest refused. */
|
|
802
|
+
async answerServerRequest(request) {
|
|
803
|
+
const id = request.id;
|
|
804
|
+
let reply;
|
|
805
|
+
if (request.method === "elicitation/create") {
|
|
806
|
+
reply = { jsonrpc: "2.0", id, result: await this.elicit(request.params) };
|
|
807
|
+
} else if (request.method === "ping") {
|
|
808
|
+
reply = { jsonrpc: "2.0", id, result: {} };
|
|
809
|
+
} else {
|
|
810
|
+
reply = { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${request.method}` } };
|
|
811
|
+
}
|
|
812
|
+
const result = await this.post(JSON.stringify(reply));
|
|
813
|
+
if (!result.ok) this.log(`HTTP ${result.status} answering ${request.method}: ${result.body}`);
|
|
814
|
+
}
|
|
815
|
+
async elicit(params) {
|
|
816
|
+
if (!this.elicitationHandler) return { action: "decline" };
|
|
817
|
+
try {
|
|
818
|
+
return await this.elicitationHandler(params);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
this.log("Elicitation handler error:", error);
|
|
821
|
+
return { action: "cancel" };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
closeStream(reason) {
|
|
825
|
+
const controller = this.stream;
|
|
826
|
+
this.stream = void 0;
|
|
827
|
+
controller?.abort();
|
|
828
|
+
this.rejectPending(reason);
|
|
829
|
+
}
|
|
830
|
+
rejectPending(reason) {
|
|
831
|
+
for (const [id, pending] of this.pending) {
|
|
832
|
+
clearTimeout(pending.timer);
|
|
833
|
+
pending.resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: reason } });
|
|
834
|
+
}
|
|
835
|
+
this.pending.clear();
|
|
836
|
+
}
|
|
837
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
838
|
+
// POSTING
|
|
839
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
840
|
+
/** POST a message and resolve with the response the stream carries for `id`. */
|
|
841
|
+
async postAndAwait(id, body) {
|
|
842
|
+
const answer = new Promise((resolve) => {
|
|
843
|
+
const timer = setTimeout(() => {
|
|
844
|
+
this.pending.delete(id);
|
|
845
|
+
resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: `Request timeout after ${this.timeout}ms` } });
|
|
846
|
+
}, this.timeout);
|
|
847
|
+
this.pending.set(id, { resolve, timer });
|
|
848
|
+
});
|
|
849
|
+
const result = await this.post(body);
|
|
850
|
+
if (!result.ok) {
|
|
851
|
+
const pending = this.pending.get(id);
|
|
852
|
+
if (pending) {
|
|
853
|
+
clearTimeout(pending.timer);
|
|
854
|
+
this.pending.delete(id);
|
|
855
|
+
}
|
|
856
|
+
return {
|
|
857
|
+
jsonrpc: "2.0",
|
|
858
|
+
id,
|
|
859
|
+
error: { code: -32e3, message: `HTTP ${result.status}: ${result.statusText}`, data: result.body }
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
return answer;
|
|
863
|
+
}
|
|
864
|
+
async post(body) {
|
|
865
|
+
const endpoint = this.messageEndpoint;
|
|
866
|
+
if (!endpoint) throw new Error("SSE transport has no message endpoint. Call connect() first.");
|
|
867
|
+
const headers = this.buildHeaders();
|
|
868
|
+
this.lastRequestHeaders = headers;
|
|
869
|
+
this.log(`POST ${endpoint}`, body);
|
|
870
|
+
const controller = new AbortController();
|
|
871
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
872
|
+
try {
|
|
873
|
+
const response = await fetch(endpoint, { method: "POST", headers, body, signal: controller.signal });
|
|
874
|
+
const text = await response.text();
|
|
875
|
+
return { ok: response.ok, status: response.status, statusText: response.statusText, body: text };
|
|
876
|
+
} catch (error) {
|
|
877
|
+
if (isAbortError(error)) {
|
|
878
|
+
return { ok: false, status: 0, statusText: `timeout after ${this.timeout}ms`, body: "" };
|
|
879
|
+
}
|
|
880
|
+
throw error;
|
|
881
|
+
} finally {
|
|
882
|
+
clearTimeout(timer);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
886
|
+
// HELPERS
|
|
887
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
888
|
+
async requestAnonymousToken() {
|
|
889
|
+
const url = new URL(this.baseUrl);
|
|
890
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/oauth/token`;
|
|
891
|
+
url.search = "";
|
|
892
|
+
const tokenUrl = url.toString();
|
|
893
|
+
try {
|
|
894
|
+
const response = await fetch(tokenUrl, {
|
|
895
|
+
method: "POST",
|
|
896
|
+
headers: { "Content-Type": "application/json" },
|
|
897
|
+
body: JSON.stringify({ grant_type: "anonymous", client_id: crypto.randomUUID(), resource: this.baseUrl })
|
|
898
|
+
});
|
|
899
|
+
if (!response.ok) {
|
|
900
|
+
this.log(`No anonymous token (HTTP ${response.status})`);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const token = await response.json();
|
|
904
|
+
if (token.access_token) this.authToken = token.access_token;
|
|
905
|
+
} catch (error) {
|
|
906
|
+
this.log("Error requesting anonymous token:", error);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/** `<baseUrl><entryPath>/sse`, built from URL parts so query params on `baseUrl` survive. */
|
|
910
|
+
sseUrl() {
|
|
911
|
+
const url = new URL(this.baseUrl);
|
|
912
|
+
const entry = (this.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
913
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
914
|
+
url.pathname = entry ? `${basePath}/${entry}/sse` : `${basePath}/sse`;
|
|
915
|
+
return url.toString();
|
|
916
|
+
}
|
|
917
|
+
buildHeaders() {
|
|
918
|
+
const headers = {
|
|
919
|
+
"Content-Type": "application/json",
|
|
920
|
+
Accept: "application/json, text/event-stream"
|
|
921
|
+
};
|
|
922
|
+
if (this.clientInfo) headers["User-Agent"] = `${this.clientInfo.name}/${this.clientInfo.version}`;
|
|
923
|
+
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
924
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
925
|
+
Object.assign(headers, this.authHeaders);
|
|
926
|
+
return headers;
|
|
927
|
+
}
|
|
928
|
+
ensureConnected() {
|
|
929
|
+
if (this.state !== "connected") {
|
|
930
|
+
throw new Error("Transport not connected. Call connect() first.");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
log(message, data) {
|
|
934
|
+
if (this.debug) {
|
|
935
|
+
console.log(`[SSE] ${message}`, data ?? "");
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
function splitSSEEvents(buffer) {
|
|
940
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
941
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
942
|
+
const completeEvents = normalized.split("\n\n");
|
|
943
|
+
const remainder = completeEvents.pop() ?? "";
|
|
944
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
945
|
+
}
|
|
946
|
+
function parseSSEEvent(text) {
|
|
947
|
+
let type = "message";
|
|
948
|
+
const data = [];
|
|
949
|
+
for (const line of text.split("\n")) {
|
|
950
|
+
if (line.startsWith(":")) continue;
|
|
951
|
+
const colon = line.indexOf(":");
|
|
952
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
953
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
954
|
+
if (field === "event") type = value;
|
|
955
|
+
else if (field === "data") data.push(value);
|
|
956
|
+
}
|
|
957
|
+
return data.length > 0 ? { type, data: data.join("\n") } : void 0;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// libs/testing/src/transport/streamable-http.transport.ts
|
|
961
|
+
var DEFAULT_TIMEOUT2 = 3e4;
|
|
492
962
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
493
963
|
var StreamableHttpTransport = class {
|
|
494
964
|
config;
|
|
@@ -510,7 +980,7 @@ var StreamableHttpTransport = class {
|
|
|
510
980
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
511
981
|
// Remove trailing slash
|
|
512
982
|
entryPath: config.entryPath,
|
|
513
|
-
timeout: config.timeout ??
|
|
983
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT2,
|
|
514
984
|
auth: config.auth ?? {},
|
|
515
985
|
publicMode: config.publicMode ?? false,
|
|
516
986
|
debug: config.debug ?? false,
|
|
@@ -595,7 +1065,7 @@ var StreamableHttpTransport = class {
|
|
|
595
1065
|
return mockResponse2;
|
|
596
1066
|
}
|
|
597
1067
|
case "error":
|
|
598
|
-
throw interceptResult.error;
|
|
1068
|
+
throw markInterceptedError(interceptResult.error);
|
|
599
1069
|
case "continue":
|
|
600
1070
|
message = interceptResult.request;
|
|
601
1071
|
break;
|
|
@@ -656,7 +1126,7 @@ var StreamableHttpTransport = class {
|
|
|
656
1126
|
return jsonResponse;
|
|
657
1127
|
} catch (error) {
|
|
658
1128
|
clearTimeout(timeoutId);
|
|
659
|
-
if (error
|
|
1129
|
+
if (isAbortError(error)) {
|
|
660
1130
|
return {
|
|
661
1131
|
jsonrpc: "2.0",
|
|
662
1132
|
id: message.id ?? null,
|
|
@@ -695,7 +1165,7 @@ var StreamableHttpTransport = class {
|
|
|
695
1165
|
}
|
|
696
1166
|
} catch (error) {
|
|
697
1167
|
clearTimeout(timeoutId);
|
|
698
|
-
if (error
|
|
1168
|
+
if (!isAbortError(error)) {
|
|
699
1169
|
throw error;
|
|
700
1170
|
}
|
|
701
1171
|
}
|
|
@@ -736,7 +1206,7 @@ var StreamableHttpTransport = class {
|
|
|
736
1206
|
error: {
|
|
737
1207
|
code: -32700,
|
|
738
1208
|
message: "Parse error",
|
|
739
|
-
data: error
|
|
1209
|
+
data: errorMessage(error)
|
|
740
1210
|
}
|
|
741
1211
|
};
|
|
742
1212
|
}
|
|
@@ -850,7 +1320,7 @@ var StreamableHttpTransport = class {
|
|
|
850
1320
|
let buffer = "";
|
|
851
1321
|
try {
|
|
852
1322
|
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
853
|
-
const { completeEvents, remainder } =
|
|
1323
|
+
const { completeEvents, remainder } = splitSSEEvents2(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
854
1324
|
buffer = remainder;
|
|
855
1325
|
for (const eventText of completeEvents) {
|
|
856
1326
|
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
@@ -1106,7 +1576,7 @@ var StreamableHttpTransport = class {
|
|
|
1106
1576
|
if (this.config.clientInfo) {
|
|
1107
1577
|
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1108
1578
|
}
|
|
1109
|
-
if (this.authToken
|
|
1579
|
+
if (this.authToken) {
|
|
1110
1580
|
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1111
1581
|
}
|
|
1112
1582
|
if (this.sessionId) {
|
|
@@ -1184,7 +1654,7 @@ var StreamableHttpTransport = class {
|
|
|
1184
1654
|
};
|
|
1185
1655
|
}
|
|
1186
1656
|
};
|
|
1187
|
-
function
|
|
1657
|
+
function splitSSEEvents2(buffer) {
|
|
1188
1658
|
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1189
1659
|
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1190
1660
|
const completeEvents = normalized.split("\n\n");
|
|
@@ -1422,7 +1892,7 @@ var McpTestClientBuilder = class {
|
|
|
1422
1892
|
};
|
|
1423
1893
|
|
|
1424
1894
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1425
|
-
var
|
|
1895
|
+
var DEFAULT_TIMEOUT3 = 3e4;
|
|
1426
1896
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1427
1897
|
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1428
1898
|
var DEFAULT_CLIENT_INFO = {
|
|
@@ -1430,6 +1900,15 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1430
1900
|
version: "0.4.0"
|
|
1431
1901
|
};
|
|
1432
1902
|
var MAX_LIST_PAGES = 1e3;
|
|
1903
|
+
function isAuthFailure(error) {
|
|
1904
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
1905
|
+
}
|
|
1906
|
+
function describeInitializeError(error) {
|
|
1907
|
+
const message = error?.message ?? "Unknown error";
|
|
1908
|
+
const data = error?.data;
|
|
1909
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
1910
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
1911
|
+
}
|
|
1433
1912
|
function extractEntryPaths(data) {
|
|
1434
1913
|
let payload = data;
|
|
1435
1914
|
if (typeof payload === "string") {
|
|
@@ -1483,7 +1962,7 @@ var McpTestClient = class {
|
|
|
1483
1962
|
transport: config.transport ?? "streamable-http",
|
|
1484
1963
|
auth: config.auth ?? {},
|
|
1485
1964
|
publicMode: config.publicMode ?? false,
|
|
1486
|
-
timeout: config.timeout ??
|
|
1965
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT3,
|
|
1487
1966
|
debug: config.debug ?? false,
|
|
1488
1967
|
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
1489
1968
|
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
@@ -1516,19 +1995,22 @@ var McpTestClient = class {
|
|
|
1516
1995
|
async connect() {
|
|
1517
1996
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1518
1997
|
this.triedDiscoveredEntryPath = false;
|
|
1998
|
+
this.initResult = null;
|
|
1519
1999
|
this.transport = this.createTransport();
|
|
1520
2000
|
await this.transport.connect();
|
|
1521
2001
|
const maxRetries = 3;
|
|
1522
2002
|
const retryDelayMs = 500;
|
|
1523
2003
|
let lastError;
|
|
1524
2004
|
let reportedEntryPaths;
|
|
2005
|
+
let attemptsMade = 0;
|
|
1525
2006
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2007
|
+
attemptsMade = attempt;
|
|
1526
2008
|
const initResponse = await this.initialize();
|
|
1527
2009
|
if (initResponse.success && initResponse.data) {
|
|
1528
2010
|
this.initResult = initResponse.data;
|
|
1529
2011
|
break;
|
|
1530
2012
|
}
|
|
1531
|
-
lastError = initResponse.error
|
|
2013
|
+
lastError = describeInitializeError(initResponse.error);
|
|
1532
2014
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1533
2015
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1534
2016
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -1540,6 +2022,7 @@ var McpTestClient = class {
|
|
|
1540
2022
|
await this.transport.connect();
|
|
1541
2023
|
continue;
|
|
1542
2024
|
}
|
|
2025
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
1543
2026
|
if (attempt < maxRetries) {
|
|
1544
2027
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1545
2028
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -1548,7 +2031,7 @@ var McpTestClient = class {
|
|
|
1548
2031
|
if (!this.initResult) {
|
|
1549
2032
|
const served = reportedEntryPaths?.length ? ` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.` : "";
|
|
1550
2033
|
throw new Error(
|
|
1551
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
2034
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
1552
2035
|
);
|
|
1553
2036
|
}
|
|
1554
2037
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -1618,18 +2101,47 @@ var McpTestClient = class {
|
|
|
1618
2101
|
return this._authState;
|
|
1619
2102
|
}
|
|
1620
2103
|
/**
|
|
1621
|
-
* Authenticate with a token
|
|
2104
|
+
* Authenticate with a token.
|
|
2105
|
+
*
|
|
2106
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
2107
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
2108
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
2109
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
1622
2110
|
*/
|
|
1623
2111
|
async authenticate(token) {
|
|
2112
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
2113
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
2114
|
+
}
|
|
2115
|
+
const previousAuthState = this._authState;
|
|
2116
|
+
const previousConfigAuth = this.config.auth;
|
|
2117
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
1624
2118
|
this._authState = {
|
|
1625
2119
|
isAnonymous: false,
|
|
1626
2120
|
token,
|
|
1627
2121
|
scopes: this.parseScopesFromToken(token),
|
|
1628
2122
|
user: this.parseUserFromToken(token)
|
|
1629
2123
|
};
|
|
1630
|
-
|
|
1631
|
-
|
|
2124
|
+
const previousTransport = this.transport;
|
|
2125
|
+
if (!previousTransport) {
|
|
2126
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
2127
|
+
return;
|
|
1632
2128
|
}
|
|
2129
|
+
const previousInit = this.initResult;
|
|
2130
|
+
const previousSessionId = this._sessionId;
|
|
2131
|
+
const previousSessionInfo = this._sessionInfo;
|
|
2132
|
+
try {
|
|
2133
|
+
await this.connect();
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
await this.transport?.close().catch(() => void 0);
|
|
2136
|
+
this.transport = previousTransport;
|
|
2137
|
+
this.initResult = previousInit;
|
|
2138
|
+
this._sessionId = previousSessionId;
|
|
2139
|
+
this._sessionInfo = previousSessionInfo;
|
|
2140
|
+
this.config.auth = previousConfigAuth;
|
|
2141
|
+
this._authState = previousAuthState;
|
|
2142
|
+
throw err;
|
|
2143
|
+
}
|
|
2144
|
+
await previousTransport.close().catch(() => void 0);
|
|
1633
2145
|
this.log("debug", "Authentication updated");
|
|
1634
2146
|
}
|
|
1635
2147
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2216,7 +2728,18 @@ var McpTestClient = class {
|
|
|
2216
2728
|
notificationHandler: this.recordNotification
|
|
2217
2729
|
});
|
|
2218
2730
|
case "sse":
|
|
2219
|
-
|
|
2731
|
+
return new SseTransport({
|
|
2732
|
+
baseUrl,
|
|
2733
|
+
entryPath: this.config.entryPath,
|
|
2734
|
+
timeout: this.config.timeout,
|
|
2735
|
+
auth: this.config.auth,
|
|
2736
|
+
publicMode: this.config.publicMode,
|
|
2737
|
+
debug: this.config.debug,
|
|
2738
|
+
interceptors: this._interceptors,
|
|
2739
|
+
clientInfo: this.config.clientInfo,
|
|
2740
|
+
elicitationHandler: this._elicitationHandler,
|
|
2741
|
+
notificationHandler: this.recordNotification
|
|
2742
|
+
});
|
|
2220
2743
|
default:
|
|
2221
2744
|
throw new Error(`Unknown transport type: ${this.config.transport}`);
|
|
2222
2745
|
}
|
|
@@ -2253,10 +2776,11 @@ var McpTestClient = class {
|
|
|
2253
2776
|
requestId: id
|
|
2254
2777
|
};
|
|
2255
2778
|
} catch (err) {
|
|
2779
|
+
if (isInterceptedError(err)) throw err;
|
|
2256
2780
|
const durationMs = Date.now() - start;
|
|
2257
2781
|
const error = {
|
|
2258
2782
|
code: -32603,
|
|
2259
|
-
message: err
|
|
2783
|
+
message: errorMessage(err)
|
|
2260
2784
|
};
|
|
2261
2785
|
return {
|
|
2262
2786
|
success: false,
|
|
@@ -2404,12 +2928,12 @@ var McpTestClient = class {
|
|
|
2404
2928
|
const payload = this.decodeJwtPayload(token);
|
|
2405
2929
|
if (!payload) return [];
|
|
2406
2930
|
const scope = payload["scope"];
|
|
2407
|
-
const
|
|
2931
|
+
const scopes2 = payload["scopes"];
|
|
2408
2932
|
if (typeof scope === "string") {
|
|
2409
2933
|
return scope.split(" ");
|
|
2410
2934
|
}
|
|
2411
|
-
if (Array.isArray(
|
|
2412
|
-
return
|
|
2935
|
+
if (Array.isArray(scopes2)) {
|
|
2936
|
+
return scopes2;
|
|
2413
2937
|
}
|
|
2414
2938
|
return [];
|
|
2415
2939
|
} catch {
|
|
@@ -2555,6 +3079,8 @@ var E2E_PORT_RANGES = {
|
|
|
2555
3079
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
2556
3080
|
// Protocol revision E2E tests (50460-50479)
|
|
2557
3081
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
3082
|
+
// Testing library E2E tests (50480-50499)
|
|
3083
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
2558
3084
|
// Mock servers and utilities (50900-50999)
|
|
2559
3085
|
"mock-oauth": { start: 50900, size: 10 },
|
|
2560
3086
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -2563,6 +3089,66 @@ var E2E_PORT_RANGES = {
|
|
|
2563
3089
|
default: { start: 51e3, size: 100 }
|
|
2564
3090
|
};
|
|
2565
3091
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3092
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
3093
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
3094
|
+
function lockDir() {
|
|
3095
|
+
try {
|
|
3096
|
+
const os = require("node:os");
|
|
3097
|
+
const path = require("node:path");
|
|
3098
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
3099
|
+
} catch {
|
|
3100
|
+
return void 0;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
function pidIsAlive(pid) {
|
|
3104
|
+
try {
|
|
3105
|
+
process.kill(pid, 0);
|
|
3106
|
+
return true;
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
return error.code === "EPERM";
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
function acquirePortLock(port) {
|
|
3112
|
+
if (lockedPorts.has(port)) return false;
|
|
3113
|
+
let fs;
|
|
3114
|
+
let file;
|
|
3115
|
+
try {
|
|
3116
|
+
fs = require("node:fs");
|
|
3117
|
+
const dir = lockDir();
|
|
3118
|
+
if (!dir) return true;
|
|
3119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3120
|
+
file = require("node:path").join(dir, `${port}.lock`);
|
|
3121
|
+
} catch {
|
|
3122
|
+
return true;
|
|
3123
|
+
}
|
|
3124
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
3125
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3126
|
+
try {
|
|
3127
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
3128
|
+
lockedPorts.add(port);
|
|
3129
|
+
return true;
|
|
3130
|
+
} catch (error) {
|
|
3131
|
+
if (error.code !== "EEXIST") return true;
|
|
3132
|
+
try {
|
|
3133
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3134
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
3135
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
3136
|
+
fs.unlinkSync(file);
|
|
3137
|
+
} catch {
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
return false;
|
|
3142
|
+
}
|
|
3143
|
+
function releasePortLock(port) {
|
|
3144
|
+
if (!lockedPorts.delete(port)) return;
|
|
3145
|
+
try {
|
|
3146
|
+
const fs = require("node:fs");
|
|
3147
|
+
const dir = lockDir();
|
|
3148
|
+
if (dir) fs.unlinkSync(require("node:path").join(dir, `${port}.lock`));
|
|
3149
|
+
} catch {
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
2566
3152
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
2567
3153
|
function getPortRange(project) {
|
|
2568
3154
|
const key = project;
|
|
@@ -2573,15 +3159,22 @@ function getPortRange(project) {
|
|
|
2573
3159
|
}
|
|
2574
3160
|
async function reservePort(project, preferredPort) {
|
|
2575
3161
|
const range = getPortRange(project);
|
|
2576
|
-
|
|
3162
|
+
const handle = (port) => ({
|
|
3163
|
+
port,
|
|
3164
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
3165
|
+
releaseSocket: async () => {
|
|
3166
|
+
await releasePort(port);
|
|
3167
|
+
},
|
|
3168
|
+
release: async () => {
|
|
3169
|
+
await releasePort(port);
|
|
3170
|
+
releasePortLock(port);
|
|
3171
|
+
}
|
|
3172
|
+
});
|
|
3173
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
3174
|
+
if (wantsSpecificPort) {
|
|
2577
3175
|
const reservation = await tryReservePort(preferredPort, project);
|
|
2578
3176
|
if (reservation) {
|
|
2579
|
-
return
|
|
2580
|
-
port: preferredPort,
|
|
2581
|
-
release: async () => {
|
|
2582
|
-
await releasePort(preferredPort);
|
|
2583
|
-
}
|
|
2584
|
-
};
|
|
3177
|
+
return handle(preferredPort);
|
|
2585
3178
|
}
|
|
2586
3179
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
2587
3180
|
}
|
|
@@ -2590,30 +3183,19 @@ async function reservePort(project, preferredPort) {
|
|
|
2590
3183
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2591
3184
|
const port = range.start + index % range.size;
|
|
2592
3185
|
index = (index + 1) % range.size;
|
|
2593
|
-
if (reservedPorts.has(port)) {
|
|
3186
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
2594
3187
|
continue;
|
|
2595
3188
|
}
|
|
2596
3189
|
const reservation = await tryReservePort(port, project);
|
|
2597
3190
|
if (reservation) {
|
|
2598
3191
|
projectPortIndex.set(project, index);
|
|
2599
|
-
return
|
|
2600
|
-
port,
|
|
2601
|
-
release: async () => {
|
|
2602
|
-
await releasePort(port);
|
|
2603
|
-
}
|
|
2604
|
-
};
|
|
3192
|
+
return handle(port);
|
|
2605
3193
|
}
|
|
2606
3194
|
}
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
return {
|
|
2612
|
-
port: dynamicPort,
|
|
2613
|
-
release: async () => {
|
|
2614
|
-
await releasePort(dynamicPort);
|
|
2615
|
-
}
|
|
2616
|
-
};
|
|
3195
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
3196
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
3197
|
+
if (await tryReservePort(port, project)) {
|
|
3198
|
+
return handle(port);
|
|
2617
3199
|
}
|
|
2618
3200
|
}
|
|
2619
3201
|
throw new Error(
|
|
@@ -2621,9 +3203,13 @@ async function reservePort(project, preferredPort) {
|
|
|
2621
3203
|
);
|
|
2622
3204
|
}
|
|
2623
3205
|
async function tryReservePort(port, project) {
|
|
3206
|
+
if (!acquirePortLock(port)) {
|
|
3207
|
+
return false;
|
|
3208
|
+
}
|
|
2624
3209
|
return new Promise((resolve) => {
|
|
2625
3210
|
const server = (0, import_net.createServer)();
|
|
2626
3211
|
server.once("error", () => {
|
|
3212
|
+
releasePortLock(port);
|
|
2627
3213
|
resolve(false);
|
|
2628
3214
|
});
|
|
2629
3215
|
server.listen(port, () => {
|
|
@@ -2649,31 +3235,6 @@ async function releasePort(port) {
|
|
|
2649
3235
|
});
|
|
2650
3236
|
});
|
|
2651
3237
|
}
|
|
2652
|
-
async function findAvailablePortInRange(start, end) {
|
|
2653
|
-
for (let port = start; port < end; port++) {
|
|
2654
|
-
if (reservedPorts.has(port)) {
|
|
2655
|
-
continue;
|
|
2656
|
-
}
|
|
2657
|
-
const available = await isPortAvailable(port);
|
|
2658
|
-
if (available) {
|
|
2659
|
-
return port;
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
return null;
|
|
2663
|
-
}
|
|
2664
|
-
async function isPortAvailable(port) {
|
|
2665
|
-
return new Promise((resolve) => {
|
|
2666
|
-
const server = (0, import_net.createServer)();
|
|
2667
|
-
server.once("error", () => {
|
|
2668
|
-
resolve(false);
|
|
2669
|
-
});
|
|
2670
|
-
server.listen(port, () => {
|
|
2671
|
-
server.close(() => {
|
|
2672
|
-
resolve(true);
|
|
2673
|
-
});
|
|
2674
|
-
});
|
|
2675
|
-
});
|
|
2676
|
-
}
|
|
2677
3238
|
|
|
2678
3239
|
// libs/testing/src/server/test-server.ts
|
|
2679
3240
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -2682,8 +3243,11 @@ var TestServer = class _TestServer {
|
|
|
2682
3243
|
options;
|
|
2683
3244
|
_info;
|
|
2684
3245
|
logs = [];
|
|
3246
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
2685
3247
|
portRelease = null;
|
|
2686
|
-
|
|
3248
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
3249
|
+
portReleaseSocket = null;
|
|
3250
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
2687
3251
|
this.options = {
|
|
2688
3252
|
port,
|
|
2689
3253
|
project: options.project,
|
|
@@ -2695,6 +3259,7 @@ var TestServer = class _TestServer {
|
|
|
2695
3259
|
debug: options.debug ?? DEBUG_SERVER
|
|
2696
3260
|
};
|
|
2697
3261
|
this.portRelease = portRelease ?? null;
|
|
3262
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
2698
3263
|
this._info = {
|
|
2699
3264
|
baseUrl: `http://localhost:${port}`,
|
|
2700
3265
|
port
|
|
@@ -2707,8 +3272,8 @@ var TestServer = class _TestServer {
|
|
|
2707
3272
|
const project = options.project ?? "default";
|
|
2708
3273
|
const maxAttempts = 3;
|
|
2709
3274
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2710
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2711
|
-
const server = new _TestServer(options, port, release);
|
|
3275
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3276
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
2712
3277
|
try {
|
|
2713
3278
|
await server.startProcess();
|
|
2714
3279
|
return server;
|
|
@@ -2748,7 +3313,7 @@ var TestServer = class _TestServer {
|
|
|
2748
3313
|
}
|
|
2749
3314
|
const maxAttempts = 3;
|
|
2750
3315
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2751
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3316
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2752
3317
|
const serverOptions = {
|
|
2753
3318
|
...options,
|
|
2754
3319
|
port,
|
|
@@ -2756,7 +3321,7 @@ var TestServer = class _TestServer {
|
|
|
2756
3321
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2757
3322
|
cwd: options.cwd ?? process.cwd()
|
|
2758
3323
|
};
|
|
2759
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
3324
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
2760
3325
|
try {
|
|
2761
3326
|
await server.startProcess();
|
|
2762
3327
|
return server;
|
|
@@ -2814,10 +3379,17 @@ var TestServer = class _TestServer {
|
|
|
2814
3379
|
* Stop the test server
|
|
2815
3380
|
*/
|
|
2816
3381
|
async stop() {
|
|
2817
|
-
|
|
2818
|
-
await this.
|
|
2819
|
-
|
|
3382
|
+
try {
|
|
3383
|
+
await this.stopProcess();
|
|
3384
|
+
} finally {
|
|
3385
|
+
if (this.portRelease) {
|
|
3386
|
+
await this.portRelease();
|
|
3387
|
+
this.portRelease = null;
|
|
3388
|
+
}
|
|
3389
|
+
this.portReleaseSocket = null;
|
|
2820
3390
|
}
|
|
3391
|
+
}
|
|
3392
|
+
async stopProcess() {
|
|
2821
3393
|
if (this.process) {
|
|
2822
3394
|
this.log("Stopping server...");
|
|
2823
3395
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -2890,7 +3462,7 @@ var TestServer = class _TestServer {
|
|
|
2890
3462
|
* Restart the server
|
|
2891
3463
|
*/
|
|
2892
3464
|
async restart() {
|
|
2893
|
-
await this.
|
|
3465
|
+
await this.stopProcess();
|
|
2894
3466
|
await this.startProcess();
|
|
2895
3467
|
}
|
|
2896
3468
|
/**
|
|
@@ -2920,9 +3492,9 @@ var TestServer = class _TestServer {
|
|
|
2920
3492
|
PORT: String(this.options.port)
|
|
2921
3493
|
};
|
|
2922
3494
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
2923
|
-
if (this.
|
|
2924
|
-
await this.
|
|
2925
|
-
this.
|
|
3495
|
+
if (this.portReleaseSocket) {
|
|
3496
|
+
await this.portReleaseSocket();
|
|
3497
|
+
this.portReleaseSocket = null;
|
|
2926
3498
|
await sleep2(300);
|
|
2927
3499
|
}
|
|
2928
3500
|
this.process = (0, import_child_process.spawn)(this.options.command, [], {
|
|
@@ -2932,9 +3504,8 @@ var TestServer = class _TestServer {
|
|
|
2932
3504
|
detached: true,
|
|
2933
3505
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2934
3506
|
});
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
}
|
|
3507
|
+
this._info.pid = this.process.pid;
|
|
3508
|
+
this._info.shellPid = this.process.pid;
|
|
2938
3509
|
let processExited = false;
|
|
2939
3510
|
let exitCode = null;
|
|
2940
3511
|
let exitError = null;
|
|
@@ -3037,6 +3608,8 @@ ${allLogs}`);
|
|
|
3037
3608
|
});
|
|
3038
3609
|
if (response.ok || response.status === 404) {
|
|
3039
3610
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3611
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
3612
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3040
3613
|
return;
|
|
3041
3614
|
}
|
|
3042
3615
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3081,6 +3654,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3081
3654
|
function sleep2(ms) {
|
|
3082
3655
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3083
3656
|
}
|
|
3657
|
+
function findListeningPid(port) {
|
|
3658
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3659
|
+
return new Promise((resolve) => {
|
|
3660
|
+
(0, import_child_process.execFile)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
3661
|
+
if (error) {
|
|
3662
|
+
resolve(void 0);
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
3666
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
3667
|
+
});
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3084
3670
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3085
3671
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3086
3672
|
return env;
|
|
@@ -3197,81 +3783,163 @@ function gatewayTokenBinding(serverUrl, entryPath) {
|
|
|
3197
3783
|
}
|
|
3198
3784
|
|
|
3199
3785
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3200
|
-
var
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3786
|
+
var jestApi;
|
|
3787
|
+
function jestGlobals() {
|
|
3788
|
+
if (jestApi) return jestApi;
|
|
3789
|
+
try {
|
|
3790
|
+
const api = require("@jest/globals");
|
|
3791
|
+
jestApi = {
|
|
3792
|
+
describe: api["describe"],
|
|
3793
|
+
beforeAll: api["beforeAll"],
|
|
3794
|
+
beforeEach: api["beforeEach"],
|
|
3795
|
+
afterEach: api["afterEach"],
|
|
3796
|
+
afterAll: api["afterAll"],
|
|
3797
|
+
it: api["it"]
|
|
3798
|
+
};
|
|
3799
|
+
return jestApi;
|
|
3800
|
+
} catch (error) {
|
|
3801
|
+
throw new Error(
|
|
3802
|
+
`@frontmcp/testing fixtures ("test", "test.use", ...) can only be used inside a Jest test file: ${error instanceof Error ? error.message : String(error)}`,
|
|
3803
|
+
{ cause: error }
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3213
3806
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3807
|
+
function newScope(parent) {
|
|
3808
|
+
return {
|
|
3809
|
+
skipped: parent?.skipped ?? false,
|
|
3810
|
+
reason: parent?.reason,
|
|
3811
|
+
config: {},
|
|
3812
|
+
envs: [],
|
|
3813
|
+
configured: false,
|
|
3814
|
+
cleanupRegistered: false,
|
|
3815
|
+
beforeEachHooks: [],
|
|
3816
|
+
afterEachHooks: []
|
|
3817
|
+
};
|
|
3818
|
+
}
|
|
3819
|
+
var scopes = [newScope()];
|
|
3820
|
+
function currentScope() {
|
|
3821
|
+
return scopes[scopes.length - 1];
|
|
3822
|
+
}
|
|
3823
|
+
var serverEntries = /* @__PURE__ */ new Map();
|
|
3824
|
+
function resolveConfig(chain) {
|
|
3825
|
+
const merged = {};
|
|
3826
|
+
for (const scope of chain) {
|
|
3827
|
+
Object.assign(merged, scope.config);
|
|
3828
|
+
for (const env of scope.envs) merged.env = { ...merged.env, ...env };
|
|
3829
|
+
}
|
|
3830
|
+
return merged;
|
|
3831
|
+
}
|
|
3832
|
+
function ownerScopeOf(chain) {
|
|
3833
|
+
for (let i = chain.length - 1; i > 0; i--) {
|
|
3834
|
+
if (chain[i].configured) return chain[i];
|
|
3835
|
+
}
|
|
3836
|
+
return chain[0];
|
|
3837
|
+
}
|
|
3838
|
+
function serverKey(config) {
|
|
3839
|
+
const env = Object.fromEntries(Object.entries(config.env ?? {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
3840
|
+
return JSON.stringify({
|
|
3841
|
+
server: config.server,
|
|
3842
|
+
baseUrl: config.baseUrl,
|
|
3843
|
+
project: config.project,
|
|
3844
|
+
port: config.port,
|
|
3845
|
+
entryPath: config.entryPath,
|
|
3846
|
+
startupTimeout: config.startupTimeout,
|
|
3847
|
+
logLevel: config.logLevel,
|
|
3848
|
+
auth: config.auth,
|
|
3849
|
+
env
|
|
3850
|
+
});
|
|
3851
|
+
}
|
|
3852
|
+
function serverEnv(config) {
|
|
3853
|
+
const extra = {};
|
|
3854
|
+
if (config.auth?.mode) extra["FRONTMCP_TEST_AUTH_MODE"] = config.auth.mode;
|
|
3855
|
+
if (config.auth?.type) extra["FRONTMCP_TEST_AUTH_TYPE"] = config.auth.type;
|
|
3856
|
+
if (Object.keys(extra).length === 0 && !config.env) return void 0;
|
|
3857
|
+
return { ...extra, ...config.env };
|
|
3858
|
+
}
|
|
3859
|
+
async function startServerEntry(config, owner) {
|
|
3860
|
+
let server;
|
|
3861
|
+
let startedByUs = false;
|
|
3862
|
+
if (config.baseUrl && !config.server) {
|
|
3863
|
+
server = TestServer.connect(config.baseUrl);
|
|
3864
|
+
} else if (config.server) {
|
|
3865
|
+
const serverCommand = resolveServerCommand(config.server);
|
|
3866
|
+
const isDebug = config.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3867
|
+
if (isDebug) {
|
|
3868
|
+
console.log(`[TestFixture] Starting server: ${serverCommand}`);
|
|
3869
|
+
}
|
|
3870
|
+
try {
|
|
3871
|
+
server = await TestServer.start({
|
|
3872
|
+
project: config.project,
|
|
3873
|
+
port: config.port,
|
|
3874
|
+
command: serverCommand,
|
|
3875
|
+
env: serverEnv(config),
|
|
3876
|
+
startupTimeout: config.startupTimeout ?? 3e4,
|
|
3877
|
+
debug: isDebug
|
|
3878
|
+
});
|
|
3879
|
+
startedByUs = true;
|
|
3222
3880
|
if (isDebug) {
|
|
3223
|
-
console.log(`[TestFixture]
|
|
3881
|
+
console.log(`[TestFixture] Server started at ${server.info.baseUrl}`);
|
|
3224
3882
|
}
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
command: serverCommand,
|
|
3230
|
-
env: currentConfig.env,
|
|
3231
|
-
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
3232
|
-
debug: isDebug
|
|
3233
|
-
});
|
|
3234
|
-
serverStartedByUs = true;
|
|
3235
|
-
if (isDebug) {
|
|
3236
|
-
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
3237
|
-
}
|
|
3238
|
-
} catch (error) {
|
|
3239
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3240
|
-
throw new Error(
|
|
3241
|
-
`Failed to start test server.
|
|
3883
|
+
} catch (error) {
|
|
3884
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3885
|
+
throw new Error(
|
|
3886
|
+
`Failed to start test server.
|
|
3242
3887
|
|
|
3243
|
-
Server entry: ${
|
|
3244
|
-
Project: ${
|
|
3888
|
+
Server entry: ${config.server}
|
|
3889
|
+
Project: ${config.project ?? "default"}
|
|
3245
3890
|
Command: ${serverCommand}
|
|
3246
3891
|
|
|
3247
|
-
Error: ${errMsg}
|
|
3248
|
-
|
|
3249
|
-
}
|
|
3250
|
-
} else {
|
|
3251
|
-
throw new Error(
|
|
3252
|
-
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
3892
|
+
Error: ${errMsg}`,
|
|
3893
|
+
{ cause: error }
|
|
3253
3894
|
);
|
|
3254
3895
|
}
|
|
3896
|
+
} else {
|
|
3897
|
+
throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option');
|
|
3255
3898
|
}
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
}
|
|
3899
|
+
const sharedSecret = config.env?.["JWT_SECRET"];
|
|
3900
|
+
const binding = sharedSecret ? gatewayTokenBinding(config.baseUrl ?? server.info.baseUrl, config.entryPath) : void 0;
|
|
3901
|
+
const tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret, ...binding } : {});
|
|
3902
|
+
return { server, startedByUs, tokenFactory, owner };
|
|
3261
3903
|
}
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3904
|
+
function acquireServer(chain) {
|
|
3905
|
+
const config = resolveConfig(chain);
|
|
3906
|
+
const key = serverKey(config);
|
|
3907
|
+
let entry = serverEntries.get(key);
|
|
3908
|
+
if (!entry) {
|
|
3909
|
+
entry = startServerEntry(config, ownerScopeOf(chain));
|
|
3910
|
+
serverEntries.set(key, entry);
|
|
3911
|
+
entry.catch(() => serverEntries.delete(key));
|
|
3912
|
+
}
|
|
3913
|
+
return entry;
|
|
3914
|
+
}
|
|
3915
|
+
async function stopServers(owner) {
|
|
3916
|
+
for (const [key, pending] of Array.from(serverEntries)) {
|
|
3917
|
+
let entry;
|
|
3918
|
+
try {
|
|
3919
|
+
entry = await pending;
|
|
3920
|
+
} catch {
|
|
3921
|
+
serverEntries.delete(key);
|
|
3922
|
+
continue;
|
|
3923
|
+
}
|
|
3924
|
+
if (owner && entry.owner !== owner) continue;
|
|
3925
|
+
serverEntries.delete(key);
|
|
3926
|
+
if (entry.startedByUs) {
|
|
3927
|
+
await entry.server.stop();
|
|
3928
|
+
}
|
|
3269
3929
|
}
|
|
3930
|
+
}
|
|
3931
|
+
var fixtureContexts = /* @__PURE__ */ new WeakMap();
|
|
3932
|
+
async function buildFixtures(chain) {
|
|
3933
|
+
const config = resolveConfig(chain);
|
|
3934
|
+
const entry = await acquireServer(chain);
|
|
3935
|
+
const { server, tokenFactory } = entry;
|
|
3936
|
+
const publicMode = config.publicMode ?? config.auth?.mode === "public";
|
|
3937
|
+
const clientConfig = { ...config, publicMode };
|
|
3270
3938
|
const clientInstance = McpTestClient.create({
|
|
3271
|
-
baseUrl: resolveClientBaseUrl(
|
|
3272
|
-
entryPath:
|
|
3273
|
-
transport:
|
|
3274
|
-
publicMode
|
|
3939
|
+
baseUrl: resolveClientBaseUrl(server, clientConfig),
|
|
3940
|
+
entryPath: clientConfig.entryPath,
|
|
3941
|
+
transport: clientConfig.transport ?? "streamable-http",
|
|
3942
|
+
publicMode
|
|
3275
3943
|
}).build();
|
|
3276
3944
|
try {
|
|
3277
3945
|
await clientInstance.connect();
|
|
@@ -3285,17 +3953,24 @@ async function createTestFixtures() {
|
|
|
3285
3953
|
throw err;
|
|
3286
3954
|
}
|
|
3287
3955
|
}
|
|
3288
|
-
const
|
|
3289
|
-
const
|
|
3290
|
-
return {
|
|
3956
|
+
const context = { extraClients: [] };
|
|
3957
|
+
const fixtures = {
|
|
3291
3958
|
mcp: clientInstance,
|
|
3292
|
-
auth,
|
|
3293
|
-
server
|
|
3959
|
+
auth: createAuthFixture(tokenFactory),
|
|
3960
|
+
server: createServerFixture(server, clientConfig, clientInstance, context)
|
|
3294
3961
|
};
|
|
3962
|
+
fixtureContexts.set(fixtures, context);
|
|
3963
|
+
return fixtures;
|
|
3964
|
+
}
|
|
3965
|
+
async function createTestFixtures() {
|
|
3966
|
+
return buildFixtures(scopes.slice());
|
|
3967
|
+
}
|
|
3968
|
+
async function initializeSharedResources() {
|
|
3969
|
+
await acquireServer(scopes.slice());
|
|
3295
3970
|
}
|
|
3296
3971
|
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
3297
|
-
if (testFailed
|
|
3298
|
-
const logs =
|
|
3972
|
+
if (testFailed) {
|
|
3973
|
+
const logs = fixtures.server.getLogs();
|
|
3299
3974
|
if (logs.length > 0) {
|
|
3300
3975
|
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
3301
3976
|
const recentLogs = logs.slice(-50);
|
|
@@ -3306,20 +3981,21 @@ async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
|
3306
3981
|
console.error("[TestFixture] === End Server Logs ===\n");
|
|
3307
3982
|
}
|
|
3308
3983
|
}
|
|
3984
|
+
const extras = fixtureContexts.get(fixtures)?.extraClients ?? [];
|
|
3985
|
+
for (const client of extras) {
|
|
3986
|
+
if (client.isConnected()) {
|
|
3987
|
+
await client.disconnect().catch(() => void 0);
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3309
3990
|
if (fixtures.mcp.isConnected()) {
|
|
3310
3991
|
await fixtures.mcp.disconnect();
|
|
3311
3992
|
}
|
|
3312
3993
|
}
|
|
3313
3994
|
async function cleanupSharedResources() {
|
|
3314
|
-
|
|
3315
|
-
await serverInstance.stop();
|
|
3316
|
-
}
|
|
3317
|
-
serverInstance = null;
|
|
3318
|
-
tokenFactory = null;
|
|
3319
|
-
serverStartedByUs = false;
|
|
3995
|
+
await stopServers();
|
|
3320
3996
|
}
|
|
3321
|
-
function resolveClientBaseUrl(server) {
|
|
3322
|
-
return
|
|
3997
|
+
function resolveClientBaseUrl(server, config) {
|
|
3998
|
+
return config.baseUrl ?? server.info.baseUrl;
|
|
3323
3999
|
}
|
|
3324
4000
|
function createAuthFixture(factory) {
|
|
3325
4001
|
const users = {
|
|
@@ -3365,28 +4041,39 @@ function createAuthFixture(factory) {
|
|
|
3365
4041
|
getAudience: () => factory.getAudience()
|
|
3366
4042
|
};
|
|
3367
4043
|
}
|
|
3368
|
-
function createServerFixture(server) {
|
|
4044
|
+
function createServerFixture(server, config, mcp, context) {
|
|
3369
4045
|
return {
|
|
3370
|
-
|
|
4046
|
+
// Live view: `pid` is filled in once the server is listening and follows restarts
|
|
4047
|
+
get info() {
|
|
4048
|
+
return server.info;
|
|
4049
|
+
},
|
|
3371
4050
|
createClient: async (opts) => {
|
|
3372
|
-
|
|
3373
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3374
|
-
entryPath: opts?.entryPath ??
|
|
4051
|
+
const client = await McpTestClient.create({
|
|
4052
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
4053
|
+
entryPath: opts?.entryPath ?? config.entryPath,
|
|
3375
4054
|
transport: opts?.transport ?? "streamable-http",
|
|
3376
4055
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3377
4056
|
clientInfo: opts?.clientInfo,
|
|
3378
|
-
publicMode:
|
|
4057
|
+
publicMode: config.publicMode
|
|
3379
4058
|
}).buildAndConnect();
|
|
4059
|
+
context.extraClients.push(client);
|
|
4060
|
+
return client;
|
|
3380
4061
|
},
|
|
3381
4062
|
createClientBuilder: () => {
|
|
3382
4063
|
const builder = new McpTestClientBuilder({
|
|
3383
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3384
|
-
entryPath:
|
|
3385
|
-
publicMode:
|
|
4064
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
4065
|
+
entryPath: config.entryPath,
|
|
4066
|
+
publicMode: config.publicMode
|
|
3386
4067
|
});
|
|
3387
4068
|
return builder;
|
|
3388
4069
|
},
|
|
3389
|
-
restart: () =>
|
|
4070
|
+
restart: async () => {
|
|
4071
|
+
const reconnect = [mcp, ...context.extraClients].filter((client) => client.isConnected());
|
|
4072
|
+
await server.restart();
|
|
4073
|
+
for (const client of reconnect) {
|
|
4074
|
+
await client.reconnect();
|
|
4075
|
+
}
|
|
4076
|
+
},
|
|
3390
4077
|
getLogs: () => server.getLogs(),
|
|
3391
4078
|
clearLogs: () => server.clearLogs()
|
|
3392
4079
|
};
|
|
@@ -3397,38 +4084,71 @@ function resolveServerCommand(server) {
|
|
|
3397
4084
|
}
|
|
3398
4085
|
return `npx tsx ${server}`;
|
|
3399
4086
|
}
|
|
3400
|
-
function runWithFixtures(fn) {
|
|
3401
|
-
return async () => {
|
|
3402
|
-
const fixtures = await
|
|
3403
|
-
let
|
|
4087
|
+
function runWithFixtures(fn, chain) {
|
|
4088
|
+
return async (...extra) => {
|
|
4089
|
+
const fixtures = await buildFixtures(chain);
|
|
4090
|
+
let failure;
|
|
3404
4091
|
try {
|
|
3405
|
-
|
|
4092
|
+
for (const scope of chain) {
|
|
4093
|
+
for (const hook of scope.beforeEachHooks) await hook(fixtures);
|
|
4094
|
+
}
|
|
4095
|
+
await fn(fixtures, ...extra);
|
|
3406
4096
|
} catch (error) {
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
await
|
|
4097
|
+
failure = { error };
|
|
4098
|
+
}
|
|
4099
|
+
try {
|
|
4100
|
+
await runAfterEachHooks(chain, fixtures);
|
|
4101
|
+
} catch (error) {
|
|
4102
|
+
failure ??= { error };
|
|
3411
4103
|
}
|
|
4104
|
+
await cleanupTestFixtures(fixtures, failure !== void 0);
|
|
4105
|
+
if (failure) throw failure.error;
|
|
3412
4106
|
};
|
|
3413
4107
|
}
|
|
4108
|
+
async function runAfterEachHooks(chain, fixtures) {
|
|
4109
|
+
let firstError;
|
|
4110
|
+
let failed = false;
|
|
4111
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
4112
|
+
for (const hook of chain[i].afterEachHooks) {
|
|
4113
|
+
try {
|
|
4114
|
+
await hook(fixtures);
|
|
4115
|
+
} catch (error) {
|
|
4116
|
+
if (!failed) firstError = error;
|
|
4117
|
+
failed = true;
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
if (failed) throw firstError;
|
|
4122
|
+
}
|
|
4123
|
+
function skipTitle(name, scope) {
|
|
4124
|
+
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
4125
|
+
}
|
|
3414
4126
|
function testWithFixtures(name, fn) {
|
|
3415
|
-
const
|
|
4127
|
+
const chain = scopes.slice();
|
|
4128
|
+
const scope = currentScope();
|
|
3416
4129
|
if (scope.skipped) {
|
|
3417
|
-
it.skip(
|
|
4130
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3418
4131
|
return;
|
|
3419
4132
|
}
|
|
3420
|
-
it(name, runWithFixtures(fn));
|
|
4133
|
+
jestGlobals().it(name, runWithFixtures(fn, chain));
|
|
3421
4134
|
}
|
|
3422
4135
|
function use(config) {
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
4136
|
+
const scope = currentScope();
|
|
4137
|
+
const { env, ...rest } = config;
|
|
4138
|
+
scope.config = { ...scope.config, ...rest };
|
|
4139
|
+
if (env) scope.envs.push(env);
|
|
4140
|
+
scope.configured = true;
|
|
4141
|
+
if (!scope.cleanupRegistered) {
|
|
4142
|
+
scope.cleanupRegistered = true;
|
|
4143
|
+
jestGlobals().afterAll(async () => {
|
|
4144
|
+
await stopServers(scope === scopes[0] ? void 0 : scope);
|
|
4145
|
+
});
|
|
4146
|
+
}
|
|
3427
4147
|
}
|
|
3428
4148
|
function skip(nameOrCondition, fnOrReason) {
|
|
3429
4149
|
if (typeof nameOrCondition === "boolean") {
|
|
3430
4150
|
if (nameOrCondition) {
|
|
3431
|
-
const scope =
|
|
4151
|
+
const scope = currentScope();
|
|
3432
4152
|
scope.skipped = true;
|
|
3433
4153
|
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3434
4154
|
}
|
|
@@ -3439,53 +4159,93 @@ function skip(nameOrCondition, fnOrReason) {
|
|
|
3439
4159
|
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3440
4160
|
);
|
|
3441
4161
|
}
|
|
3442
|
-
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
4162
|
+
jestGlobals().it.skip(nameOrCondition, runWithFixtures(fnOrReason, scopes.slice()));
|
|
3443
4163
|
}
|
|
3444
4164
|
function only(name, fn) {
|
|
3445
|
-
const scope =
|
|
4165
|
+
const scope = currentScope();
|
|
3446
4166
|
if (scope.skipped) {
|
|
3447
|
-
it.skip(
|
|
4167
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, scopes.slice()));
|
|
3448
4168
|
return;
|
|
3449
4169
|
}
|
|
3450
|
-
it.only(name, runWithFixtures(fn));
|
|
4170
|
+
jestGlobals().it.only(name, runWithFixtures(fn, scopes.slice()));
|
|
3451
4171
|
}
|
|
3452
4172
|
function todo(name) {
|
|
3453
|
-
it.todo(name);
|
|
4173
|
+
jestGlobals().it.todo(name);
|
|
3454
4174
|
}
|
|
3455
|
-
function
|
|
4175
|
+
function each(table, ...tagged) {
|
|
4176
|
+
const chain = scopes.slice();
|
|
4177
|
+
const register = jestGlobals().it.each(table, ...tagged);
|
|
3456
4178
|
return (name, fn) => {
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
}
|
|
3465
|
-
});
|
|
4179
|
+
const scope = currentScope();
|
|
4180
|
+
if (scope.skipped) {
|
|
4181
|
+
const skipRegister = jestGlobals().it.skip.each(table, ...tagged);
|
|
4182
|
+
skipRegister(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
4183
|
+
return;
|
|
4184
|
+
}
|
|
4185
|
+
register(name, runWithFixtures(fn, chain));
|
|
3466
4186
|
};
|
|
3467
4187
|
}
|
|
3468
|
-
function
|
|
3469
|
-
return (
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
4188
|
+
function withScope(fn) {
|
|
4189
|
+
return (...args) => {
|
|
4190
|
+
scopes.push(newScope(currentScope()));
|
|
4191
|
+
try {
|
|
4192
|
+
fn(...args);
|
|
4193
|
+
} finally {
|
|
4194
|
+
scopes.pop();
|
|
4195
|
+
}
|
|
4196
|
+
};
|
|
4197
|
+
}
|
|
4198
|
+
function scopedRegister(pick) {
|
|
4199
|
+
return (name, fn, timeout) => {
|
|
4200
|
+
pick()(name, withScope(fn), timeout);
|
|
4201
|
+
};
|
|
4202
|
+
}
|
|
4203
|
+
function scopedEach(pick) {
|
|
4204
|
+
return (...eachArgs) => (name, fn, timeout) => {
|
|
4205
|
+
const register = pick()(...eachArgs);
|
|
4206
|
+
register(name, withScope(fn), timeout);
|
|
4207
|
+
};
|
|
4208
|
+
}
|
|
4209
|
+
var describeWithScope = Object.assign(
|
|
4210
|
+
scopedRegister(() => jestGlobals().describe),
|
|
4211
|
+
{
|
|
4212
|
+
// `describe.skip` still evaluates its callback during collection, so it needs a scope too
|
|
4213
|
+
only: Object.assign(
|
|
4214
|
+
scopedRegister(() => jestGlobals().describe.only),
|
|
4215
|
+
{
|
|
4216
|
+
each: scopedEach(() => jestGlobals().describe.only.each)
|
|
4217
|
+
}
|
|
4218
|
+
),
|
|
4219
|
+
skip: Object.assign(
|
|
4220
|
+
scopedRegister(() => jestGlobals().describe.skip),
|
|
4221
|
+
{
|
|
4222
|
+
each: scopedEach(() => jestGlobals().describe.skip.each)
|
|
4223
|
+
}
|
|
4224
|
+
),
|
|
4225
|
+
each: scopedEach(() => jestGlobals().describe.each)
|
|
4226
|
+
}
|
|
4227
|
+
);
|
|
4228
|
+
function eachHook(kind) {
|
|
4229
|
+
return (fn, timeout) => {
|
|
4230
|
+
if (typeof fn === "function" && fn.length > 0) {
|
|
4231
|
+
const scope = currentScope();
|
|
4232
|
+
(kind === "beforeEach" ? scope.beforeEachHooks : scope.afterEachHooks).push(fn);
|
|
4233
|
+
return;
|
|
4234
|
+
}
|
|
4235
|
+
jestGlobals()[kind](fn, timeout);
|
|
4236
|
+
};
|
|
3473
4237
|
}
|
|
3474
|
-
var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
|
|
3475
|
-
only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
|
|
3476
|
-
skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
|
|
3477
|
-
each: eachWithSkipScope(describe.each)
|
|
3478
|
-
});
|
|
3479
4238
|
var test = testWithFixtures;
|
|
3480
4239
|
test.use = use;
|
|
3481
|
-
test.describe =
|
|
3482
|
-
test.beforeAll = beforeAll;
|
|
3483
|
-
test.beforeEach = beforeEach;
|
|
3484
|
-
test.afterEach = afterEach;
|
|
3485
|
-
test.afterAll = afterAll;
|
|
4240
|
+
test.describe = describeWithScope;
|
|
4241
|
+
test.beforeAll = ((...args) => jestGlobals().beforeAll(...args));
|
|
4242
|
+
test.beforeEach = eachHook("beforeEach");
|
|
4243
|
+
test.afterEach = eachHook("afterEach");
|
|
4244
|
+
test.afterAll = ((...args) => jestGlobals().afterAll(...args));
|
|
3486
4245
|
test.skip = skip;
|
|
3487
4246
|
test.only = only;
|
|
3488
4247
|
test.todo = todo;
|
|
4248
|
+
test.each = each;
|
|
3489
4249
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3490
4250
|
0 && (module.exports = {
|
|
3491
4251
|
cleanupSharedResources,
|