@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/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/streamable-http.transport.ts
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 ?? DEFAULT_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 } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
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 splitSSEEvents(buffer) {
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 DEFAULT_TIMEOUT2 = 3e4;
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 ?? DEFAULT_TIMEOUT2,
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
- throw new Error("SSE transport not yet implemented");
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
- var scopes = [{ skipped: false, config: {}, configured: false, cleanupRegistered: false }];
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
- const { env, ...rest } = scope.config;
5235
- Object.assign(merged, rest);
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 testFailed = false;
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
- testFailed = true;
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
- scope.config = {
5515
- ...scope.config,
5516
- ...config,
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
- const parent = currentScope();
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 = ((...args) => jestGlobals().beforeEach(...args));
5619
- test.afterEach = ((...args) => jestGlobals().afterEach(...args));
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.8.7",
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.8.7",
91
- "@frontmcp/sdk": "1.8.7",
92
- "@frontmcp/ui": "1.8.7",
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.8.7",
116
- "@frontmcp/utils": "1.8.7",
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
  },