@frontmcp/testing 1.8.7 → 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/client/mcp-test-client.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +524 -31
- package/esm/index.mjs +525 -31
- package/esm/matchers/index.mjs +3 -0
- package/esm/package.json +8 -6
- package/esm/perf/index.mjs +466 -7
- package/esm/perf/perf-setup.mjs +3 -0
- package/esm/playwright/index.mjs +3 -0
- package/esm/setup.mjs +3 -0
- package/fixtures/fixture-types.d.ts +19 -6
- 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 +521 -31
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.d.ts +2 -1
- package/index.d.ts.map +1 -1
- package/index.js +523 -31
- package/package.json +8 -6
- package/perf/index.js +463 -7
- 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/index.js
CHANGED
|
@@ -60,6 +60,7 @@ __export(index_exports, {
|
|
|
60
60
|
RegressionDetector: () => RegressionDetector,
|
|
61
61
|
ReportGenerator: () => ReportGenerator,
|
|
62
62
|
ServerStartError: () => ServerStartError,
|
|
63
|
+
SseTransport: () => SseTransport,
|
|
63
64
|
StreamableHttpTransport: () => StreamableHttpTransport,
|
|
64
65
|
TestClientError: () => TestClientError,
|
|
65
66
|
TestServer: () => TestServer,
|
|
@@ -486,8 +487,453 @@ function isInterceptedError(err) {
|
|
|
486
487
|
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
487
488
|
}
|
|
488
489
|
|
|
489
|
-
// libs/testing/src/transport/
|
|
490
|
+
// libs/testing/src/transport/sse.transport.ts
|
|
490
491
|
var DEFAULT_TIMEOUT = 3e4;
|
|
492
|
+
var SseTransport = class {
|
|
493
|
+
baseUrl;
|
|
494
|
+
entryPath;
|
|
495
|
+
timeout;
|
|
496
|
+
authHeaders;
|
|
497
|
+
publicMode;
|
|
498
|
+
debug;
|
|
499
|
+
clientInfo;
|
|
500
|
+
notificationHandler;
|
|
501
|
+
interceptors;
|
|
502
|
+
elicitationHandler;
|
|
503
|
+
state = "disconnected";
|
|
504
|
+
authToken;
|
|
505
|
+
sessionId;
|
|
506
|
+
messageEndpoint;
|
|
507
|
+
stream;
|
|
508
|
+
pending = /* @__PURE__ */ new Map();
|
|
509
|
+
connectionCount = 0;
|
|
510
|
+
reconnectCount = 0;
|
|
511
|
+
lastRequestHeaders = {};
|
|
512
|
+
constructor(config) {
|
|
513
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
514
|
+
this.entryPath = config.entryPath;
|
|
515
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
516
|
+
this.authHeaders = config.auth?.headers ?? {};
|
|
517
|
+
this.authToken = config.auth?.token;
|
|
518
|
+
this.publicMode = config.publicMode ?? false;
|
|
519
|
+
this.debug = config.debug ?? false;
|
|
520
|
+
this.clientInfo = config.clientInfo;
|
|
521
|
+
this.notificationHandler = config.notificationHandler;
|
|
522
|
+
this.interceptors = config.interceptors;
|
|
523
|
+
this.elicitationHandler = config.elicitationHandler;
|
|
524
|
+
}
|
|
525
|
+
async connect() {
|
|
526
|
+
this.state = "connecting";
|
|
527
|
+
this.connectionCount++;
|
|
528
|
+
try {
|
|
529
|
+
if (!this.publicMode && !this.authToken) {
|
|
530
|
+
await this.requestAnonymousToken();
|
|
531
|
+
}
|
|
532
|
+
await this.openStream();
|
|
533
|
+
this.state = "connected";
|
|
534
|
+
this.log(`Connected; messages go to ${this.messageEndpoint}`);
|
|
535
|
+
} catch (error) {
|
|
536
|
+
this.state = "error";
|
|
537
|
+
this.stream?.abort();
|
|
538
|
+
this.stream = void 0;
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async request(message) {
|
|
543
|
+
this.ensureConnected();
|
|
544
|
+
const startTime = Date.now();
|
|
545
|
+
if (this.interceptors) {
|
|
546
|
+
const intercepted = await this.interceptors.processRequest(message, {
|
|
547
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
548
|
+
transport: "sse",
|
|
549
|
+
sessionId: this.sessionId
|
|
550
|
+
});
|
|
551
|
+
switch (intercepted.type) {
|
|
552
|
+
case "mock":
|
|
553
|
+
return await this.interceptors.processResponse(
|
|
554
|
+
message,
|
|
555
|
+
intercepted.response,
|
|
556
|
+
Date.now() - startTime
|
|
557
|
+
);
|
|
558
|
+
case "error":
|
|
559
|
+
throw markInterceptedError(intercepted.error);
|
|
560
|
+
case "continue":
|
|
561
|
+
message = intercepted.request;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
let response;
|
|
566
|
+
if (message.id === void 0) {
|
|
567
|
+
await this.post(JSON.stringify(message));
|
|
568
|
+
response = { jsonrpc: "2.0", id: null, result: void 0 };
|
|
569
|
+
} else {
|
|
570
|
+
response = await this.postAndAwait(message.id, JSON.stringify(message));
|
|
571
|
+
}
|
|
572
|
+
if (this.interceptors) {
|
|
573
|
+
response = await this.interceptors.processResponse(message, response, Date.now() - startTime);
|
|
574
|
+
}
|
|
575
|
+
return response;
|
|
576
|
+
}
|
|
577
|
+
async notify(message) {
|
|
578
|
+
this.ensureConnected();
|
|
579
|
+
const result = await this.post(JSON.stringify(message));
|
|
580
|
+
if (!result.ok) this.log(`HTTP ${result.status} on notification: ${result.body}`);
|
|
581
|
+
}
|
|
582
|
+
async sendRaw(data) {
|
|
583
|
+
this.ensureConnected();
|
|
584
|
+
let id;
|
|
585
|
+
try {
|
|
586
|
+
const parsed = JSON.parse(data);
|
|
587
|
+
if (typeof parsed.id === "string" || typeof parsed.id === "number") id = parsed.id;
|
|
588
|
+
} catch {
|
|
589
|
+
}
|
|
590
|
+
if (id !== void 0) return this.postAndAwait(id, data);
|
|
591
|
+
const result = await this.post(data);
|
|
592
|
+
if (!result.ok) {
|
|
593
|
+
return { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error", data: result.body } };
|
|
594
|
+
}
|
|
595
|
+
return { jsonrpc: "2.0", id: null, result: void 0 };
|
|
596
|
+
}
|
|
597
|
+
async close() {
|
|
598
|
+
this.closeStream("Transport closed");
|
|
599
|
+
this.state = "disconnected";
|
|
600
|
+
this.sessionId = void 0;
|
|
601
|
+
this.messageEndpoint = void 0;
|
|
602
|
+
this.log("SSE transport closed");
|
|
603
|
+
}
|
|
604
|
+
isConnected() {
|
|
605
|
+
return this.state === "connected";
|
|
606
|
+
}
|
|
607
|
+
getState() {
|
|
608
|
+
return this.state;
|
|
609
|
+
}
|
|
610
|
+
getSessionId() {
|
|
611
|
+
return this.sessionId;
|
|
612
|
+
}
|
|
613
|
+
setAuthToken(token) {
|
|
614
|
+
this.authToken = token;
|
|
615
|
+
}
|
|
616
|
+
setTimeout(ms) {
|
|
617
|
+
this.timeout = ms;
|
|
618
|
+
}
|
|
619
|
+
getMessageEndpoint() {
|
|
620
|
+
return this.messageEndpoint;
|
|
621
|
+
}
|
|
622
|
+
getConnectionCount() {
|
|
623
|
+
return this.connectionCount;
|
|
624
|
+
}
|
|
625
|
+
getReconnectCount() {
|
|
626
|
+
return this.reconnectCount;
|
|
627
|
+
}
|
|
628
|
+
getLastRequestHeaders() {
|
|
629
|
+
return { ...this.lastRequestHeaders };
|
|
630
|
+
}
|
|
631
|
+
setInterceptors(interceptors2) {
|
|
632
|
+
this.interceptors = interceptors2;
|
|
633
|
+
}
|
|
634
|
+
getInterceptors() {
|
|
635
|
+
return this.interceptors;
|
|
636
|
+
}
|
|
637
|
+
setElicitationHandler(handler) {
|
|
638
|
+
this.elicitationHandler = handler;
|
|
639
|
+
}
|
|
640
|
+
async simulateDisconnect() {
|
|
641
|
+
this.closeStream("Simulated disconnect");
|
|
642
|
+
this.state = "disconnected";
|
|
643
|
+
this.sessionId = void 0;
|
|
644
|
+
this.messageEndpoint = void 0;
|
|
645
|
+
}
|
|
646
|
+
async waitForReconnect(timeoutMs) {
|
|
647
|
+
this.reconnectCount++;
|
|
648
|
+
let timer;
|
|
649
|
+
try {
|
|
650
|
+
await Promise.race([
|
|
651
|
+
this.connect(),
|
|
652
|
+
new Promise((_, reject) => {
|
|
653
|
+
timer = setTimeout(() => reject(new Error("Timeout waiting for reconnection")), timeoutMs);
|
|
654
|
+
})
|
|
655
|
+
]);
|
|
656
|
+
} finally {
|
|
657
|
+
clearTimeout(timer);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
661
|
+
// STREAM
|
|
662
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
663
|
+
/** Open `GET <entry>/sse` and wait for the `endpoint` event. */
|
|
664
|
+
async openStream() {
|
|
665
|
+
const controller = new AbortController();
|
|
666
|
+
this.stream = controller;
|
|
667
|
+
const url = this.sseUrl();
|
|
668
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
669
|
+
this.log(`GET ${url}`);
|
|
670
|
+
const connectTimer = setTimeout(() => controller.abort(), this.timeout);
|
|
671
|
+
let response;
|
|
672
|
+
try {
|
|
673
|
+
response = await fetch(url, {
|
|
674
|
+
method: "GET",
|
|
675
|
+
headers: { ...headers, Accept: "text/event-stream" },
|
|
676
|
+
signal: controller.signal
|
|
677
|
+
});
|
|
678
|
+
} catch (error) {
|
|
679
|
+
clearTimeout(connectTimer);
|
|
680
|
+
throw new Error(
|
|
681
|
+
isAbortError(error) ? `SSE connection to ${url} timed out after ${this.timeout}ms` : `SSE connection to ${url} failed: ${errorMessage(error)}`,
|
|
682
|
+
{ cause: error }
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
686
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
687
|
+
clearTimeout(connectTimer);
|
|
688
|
+
const body = await response.text().catch(() => "");
|
|
689
|
+
throw new Error(
|
|
690
|
+
`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 } } })).`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
const endpoint = new Promise((resolve, reject) => {
|
|
694
|
+
const reader = response.body?.getReader();
|
|
695
|
+
if (!reader) {
|
|
696
|
+
reject(new Error(`SSE connection to ${url} has no body`));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
void this.readStream(controller, reader, resolve, reject);
|
|
700
|
+
});
|
|
701
|
+
try {
|
|
702
|
+
const data = await endpoint;
|
|
703
|
+
const messageUrl = new URL(data, url);
|
|
704
|
+
this.messageEndpoint = messageUrl.toString();
|
|
705
|
+
this.sessionId = messageUrl.searchParams.get("sessionId") ?? void 0;
|
|
706
|
+
} finally {
|
|
707
|
+
clearTimeout(connectTimer);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async readStream(controller, reader, onEndpoint, onEndpointError) {
|
|
711
|
+
const decoder = new TextDecoder();
|
|
712
|
+
let buffer = "";
|
|
713
|
+
let endpointSeen = false;
|
|
714
|
+
try {
|
|
715
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
716
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
717
|
+
buffer = remainder;
|
|
718
|
+
for (const text of completeEvents) {
|
|
719
|
+
const event = parseSSEEvent(text);
|
|
720
|
+
if (!event) continue;
|
|
721
|
+
if (event.type === "endpoint") {
|
|
722
|
+
if (!endpointSeen) {
|
|
723
|
+
endpointSeen = true;
|
|
724
|
+
onEndpoint(event.data);
|
|
725
|
+
}
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
await this.handleMessage(event.data);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
} catch (error) {
|
|
732
|
+
if (!controller.signal.aborted) this.log("SSE stream failed:", error);
|
|
733
|
+
} finally {
|
|
734
|
+
reader.releaseLock();
|
|
735
|
+
}
|
|
736
|
+
if (!endpointSeen) {
|
|
737
|
+
onEndpointError(
|
|
738
|
+
new Error(
|
|
739
|
+
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`
|
|
740
|
+
)
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
if (this.stream === controller) {
|
|
744
|
+
this.stream = void 0;
|
|
745
|
+
this.rejectPending("SSE stream closed by the server");
|
|
746
|
+
if (this.state === "connected") this.state = "disconnected";
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async handleMessage(data) {
|
|
750
|
+
let message;
|
|
751
|
+
try {
|
|
752
|
+
message = JSON.parse(data);
|
|
753
|
+
} catch {
|
|
754
|
+
this.log("Ignoring SSE event that is not JSON:", data);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (!message || typeof message !== "object") return;
|
|
758
|
+
if (typeof message.method === "string") {
|
|
759
|
+
const request = message;
|
|
760
|
+
if (request.id === void 0 || request.id === null) {
|
|
761
|
+
this.notificationHandler?.(request);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
await this.answerServerRequest(request);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (message.id !== void 0 && message.id !== null) {
|
|
768
|
+
const pending = this.pending.get(message.id);
|
|
769
|
+
if (pending) {
|
|
770
|
+
clearTimeout(pending.timer);
|
|
771
|
+
this.pending.delete(message.id);
|
|
772
|
+
pending.resolve(message);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/** Server→client requests: elicitation goes to the handler, `ping` is answered, the rest refused. */
|
|
777
|
+
async answerServerRequest(request) {
|
|
778
|
+
const id = request.id;
|
|
779
|
+
let reply;
|
|
780
|
+
if (request.method === "elicitation/create") {
|
|
781
|
+
reply = { jsonrpc: "2.0", id, result: await this.elicit(request.params) };
|
|
782
|
+
} else if (request.method === "ping") {
|
|
783
|
+
reply = { jsonrpc: "2.0", id, result: {} };
|
|
784
|
+
} else {
|
|
785
|
+
reply = { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${request.method}` } };
|
|
786
|
+
}
|
|
787
|
+
const result = await this.post(JSON.stringify(reply));
|
|
788
|
+
if (!result.ok) this.log(`HTTP ${result.status} answering ${request.method}: ${result.body}`);
|
|
789
|
+
}
|
|
790
|
+
async elicit(params) {
|
|
791
|
+
if (!this.elicitationHandler) return { action: "decline" };
|
|
792
|
+
try {
|
|
793
|
+
return await this.elicitationHandler(params);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
this.log("Elicitation handler error:", error);
|
|
796
|
+
return { action: "cancel" };
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
closeStream(reason) {
|
|
800
|
+
const controller = this.stream;
|
|
801
|
+
this.stream = void 0;
|
|
802
|
+
controller?.abort();
|
|
803
|
+
this.rejectPending(reason);
|
|
804
|
+
}
|
|
805
|
+
rejectPending(reason) {
|
|
806
|
+
for (const [id, pending] of this.pending) {
|
|
807
|
+
clearTimeout(pending.timer);
|
|
808
|
+
pending.resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: reason } });
|
|
809
|
+
}
|
|
810
|
+
this.pending.clear();
|
|
811
|
+
}
|
|
812
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
813
|
+
// POSTING
|
|
814
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
815
|
+
/** POST a message and resolve with the response the stream carries for `id`. */
|
|
816
|
+
async postAndAwait(id, body) {
|
|
817
|
+
const answer = new Promise((resolve) => {
|
|
818
|
+
const timer = setTimeout(() => {
|
|
819
|
+
this.pending.delete(id);
|
|
820
|
+
resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: `Request timeout after ${this.timeout}ms` } });
|
|
821
|
+
}, this.timeout);
|
|
822
|
+
this.pending.set(id, { resolve, timer });
|
|
823
|
+
});
|
|
824
|
+
const result = await this.post(body);
|
|
825
|
+
if (!result.ok) {
|
|
826
|
+
const pending = this.pending.get(id);
|
|
827
|
+
if (pending) {
|
|
828
|
+
clearTimeout(pending.timer);
|
|
829
|
+
this.pending.delete(id);
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
jsonrpc: "2.0",
|
|
833
|
+
id,
|
|
834
|
+
error: { code: -32e3, message: `HTTP ${result.status}: ${result.statusText}`, data: result.body }
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
return answer;
|
|
838
|
+
}
|
|
839
|
+
async post(body) {
|
|
840
|
+
const endpoint = this.messageEndpoint;
|
|
841
|
+
if (!endpoint) throw new Error("SSE transport has no message endpoint. Call connect() first.");
|
|
842
|
+
const headers = this.buildHeaders();
|
|
843
|
+
this.lastRequestHeaders = headers;
|
|
844
|
+
this.log(`POST ${endpoint}`, body);
|
|
845
|
+
const controller = new AbortController();
|
|
846
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
847
|
+
try {
|
|
848
|
+
const response = await fetch(endpoint, { method: "POST", headers, body, signal: controller.signal });
|
|
849
|
+
const text = await response.text();
|
|
850
|
+
return { ok: response.ok, status: response.status, statusText: response.statusText, body: text };
|
|
851
|
+
} catch (error) {
|
|
852
|
+
if (isAbortError(error)) {
|
|
853
|
+
return { ok: false, status: 0, statusText: `timeout after ${this.timeout}ms`, body: "" };
|
|
854
|
+
}
|
|
855
|
+
throw error;
|
|
856
|
+
} finally {
|
|
857
|
+
clearTimeout(timer);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
861
|
+
// HELPERS
|
|
862
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
863
|
+
async requestAnonymousToken() {
|
|
864
|
+
const url = new URL(this.baseUrl);
|
|
865
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/oauth/token`;
|
|
866
|
+
url.search = "";
|
|
867
|
+
const tokenUrl = url.toString();
|
|
868
|
+
try {
|
|
869
|
+
const response = await fetch(tokenUrl, {
|
|
870
|
+
method: "POST",
|
|
871
|
+
headers: { "Content-Type": "application/json" },
|
|
872
|
+
body: JSON.stringify({ grant_type: "anonymous", client_id: crypto.randomUUID(), resource: this.baseUrl })
|
|
873
|
+
});
|
|
874
|
+
if (!response.ok) {
|
|
875
|
+
this.log(`No anonymous token (HTTP ${response.status})`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const token = await response.json();
|
|
879
|
+
if (token.access_token) this.authToken = token.access_token;
|
|
880
|
+
} catch (error) {
|
|
881
|
+
this.log("Error requesting anonymous token:", error);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** `<baseUrl><entryPath>/sse`, built from URL parts so query params on `baseUrl` survive. */
|
|
885
|
+
sseUrl() {
|
|
886
|
+
const url = new URL(this.baseUrl);
|
|
887
|
+
const entry = (this.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
888
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
889
|
+
url.pathname = entry ? `${basePath}/${entry}/sse` : `${basePath}/sse`;
|
|
890
|
+
return url.toString();
|
|
891
|
+
}
|
|
892
|
+
buildHeaders() {
|
|
893
|
+
const headers = {
|
|
894
|
+
"Content-Type": "application/json",
|
|
895
|
+
Accept: "application/json, text/event-stream"
|
|
896
|
+
};
|
|
897
|
+
if (this.clientInfo) headers["User-Agent"] = `${this.clientInfo.name}/${this.clientInfo.version}`;
|
|
898
|
+
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
899
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
900
|
+
Object.assign(headers, this.authHeaders);
|
|
901
|
+
return headers;
|
|
902
|
+
}
|
|
903
|
+
ensureConnected() {
|
|
904
|
+
if (this.state !== "connected") {
|
|
905
|
+
throw new Error("Transport not connected. Call connect() first.");
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
log(message, data) {
|
|
909
|
+
if (this.debug) {
|
|
910
|
+
console.log(`[SSE] ${message}`, data ?? "");
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
function splitSSEEvents(buffer) {
|
|
915
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
916
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
917
|
+
const completeEvents = normalized.split("\n\n");
|
|
918
|
+
const remainder = completeEvents.pop() ?? "";
|
|
919
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
920
|
+
}
|
|
921
|
+
function parseSSEEvent(text) {
|
|
922
|
+
let type = "message";
|
|
923
|
+
const data = [];
|
|
924
|
+
for (const line of text.split("\n")) {
|
|
925
|
+
if (line.startsWith(":")) continue;
|
|
926
|
+
const colon = line.indexOf(":");
|
|
927
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
928
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
929
|
+
if (field === "event") type = value;
|
|
930
|
+
else if (field === "data") data.push(value);
|
|
931
|
+
}
|
|
932
|
+
return data.length > 0 ? { type, data: data.join("\n") } : void 0;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// libs/testing/src/transport/streamable-http.transport.ts
|
|
936
|
+
var DEFAULT_TIMEOUT2 = 3e4;
|
|
491
937
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
492
938
|
var StreamableHttpTransport = class {
|
|
493
939
|
config;
|
|
@@ -509,7 +955,7 @@ var StreamableHttpTransport = class {
|
|
|
509
955
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
510
956
|
// Remove trailing slash
|
|
511
957
|
entryPath: config.entryPath,
|
|
512
|
-
timeout: config.timeout ??
|
|
958
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT2,
|
|
513
959
|
auth: config.auth ?? {},
|
|
514
960
|
publicMode: config.publicMode ?? false,
|
|
515
961
|
debug: config.debug ?? false,
|
|
@@ -849,7 +1295,7 @@ var StreamableHttpTransport = class {
|
|
|
849
1295
|
let buffer = "";
|
|
850
1296
|
try {
|
|
851
1297
|
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
852
|
-
const { completeEvents, remainder } =
|
|
1298
|
+
const { completeEvents, remainder } = splitSSEEvents2(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
853
1299
|
buffer = remainder;
|
|
854
1300
|
for (const eventText of completeEvents) {
|
|
855
1301
|
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
@@ -1183,7 +1629,7 @@ var StreamableHttpTransport = class {
|
|
|
1183
1629
|
};
|
|
1184
1630
|
}
|
|
1185
1631
|
};
|
|
1186
|
-
function
|
|
1632
|
+
function splitSSEEvents2(buffer) {
|
|
1187
1633
|
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1188
1634
|
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1189
1635
|
const completeEvents = normalized.split("\n\n");
|
|
@@ -1440,7 +1886,7 @@ var McpTestClientBuilder = class {
|
|
|
1440
1886
|
};
|
|
1441
1887
|
|
|
1442
1888
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1443
|
-
var
|
|
1889
|
+
var DEFAULT_TIMEOUT3 = 3e4;
|
|
1444
1890
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1445
1891
|
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1446
1892
|
var DEFAULT_CLIENT_INFO = {
|
|
@@ -1510,7 +1956,7 @@ var McpTestClient = class {
|
|
|
1510
1956
|
transport: config.transport ?? "streamable-http",
|
|
1511
1957
|
auth: config.auth ?? {},
|
|
1512
1958
|
publicMode: config.publicMode ?? false,
|
|
1513
|
-
timeout: config.timeout ??
|
|
1959
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT3,
|
|
1514
1960
|
debug: config.debug ?? false,
|
|
1515
1961
|
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
1516
1962
|
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
@@ -2276,7 +2722,18 @@ var McpTestClient = class {
|
|
|
2276
2722
|
notificationHandler: this.recordNotification
|
|
2277
2723
|
});
|
|
2278
2724
|
case "sse":
|
|
2279
|
-
|
|
2725
|
+
return new SseTransport({
|
|
2726
|
+
baseUrl,
|
|
2727
|
+
entryPath: this.config.entryPath,
|
|
2728
|
+
timeout: this.config.timeout,
|
|
2729
|
+
auth: this.config.auth,
|
|
2730
|
+
publicMode: this.config.publicMode,
|
|
2731
|
+
debug: this.config.debug,
|
|
2732
|
+
interceptors: this._interceptors,
|
|
2733
|
+
clientInfo: this.config.clientInfo,
|
|
2734
|
+
elicitationHandler: this._elicitationHandler,
|
|
2735
|
+
notificationHandler: this.recordNotification
|
|
2736
|
+
});
|
|
2280
2737
|
default:
|
|
2281
2738
|
throw new Error(`Unknown transport type: ${this.config.transport}`);
|
|
2282
2739
|
}
|
|
@@ -5223,7 +5680,19 @@ function jestGlobals() {
|
|
|
5223
5680
|
);
|
|
5224
5681
|
}
|
|
5225
5682
|
}
|
|
5226
|
-
|
|
5683
|
+
function newScope(parent) {
|
|
5684
|
+
return {
|
|
5685
|
+
skipped: parent?.skipped ?? false,
|
|
5686
|
+
reason: parent?.reason,
|
|
5687
|
+
config: {},
|
|
5688
|
+
envs: [],
|
|
5689
|
+
configured: false,
|
|
5690
|
+
cleanupRegistered: false,
|
|
5691
|
+
beforeEachHooks: [],
|
|
5692
|
+
afterEachHooks: []
|
|
5693
|
+
};
|
|
5694
|
+
}
|
|
5695
|
+
var scopes = [newScope()];
|
|
5227
5696
|
function currentScope() {
|
|
5228
5697
|
return scopes[scopes.length - 1];
|
|
5229
5698
|
}
|
|
@@ -5231,9 +5700,8 @@ var serverEntries = /* @__PURE__ */ new Map();
|
|
|
5231
5700
|
function resolveConfig(chain) {
|
|
5232
5701
|
const merged = {};
|
|
5233
5702
|
for (const scope of chain) {
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
if (env) merged.env = { ...merged.env, ...env };
|
|
5703
|
+
Object.assign(merged, scope.config);
|
|
5704
|
+
for (const env of scope.envs) merged.env = { ...merged.env, ...env };
|
|
5237
5705
|
}
|
|
5238
5706
|
return merged;
|
|
5239
5707
|
}
|
|
@@ -5486,17 +5954,39 @@ function resolveServerCommand(server) {
|
|
|
5486
5954
|
function runWithFixtures(fn, chain) {
|
|
5487
5955
|
return async (...extra) => {
|
|
5488
5956
|
const fixtures = await buildFixtures(chain);
|
|
5489
|
-
let
|
|
5957
|
+
let failure;
|
|
5490
5958
|
try {
|
|
5959
|
+
for (const scope of chain) {
|
|
5960
|
+
for (const hook of scope.beforeEachHooks) await hook(fixtures);
|
|
5961
|
+
}
|
|
5491
5962
|
await fn(fixtures, ...extra);
|
|
5492
5963
|
} catch (error) {
|
|
5493
|
-
|
|
5494
|
-
throw error;
|
|
5495
|
-
} finally {
|
|
5496
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
5964
|
+
failure = { error };
|
|
5497
5965
|
}
|
|
5966
|
+
try {
|
|
5967
|
+
await runAfterEachHooks(chain, fixtures);
|
|
5968
|
+
} catch (error) {
|
|
5969
|
+
failure ??= { error };
|
|
5970
|
+
}
|
|
5971
|
+
await cleanupTestFixtures(fixtures, failure !== void 0);
|
|
5972
|
+
if (failure) throw failure.error;
|
|
5498
5973
|
};
|
|
5499
5974
|
}
|
|
5975
|
+
async function runAfterEachHooks(chain, fixtures) {
|
|
5976
|
+
let firstError;
|
|
5977
|
+
let failed = false;
|
|
5978
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
5979
|
+
for (const hook of chain[i].afterEachHooks) {
|
|
5980
|
+
try {
|
|
5981
|
+
await hook(fixtures);
|
|
5982
|
+
} catch (error) {
|
|
5983
|
+
if (!failed) firstError = error;
|
|
5984
|
+
failed = true;
|
|
5985
|
+
}
|
|
5986
|
+
}
|
|
5987
|
+
}
|
|
5988
|
+
if (failed) throw firstError;
|
|
5989
|
+
}
|
|
5500
5990
|
function skipTitle(name, scope) {
|
|
5501
5991
|
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
5502
5992
|
}
|
|
@@ -5511,11 +6001,9 @@ function testWithFixtures(name, fn) {
|
|
|
5511
6001
|
}
|
|
5512
6002
|
function use(config) {
|
|
5513
6003
|
const scope = currentScope();
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
env: config.env ? { ...scope.config.env, ...config.env } : scope.config.env
|
|
5518
|
-
};
|
|
6004
|
+
const { env, ...rest } = config;
|
|
6005
|
+
scope.config = { ...scope.config, ...rest };
|
|
6006
|
+
if (env) scope.envs.push(env);
|
|
5519
6007
|
scope.configured = true;
|
|
5520
6008
|
if (!scope.cleanupRegistered) {
|
|
5521
6009
|
scope.cleanupRegistered = true;
|
|
@@ -5566,14 +6054,7 @@ function each(table, ...tagged) {
|
|
|
5566
6054
|
}
|
|
5567
6055
|
function withScope(fn) {
|
|
5568
6056
|
return (...args) => {
|
|
5569
|
-
|
|
5570
|
-
scopes.push({
|
|
5571
|
-
skipped: parent.skipped,
|
|
5572
|
-
reason: parent.reason,
|
|
5573
|
-
config: {},
|
|
5574
|
-
configured: false,
|
|
5575
|
-
cleanupRegistered: false
|
|
5576
|
-
});
|
|
6057
|
+
scopes.push(newScope(currentScope()));
|
|
5577
6058
|
try {
|
|
5578
6059
|
fn(...args);
|
|
5579
6060
|
} finally {
|
|
@@ -5611,12 +6092,22 @@ var describeWithScope = Object.assign(
|
|
|
5611
6092
|
each: scopedEach(() => jestGlobals().describe.each)
|
|
5612
6093
|
}
|
|
5613
6094
|
);
|
|
6095
|
+
function eachHook(kind) {
|
|
6096
|
+
return (fn, timeout) => {
|
|
6097
|
+
if (typeof fn === "function" && fn.length > 0) {
|
|
6098
|
+
const scope = currentScope();
|
|
6099
|
+
(kind === "beforeEach" ? scope.beforeEachHooks : scope.afterEachHooks).push(fn);
|
|
6100
|
+
return;
|
|
6101
|
+
}
|
|
6102
|
+
jestGlobals()[kind](fn, timeout);
|
|
6103
|
+
};
|
|
6104
|
+
}
|
|
5614
6105
|
var test = testWithFixtures;
|
|
5615
6106
|
test.use = use;
|
|
5616
6107
|
test.describe = describeWithScope;
|
|
5617
6108
|
test.beforeAll = ((...args) => jestGlobals().beforeAll(...args));
|
|
5618
|
-
test.beforeEach = (
|
|
5619
|
-
test.afterEach = (
|
|
6109
|
+
test.beforeEach = eachHook("beforeEach");
|
|
6110
|
+
test.afterEach = eachHook("afterEach");
|
|
5620
6111
|
test.afterAll = ((...args) => jestGlobals().afterAll(...args));
|
|
5621
6112
|
test.skip = skip;
|
|
5622
6113
|
test.only = only;
|
|
@@ -8746,6 +9237,7 @@ function isTcpPortListening(port, host = "127.0.0.1", timeoutMs = 1500) {
|
|
|
8746
9237
|
RegressionDetector,
|
|
8747
9238
|
ReportGenerator,
|
|
8748
9239
|
ServerStartError,
|
|
9240
|
+
SseTransport,
|
|
8749
9241
|
StreamableHttpTransport,
|
|
8750
9242
|
TestClientError,
|
|
8751
9243
|
TestServer,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontmcp/testing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "E2E testing framework for FrontMCP servers - MCP client, auth mocks, Playwright integration",
|
|
5
5
|
"author": "AgentFront <info@agentfront.dev>",
|
|
6
6
|
"homepage": "https://docs.agentfront.dev",
|
|
@@ -87,9 +87,9 @@
|
|
|
87
87
|
"./esm": null
|
|
88
88
|
},
|
|
89
89
|
"peerDependencies": {
|
|
90
|
-
"@frontmcp/lazy-zod": "1.
|
|
91
|
-
"@frontmcp/sdk": "1.
|
|
92
|
-
"@frontmcp/ui": "1.
|
|
90
|
+
"@frontmcp/lazy-zod": "1.9.0",
|
|
91
|
+
"@frontmcp/sdk": "1.9.0",
|
|
92
|
+
"@frontmcp/ui": "1.9.0",
|
|
93
93
|
"@jest/globals": "^29.0.0 || ^30.0.0",
|
|
94
94
|
"@playwright/test": "^1.40.0",
|
|
95
95
|
"jest": "^29.0.0 || ^30.0.0"
|
|
@@ -112,8 +112,10 @@
|
|
|
112
112
|
"node": ">=24.0.0"
|
|
113
113
|
},
|
|
114
114
|
"dependencies": {
|
|
115
|
-
"@frontmcp/protocol": "1.
|
|
116
|
-
"@frontmcp/utils": "1.
|
|
115
|
+
"@frontmcp/protocol": "1.9.0",
|
|
116
|
+
"@frontmcp/utils": "1.9.0",
|
|
117
|
+
"@swc/core": "~1.15.41",
|
|
118
|
+
"@swc/jest": "~0.2.38",
|
|
117
119
|
"jose": "^6.0.11",
|
|
118
120
|
"tslib": "^2.3.0"
|
|
119
121
|
},
|