@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/esm/fixtures/index.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
var __frontmcpModule = typeof process !== "undefined" && typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("module") : undefined;
|
|
2
|
+
const require = __frontmcpModule ? __frontmcpModule.createRequire(import.meta.url || 'file:///') : undefined;
|
|
3
|
+
// __frontmcpCreateRequire
|
|
1
4
|
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
5
|
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
6
|
}) : x)(function(x) {
|
|
@@ -5,16 +8,6 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
5
8
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
9
|
});
|
|
7
10
|
|
|
8
|
-
// libs/testing/src/fixtures/test-fixture.ts
|
|
9
|
-
import {
|
|
10
|
-
afterAll as _afterAll,
|
|
11
|
-
afterEach as _afterEach,
|
|
12
|
-
beforeAll as _beforeAll,
|
|
13
|
-
beforeEach as _beforeEach,
|
|
14
|
-
describe as _describe,
|
|
15
|
-
it as _it
|
|
16
|
-
} from "@jest/globals";
|
|
17
|
-
|
|
18
11
|
// libs/testing/src/auth/token-factory.ts
|
|
19
12
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
20
13
|
var TestTokenFactory = class {
|
|
@@ -73,14 +66,15 @@ var TestTokenFactory = class {
|
|
|
73
66
|
*/
|
|
74
67
|
async createTestToken(options) {
|
|
75
68
|
await this.ensureKeys();
|
|
76
|
-
const
|
|
69
|
+
const nowMs = Date.now();
|
|
70
|
+
const iat = Math.floor(nowMs / 1e3);
|
|
77
71
|
const exp = options.exp ?? 3600;
|
|
78
72
|
const payload = {
|
|
79
73
|
iss: options.iss ?? this.issuer,
|
|
80
74
|
sub: options.sub,
|
|
81
75
|
aud: options.aud ?? this.audience,
|
|
82
|
-
iat
|
|
83
|
-
exp:
|
|
76
|
+
iat,
|
|
77
|
+
exp: Math.ceil(nowMs / 1e3) + exp,
|
|
84
78
|
scope: options.scopes?.join(" "),
|
|
85
79
|
...options.claims
|
|
86
80
|
};
|
|
@@ -105,10 +99,10 @@ var TestTokenFactory = class {
|
|
|
105
99
|
/**
|
|
106
100
|
* Create a regular user token
|
|
107
101
|
*/
|
|
108
|
-
async createUserToken(sub = "user-001",
|
|
102
|
+
async createUserToken(sub = "user-001", scopes2 = ["read", "write"]) {
|
|
109
103
|
return this.createTestToken({
|
|
110
104
|
sub,
|
|
111
|
-
scopes,
|
|
105
|
+
scopes: scopes2,
|
|
112
106
|
claims: {
|
|
113
107
|
email: "user@test.local",
|
|
114
108
|
name: "Test User",
|
|
@@ -119,10 +113,11 @@ var TestTokenFactory = class {
|
|
|
119
113
|
/**
|
|
120
114
|
* Create an anonymous user token
|
|
121
115
|
*/
|
|
122
|
-
async createAnonymousToken() {
|
|
116
|
+
async createAnonymousToken(expiresIn) {
|
|
123
117
|
return this.createTestToken({
|
|
124
118
|
sub: `anon:${Date.now()}`,
|
|
125
119
|
scopes: ["anonymous"],
|
|
120
|
+
exp: expiresIn,
|
|
126
121
|
claims: {
|
|
127
122
|
name: "Anonymous",
|
|
128
123
|
role: "anonymous"
|
|
@@ -471,8 +466,479 @@ function sleep(ms) {
|
|
|
471
466
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
472
467
|
}
|
|
473
468
|
|
|
474
|
-
// libs/testing/src/transport/
|
|
469
|
+
// libs/testing/src/transport/error-utils.ts
|
|
470
|
+
function errorName(err) {
|
|
471
|
+
if (typeof err !== "object" || err === null) return void 0;
|
|
472
|
+
const name = err.name;
|
|
473
|
+
return typeof name === "string" ? name : void 0;
|
|
474
|
+
}
|
|
475
|
+
function isAbortError(err) {
|
|
476
|
+
return errorName(err) === "AbortError";
|
|
477
|
+
}
|
|
478
|
+
function errorMessage(err, fallback = "Unknown error") {
|
|
479
|
+
if (typeof err === "string" && err) return err;
|
|
480
|
+
if (typeof err === "object" && err !== null) {
|
|
481
|
+
const message = err.message;
|
|
482
|
+
if (typeof message === "string" && message) return message;
|
|
483
|
+
}
|
|
484
|
+
return fallback;
|
|
485
|
+
}
|
|
486
|
+
var interceptedErrors = /* @__PURE__ */ new WeakSet();
|
|
487
|
+
function markInterceptedError(err) {
|
|
488
|
+
interceptedErrors.add(err);
|
|
489
|
+
return err;
|
|
490
|
+
}
|
|
491
|
+
function isInterceptedError(err) {
|
|
492
|
+
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// libs/testing/src/transport/sse.transport.ts
|
|
475
496
|
var DEFAULT_TIMEOUT = 3e4;
|
|
497
|
+
var SseTransport = class {
|
|
498
|
+
baseUrl;
|
|
499
|
+
entryPath;
|
|
500
|
+
timeout;
|
|
501
|
+
authHeaders;
|
|
502
|
+
publicMode;
|
|
503
|
+
debug;
|
|
504
|
+
clientInfo;
|
|
505
|
+
notificationHandler;
|
|
506
|
+
interceptors;
|
|
507
|
+
elicitationHandler;
|
|
508
|
+
state = "disconnected";
|
|
509
|
+
authToken;
|
|
510
|
+
sessionId;
|
|
511
|
+
messageEndpoint;
|
|
512
|
+
stream;
|
|
513
|
+
pending = /* @__PURE__ */ new Map();
|
|
514
|
+
connectionCount = 0;
|
|
515
|
+
reconnectCount = 0;
|
|
516
|
+
lastRequestHeaders = {};
|
|
517
|
+
constructor(config) {
|
|
518
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
519
|
+
this.entryPath = config.entryPath;
|
|
520
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
521
|
+
this.authHeaders = config.auth?.headers ?? {};
|
|
522
|
+
this.authToken = config.auth?.token;
|
|
523
|
+
this.publicMode = config.publicMode ?? false;
|
|
524
|
+
this.debug = config.debug ?? false;
|
|
525
|
+
this.clientInfo = config.clientInfo;
|
|
526
|
+
this.notificationHandler = config.notificationHandler;
|
|
527
|
+
this.interceptors = config.interceptors;
|
|
528
|
+
this.elicitationHandler = config.elicitationHandler;
|
|
529
|
+
}
|
|
530
|
+
async connect() {
|
|
531
|
+
this.state = "connecting";
|
|
532
|
+
this.connectionCount++;
|
|
533
|
+
try {
|
|
534
|
+
if (!this.publicMode && !this.authToken) {
|
|
535
|
+
await this.requestAnonymousToken();
|
|
536
|
+
}
|
|
537
|
+
await this.openStream();
|
|
538
|
+
this.state = "connected";
|
|
539
|
+
this.log(`Connected; messages go to ${this.messageEndpoint}`);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
this.state = "error";
|
|
542
|
+
this.stream?.abort();
|
|
543
|
+
this.stream = void 0;
|
|
544
|
+
throw error;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async request(message) {
|
|
548
|
+
this.ensureConnected();
|
|
549
|
+
const startTime = Date.now();
|
|
550
|
+
if (this.interceptors) {
|
|
551
|
+
const intercepted = await this.interceptors.processRequest(message, {
|
|
552
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
553
|
+
transport: "sse",
|
|
554
|
+
sessionId: this.sessionId
|
|
555
|
+
});
|
|
556
|
+
switch (intercepted.type) {
|
|
557
|
+
case "mock":
|
|
558
|
+
return await this.interceptors.processResponse(
|
|
559
|
+
message,
|
|
560
|
+
intercepted.response,
|
|
561
|
+
Date.now() - startTime
|
|
562
|
+
);
|
|
563
|
+
case "error":
|
|
564
|
+
throw markInterceptedError(intercepted.error);
|
|
565
|
+
case "continue":
|
|
566
|
+
message = intercepted.request;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
let response;
|
|
571
|
+
if (message.id === void 0) {
|
|
572
|
+
await this.post(JSON.stringify(message));
|
|
573
|
+
response = { jsonrpc: "2.0", id: null, result: void 0 };
|
|
574
|
+
} else {
|
|
575
|
+
response = await this.postAndAwait(message.id, JSON.stringify(message));
|
|
576
|
+
}
|
|
577
|
+
if (this.interceptors) {
|
|
578
|
+
response = await this.interceptors.processResponse(message, response, Date.now() - startTime);
|
|
579
|
+
}
|
|
580
|
+
return response;
|
|
581
|
+
}
|
|
582
|
+
async notify(message) {
|
|
583
|
+
this.ensureConnected();
|
|
584
|
+
const result = await this.post(JSON.stringify(message));
|
|
585
|
+
if (!result.ok) this.log(`HTTP ${result.status} on notification: ${result.body}`);
|
|
586
|
+
}
|
|
587
|
+
async sendRaw(data) {
|
|
588
|
+
this.ensureConnected();
|
|
589
|
+
let id;
|
|
590
|
+
try {
|
|
591
|
+
const parsed = JSON.parse(data);
|
|
592
|
+
if (typeof parsed.id === "string" || typeof parsed.id === "number") id = parsed.id;
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
if (id !== void 0) return this.postAndAwait(id, data);
|
|
596
|
+
const result = await this.post(data);
|
|
597
|
+
if (!result.ok) {
|
|
598
|
+
return { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error", data: result.body } };
|
|
599
|
+
}
|
|
600
|
+
return { jsonrpc: "2.0", id: null, result: void 0 };
|
|
601
|
+
}
|
|
602
|
+
async close() {
|
|
603
|
+
this.closeStream("Transport closed");
|
|
604
|
+
this.state = "disconnected";
|
|
605
|
+
this.sessionId = void 0;
|
|
606
|
+
this.messageEndpoint = void 0;
|
|
607
|
+
this.log("SSE transport closed");
|
|
608
|
+
}
|
|
609
|
+
isConnected() {
|
|
610
|
+
return this.state === "connected";
|
|
611
|
+
}
|
|
612
|
+
getState() {
|
|
613
|
+
return this.state;
|
|
614
|
+
}
|
|
615
|
+
getSessionId() {
|
|
616
|
+
return this.sessionId;
|
|
617
|
+
}
|
|
618
|
+
setAuthToken(token) {
|
|
619
|
+
this.authToken = token;
|
|
620
|
+
}
|
|
621
|
+
setTimeout(ms) {
|
|
622
|
+
this.timeout = ms;
|
|
623
|
+
}
|
|
624
|
+
getMessageEndpoint() {
|
|
625
|
+
return this.messageEndpoint;
|
|
626
|
+
}
|
|
627
|
+
getConnectionCount() {
|
|
628
|
+
return this.connectionCount;
|
|
629
|
+
}
|
|
630
|
+
getReconnectCount() {
|
|
631
|
+
return this.reconnectCount;
|
|
632
|
+
}
|
|
633
|
+
getLastRequestHeaders() {
|
|
634
|
+
return { ...this.lastRequestHeaders };
|
|
635
|
+
}
|
|
636
|
+
setInterceptors(interceptors2) {
|
|
637
|
+
this.interceptors = interceptors2;
|
|
638
|
+
}
|
|
639
|
+
getInterceptors() {
|
|
640
|
+
return this.interceptors;
|
|
641
|
+
}
|
|
642
|
+
setElicitationHandler(handler) {
|
|
643
|
+
this.elicitationHandler = handler;
|
|
644
|
+
}
|
|
645
|
+
async simulateDisconnect() {
|
|
646
|
+
this.closeStream("Simulated disconnect");
|
|
647
|
+
this.state = "disconnected";
|
|
648
|
+
this.sessionId = void 0;
|
|
649
|
+
this.messageEndpoint = void 0;
|
|
650
|
+
}
|
|
651
|
+
async waitForReconnect(timeoutMs) {
|
|
652
|
+
this.reconnectCount++;
|
|
653
|
+
let timer;
|
|
654
|
+
try {
|
|
655
|
+
await Promise.race([
|
|
656
|
+
this.connect(),
|
|
657
|
+
new Promise((_, reject) => {
|
|
658
|
+
timer = setTimeout(() => reject(new Error("Timeout waiting for reconnection")), timeoutMs);
|
|
659
|
+
})
|
|
660
|
+
]);
|
|
661
|
+
} finally {
|
|
662
|
+
clearTimeout(timer);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
666
|
+
// STREAM
|
|
667
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
668
|
+
/** Open `GET <entry>/sse` and wait for the `endpoint` event. */
|
|
669
|
+
async openStream() {
|
|
670
|
+
const controller = new AbortController();
|
|
671
|
+
this.stream = controller;
|
|
672
|
+
const url = this.sseUrl();
|
|
673
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
674
|
+
this.log(`GET ${url}`);
|
|
675
|
+
const connectTimer = setTimeout(() => controller.abort(), this.timeout);
|
|
676
|
+
let response;
|
|
677
|
+
try {
|
|
678
|
+
response = await fetch(url, {
|
|
679
|
+
method: "GET",
|
|
680
|
+
headers: { ...headers, Accept: "text/event-stream" },
|
|
681
|
+
signal: controller.signal
|
|
682
|
+
});
|
|
683
|
+
} catch (error) {
|
|
684
|
+
clearTimeout(connectTimer);
|
|
685
|
+
throw new Error(
|
|
686
|
+
isAbortError(error) ? `SSE connection to ${url} timed out after ${this.timeout}ms` : `SSE connection to ${url} failed: ${errorMessage(error)}`,
|
|
687
|
+
{ cause: error }
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
691
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
692
|
+
clearTimeout(connectTimer);
|
|
693
|
+
const body = await response.text().catch(() => "");
|
|
694
|
+
throw new Error(
|
|
695
|
+
`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 } } })).`
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const endpoint = new Promise((resolve, reject) => {
|
|
699
|
+
const reader = response.body?.getReader();
|
|
700
|
+
if (!reader) {
|
|
701
|
+
reject(new Error(`SSE connection to ${url} has no body`));
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
void this.readStream(controller, reader, resolve, reject);
|
|
705
|
+
});
|
|
706
|
+
try {
|
|
707
|
+
const data = await endpoint;
|
|
708
|
+
const messageUrl = new URL(data, url);
|
|
709
|
+
this.messageEndpoint = messageUrl.toString();
|
|
710
|
+
this.sessionId = messageUrl.searchParams.get("sessionId") ?? void 0;
|
|
711
|
+
} finally {
|
|
712
|
+
clearTimeout(connectTimer);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async readStream(controller, reader, onEndpoint, onEndpointError) {
|
|
716
|
+
const decoder = new TextDecoder();
|
|
717
|
+
let buffer = "";
|
|
718
|
+
let endpointSeen = false;
|
|
719
|
+
try {
|
|
720
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
721
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
722
|
+
buffer = remainder;
|
|
723
|
+
for (const text of completeEvents) {
|
|
724
|
+
const event = parseSSEEvent(text);
|
|
725
|
+
if (!event) continue;
|
|
726
|
+
if (event.type === "endpoint") {
|
|
727
|
+
if (!endpointSeen) {
|
|
728
|
+
endpointSeen = true;
|
|
729
|
+
onEndpoint(event.data);
|
|
730
|
+
}
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
await this.handleMessage(event.data);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (!controller.signal.aborted) this.log("SSE stream failed:", error);
|
|
738
|
+
} finally {
|
|
739
|
+
reader.releaseLock();
|
|
740
|
+
}
|
|
741
|
+
if (!endpointSeen) {
|
|
742
|
+
onEndpointError(
|
|
743
|
+
new Error(
|
|
744
|
+
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`
|
|
745
|
+
)
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
if (this.stream === controller) {
|
|
749
|
+
this.stream = void 0;
|
|
750
|
+
this.rejectPending("SSE stream closed by the server");
|
|
751
|
+
if (this.state === "connected") this.state = "disconnected";
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
async handleMessage(data) {
|
|
755
|
+
let message;
|
|
756
|
+
try {
|
|
757
|
+
message = JSON.parse(data);
|
|
758
|
+
} catch {
|
|
759
|
+
this.log("Ignoring SSE event that is not JSON:", data);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (!message || typeof message !== "object") return;
|
|
763
|
+
if (typeof message.method === "string") {
|
|
764
|
+
const request = message;
|
|
765
|
+
if (request.id === void 0 || request.id === null) {
|
|
766
|
+
this.notificationHandler?.(request);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
await this.answerServerRequest(request);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
if (message.id !== void 0 && message.id !== null) {
|
|
773
|
+
const pending = this.pending.get(message.id);
|
|
774
|
+
if (pending) {
|
|
775
|
+
clearTimeout(pending.timer);
|
|
776
|
+
this.pending.delete(message.id);
|
|
777
|
+
pending.resolve(message);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/** Server→client requests: elicitation goes to the handler, `ping` is answered, the rest refused. */
|
|
782
|
+
async answerServerRequest(request) {
|
|
783
|
+
const id = request.id;
|
|
784
|
+
let reply;
|
|
785
|
+
if (request.method === "elicitation/create") {
|
|
786
|
+
reply = { jsonrpc: "2.0", id, result: await this.elicit(request.params) };
|
|
787
|
+
} else if (request.method === "ping") {
|
|
788
|
+
reply = { jsonrpc: "2.0", id, result: {} };
|
|
789
|
+
} else {
|
|
790
|
+
reply = { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${request.method}` } };
|
|
791
|
+
}
|
|
792
|
+
const result = await this.post(JSON.stringify(reply));
|
|
793
|
+
if (!result.ok) this.log(`HTTP ${result.status} answering ${request.method}: ${result.body}`);
|
|
794
|
+
}
|
|
795
|
+
async elicit(params) {
|
|
796
|
+
if (!this.elicitationHandler) return { action: "decline" };
|
|
797
|
+
try {
|
|
798
|
+
return await this.elicitationHandler(params);
|
|
799
|
+
} catch (error) {
|
|
800
|
+
this.log("Elicitation handler error:", error);
|
|
801
|
+
return { action: "cancel" };
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
closeStream(reason) {
|
|
805
|
+
const controller = this.stream;
|
|
806
|
+
this.stream = void 0;
|
|
807
|
+
controller?.abort();
|
|
808
|
+
this.rejectPending(reason);
|
|
809
|
+
}
|
|
810
|
+
rejectPending(reason) {
|
|
811
|
+
for (const [id, pending] of this.pending) {
|
|
812
|
+
clearTimeout(pending.timer);
|
|
813
|
+
pending.resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: reason } });
|
|
814
|
+
}
|
|
815
|
+
this.pending.clear();
|
|
816
|
+
}
|
|
817
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
818
|
+
// POSTING
|
|
819
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
820
|
+
/** POST a message and resolve with the response the stream carries for `id`. */
|
|
821
|
+
async postAndAwait(id, body) {
|
|
822
|
+
const answer = new Promise((resolve) => {
|
|
823
|
+
const timer = setTimeout(() => {
|
|
824
|
+
this.pending.delete(id);
|
|
825
|
+
resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: `Request timeout after ${this.timeout}ms` } });
|
|
826
|
+
}, this.timeout);
|
|
827
|
+
this.pending.set(id, { resolve, timer });
|
|
828
|
+
});
|
|
829
|
+
const result = await this.post(body);
|
|
830
|
+
if (!result.ok) {
|
|
831
|
+
const pending = this.pending.get(id);
|
|
832
|
+
if (pending) {
|
|
833
|
+
clearTimeout(pending.timer);
|
|
834
|
+
this.pending.delete(id);
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
jsonrpc: "2.0",
|
|
838
|
+
id,
|
|
839
|
+
error: { code: -32e3, message: `HTTP ${result.status}: ${result.statusText}`, data: result.body }
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
return answer;
|
|
843
|
+
}
|
|
844
|
+
async post(body) {
|
|
845
|
+
const endpoint = this.messageEndpoint;
|
|
846
|
+
if (!endpoint) throw new Error("SSE transport has no message endpoint. Call connect() first.");
|
|
847
|
+
const headers = this.buildHeaders();
|
|
848
|
+
this.lastRequestHeaders = headers;
|
|
849
|
+
this.log(`POST ${endpoint}`, body);
|
|
850
|
+
const controller = new AbortController();
|
|
851
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
852
|
+
try {
|
|
853
|
+
const response = await fetch(endpoint, { method: "POST", headers, body, signal: controller.signal });
|
|
854
|
+
const text = await response.text();
|
|
855
|
+
return { ok: response.ok, status: response.status, statusText: response.statusText, body: text };
|
|
856
|
+
} catch (error) {
|
|
857
|
+
if (isAbortError(error)) {
|
|
858
|
+
return { ok: false, status: 0, statusText: `timeout after ${this.timeout}ms`, body: "" };
|
|
859
|
+
}
|
|
860
|
+
throw error;
|
|
861
|
+
} finally {
|
|
862
|
+
clearTimeout(timer);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
866
|
+
// HELPERS
|
|
867
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
868
|
+
async requestAnonymousToken() {
|
|
869
|
+
const url = new URL(this.baseUrl);
|
|
870
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/oauth/token`;
|
|
871
|
+
url.search = "";
|
|
872
|
+
const tokenUrl = url.toString();
|
|
873
|
+
try {
|
|
874
|
+
const response = await fetch(tokenUrl, {
|
|
875
|
+
method: "POST",
|
|
876
|
+
headers: { "Content-Type": "application/json" },
|
|
877
|
+
body: JSON.stringify({ grant_type: "anonymous", client_id: crypto.randomUUID(), resource: this.baseUrl })
|
|
878
|
+
});
|
|
879
|
+
if (!response.ok) {
|
|
880
|
+
this.log(`No anonymous token (HTTP ${response.status})`);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const token = await response.json();
|
|
884
|
+
if (token.access_token) this.authToken = token.access_token;
|
|
885
|
+
} catch (error) {
|
|
886
|
+
this.log("Error requesting anonymous token:", error);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/** `<baseUrl><entryPath>/sse`, built from URL parts so query params on `baseUrl` survive. */
|
|
890
|
+
sseUrl() {
|
|
891
|
+
const url = new URL(this.baseUrl);
|
|
892
|
+
const entry = (this.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
893
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
894
|
+
url.pathname = entry ? `${basePath}/${entry}/sse` : `${basePath}/sse`;
|
|
895
|
+
return url.toString();
|
|
896
|
+
}
|
|
897
|
+
buildHeaders() {
|
|
898
|
+
const headers = {
|
|
899
|
+
"Content-Type": "application/json",
|
|
900
|
+
Accept: "application/json, text/event-stream"
|
|
901
|
+
};
|
|
902
|
+
if (this.clientInfo) headers["User-Agent"] = `${this.clientInfo.name}/${this.clientInfo.version}`;
|
|
903
|
+
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
904
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
905
|
+
Object.assign(headers, this.authHeaders);
|
|
906
|
+
return headers;
|
|
907
|
+
}
|
|
908
|
+
ensureConnected() {
|
|
909
|
+
if (this.state !== "connected") {
|
|
910
|
+
throw new Error("Transport not connected. Call connect() first.");
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
log(message, data) {
|
|
914
|
+
if (this.debug) {
|
|
915
|
+
console.log(`[SSE] ${message}`, data ?? "");
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
function splitSSEEvents(buffer) {
|
|
920
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
921
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
922
|
+
const completeEvents = normalized.split("\n\n");
|
|
923
|
+
const remainder = completeEvents.pop() ?? "";
|
|
924
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
925
|
+
}
|
|
926
|
+
function parseSSEEvent(text) {
|
|
927
|
+
let type = "message";
|
|
928
|
+
const data = [];
|
|
929
|
+
for (const line of text.split("\n")) {
|
|
930
|
+
if (line.startsWith(":")) continue;
|
|
931
|
+
const colon = line.indexOf(":");
|
|
932
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
933
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
934
|
+
if (field === "event") type = value;
|
|
935
|
+
else if (field === "data") data.push(value);
|
|
936
|
+
}
|
|
937
|
+
return data.length > 0 ? { type, data: data.join("\n") } : void 0;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// libs/testing/src/transport/streamable-http.transport.ts
|
|
941
|
+
var DEFAULT_TIMEOUT2 = 3e4;
|
|
476
942
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
477
943
|
var StreamableHttpTransport = class {
|
|
478
944
|
config;
|
|
@@ -494,7 +960,7 @@ var StreamableHttpTransport = class {
|
|
|
494
960
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
495
961
|
// Remove trailing slash
|
|
496
962
|
entryPath: config.entryPath,
|
|
497
|
-
timeout: config.timeout ??
|
|
963
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT2,
|
|
498
964
|
auth: config.auth ?? {},
|
|
499
965
|
publicMode: config.publicMode ?? false,
|
|
500
966
|
debug: config.debug ?? false,
|
|
@@ -579,7 +1045,7 @@ var StreamableHttpTransport = class {
|
|
|
579
1045
|
return mockResponse2;
|
|
580
1046
|
}
|
|
581
1047
|
case "error":
|
|
582
|
-
throw interceptResult.error;
|
|
1048
|
+
throw markInterceptedError(interceptResult.error);
|
|
583
1049
|
case "continue":
|
|
584
1050
|
message = interceptResult.request;
|
|
585
1051
|
break;
|
|
@@ -640,7 +1106,7 @@ var StreamableHttpTransport = class {
|
|
|
640
1106
|
return jsonResponse;
|
|
641
1107
|
} catch (error) {
|
|
642
1108
|
clearTimeout(timeoutId);
|
|
643
|
-
if (error
|
|
1109
|
+
if (isAbortError(error)) {
|
|
644
1110
|
return {
|
|
645
1111
|
jsonrpc: "2.0",
|
|
646
1112
|
id: message.id ?? null,
|
|
@@ -679,7 +1145,7 @@ var StreamableHttpTransport = class {
|
|
|
679
1145
|
}
|
|
680
1146
|
} catch (error) {
|
|
681
1147
|
clearTimeout(timeoutId);
|
|
682
|
-
if (error
|
|
1148
|
+
if (!isAbortError(error)) {
|
|
683
1149
|
throw error;
|
|
684
1150
|
}
|
|
685
1151
|
}
|
|
@@ -720,7 +1186,7 @@ var StreamableHttpTransport = class {
|
|
|
720
1186
|
error: {
|
|
721
1187
|
code: -32700,
|
|
722
1188
|
message: "Parse error",
|
|
723
|
-
data: error
|
|
1189
|
+
data: errorMessage(error)
|
|
724
1190
|
}
|
|
725
1191
|
};
|
|
726
1192
|
}
|
|
@@ -834,7 +1300,7 @@ var StreamableHttpTransport = class {
|
|
|
834
1300
|
let buffer = "";
|
|
835
1301
|
try {
|
|
836
1302
|
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
837
|
-
const { completeEvents, remainder } =
|
|
1303
|
+
const { completeEvents, remainder } = splitSSEEvents2(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
838
1304
|
buffer = remainder;
|
|
839
1305
|
for (const eventText of completeEvents) {
|
|
840
1306
|
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
@@ -1090,7 +1556,7 @@ var StreamableHttpTransport = class {
|
|
|
1090
1556
|
if (this.config.clientInfo) {
|
|
1091
1557
|
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1092
1558
|
}
|
|
1093
|
-
if (this.authToken
|
|
1559
|
+
if (this.authToken) {
|
|
1094
1560
|
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1095
1561
|
}
|
|
1096
1562
|
if (this.sessionId) {
|
|
@@ -1168,7 +1634,7 @@ var StreamableHttpTransport = class {
|
|
|
1168
1634
|
};
|
|
1169
1635
|
}
|
|
1170
1636
|
};
|
|
1171
|
-
function
|
|
1637
|
+
function splitSSEEvents2(buffer) {
|
|
1172
1638
|
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1173
1639
|
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1174
1640
|
const completeEvents = normalized.split("\n\n");
|
|
@@ -1406,7 +1872,7 @@ var McpTestClientBuilder = class {
|
|
|
1406
1872
|
};
|
|
1407
1873
|
|
|
1408
1874
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1409
|
-
var
|
|
1875
|
+
var DEFAULT_TIMEOUT3 = 3e4;
|
|
1410
1876
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1411
1877
|
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1412
1878
|
var DEFAULT_CLIENT_INFO = {
|
|
@@ -1414,6 +1880,15 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1414
1880
|
version: "0.4.0"
|
|
1415
1881
|
};
|
|
1416
1882
|
var MAX_LIST_PAGES = 1e3;
|
|
1883
|
+
function isAuthFailure(error) {
|
|
1884
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
1885
|
+
}
|
|
1886
|
+
function describeInitializeError(error) {
|
|
1887
|
+
const message = error?.message ?? "Unknown error";
|
|
1888
|
+
const data = error?.data;
|
|
1889
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
1890
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
1891
|
+
}
|
|
1417
1892
|
function extractEntryPaths(data) {
|
|
1418
1893
|
let payload = data;
|
|
1419
1894
|
if (typeof payload === "string") {
|
|
@@ -1467,7 +1942,7 @@ var McpTestClient = class {
|
|
|
1467
1942
|
transport: config.transport ?? "streamable-http",
|
|
1468
1943
|
auth: config.auth ?? {},
|
|
1469
1944
|
publicMode: config.publicMode ?? false,
|
|
1470
|
-
timeout: config.timeout ??
|
|
1945
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT3,
|
|
1471
1946
|
debug: config.debug ?? false,
|
|
1472
1947
|
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
1473
1948
|
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
@@ -1500,19 +1975,22 @@ var McpTestClient = class {
|
|
|
1500
1975
|
async connect() {
|
|
1501
1976
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1502
1977
|
this.triedDiscoveredEntryPath = false;
|
|
1978
|
+
this.initResult = null;
|
|
1503
1979
|
this.transport = this.createTransport();
|
|
1504
1980
|
await this.transport.connect();
|
|
1505
1981
|
const maxRetries = 3;
|
|
1506
1982
|
const retryDelayMs = 500;
|
|
1507
1983
|
let lastError;
|
|
1508
1984
|
let reportedEntryPaths;
|
|
1985
|
+
let attemptsMade = 0;
|
|
1509
1986
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1987
|
+
attemptsMade = attempt;
|
|
1510
1988
|
const initResponse = await this.initialize();
|
|
1511
1989
|
if (initResponse.success && initResponse.data) {
|
|
1512
1990
|
this.initResult = initResponse.data;
|
|
1513
1991
|
break;
|
|
1514
1992
|
}
|
|
1515
|
-
lastError = initResponse.error
|
|
1993
|
+
lastError = describeInitializeError(initResponse.error);
|
|
1516
1994
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1517
1995
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1518
1996
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -1524,6 +2002,7 @@ var McpTestClient = class {
|
|
|
1524
2002
|
await this.transport.connect();
|
|
1525
2003
|
continue;
|
|
1526
2004
|
}
|
|
2005
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
1527
2006
|
if (attempt < maxRetries) {
|
|
1528
2007
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1529
2008
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -1532,7 +2011,7 @@ var McpTestClient = class {
|
|
|
1532
2011
|
if (!this.initResult) {
|
|
1533
2012
|
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.` : "";
|
|
1534
2013
|
throw new Error(
|
|
1535
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
2014
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
1536
2015
|
);
|
|
1537
2016
|
}
|
|
1538
2017
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -1602,18 +2081,47 @@ var McpTestClient = class {
|
|
|
1602
2081
|
return this._authState;
|
|
1603
2082
|
}
|
|
1604
2083
|
/**
|
|
1605
|
-
* Authenticate with a token
|
|
2084
|
+
* Authenticate with a token.
|
|
2085
|
+
*
|
|
2086
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
2087
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
2088
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
2089
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
1606
2090
|
*/
|
|
1607
2091
|
async authenticate(token) {
|
|
2092
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
2093
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
2094
|
+
}
|
|
2095
|
+
const previousAuthState = this._authState;
|
|
2096
|
+
const previousConfigAuth = this.config.auth;
|
|
2097
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
1608
2098
|
this._authState = {
|
|
1609
2099
|
isAnonymous: false,
|
|
1610
2100
|
token,
|
|
1611
2101
|
scopes: this.parseScopesFromToken(token),
|
|
1612
2102
|
user: this.parseUserFromToken(token)
|
|
1613
2103
|
};
|
|
1614
|
-
|
|
1615
|
-
|
|
2104
|
+
const previousTransport = this.transport;
|
|
2105
|
+
if (!previousTransport) {
|
|
2106
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
2107
|
+
return;
|
|
1616
2108
|
}
|
|
2109
|
+
const previousInit = this.initResult;
|
|
2110
|
+
const previousSessionId = this._sessionId;
|
|
2111
|
+
const previousSessionInfo = this._sessionInfo;
|
|
2112
|
+
try {
|
|
2113
|
+
await this.connect();
|
|
2114
|
+
} catch (err) {
|
|
2115
|
+
await this.transport?.close().catch(() => void 0);
|
|
2116
|
+
this.transport = previousTransport;
|
|
2117
|
+
this.initResult = previousInit;
|
|
2118
|
+
this._sessionId = previousSessionId;
|
|
2119
|
+
this._sessionInfo = previousSessionInfo;
|
|
2120
|
+
this.config.auth = previousConfigAuth;
|
|
2121
|
+
this._authState = previousAuthState;
|
|
2122
|
+
throw err;
|
|
2123
|
+
}
|
|
2124
|
+
await previousTransport.close().catch(() => void 0);
|
|
1617
2125
|
this.log("debug", "Authentication updated");
|
|
1618
2126
|
}
|
|
1619
2127
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2200,7 +2708,18 @@ var McpTestClient = class {
|
|
|
2200
2708
|
notificationHandler: this.recordNotification
|
|
2201
2709
|
});
|
|
2202
2710
|
case "sse":
|
|
2203
|
-
|
|
2711
|
+
return new SseTransport({
|
|
2712
|
+
baseUrl,
|
|
2713
|
+
entryPath: this.config.entryPath,
|
|
2714
|
+
timeout: this.config.timeout,
|
|
2715
|
+
auth: this.config.auth,
|
|
2716
|
+
publicMode: this.config.publicMode,
|
|
2717
|
+
debug: this.config.debug,
|
|
2718
|
+
interceptors: this._interceptors,
|
|
2719
|
+
clientInfo: this.config.clientInfo,
|
|
2720
|
+
elicitationHandler: this._elicitationHandler,
|
|
2721
|
+
notificationHandler: this.recordNotification
|
|
2722
|
+
});
|
|
2204
2723
|
default:
|
|
2205
2724
|
throw new Error(`Unknown transport type: ${this.config.transport}`);
|
|
2206
2725
|
}
|
|
@@ -2237,10 +2756,11 @@ var McpTestClient = class {
|
|
|
2237
2756
|
requestId: id
|
|
2238
2757
|
};
|
|
2239
2758
|
} catch (err) {
|
|
2759
|
+
if (isInterceptedError(err)) throw err;
|
|
2240
2760
|
const durationMs = Date.now() - start;
|
|
2241
2761
|
const error = {
|
|
2242
2762
|
code: -32603,
|
|
2243
|
-
message: err
|
|
2763
|
+
message: errorMessage(err)
|
|
2244
2764
|
};
|
|
2245
2765
|
return {
|
|
2246
2766
|
success: false,
|
|
@@ -2388,12 +2908,12 @@ var McpTestClient = class {
|
|
|
2388
2908
|
const payload = this.decodeJwtPayload(token);
|
|
2389
2909
|
if (!payload) return [];
|
|
2390
2910
|
const scope = payload["scope"];
|
|
2391
|
-
const
|
|
2911
|
+
const scopes2 = payload["scopes"];
|
|
2392
2912
|
if (typeof scope === "string") {
|
|
2393
2913
|
return scope.split(" ");
|
|
2394
2914
|
}
|
|
2395
|
-
if (Array.isArray(
|
|
2396
|
-
return
|
|
2915
|
+
if (Array.isArray(scopes2)) {
|
|
2916
|
+
return scopes2;
|
|
2397
2917
|
}
|
|
2398
2918
|
return [];
|
|
2399
2919
|
} catch {
|
|
@@ -2468,7 +2988,7 @@ var ProgressCollector = class {
|
|
|
2468
2988
|
};
|
|
2469
2989
|
|
|
2470
2990
|
// libs/testing/src/server/test-server.ts
|
|
2471
|
-
import { spawn } from "child_process";
|
|
2991
|
+
import { execFile, spawn } from "child_process";
|
|
2472
2992
|
import { sha256Hex } from "@frontmcp/utils";
|
|
2473
2993
|
|
|
2474
2994
|
// libs/testing/src/errors/index.ts
|
|
@@ -2539,6 +3059,8 @@ var E2E_PORT_RANGES = {
|
|
|
2539
3059
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
2540
3060
|
// Protocol revision E2E tests (50460-50479)
|
|
2541
3061
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
3062
|
+
// Testing library E2E tests (50480-50499)
|
|
3063
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
2542
3064
|
// Mock servers and utilities (50900-50999)
|
|
2543
3065
|
"mock-oauth": { start: 50900, size: 10 },
|
|
2544
3066
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -2547,6 +3069,66 @@ var E2E_PORT_RANGES = {
|
|
|
2547
3069
|
default: { start: 51e3, size: 100 }
|
|
2548
3070
|
};
|
|
2549
3071
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3072
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
3073
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
3074
|
+
function lockDir() {
|
|
3075
|
+
try {
|
|
3076
|
+
const os = __require("node:os");
|
|
3077
|
+
const path = __require("node:path");
|
|
3078
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
3079
|
+
} catch {
|
|
3080
|
+
return void 0;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
function pidIsAlive(pid) {
|
|
3084
|
+
try {
|
|
3085
|
+
process.kill(pid, 0);
|
|
3086
|
+
return true;
|
|
3087
|
+
} catch (error) {
|
|
3088
|
+
return error.code === "EPERM";
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
function acquirePortLock(port) {
|
|
3092
|
+
if (lockedPorts.has(port)) return false;
|
|
3093
|
+
let fs;
|
|
3094
|
+
let file;
|
|
3095
|
+
try {
|
|
3096
|
+
fs = __require("node:fs");
|
|
3097
|
+
const dir = lockDir();
|
|
3098
|
+
if (!dir) return true;
|
|
3099
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3100
|
+
file = __require("node:path").join(dir, `${port}.lock`);
|
|
3101
|
+
} catch {
|
|
3102
|
+
return true;
|
|
3103
|
+
}
|
|
3104
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
3105
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3106
|
+
try {
|
|
3107
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
3108
|
+
lockedPorts.add(port);
|
|
3109
|
+
return true;
|
|
3110
|
+
} catch (error) {
|
|
3111
|
+
if (error.code !== "EEXIST") return true;
|
|
3112
|
+
try {
|
|
3113
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3114
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
3115
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
3116
|
+
fs.unlinkSync(file);
|
|
3117
|
+
} catch {
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
return false;
|
|
3122
|
+
}
|
|
3123
|
+
function releasePortLock(port) {
|
|
3124
|
+
if (!lockedPorts.delete(port)) return;
|
|
3125
|
+
try {
|
|
3126
|
+
const fs = __require("node:fs");
|
|
3127
|
+
const dir = lockDir();
|
|
3128
|
+
if (dir) fs.unlinkSync(__require("node:path").join(dir, `${port}.lock`));
|
|
3129
|
+
} catch {
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
2550
3132
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
2551
3133
|
function getPortRange(project) {
|
|
2552
3134
|
const key = project;
|
|
@@ -2557,15 +3139,22 @@ function getPortRange(project) {
|
|
|
2557
3139
|
}
|
|
2558
3140
|
async function reservePort(project, preferredPort) {
|
|
2559
3141
|
const range = getPortRange(project);
|
|
2560
|
-
|
|
3142
|
+
const handle = (port) => ({
|
|
3143
|
+
port,
|
|
3144
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
3145
|
+
releaseSocket: async () => {
|
|
3146
|
+
await releasePort(port);
|
|
3147
|
+
},
|
|
3148
|
+
release: async () => {
|
|
3149
|
+
await releasePort(port);
|
|
3150
|
+
releasePortLock(port);
|
|
3151
|
+
}
|
|
3152
|
+
});
|
|
3153
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
3154
|
+
if (wantsSpecificPort) {
|
|
2561
3155
|
const reservation = await tryReservePort(preferredPort, project);
|
|
2562
3156
|
if (reservation) {
|
|
2563
|
-
return
|
|
2564
|
-
port: preferredPort,
|
|
2565
|
-
release: async () => {
|
|
2566
|
-
await releasePort(preferredPort);
|
|
2567
|
-
}
|
|
2568
|
-
};
|
|
3157
|
+
return handle(preferredPort);
|
|
2569
3158
|
}
|
|
2570
3159
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
2571
3160
|
}
|
|
@@ -2574,30 +3163,19 @@ async function reservePort(project, preferredPort) {
|
|
|
2574
3163
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2575
3164
|
const port = range.start + index % range.size;
|
|
2576
3165
|
index = (index + 1) % range.size;
|
|
2577
|
-
if (reservedPorts.has(port)) {
|
|
3166
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
2578
3167
|
continue;
|
|
2579
3168
|
}
|
|
2580
3169
|
const reservation = await tryReservePort(port, project);
|
|
2581
3170
|
if (reservation) {
|
|
2582
3171
|
projectPortIndex.set(project, index);
|
|
2583
|
-
return
|
|
2584
|
-
port,
|
|
2585
|
-
release: async () => {
|
|
2586
|
-
await releasePort(port);
|
|
2587
|
-
}
|
|
2588
|
-
};
|
|
3172
|
+
return handle(port);
|
|
2589
3173
|
}
|
|
2590
3174
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
return {
|
|
2596
|
-
port: dynamicPort,
|
|
2597
|
-
release: async () => {
|
|
2598
|
-
await releasePort(dynamicPort);
|
|
2599
|
-
}
|
|
2600
|
-
};
|
|
3175
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
3176
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
3177
|
+
if (await tryReservePort(port, project)) {
|
|
3178
|
+
return handle(port);
|
|
2601
3179
|
}
|
|
2602
3180
|
}
|
|
2603
3181
|
throw new Error(
|
|
@@ -2605,9 +3183,13 @@ async function reservePort(project, preferredPort) {
|
|
|
2605
3183
|
);
|
|
2606
3184
|
}
|
|
2607
3185
|
async function tryReservePort(port, project) {
|
|
3186
|
+
if (!acquirePortLock(port)) {
|
|
3187
|
+
return false;
|
|
3188
|
+
}
|
|
2608
3189
|
return new Promise((resolve) => {
|
|
2609
3190
|
const server = createServer();
|
|
2610
3191
|
server.once("error", () => {
|
|
3192
|
+
releasePortLock(port);
|
|
2611
3193
|
resolve(false);
|
|
2612
3194
|
});
|
|
2613
3195
|
server.listen(port, () => {
|
|
@@ -2633,31 +3215,6 @@ async function releasePort(port) {
|
|
|
2633
3215
|
});
|
|
2634
3216
|
});
|
|
2635
3217
|
}
|
|
2636
|
-
async function findAvailablePortInRange(start, end) {
|
|
2637
|
-
for (let port = start; port < end; port++) {
|
|
2638
|
-
if (reservedPorts.has(port)) {
|
|
2639
|
-
continue;
|
|
2640
|
-
}
|
|
2641
|
-
const available = await isPortAvailable(port);
|
|
2642
|
-
if (available) {
|
|
2643
|
-
return port;
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
return null;
|
|
2647
|
-
}
|
|
2648
|
-
async function isPortAvailable(port) {
|
|
2649
|
-
return new Promise((resolve) => {
|
|
2650
|
-
const server = createServer();
|
|
2651
|
-
server.once("error", () => {
|
|
2652
|
-
resolve(false);
|
|
2653
|
-
});
|
|
2654
|
-
server.listen(port, () => {
|
|
2655
|
-
server.close(() => {
|
|
2656
|
-
resolve(true);
|
|
2657
|
-
});
|
|
2658
|
-
});
|
|
2659
|
-
});
|
|
2660
|
-
}
|
|
2661
3218
|
|
|
2662
3219
|
// libs/testing/src/server/test-server.ts
|
|
2663
3220
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -2666,8 +3223,11 @@ var TestServer = class _TestServer {
|
|
|
2666
3223
|
options;
|
|
2667
3224
|
_info;
|
|
2668
3225
|
logs = [];
|
|
3226
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
2669
3227
|
portRelease = null;
|
|
2670
|
-
|
|
3228
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
3229
|
+
portReleaseSocket = null;
|
|
3230
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
2671
3231
|
this.options = {
|
|
2672
3232
|
port,
|
|
2673
3233
|
project: options.project,
|
|
@@ -2679,6 +3239,7 @@ var TestServer = class _TestServer {
|
|
|
2679
3239
|
debug: options.debug ?? DEBUG_SERVER
|
|
2680
3240
|
};
|
|
2681
3241
|
this.portRelease = portRelease ?? null;
|
|
3242
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
2682
3243
|
this._info = {
|
|
2683
3244
|
baseUrl: `http://localhost:${port}`,
|
|
2684
3245
|
port
|
|
@@ -2691,8 +3252,8 @@ var TestServer = class _TestServer {
|
|
|
2691
3252
|
const project = options.project ?? "default";
|
|
2692
3253
|
const maxAttempts = 3;
|
|
2693
3254
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2694
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2695
|
-
const server = new _TestServer(options, port, release);
|
|
3255
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3256
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
2696
3257
|
try {
|
|
2697
3258
|
await server.startProcess();
|
|
2698
3259
|
return server;
|
|
@@ -2732,7 +3293,7 @@ var TestServer = class _TestServer {
|
|
|
2732
3293
|
}
|
|
2733
3294
|
const maxAttempts = 3;
|
|
2734
3295
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2735
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3296
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2736
3297
|
const serverOptions = {
|
|
2737
3298
|
...options,
|
|
2738
3299
|
port,
|
|
@@ -2740,7 +3301,7 @@ var TestServer = class _TestServer {
|
|
|
2740
3301
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2741
3302
|
cwd: options.cwd ?? process.cwd()
|
|
2742
3303
|
};
|
|
2743
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
3304
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
2744
3305
|
try {
|
|
2745
3306
|
await server.startProcess();
|
|
2746
3307
|
return server;
|
|
@@ -2798,10 +3359,17 @@ var TestServer = class _TestServer {
|
|
|
2798
3359
|
* Stop the test server
|
|
2799
3360
|
*/
|
|
2800
3361
|
async stop() {
|
|
2801
|
-
|
|
2802
|
-
await this.
|
|
2803
|
-
|
|
3362
|
+
try {
|
|
3363
|
+
await this.stopProcess();
|
|
3364
|
+
} finally {
|
|
3365
|
+
if (this.portRelease) {
|
|
3366
|
+
await this.portRelease();
|
|
3367
|
+
this.portRelease = null;
|
|
3368
|
+
}
|
|
3369
|
+
this.portReleaseSocket = null;
|
|
2804
3370
|
}
|
|
3371
|
+
}
|
|
3372
|
+
async stopProcess() {
|
|
2805
3373
|
if (this.process) {
|
|
2806
3374
|
this.log("Stopping server...");
|
|
2807
3375
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -2874,7 +3442,7 @@ var TestServer = class _TestServer {
|
|
|
2874
3442
|
* Restart the server
|
|
2875
3443
|
*/
|
|
2876
3444
|
async restart() {
|
|
2877
|
-
await this.
|
|
3445
|
+
await this.stopProcess();
|
|
2878
3446
|
await this.startProcess();
|
|
2879
3447
|
}
|
|
2880
3448
|
/**
|
|
@@ -2904,9 +3472,9 @@ var TestServer = class _TestServer {
|
|
|
2904
3472
|
PORT: String(this.options.port)
|
|
2905
3473
|
};
|
|
2906
3474
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
2907
|
-
if (this.
|
|
2908
|
-
await this.
|
|
2909
|
-
this.
|
|
3475
|
+
if (this.portReleaseSocket) {
|
|
3476
|
+
await this.portReleaseSocket();
|
|
3477
|
+
this.portReleaseSocket = null;
|
|
2910
3478
|
await sleep2(300);
|
|
2911
3479
|
}
|
|
2912
3480
|
this.process = spawn(this.options.command, [], {
|
|
@@ -2916,9 +3484,8 @@ var TestServer = class _TestServer {
|
|
|
2916
3484
|
detached: true,
|
|
2917
3485
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2918
3486
|
});
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
}
|
|
3487
|
+
this._info.pid = this.process.pid;
|
|
3488
|
+
this._info.shellPid = this.process.pid;
|
|
2922
3489
|
let processExited = false;
|
|
2923
3490
|
let exitCode = null;
|
|
2924
3491
|
let exitError = null;
|
|
@@ -3021,6 +3588,8 @@ ${allLogs}`);
|
|
|
3021
3588
|
});
|
|
3022
3589
|
if (response.ok || response.status === 404) {
|
|
3023
3590
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3591
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
3592
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3024
3593
|
return;
|
|
3025
3594
|
}
|
|
3026
3595
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3065,6 +3634,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3065
3634
|
function sleep2(ms) {
|
|
3066
3635
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3067
3636
|
}
|
|
3637
|
+
function findListeningPid(port) {
|
|
3638
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3639
|
+
return new Promise((resolve) => {
|
|
3640
|
+
execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
3641
|
+
if (error) {
|
|
3642
|
+
resolve(void 0);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
3646
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
3647
|
+
});
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3068
3650
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3069
3651
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3070
3652
|
return env;
|
|
@@ -3181,81 +3763,163 @@ function gatewayTokenBinding(serverUrl, entryPath) {
|
|
|
3181
3763
|
}
|
|
3182
3764
|
|
|
3183
3765
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3184
|
-
var
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3766
|
+
var jestApi;
|
|
3767
|
+
function jestGlobals() {
|
|
3768
|
+
if (jestApi) return jestApi;
|
|
3769
|
+
try {
|
|
3770
|
+
const api = __require("@jest/globals");
|
|
3771
|
+
jestApi = {
|
|
3772
|
+
describe: api["describe"],
|
|
3773
|
+
beforeAll: api["beforeAll"],
|
|
3774
|
+
beforeEach: api["beforeEach"],
|
|
3775
|
+
afterEach: api["afterEach"],
|
|
3776
|
+
afterAll: api["afterAll"],
|
|
3777
|
+
it: api["it"]
|
|
3778
|
+
};
|
|
3779
|
+
return jestApi;
|
|
3780
|
+
} catch (error) {
|
|
3781
|
+
throw new Error(
|
|
3782
|
+
`@frontmcp/testing fixtures ("test", "test.use", ...) can only be used inside a Jest test file: ${error instanceof Error ? error.message : String(error)}`,
|
|
3783
|
+
{ cause: error }
|
|
3784
|
+
);
|
|
3785
|
+
}
|
|
3197
3786
|
}
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3787
|
+
function newScope(parent) {
|
|
3788
|
+
return {
|
|
3789
|
+
skipped: parent?.skipped ?? false,
|
|
3790
|
+
reason: parent?.reason,
|
|
3791
|
+
config: {},
|
|
3792
|
+
envs: [],
|
|
3793
|
+
configured: false,
|
|
3794
|
+
cleanupRegistered: false,
|
|
3795
|
+
beforeEachHooks: [],
|
|
3796
|
+
afterEachHooks: []
|
|
3797
|
+
};
|
|
3798
|
+
}
|
|
3799
|
+
var scopes = [newScope()];
|
|
3800
|
+
function currentScope() {
|
|
3801
|
+
return scopes[scopes.length - 1];
|
|
3802
|
+
}
|
|
3803
|
+
var serverEntries = /* @__PURE__ */ new Map();
|
|
3804
|
+
function resolveConfig(chain) {
|
|
3805
|
+
const merged = {};
|
|
3806
|
+
for (const scope of chain) {
|
|
3807
|
+
Object.assign(merged, scope.config);
|
|
3808
|
+
for (const env of scope.envs) merged.env = { ...merged.env, ...env };
|
|
3809
|
+
}
|
|
3810
|
+
return merged;
|
|
3811
|
+
}
|
|
3812
|
+
function ownerScopeOf(chain) {
|
|
3813
|
+
for (let i = chain.length - 1; i > 0; i--) {
|
|
3814
|
+
if (chain[i].configured) return chain[i];
|
|
3815
|
+
}
|
|
3816
|
+
return chain[0];
|
|
3817
|
+
}
|
|
3818
|
+
function serverKey(config) {
|
|
3819
|
+
const env = Object.fromEntries(Object.entries(config.env ?? {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
3820
|
+
return JSON.stringify({
|
|
3821
|
+
server: config.server,
|
|
3822
|
+
baseUrl: config.baseUrl,
|
|
3823
|
+
project: config.project,
|
|
3824
|
+
port: config.port,
|
|
3825
|
+
entryPath: config.entryPath,
|
|
3826
|
+
startupTimeout: config.startupTimeout,
|
|
3827
|
+
logLevel: config.logLevel,
|
|
3828
|
+
auth: config.auth,
|
|
3829
|
+
env
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
function serverEnv(config) {
|
|
3833
|
+
const extra = {};
|
|
3834
|
+
if (config.auth?.mode) extra["FRONTMCP_TEST_AUTH_MODE"] = config.auth.mode;
|
|
3835
|
+
if (config.auth?.type) extra["FRONTMCP_TEST_AUTH_TYPE"] = config.auth.type;
|
|
3836
|
+
if (Object.keys(extra).length === 0 && !config.env) return void 0;
|
|
3837
|
+
return { ...extra, ...config.env };
|
|
3838
|
+
}
|
|
3839
|
+
async function startServerEntry(config, owner) {
|
|
3840
|
+
let server;
|
|
3841
|
+
let startedByUs = false;
|
|
3842
|
+
if (config.baseUrl && !config.server) {
|
|
3843
|
+
server = TestServer.connect(config.baseUrl);
|
|
3844
|
+
} else if (config.server) {
|
|
3845
|
+
const serverCommand = resolveServerCommand(config.server);
|
|
3846
|
+
const isDebug = config.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3847
|
+
if (isDebug) {
|
|
3848
|
+
console.log(`[TestFixture] Starting server: ${serverCommand}`);
|
|
3849
|
+
}
|
|
3850
|
+
try {
|
|
3851
|
+
server = await TestServer.start({
|
|
3852
|
+
project: config.project,
|
|
3853
|
+
port: config.port,
|
|
3854
|
+
command: serverCommand,
|
|
3855
|
+
env: serverEnv(config),
|
|
3856
|
+
startupTimeout: config.startupTimeout ?? 3e4,
|
|
3857
|
+
debug: isDebug
|
|
3858
|
+
});
|
|
3859
|
+
startedByUs = true;
|
|
3206
3860
|
if (isDebug) {
|
|
3207
|
-
console.log(`[TestFixture]
|
|
3861
|
+
console.log(`[TestFixture] Server started at ${server.info.baseUrl}`);
|
|
3208
3862
|
}
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
command: serverCommand,
|
|
3214
|
-
env: currentConfig.env,
|
|
3215
|
-
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
3216
|
-
debug: isDebug
|
|
3217
|
-
});
|
|
3218
|
-
serverStartedByUs = true;
|
|
3219
|
-
if (isDebug) {
|
|
3220
|
-
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
3221
|
-
}
|
|
3222
|
-
} catch (error) {
|
|
3223
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3224
|
-
throw new Error(
|
|
3225
|
-
`Failed to start test server.
|
|
3863
|
+
} catch (error) {
|
|
3864
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3865
|
+
throw new Error(
|
|
3866
|
+
`Failed to start test server.
|
|
3226
3867
|
|
|
3227
|
-
Server entry: ${
|
|
3228
|
-
Project: ${
|
|
3868
|
+
Server entry: ${config.server}
|
|
3869
|
+
Project: ${config.project ?? "default"}
|
|
3229
3870
|
Command: ${serverCommand}
|
|
3230
3871
|
|
|
3231
|
-
Error: ${errMsg}
|
|
3232
|
-
|
|
3233
|
-
}
|
|
3234
|
-
} else {
|
|
3235
|
-
throw new Error(
|
|
3236
|
-
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
3872
|
+
Error: ${errMsg}`,
|
|
3873
|
+
{ cause: error }
|
|
3237
3874
|
);
|
|
3238
3875
|
}
|
|
3876
|
+
} else {
|
|
3877
|
+
throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option');
|
|
3239
3878
|
}
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
}
|
|
3879
|
+
const sharedSecret = config.env?.["JWT_SECRET"];
|
|
3880
|
+
const binding = sharedSecret ? gatewayTokenBinding(config.baseUrl ?? server.info.baseUrl, config.entryPath) : void 0;
|
|
3881
|
+
const tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret, ...binding } : {});
|
|
3882
|
+
return { server, startedByUs, tokenFactory, owner };
|
|
3245
3883
|
}
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3884
|
+
function acquireServer(chain) {
|
|
3885
|
+
const config = resolveConfig(chain);
|
|
3886
|
+
const key = serverKey(config);
|
|
3887
|
+
let entry = serverEntries.get(key);
|
|
3888
|
+
if (!entry) {
|
|
3889
|
+
entry = startServerEntry(config, ownerScopeOf(chain));
|
|
3890
|
+
serverEntries.set(key, entry);
|
|
3891
|
+
entry.catch(() => serverEntries.delete(key));
|
|
3892
|
+
}
|
|
3893
|
+
return entry;
|
|
3894
|
+
}
|
|
3895
|
+
async function stopServers(owner) {
|
|
3896
|
+
for (const [key, pending] of Array.from(serverEntries)) {
|
|
3897
|
+
let entry;
|
|
3898
|
+
try {
|
|
3899
|
+
entry = await pending;
|
|
3900
|
+
} catch {
|
|
3901
|
+
serverEntries.delete(key);
|
|
3902
|
+
continue;
|
|
3903
|
+
}
|
|
3904
|
+
if (owner && entry.owner !== owner) continue;
|
|
3905
|
+
serverEntries.delete(key);
|
|
3906
|
+
if (entry.startedByUs) {
|
|
3907
|
+
await entry.server.stop();
|
|
3908
|
+
}
|
|
3253
3909
|
}
|
|
3910
|
+
}
|
|
3911
|
+
var fixtureContexts = /* @__PURE__ */ new WeakMap();
|
|
3912
|
+
async function buildFixtures(chain) {
|
|
3913
|
+
const config = resolveConfig(chain);
|
|
3914
|
+
const entry = await acquireServer(chain);
|
|
3915
|
+
const { server, tokenFactory } = entry;
|
|
3916
|
+
const publicMode = config.publicMode ?? config.auth?.mode === "public";
|
|
3917
|
+
const clientConfig = { ...config, publicMode };
|
|
3254
3918
|
const clientInstance = McpTestClient.create({
|
|
3255
|
-
baseUrl: resolveClientBaseUrl(
|
|
3256
|
-
entryPath:
|
|
3257
|
-
transport:
|
|
3258
|
-
publicMode
|
|
3919
|
+
baseUrl: resolveClientBaseUrl(server, clientConfig),
|
|
3920
|
+
entryPath: clientConfig.entryPath,
|
|
3921
|
+
transport: clientConfig.transport ?? "streamable-http",
|
|
3922
|
+
publicMode
|
|
3259
3923
|
}).build();
|
|
3260
3924
|
try {
|
|
3261
3925
|
await clientInstance.connect();
|
|
@@ -3269,17 +3933,24 @@ async function createTestFixtures() {
|
|
|
3269
3933
|
throw err;
|
|
3270
3934
|
}
|
|
3271
3935
|
}
|
|
3272
|
-
const
|
|
3273
|
-
const
|
|
3274
|
-
return {
|
|
3936
|
+
const context = { extraClients: [] };
|
|
3937
|
+
const fixtures = {
|
|
3275
3938
|
mcp: clientInstance,
|
|
3276
|
-
auth,
|
|
3277
|
-
server
|
|
3939
|
+
auth: createAuthFixture(tokenFactory),
|
|
3940
|
+
server: createServerFixture(server, clientConfig, clientInstance, context)
|
|
3278
3941
|
};
|
|
3942
|
+
fixtureContexts.set(fixtures, context);
|
|
3943
|
+
return fixtures;
|
|
3944
|
+
}
|
|
3945
|
+
async function createTestFixtures() {
|
|
3946
|
+
return buildFixtures(scopes.slice());
|
|
3947
|
+
}
|
|
3948
|
+
async function initializeSharedResources() {
|
|
3949
|
+
await acquireServer(scopes.slice());
|
|
3279
3950
|
}
|
|
3280
3951
|
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
3281
|
-
if (testFailed
|
|
3282
|
-
const logs =
|
|
3952
|
+
if (testFailed) {
|
|
3953
|
+
const logs = fixtures.server.getLogs();
|
|
3283
3954
|
if (logs.length > 0) {
|
|
3284
3955
|
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
3285
3956
|
const recentLogs = logs.slice(-50);
|
|
@@ -3290,20 +3961,21 @@ async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
|
3290
3961
|
console.error("[TestFixture] === End Server Logs ===\n");
|
|
3291
3962
|
}
|
|
3292
3963
|
}
|
|
3964
|
+
const extras = fixtureContexts.get(fixtures)?.extraClients ?? [];
|
|
3965
|
+
for (const client of extras) {
|
|
3966
|
+
if (client.isConnected()) {
|
|
3967
|
+
await client.disconnect().catch(() => void 0);
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3293
3970
|
if (fixtures.mcp.isConnected()) {
|
|
3294
3971
|
await fixtures.mcp.disconnect();
|
|
3295
3972
|
}
|
|
3296
3973
|
}
|
|
3297
3974
|
async function cleanupSharedResources() {
|
|
3298
|
-
|
|
3299
|
-
await serverInstance.stop();
|
|
3300
|
-
}
|
|
3301
|
-
serverInstance = null;
|
|
3302
|
-
tokenFactory = null;
|
|
3303
|
-
serverStartedByUs = false;
|
|
3975
|
+
await stopServers();
|
|
3304
3976
|
}
|
|
3305
|
-
function resolveClientBaseUrl(server) {
|
|
3306
|
-
return
|
|
3977
|
+
function resolveClientBaseUrl(server, config) {
|
|
3978
|
+
return config.baseUrl ?? server.info.baseUrl;
|
|
3307
3979
|
}
|
|
3308
3980
|
function createAuthFixture(factory) {
|
|
3309
3981
|
const users = {
|
|
@@ -3349,28 +4021,39 @@ function createAuthFixture(factory) {
|
|
|
3349
4021
|
getAudience: () => factory.getAudience()
|
|
3350
4022
|
};
|
|
3351
4023
|
}
|
|
3352
|
-
function createServerFixture(server) {
|
|
4024
|
+
function createServerFixture(server, config, mcp, context) {
|
|
3353
4025
|
return {
|
|
3354
|
-
|
|
4026
|
+
// Live view: `pid` is filled in once the server is listening and follows restarts
|
|
4027
|
+
get info() {
|
|
4028
|
+
return server.info;
|
|
4029
|
+
},
|
|
3355
4030
|
createClient: async (opts) => {
|
|
3356
|
-
|
|
3357
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3358
|
-
entryPath: opts?.entryPath ??
|
|
4031
|
+
const client = await McpTestClient.create({
|
|
4032
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
4033
|
+
entryPath: opts?.entryPath ?? config.entryPath,
|
|
3359
4034
|
transport: opts?.transport ?? "streamable-http",
|
|
3360
4035
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3361
4036
|
clientInfo: opts?.clientInfo,
|
|
3362
|
-
publicMode:
|
|
4037
|
+
publicMode: config.publicMode
|
|
3363
4038
|
}).buildAndConnect();
|
|
4039
|
+
context.extraClients.push(client);
|
|
4040
|
+
return client;
|
|
3364
4041
|
},
|
|
3365
4042
|
createClientBuilder: () => {
|
|
3366
4043
|
const builder = new McpTestClientBuilder({
|
|
3367
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3368
|
-
entryPath:
|
|
3369
|
-
publicMode:
|
|
4044
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
4045
|
+
entryPath: config.entryPath,
|
|
4046
|
+
publicMode: config.publicMode
|
|
3370
4047
|
});
|
|
3371
4048
|
return builder;
|
|
3372
4049
|
},
|
|
3373
|
-
restart: () =>
|
|
4050
|
+
restart: async () => {
|
|
4051
|
+
const reconnect = [mcp, ...context.extraClients].filter((client) => client.isConnected());
|
|
4052
|
+
await server.restart();
|
|
4053
|
+
for (const client of reconnect) {
|
|
4054
|
+
await client.reconnect();
|
|
4055
|
+
}
|
|
4056
|
+
},
|
|
3374
4057
|
getLogs: () => server.getLogs(),
|
|
3375
4058
|
clearLogs: () => server.clearLogs()
|
|
3376
4059
|
};
|
|
@@ -3381,38 +4064,71 @@ function resolveServerCommand(server) {
|
|
|
3381
4064
|
}
|
|
3382
4065
|
return `npx tsx ${server}`;
|
|
3383
4066
|
}
|
|
3384
|
-
function runWithFixtures(fn) {
|
|
3385
|
-
return async () => {
|
|
3386
|
-
const fixtures = await
|
|
3387
|
-
let
|
|
4067
|
+
function runWithFixtures(fn, chain) {
|
|
4068
|
+
return async (...extra) => {
|
|
4069
|
+
const fixtures = await buildFixtures(chain);
|
|
4070
|
+
let failure;
|
|
3388
4071
|
try {
|
|
3389
|
-
|
|
4072
|
+
for (const scope of chain) {
|
|
4073
|
+
for (const hook of scope.beforeEachHooks) await hook(fixtures);
|
|
4074
|
+
}
|
|
4075
|
+
await fn(fixtures, ...extra);
|
|
3390
4076
|
} catch (error) {
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
await
|
|
4077
|
+
failure = { error };
|
|
4078
|
+
}
|
|
4079
|
+
try {
|
|
4080
|
+
await runAfterEachHooks(chain, fixtures);
|
|
4081
|
+
} catch (error) {
|
|
4082
|
+
failure ??= { error };
|
|
3395
4083
|
}
|
|
4084
|
+
await cleanupTestFixtures(fixtures, failure !== void 0);
|
|
4085
|
+
if (failure) throw failure.error;
|
|
3396
4086
|
};
|
|
3397
4087
|
}
|
|
4088
|
+
async function runAfterEachHooks(chain, fixtures) {
|
|
4089
|
+
let firstError;
|
|
4090
|
+
let failed = false;
|
|
4091
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
4092
|
+
for (const hook of chain[i].afterEachHooks) {
|
|
4093
|
+
try {
|
|
4094
|
+
await hook(fixtures);
|
|
4095
|
+
} catch (error) {
|
|
4096
|
+
if (!failed) firstError = error;
|
|
4097
|
+
failed = true;
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
if (failed) throw firstError;
|
|
4102
|
+
}
|
|
4103
|
+
function skipTitle(name, scope) {
|
|
4104
|
+
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
4105
|
+
}
|
|
3398
4106
|
function testWithFixtures(name, fn) {
|
|
3399
|
-
const
|
|
4107
|
+
const chain = scopes.slice();
|
|
4108
|
+
const scope = currentScope();
|
|
3400
4109
|
if (scope.skipped) {
|
|
3401
|
-
it.skip(
|
|
4110
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3402
4111
|
return;
|
|
3403
4112
|
}
|
|
3404
|
-
it(name, runWithFixtures(fn));
|
|
4113
|
+
jestGlobals().it(name, runWithFixtures(fn, chain));
|
|
3405
4114
|
}
|
|
3406
4115
|
function use(config) {
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
4116
|
+
const scope = currentScope();
|
|
4117
|
+
const { env, ...rest } = config;
|
|
4118
|
+
scope.config = { ...scope.config, ...rest };
|
|
4119
|
+
if (env) scope.envs.push(env);
|
|
4120
|
+
scope.configured = true;
|
|
4121
|
+
if (!scope.cleanupRegistered) {
|
|
4122
|
+
scope.cleanupRegistered = true;
|
|
4123
|
+
jestGlobals().afterAll(async () => {
|
|
4124
|
+
await stopServers(scope === scopes[0] ? void 0 : scope);
|
|
4125
|
+
});
|
|
4126
|
+
}
|
|
3411
4127
|
}
|
|
3412
4128
|
function skip(nameOrCondition, fnOrReason) {
|
|
3413
4129
|
if (typeof nameOrCondition === "boolean") {
|
|
3414
4130
|
if (nameOrCondition) {
|
|
3415
|
-
const scope =
|
|
4131
|
+
const scope = currentScope();
|
|
3416
4132
|
scope.skipped = true;
|
|
3417
4133
|
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3418
4134
|
}
|
|
@@ -3423,53 +4139,93 @@ function skip(nameOrCondition, fnOrReason) {
|
|
|
3423
4139
|
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3424
4140
|
);
|
|
3425
4141
|
}
|
|
3426
|
-
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
4142
|
+
jestGlobals().it.skip(nameOrCondition, runWithFixtures(fnOrReason, scopes.slice()));
|
|
3427
4143
|
}
|
|
3428
4144
|
function only(name, fn) {
|
|
3429
|
-
const scope =
|
|
4145
|
+
const scope = currentScope();
|
|
3430
4146
|
if (scope.skipped) {
|
|
3431
|
-
it.skip(
|
|
4147
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, scopes.slice()));
|
|
3432
4148
|
return;
|
|
3433
4149
|
}
|
|
3434
|
-
it.only(name, runWithFixtures(fn));
|
|
4150
|
+
jestGlobals().it.only(name, runWithFixtures(fn, scopes.slice()));
|
|
3435
4151
|
}
|
|
3436
4152
|
function todo(name) {
|
|
3437
|
-
it.todo(name);
|
|
4153
|
+
jestGlobals().it.todo(name);
|
|
3438
4154
|
}
|
|
3439
|
-
function
|
|
4155
|
+
function each(table, ...tagged) {
|
|
4156
|
+
const chain = scopes.slice();
|
|
4157
|
+
const register = jestGlobals().it.each(table, ...tagged);
|
|
3440
4158
|
return (name, fn) => {
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
}
|
|
3449
|
-
});
|
|
4159
|
+
const scope = currentScope();
|
|
4160
|
+
if (scope.skipped) {
|
|
4161
|
+
const skipRegister = jestGlobals().it.skip.each(table, ...tagged);
|
|
4162
|
+
skipRegister(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4165
|
+
register(name, runWithFixtures(fn, chain));
|
|
3450
4166
|
};
|
|
3451
4167
|
}
|
|
3452
|
-
function
|
|
3453
|
-
return (
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
4168
|
+
function withScope(fn) {
|
|
4169
|
+
return (...args) => {
|
|
4170
|
+
scopes.push(newScope(currentScope()));
|
|
4171
|
+
try {
|
|
4172
|
+
fn(...args);
|
|
4173
|
+
} finally {
|
|
4174
|
+
scopes.pop();
|
|
4175
|
+
}
|
|
4176
|
+
};
|
|
4177
|
+
}
|
|
4178
|
+
function scopedRegister(pick) {
|
|
4179
|
+
return (name, fn, timeout) => {
|
|
4180
|
+
pick()(name, withScope(fn), timeout);
|
|
4181
|
+
};
|
|
4182
|
+
}
|
|
4183
|
+
function scopedEach(pick) {
|
|
4184
|
+
return (...eachArgs) => (name, fn, timeout) => {
|
|
4185
|
+
const register = pick()(...eachArgs);
|
|
4186
|
+
register(name, withScope(fn), timeout);
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
var describeWithScope = Object.assign(
|
|
4190
|
+
scopedRegister(() => jestGlobals().describe),
|
|
4191
|
+
{
|
|
4192
|
+
// `describe.skip` still evaluates its callback during collection, so it needs a scope too
|
|
4193
|
+
only: Object.assign(
|
|
4194
|
+
scopedRegister(() => jestGlobals().describe.only),
|
|
4195
|
+
{
|
|
4196
|
+
each: scopedEach(() => jestGlobals().describe.only.each)
|
|
4197
|
+
}
|
|
4198
|
+
),
|
|
4199
|
+
skip: Object.assign(
|
|
4200
|
+
scopedRegister(() => jestGlobals().describe.skip),
|
|
4201
|
+
{
|
|
4202
|
+
each: scopedEach(() => jestGlobals().describe.skip.each)
|
|
4203
|
+
}
|
|
4204
|
+
),
|
|
4205
|
+
each: scopedEach(() => jestGlobals().describe.each)
|
|
4206
|
+
}
|
|
4207
|
+
);
|
|
4208
|
+
function eachHook(kind) {
|
|
4209
|
+
return (fn, timeout) => {
|
|
4210
|
+
if (typeof fn === "function" && fn.length > 0) {
|
|
4211
|
+
const scope = currentScope();
|
|
4212
|
+
(kind === "beforeEach" ? scope.beforeEachHooks : scope.afterEachHooks).push(fn);
|
|
4213
|
+
return;
|
|
4214
|
+
}
|
|
4215
|
+
jestGlobals()[kind](fn, timeout);
|
|
4216
|
+
};
|
|
3457
4217
|
}
|
|
3458
|
-
var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
|
|
3459
|
-
only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
|
|
3460
|
-
skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
|
|
3461
|
-
each: eachWithSkipScope(describe.each)
|
|
3462
|
-
});
|
|
3463
4218
|
var test = testWithFixtures;
|
|
3464
4219
|
test.use = use;
|
|
3465
|
-
test.describe =
|
|
3466
|
-
test.beforeAll = beforeAll;
|
|
3467
|
-
test.beforeEach = beforeEach;
|
|
3468
|
-
test.afterEach = afterEach;
|
|
3469
|
-
test.afterAll = afterAll;
|
|
4220
|
+
test.describe = describeWithScope;
|
|
4221
|
+
test.beforeAll = ((...args) => jestGlobals().beforeAll(...args));
|
|
4222
|
+
test.beforeEach = eachHook("beforeEach");
|
|
4223
|
+
test.afterEach = eachHook("afterEach");
|
|
4224
|
+
test.afterAll = ((...args) => jestGlobals().afterAll(...args));
|
|
3470
4225
|
test.skip = skip;
|
|
3471
4226
|
test.only = only;
|
|
3472
4227
|
test.todo = todo;
|
|
4228
|
+
test.each = each;
|
|
3473
4229
|
export {
|
|
3474
4230
|
cleanupSharedResources,
|
|
3475
4231
|
cleanupTestFixtures,
|