@frontmcp/testing 1.8.6 → 1.8.7

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.
Files changed (39) hide show
  1. package/auth/mock-oauth-server.d.ts.map +1 -1
  2. package/auth/token-factory.d.ts +2 -2
  3. package/auth/token-factory.d.ts.map +1 -1
  4. package/client/mcp-test-client.d.ts +6 -1
  5. package/client/mcp-test-client.d.ts.map +1 -1
  6. package/esm/fixtures/index.mjs +487 -224
  7. package/esm/index.mjs +644 -347
  8. package/esm/package.json +6 -6
  9. package/esm/perf/index.mjs +407 -272
  10. package/expect.d.ts +12 -11
  11. package/expect.d.ts.map +1 -1
  12. package/fixtures/fixture-types.d.ts +22 -4
  13. package/fixtures/fixture-types.d.ts.map +1 -1
  14. package/fixtures/index.js +486 -216
  15. package/fixtures/test-fixture.d.ts +6 -5
  16. package/fixtures/test-fixture.d.ts.map +1 -1
  17. package/http-mock/http-mock.d.ts +5 -3
  18. package/http-mock/http-mock.d.ts.map +1 -1
  19. package/http-mock/http-mock.types.d.ts +17 -6
  20. package/http-mock/http-mock.types.d.ts.map +1 -1
  21. package/http-mock/index.d.ts +1 -1
  22. package/http-mock/index.d.ts.map +1 -1
  23. package/index.d.ts +1 -1
  24. package/index.d.ts.map +1 -1
  25. package/index.js +652 -348
  26. package/package.json +6 -6
  27. package/perf/baseline-store.d.ts +1 -1
  28. package/perf/baseline-store.d.ts.map +1 -1
  29. package/perf/index.js +410 -285
  30. package/perf/perf-test.d.ts.map +1 -1
  31. package/perf/report-generator.d.ts +1 -1
  32. package/perf/report-generator.d.ts.map +1 -1
  33. package/server/port-registry.d.ts +7 -1
  34. package/server/port-registry.d.ts.map +1 -1
  35. package/server/test-server.d.ts +10 -1
  36. package/server/test-server.d.ts.map +1 -1
  37. package/transport/error-utils.d.ts +15 -0
  38. package/transport/error-utils.d.ts.map +1 -0
  39. package/transport/streamable-http.transport.d.ts.map +1 -1
@@ -772,6 +772,192 @@ function getMeasurementsForProject(project) {
772
772
  return getGlobalMeasurementsArray().filter((m) => m.project === project);
773
773
  }
774
774
 
775
+ // libs/testing/src/auth/token-factory.ts
776
+ import { exportJWK, generateKeyPair, SignJWT } from "jose";
777
+ var TestTokenFactory = class {
778
+ issuer;
779
+ audience;
780
+ privateKey = null;
781
+ publicKey = null;
782
+ jwk = null;
783
+ keyId;
784
+ /** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
785
+ hmacSecret;
786
+ constructor(options = {}) {
787
+ this.issuer = options.issuer ?? "https://test.frontmcp.local";
788
+ this.audience = options.audience ?? "frontmcp-test";
789
+ this.keyId = `test-key-${Date.now()}`;
790
+ this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
791
+ }
792
+ /** True when this factory signs HS256 with a shared secret (gateway mode). */
793
+ get isHmac() {
794
+ return this.hmacSecret !== null;
795
+ }
796
+ /** The JWS `alg` this factory signs with. */
797
+ get alg() {
798
+ return this.isHmac ? "HS256" : "RS256";
799
+ }
800
+ /**
801
+ * The signing key: the shared HMAC secret in gateway mode, otherwise the
802
+ * generated RSA private key (lazily created via ensureKeys()).
803
+ */
804
+ signingKey() {
805
+ if (this.hmacSecret) return this.hmacSecret;
806
+ if (!this.privateKey) {
807
+ throw new Error("Private key not initialized");
808
+ }
809
+ return this.privateKey;
810
+ }
811
+ /**
812
+ * Initialize the key pair (called automatically on first use). No-op in HS256
813
+ * mode — there is no asymmetric keypair, just the shared secret.
814
+ */
815
+ async ensureKeys() {
816
+ if (this.isHmac) return;
817
+ if (this.privateKey && this.publicKey) return;
818
+ const { publicKey, privateKey } = await generateKeyPair("RS256", {
819
+ extractable: true
820
+ });
821
+ this.privateKey = privateKey;
822
+ this.publicKey = publicKey;
823
+ this.jwk = await exportJWK(publicKey);
824
+ this.jwk.kid = this.keyId;
825
+ this.jwk.use = "sig";
826
+ this.jwk.alg = "RS256";
827
+ }
828
+ /**
829
+ * Create a JWT token with the specified claims
830
+ */
831
+ async createTestToken(options) {
832
+ await this.ensureKeys();
833
+ const nowMs = Date.now();
834
+ const iat = Math.floor(nowMs / 1e3);
835
+ const exp = options.exp ?? 3600;
836
+ const payload = {
837
+ iss: options.iss ?? this.issuer,
838
+ sub: options.sub,
839
+ aud: options.aud ?? this.audience,
840
+ iat,
841
+ exp: Math.ceil(nowMs / 1e3) + exp,
842
+ scope: options.scopes?.join(" "),
843
+ ...options.claims
844
+ };
845
+ const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
846
+ const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
847
+ return token;
848
+ }
849
+ /**
850
+ * Create an admin token with full access
851
+ */
852
+ async createAdminToken(sub = "admin-001") {
853
+ return this.createTestToken({
854
+ sub,
855
+ scopes: ["admin:*", "read", "write", "delete"],
856
+ claims: {
857
+ email: "admin@test.local",
858
+ name: "Test Admin",
859
+ role: "admin"
860
+ }
861
+ });
862
+ }
863
+ /**
864
+ * Create a regular user token
865
+ */
866
+ async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
867
+ return this.createTestToken({
868
+ sub,
869
+ scopes,
870
+ claims: {
871
+ email: "user@test.local",
872
+ name: "Test User",
873
+ role: "user"
874
+ }
875
+ });
876
+ }
877
+ /**
878
+ * Create an anonymous user token
879
+ */
880
+ async createAnonymousToken(expiresIn) {
881
+ return this.createTestToken({
882
+ sub: `anon:${Date.now()}`,
883
+ scopes: ["anonymous"],
884
+ exp: expiresIn,
885
+ claims: {
886
+ name: "Anonymous",
887
+ role: "anonymous"
888
+ }
889
+ });
890
+ }
891
+ /**
892
+ * Create an expired token (for testing token expiration)
893
+ */
894
+ async createExpiredToken(options) {
895
+ await this.ensureKeys();
896
+ const now = Math.floor(Date.now() / 1e3);
897
+ const payload = {
898
+ iss: this.issuer,
899
+ sub: options.sub,
900
+ aud: this.audience,
901
+ iat: now - 7200,
902
+ // 2 hours ago
903
+ exp: now - 3600
904
+ // Expired 1 hour ago
905
+ };
906
+ const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
907
+ const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
908
+ return token;
909
+ }
910
+ /**
911
+ * Create a token with an invalid signature (for testing signature validation)
912
+ */
913
+ createTokenWithInvalidSignature(options) {
914
+ const now = Math.floor(Date.now() / 1e3);
915
+ const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
916
+ const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
917
+ const payload = Buffer.from(
918
+ JSON.stringify({
919
+ iss: this.issuer,
920
+ sub: options.sub,
921
+ aud: this.audience,
922
+ iat: now,
923
+ exp: now + 3600
924
+ })
925
+ ).toString("base64url");
926
+ const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
927
+ return `${header}.${payload}.${signature}`;
928
+ }
929
+ /**
930
+ * Get the public JWKS for verifying tokens.
931
+ *
932
+ * Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
933
+ * the shared secret, not a published public key, so there is no JWKS to serve.
934
+ */
935
+ async getPublicJwks() {
936
+ if (this.isHmac) {
937
+ throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
938
+ }
939
+ await this.ensureKeys();
940
+ if (!this.jwk) {
941
+ throw new Error("JWK not initialized");
942
+ }
943
+ return {
944
+ keys: [this.jwk]
945
+ };
946
+ }
947
+ /**
948
+ * Get the issuer URL
949
+ */
950
+ getIssuer() {
951
+ return this.issuer;
952
+ }
953
+ /**
954
+ * Get the audience
955
+ */
956
+ getAudience() {
957
+ return this.audience;
958
+ }
959
+ };
960
+
775
961
  // libs/testing/src/interceptor/mock-registry.ts
776
962
  var DefaultMockRegistry = class {
777
963
  mocks = [];
@@ -1044,6 +1230,32 @@ function sleep3(ms) {
1044
1230
  return new Promise((resolve) => setTimeout(resolve, ms));
1045
1231
  }
1046
1232
 
1233
+ // libs/testing/src/transport/error-utils.ts
1234
+ function errorName(err) {
1235
+ if (typeof err !== "object" || err === null) return void 0;
1236
+ const name = err.name;
1237
+ return typeof name === "string" ? name : void 0;
1238
+ }
1239
+ function isAbortError(err) {
1240
+ return errorName(err) === "AbortError";
1241
+ }
1242
+ function errorMessage(err, fallback = "Unknown error") {
1243
+ if (typeof err === "string" && err) return err;
1244
+ if (typeof err === "object" && err !== null) {
1245
+ const message = err.message;
1246
+ if (typeof message === "string" && message) return message;
1247
+ }
1248
+ return fallback;
1249
+ }
1250
+ var interceptedErrors = /* @__PURE__ */ new WeakSet();
1251
+ function markInterceptedError(err) {
1252
+ interceptedErrors.add(err);
1253
+ return err;
1254
+ }
1255
+ function isInterceptedError(err) {
1256
+ return typeof err === "object" && err !== null && interceptedErrors.has(err);
1257
+ }
1258
+
1047
1259
  // libs/testing/src/transport/streamable-http.transport.ts
1048
1260
  var DEFAULT_TIMEOUT = 3e4;
1049
1261
  var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
@@ -1152,7 +1364,7 @@ var StreamableHttpTransport = class {
1152
1364
  return mockResponse2;
1153
1365
  }
1154
1366
  case "error":
1155
- throw interceptResult.error;
1367
+ throw markInterceptedError(interceptResult.error);
1156
1368
  case "continue":
1157
1369
  message = interceptResult.request;
1158
1370
  break;
@@ -1213,7 +1425,7 @@ var StreamableHttpTransport = class {
1213
1425
  return jsonResponse;
1214
1426
  } catch (error) {
1215
1427
  clearTimeout(timeoutId);
1216
- if (error instanceof Error && error.name === "AbortError") {
1428
+ if (isAbortError(error)) {
1217
1429
  return {
1218
1430
  jsonrpc: "2.0",
1219
1431
  id: message.id ?? null,
@@ -1252,7 +1464,7 @@ var StreamableHttpTransport = class {
1252
1464
  }
1253
1465
  } catch (error) {
1254
1466
  clearTimeout(timeoutId);
1255
- if (error instanceof Error && error.name !== "AbortError") {
1467
+ if (!isAbortError(error)) {
1256
1468
  throw error;
1257
1469
  }
1258
1470
  }
@@ -1293,7 +1505,7 @@ var StreamableHttpTransport = class {
1293
1505
  error: {
1294
1506
  code: -32700,
1295
1507
  message: "Parse error",
1296
- data: error instanceof Error ? error.message : "Unknown error"
1508
+ data: errorMessage(error)
1297
1509
  }
1298
1510
  };
1299
1511
  }
@@ -1663,7 +1875,7 @@ var StreamableHttpTransport = class {
1663
1875
  if (this.config.clientInfo) {
1664
1876
  headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
1665
1877
  }
1666
- if (this.authToken && !this.publicMode) {
1878
+ if (this.authToken) {
1667
1879
  headers["Authorization"] = `Bearer ${this.authToken}`;
1668
1880
  }
1669
1881
  if (this.sessionId) {
@@ -1987,6 +2199,15 @@ var DEFAULT_CLIENT_INFO = {
1987
2199
  version: "0.4.0"
1988
2200
  };
1989
2201
  var MAX_LIST_PAGES = 1e3;
2202
+ function isAuthFailure(error) {
2203
+ return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
2204
+ }
2205
+ function describeInitializeError(error) {
2206
+ const message = error?.message ?? "Unknown error";
2207
+ const data = error?.data;
2208
+ const detail = typeof data === "string" ? data.trim() : "";
2209
+ return detail ? `${message} - ${detail.slice(0, 500)}` : message;
2210
+ }
1990
2211
  function extractEntryPaths(data) {
1991
2212
  let payload = data;
1992
2213
  if (typeof payload === "string") {
@@ -2073,19 +2294,22 @@ var McpTestClient = class {
2073
2294
  async connect() {
2074
2295
  this.log("debug", `Connecting to ${this.config.baseUrl}...`);
2075
2296
  this.triedDiscoveredEntryPath = false;
2297
+ this.initResult = null;
2076
2298
  this.transport = this.createTransport();
2077
2299
  await this.transport.connect();
2078
2300
  const maxRetries = 3;
2079
2301
  const retryDelayMs = 500;
2080
2302
  let lastError;
2081
2303
  let reportedEntryPaths;
2304
+ let attemptsMade = 0;
2082
2305
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
2306
+ attemptsMade = attempt;
2083
2307
  const initResponse = await this.initialize();
2084
2308
  if (initResponse.success && initResponse.data) {
2085
2309
  this.initResult = initResponse.data;
2086
2310
  break;
2087
2311
  }
2088
- lastError = initResponse.error?.message ?? "Unknown error";
2312
+ lastError = describeInitializeError(initResponse.error);
2089
2313
  reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
2090
2314
  if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
2091
2315
  this.triedDiscoveredEntryPath = true;
@@ -2097,6 +2321,7 @@ var McpTestClient = class {
2097
2321
  await this.transport.connect();
2098
2322
  continue;
2099
2323
  }
2324
+ if (isAuthFailure(initResponse.error)) break;
2100
2325
  if (attempt < maxRetries) {
2101
2326
  this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
2102
2327
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
@@ -2105,7 +2330,7 @@ var McpTestClient = class {
2105
2330
  if (!this.initResult) {
2106
2331
  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.` : "";
2107
2332
  throw new Error(
2108
- `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
2333
+ `Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
2109
2334
  );
2110
2335
  }
2111
2336
  this._sessionId = this.transport.getSessionId();
@@ -2175,18 +2400,47 @@ var McpTestClient = class {
2175
2400
  return this._authState;
2176
2401
  }
2177
2402
  /**
2178
- * Authenticate with a token
2403
+ * Authenticate with a token.
2404
+ *
2405
+ * When connected, this opens a NEW session with the token (the previous session was created
2406
+ * for the previous identity) and rejects if the server refuses it — for example because the
2407
+ * token is expired or badly signed. On rejection the client keeps its previous identity and
2408
+ * session. The token is kept in the client config, so `reconnect()` keeps using it.
2179
2409
  */
2180
2410
  async authenticate(token) {
2411
+ if (typeof token !== "string" || token.trim() === "") {
2412
+ throw new Error("authenticate() requires a non-empty token");
2413
+ }
2414
+ const previousAuthState = this._authState;
2415
+ const previousConfigAuth = this.config.auth;
2416
+ this.config.auth = { ...previousConfigAuth, token };
2181
2417
  this._authState = {
2182
2418
  isAnonymous: false,
2183
2419
  token,
2184
2420
  scopes: this.parseScopesFromToken(token),
2185
2421
  user: this.parseUserFromToken(token)
2186
2422
  };
2187
- if (this.transport) {
2188
- this.transport.setAuthToken(token);
2423
+ const previousTransport = this.transport;
2424
+ if (!previousTransport) {
2425
+ this.log("debug", "Authentication updated (not connected yet)");
2426
+ return;
2189
2427
  }
2428
+ const previousInit = this.initResult;
2429
+ const previousSessionId = this._sessionId;
2430
+ const previousSessionInfo = this._sessionInfo;
2431
+ try {
2432
+ await this.connect();
2433
+ } catch (err) {
2434
+ await this.transport?.close().catch(() => void 0);
2435
+ this.transport = previousTransport;
2436
+ this.initResult = previousInit;
2437
+ this._sessionId = previousSessionId;
2438
+ this._sessionInfo = previousSessionInfo;
2439
+ this.config.auth = previousConfigAuth;
2440
+ this._authState = previousAuthState;
2441
+ throw err;
2442
+ }
2443
+ await previousTransport.close().catch(() => void 0);
2190
2444
  this.log("debug", "Authentication updated");
2191
2445
  }
2192
2446
  // ═══════════════════════════════════════════════════════════════════
@@ -2810,10 +3064,11 @@ var McpTestClient = class {
2810
3064
  requestId: id
2811
3065
  };
2812
3066
  } catch (err) {
3067
+ if (isInterceptedError(err)) throw err;
2813
3068
  const durationMs = Date.now() - start;
2814
3069
  const error = {
2815
3070
  code: -32603,
2816
- message: err instanceof Error ? err.message : "Unknown error"
3071
+ message: errorMessage(err)
2817
3072
  };
2818
3073
  return {
2819
3074
  success: false,
@@ -3040,192 +3295,8 @@ var ProgressCollector = class {
3040
3295
  }
3041
3296
  };
3042
3297
 
3043
- // libs/testing/src/auth/token-factory.ts
3044
- import { exportJWK, generateKeyPair, SignJWT } from "jose";
3045
- var TestTokenFactory = class {
3046
- issuer;
3047
- audience;
3048
- privateKey = null;
3049
- publicKey = null;
3050
- jwk = null;
3051
- keyId;
3052
- /** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
3053
- hmacSecret;
3054
- constructor(options = {}) {
3055
- this.issuer = options.issuer ?? "https://test.frontmcp.local";
3056
- this.audience = options.audience ?? "frontmcp-test";
3057
- this.keyId = `test-key-${Date.now()}`;
3058
- this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
3059
- }
3060
- /** True when this factory signs HS256 with a shared secret (gateway mode). */
3061
- get isHmac() {
3062
- return this.hmacSecret !== null;
3063
- }
3064
- /** The JWS `alg` this factory signs with. */
3065
- get alg() {
3066
- return this.isHmac ? "HS256" : "RS256";
3067
- }
3068
- /**
3069
- * The signing key: the shared HMAC secret in gateway mode, otherwise the
3070
- * generated RSA private key (lazily created via ensureKeys()).
3071
- */
3072
- signingKey() {
3073
- if (this.hmacSecret) return this.hmacSecret;
3074
- if (!this.privateKey) {
3075
- throw new Error("Private key not initialized");
3076
- }
3077
- return this.privateKey;
3078
- }
3079
- /**
3080
- * Initialize the key pair (called automatically on first use). No-op in HS256
3081
- * mode — there is no asymmetric keypair, just the shared secret.
3082
- */
3083
- async ensureKeys() {
3084
- if (this.isHmac) return;
3085
- if (this.privateKey && this.publicKey) return;
3086
- const { publicKey, privateKey } = await generateKeyPair("RS256", {
3087
- extractable: true
3088
- });
3089
- this.privateKey = privateKey;
3090
- this.publicKey = publicKey;
3091
- this.jwk = await exportJWK(publicKey);
3092
- this.jwk.kid = this.keyId;
3093
- this.jwk.use = "sig";
3094
- this.jwk.alg = "RS256";
3095
- }
3096
- /**
3097
- * Create a JWT token with the specified claims
3098
- */
3099
- async createTestToken(options) {
3100
- await this.ensureKeys();
3101
- const now = Math.floor(Date.now() / 1e3);
3102
- const exp = options.exp ?? 3600;
3103
- const payload = {
3104
- iss: options.iss ?? this.issuer,
3105
- sub: options.sub,
3106
- aud: options.aud ?? this.audience,
3107
- iat: now,
3108
- exp: now + exp,
3109
- scope: options.scopes?.join(" "),
3110
- ...options.claims
3111
- };
3112
- const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
3113
- const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
3114
- return token;
3115
- }
3116
- /**
3117
- * Create an admin token with full access
3118
- */
3119
- async createAdminToken(sub = "admin-001") {
3120
- return this.createTestToken({
3121
- sub,
3122
- scopes: ["admin:*", "read", "write", "delete"],
3123
- claims: {
3124
- email: "admin@test.local",
3125
- name: "Test Admin",
3126
- role: "admin"
3127
- }
3128
- });
3129
- }
3130
- /**
3131
- * Create a regular user token
3132
- */
3133
- async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
3134
- return this.createTestToken({
3135
- sub,
3136
- scopes,
3137
- claims: {
3138
- email: "user@test.local",
3139
- name: "Test User",
3140
- role: "user"
3141
- }
3142
- });
3143
- }
3144
- /**
3145
- * Create an anonymous user token
3146
- */
3147
- async createAnonymousToken() {
3148
- return this.createTestToken({
3149
- sub: `anon:${Date.now()}`,
3150
- scopes: ["anonymous"],
3151
- claims: {
3152
- name: "Anonymous",
3153
- role: "anonymous"
3154
- }
3155
- });
3156
- }
3157
- /**
3158
- * Create an expired token (for testing token expiration)
3159
- */
3160
- async createExpiredToken(options) {
3161
- await this.ensureKeys();
3162
- const now = Math.floor(Date.now() / 1e3);
3163
- const payload = {
3164
- iss: this.issuer,
3165
- sub: options.sub,
3166
- aud: this.audience,
3167
- iat: now - 7200,
3168
- // 2 hours ago
3169
- exp: now - 3600
3170
- // Expired 1 hour ago
3171
- };
3172
- const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
3173
- const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
3174
- return token;
3175
- }
3176
- /**
3177
- * Create a token with an invalid signature (for testing signature validation)
3178
- */
3179
- createTokenWithInvalidSignature(options) {
3180
- const now = Math.floor(Date.now() / 1e3);
3181
- const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
3182
- const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
3183
- const payload = Buffer.from(
3184
- JSON.stringify({
3185
- iss: this.issuer,
3186
- sub: options.sub,
3187
- aud: this.audience,
3188
- iat: now,
3189
- exp: now + 3600
3190
- })
3191
- ).toString("base64url");
3192
- const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
3193
- return `${header}.${payload}.${signature}`;
3194
- }
3195
- /**
3196
- * Get the public JWKS for verifying tokens.
3197
- *
3198
- * Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
3199
- * the shared secret, not a published public key, so there is no JWKS to serve.
3200
- */
3201
- async getPublicJwks() {
3202
- if (this.isHmac) {
3203
- throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
3204
- }
3205
- await this.ensureKeys();
3206
- if (!this.jwk) {
3207
- throw new Error("JWK not initialized");
3208
- }
3209
- return {
3210
- keys: [this.jwk]
3211
- };
3212
- }
3213
- /**
3214
- * Get the issuer URL
3215
- */
3216
- getIssuer() {
3217
- return this.issuer;
3218
- }
3219
- /**
3220
- * Get the audience
3221
- */
3222
- getAudience() {
3223
- return this.audience;
3224
- }
3225
- };
3226
-
3227
3298
  // libs/testing/src/server/test-server.ts
3228
- import { spawn } from "child_process";
3299
+ import { execFile, spawn } from "child_process";
3229
3300
  import { sha256Hex } from "@frontmcp/utils";
3230
3301
 
3231
3302
  // libs/testing/src/errors/index.ts
@@ -3296,6 +3367,8 @@ var E2E_PORT_RANGES = {
3296
3367
  "demo-e2e-distributed": { start: 50440, size: 20 },
3297
3368
  // Protocol revision E2E tests (50460-50479)
3298
3369
  "demo-e2e-protocol-20260728": { start: 50460, size: 20 },
3370
+ // Testing library E2E tests (50480-50499)
3371
+ "demo-e2e-testing": { start: 50480, size: 20 },
3299
3372
  // Mock servers and utilities (50900-50999)
3300
3373
  "mock-oauth": { start: 50900, size: 10 },
3301
3374
  "mock-api": { start: 50910, size: 10 },
@@ -3304,6 +3377,66 @@ var E2E_PORT_RANGES = {
3304
3377
  default: { start: 51e3, size: 100 }
3305
3378
  };
3306
3379
  var reservedPorts = /* @__PURE__ */ new Map();
3380
+ var lockedPorts = /* @__PURE__ */ new Set();
3381
+ var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
3382
+ function lockDir() {
3383
+ try {
3384
+ const os = __require("node:os");
3385
+ const path = __require("node:path");
3386
+ return path.join(os.tmpdir(), "frontmcp-testing-ports");
3387
+ } catch {
3388
+ return void 0;
3389
+ }
3390
+ }
3391
+ function pidIsAlive(pid) {
3392
+ try {
3393
+ process.kill(pid, 0);
3394
+ return true;
3395
+ } catch (error) {
3396
+ return error.code === "EPERM";
3397
+ }
3398
+ }
3399
+ function acquirePortLock(port) {
3400
+ if (lockedPorts.has(port)) return false;
3401
+ let fs;
3402
+ let file;
3403
+ try {
3404
+ fs = __require("node:fs");
3405
+ const dir = lockDir();
3406
+ if (!dir) return true;
3407
+ fs.mkdirSync(dir, { recursive: true });
3408
+ file = __require("node:path").join(dir, `${port}.lock`);
3409
+ } catch {
3410
+ return true;
3411
+ }
3412
+ const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
3413
+ for (let attempt = 0; attempt < 2; attempt++) {
3414
+ try {
3415
+ fs.writeFileSync(file, payload, { flag: "wx" });
3416
+ lockedPorts.add(port);
3417
+ return true;
3418
+ } catch (error) {
3419
+ if (error.code !== "EEXIST") return true;
3420
+ try {
3421
+ const held = JSON.parse(fs.readFileSync(file, "utf8"));
3422
+ const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
3423
+ if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
3424
+ fs.unlinkSync(file);
3425
+ } catch {
3426
+ }
3427
+ }
3428
+ }
3429
+ return false;
3430
+ }
3431
+ function releasePortLock(port) {
3432
+ if (!lockedPorts.delete(port)) return;
3433
+ try {
3434
+ const fs = __require("node:fs");
3435
+ const dir = lockDir();
3436
+ if (dir) fs.unlinkSync(__require("node:path").join(dir, `${port}.lock`));
3437
+ } catch {
3438
+ }
3439
+ }
3307
3440
  var projectPortIndex = /* @__PURE__ */ new Map();
3308
3441
  function getPortRange(project) {
3309
3442
  const key = project;
@@ -3314,15 +3447,22 @@ function getPortRange(project) {
3314
3447
  }
3315
3448
  async function reservePort(project, preferredPort) {
3316
3449
  const range = getPortRange(project);
3317
- if (preferredPort !== void 0) {
3450
+ const handle = (port) => ({
3451
+ port,
3452
+ // Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
3453
+ releaseSocket: async () => {
3454
+ await releasePort(port);
3455
+ },
3456
+ release: async () => {
3457
+ await releasePort(port);
3458
+ releasePortLock(port);
3459
+ }
3460
+ });
3461
+ const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
3462
+ if (wantsSpecificPort) {
3318
3463
  const reservation = await tryReservePort(preferredPort, project);
3319
3464
  if (reservation) {
3320
- return {
3321
- port: preferredPort,
3322
- release: async () => {
3323
- await releasePort(preferredPort);
3324
- }
3325
- };
3465
+ return handle(preferredPort);
3326
3466
  }
3327
3467
  console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
3328
3468
  }
@@ -3331,30 +3471,19 @@ async function reservePort(project, preferredPort) {
3331
3471
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
3332
3472
  const port = range.start + index % range.size;
3333
3473
  index = (index + 1) % range.size;
3334
- if (reservedPorts.has(port)) {
3474
+ if (reservedPorts.has(port) || lockedPorts.has(port)) {
3335
3475
  continue;
3336
3476
  }
3337
3477
  const reservation = await tryReservePort(port, project);
3338
3478
  if (reservation) {
3339
3479
  projectPortIndex.set(project, index);
3340
- return {
3341
- port,
3342
- release: async () => {
3343
- await releasePort(port);
3344
- }
3345
- };
3480
+ return handle(port);
3346
3481
  }
3347
3482
  }
3348
- const dynamicPort = await findAvailablePortInRange(51e3, 52e3);
3349
- if (dynamicPort) {
3350
- const reservation = await tryReservePort(dynamicPort, project);
3351
- if (reservation) {
3352
- return {
3353
- port: dynamicPort,
3354
- release: async () => {
3355
- await releasePort(dynamicPort);
3356
- }
3357
- };
3483
+ for (let port = 51e3; port < 52e3; port++) {
3484
+ if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
3485
+ if (await tryReservePort(port, project)) {
3486
+ return handle(port);
3358
3487
  }
3359
3488
  }
3360
3489
  throw new Error(
@@ -3362,9 +3491,13 @@ async function reservePort(project, preferredPort) {
3362
3491
  );
3363
3492
  }
3364
3493
  async function tryReservePort(port, project) {
3494
+ if (!acquirePortLock(port)) {
3495
+ return false;
3496
+ }
3365
3497
  return new Promise((resolve) => {
3366
3498
  const server = createServer();
3367
3499
  server.once("error", () => {
3500
+ releasePortLock(port);
3368
3501
  resolve(false);
3369
3502
  });
3370
3503
  server.listen(port, () => {
@@ -3390,31 +3523,6 @@ async function releasePort(port) {
3390
3523
  });
3391
3524
  });
3392
3525
  }
3393
- async function findAvailablePortInRange(start, end) {
3394
- for (let port = start; port < end; port++) {
3395
- if (reservedPorts.has(port)) {
3396
- continue;
3397
- }
3398
- const available = await isPortAvailable(port);
3399
- if (available) {
3400
- return port;
3401
- }
3402
- }
3403
- return null;
3404
- }
3405
- async function isPortAvailable(port) {
3406
- return new Promise((resolve) => {
3407
- const server = createServer();
3408
- server.once("error", () => {
3409
- resolve(false);
3410
- });
3411
- server.listen(port, () => {
3412
- server.close(() => {
3413
- resolve(true);
3414
- });
3415
- });
3416
- });
3417
- }
3418
3526
 
3419
3527
  // libs/testing/src/server/test-server.ts
3420
3528
  var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
@@ -3423,8 +3531,11 @@ var TestServer = class _TestServer {
3423
3531
  options;
3424
3532
  _info;
3425
3533
  logs = [];
3534
+ /** Frees the port reservation (socket and cross-process lock); held until stop() */
3426
3535
  portRelease = null;
3427
- constructor(options, port, portRelease) {
3536
+ /** Frees only the reservation socket, just before the server is spawned */
3537
+ portReleaseSocket = null;
3538
+ constructor(options, port, portRelease, portReleaseSocket) {
3428
3539
  this.options = {
3429
3540
  port,
3430
3541
  project: options.project,
@@ -3436,6 +3547,7 @@ var TestServer = class _TestServer {
3436
3547
  debug: options.debug ?? DEBUG_SERVER
3437
3548
  };
3438
3549
  this.portRelease = portRelease ?? null;
3550
+ this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
3439
3551
  this._info = {
3440
3552
  baseUrl: `http://localhost:${port}`,
3441
3553
  port
@@ -3448,8 +3560,8 @@ var TestServer = class _TestServer {
3448
3560
  const project = options.project ?? "default";
3449
3561
  const maxAttempts = 3;
3450
3562
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3451
- const { port, release } = await reservePort(project, options.port);
3452
- const server = new _TestServer(options, port, release);
3563
+ const { port, release, releaseSocket } = await reservePort(project, options.port);
3564
+ const server = new _TestServer(options, port, release, releaseSocket);
3453
3565
  try {
3454
3566
  await server.startProcess();
3455
3567
  return server;
@@ -3489,7 +3601,7 @@ var TestServer = class _TestServer {
3489
3601
  }
3490
3602
  const maxAttempts = 3;
3491
3603
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3492
- const { port, release } = await reservePort(project, options.port);
3604
+ const { port, release, releaseSocket } = await reservePort(project, options.port);
3493
3605
  const serverOptions = {
3494
3606
  ...options,
3495
3607
  port,
@@ -3497,7 +3609,7 @@ var TestServer = class _TestServer {
3497
3609
  command: `npx nx serve ${project} --port ${port}`,
3498
3610
  cwd: options.cwd ?? process.cwd()
3499
3611
  };
3500
- const server = new _TestServer(serverOptions, port, release);
3612
+ const server = new _TestServer(serverOptions, port, release, releaseSocket);
3501
3613
  try {
3502
3614
  await server.startProcess();
3503
3615
  return server;
@@ -3555,10 +3667,17 @@ var TestServer = class _TestServer {
3555
3667
  * Stop the test server
3556
3668
  */
3557
3669
  async stop() {
3558
- if (this.portRelease) {
3559
- await this.portRelease();
3560
- this.portRelease = null;
3670
+ try {
3671
+ await this.stopProcess();
3672
+ } finally {
3673
+ if (this.portRelease) {
3674
+ await this.portRelease();
3675
+ this.portRelease = null;
3676
+ }
3677
+ this.portReleaseSocket = null;
3561
3678
  }
3679
+ }
3680
+ async stopProcess() {
3562
3681
  if (this.process) {
3563
3682
  this.log("Stopping server...");
3564
3683
  if (this.process.exitCode !== null || this.process.signalCode !== null) {
@@ -3631,7 +3750,7 @@ var TestServer = class _TestServer {
3631
3750
  * Restart the server
3632
3751
  */
3633
3752
  async restart() {
3634
- await this.stop();
3753
+ await this.stopProcess();
3635
3754
  await this.startProcess();
3636
3755
  }
3637
3756
  /**
@@ -3661,9 +3780,9 @@ var TestServer = class _TestServer {
3661
3780
  PORT: String(this.options.port)
3662
3781
  };
3663
3782
  const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
3664
- if (this.portRelease) {
3665
- await this.portRelease();
3666
- this.portRelease = null;
3783
+ if (this.portReleaseSocket) {
3784
+ await this.portReleaseSocket();
3785
+ this.portReleaseSocket = null;
3667
3786
  await sleep4(300);
3668
3787
  }
3669
3788
  this.process = spawn(this.options.command, [], {
@@ -3673,9 +3792,8 @@ var TestServer = class _TestServer {
3673
3792
  detached: true,
3674
3793
  stdio: ["pipe", "pipe", "pipe"]
3675
3794
  });
3676
- if (this.process.pid !== void 0) {
3677
- this._info.pid = this.process.pid;
3678
- }
3795
+ this._info.pid = this.process.pid;
3796
+ this._info.shellPid = this.process.pid;
3679
3797
  let processExited = false;
3680
3798
  let exitCode = null;
3681
3799
  let exitError = null;
@@ -3778,6 +3896,8 @@ ${allLogs}`);
3778
3896
  });
3779
3897
  if (response.ok || response.status === 404) {
3780
3898
  this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
3899
+ const listeningPid = await findListeningPid(this.options.port);
3900
+ if (listeningPid !== void 0) this._info.pid = listeningPid;
3781
3901
  return;
3782
3902
  }
3783
3903
  lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
@@ -3822,6 +3942,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
3822
3942
  function sleep4(ms) {
3823
3943
  return new Promise((resolve) => setTimeout(resolve, ms));
3824
3944
  }
3945
+ function findListeningPid(port) {
3946
+ if (process.platform === "win32") return Promise.resolve(void 0);
3947
+ return new Promise((resolve) => {
3948
+ execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
3949
+ if (error) {
3950
+ resolve(void 0);
3951
+ return;
3952
+ }
3953
+ const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
3954
+ resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
3955
+ });
3956
+ });
3957
+ }
3825
3958
  function withWorkspaceProtocolFallback(env, cwd) {
3826
3959
  if (findInstalledProtocolPackageDir(cwd)) {
3827
3960
  return env;
@@ -3969,7 +4102,8 @@ Server entry: ${currentConfig.server}
3969
4102
  Project: ${currentConfig.project ?? "default"}
3970
4103
  Command: ${serverCommand}
3971
4104
 
3972
- Error: ${errMsg}`
4105
+ Error: ${errMsg}`,
4106
+ { cause: error }
3973
4107
  );
3974
4108
  }
3975
4109
  } else {
@@ -4159,16 +4293,19 @@ function todo(name) {
4159
4293
  }
4160
4294
  var perfTest = perfTestWithFixtures;
4161
4295
  perfTest.use = use;
4162
- perfTest.describe = describe;
4163
- perfTest.beforeAll = beforeAll;
4164
- perfTest.beforeEach = beforeEach;
4165
- perfTest.afterEach = afterEach;
4166
- perfTest.afterAll = afterAll;
4296
+ for (const hook of ["describe", "beforeAll", "beforeEach", "afterEach", "afterAll"]) {
4297
+ Object.defineProperty(perfTest, hook, {
4298
+ get: () => globalThis[hook],
4299
+ enumerable: true,
4300
+ configurable: true
4301
+ });
4302
+ }
4167
4303
  perfTest.skip = skip;
4168
4304
  perfTest.only = only;
4169
4305
  perfTest.todo = todo;
4170
4306
 
4171
4307
  // libs/testing/src/perf/baseline-store.ts
4308
+ import { ensureDir, fileExists, readFile, writeFile } from "@frontmcp/utils";
4172
4309
  var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
4173
4310
  var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
4174
4311
  var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
@@ -4183,7 +4320,6 @@ var BaselineStore = class {
4183
4320
  */
4184
4321
  async load() {
4185
4322
  try {
4186
- const { readFile, fileExists } = await import("@frontmcp/utils");
4187
4323
  if (!await fileExists(this.baselinePath)) {
4188
4324
  return null;
4189
4325
  }
@@ -4198,7 +4334,6 @@ var BaselineStore = class {
4198
4334
  * Save baseline to local file.
4199
4335
  */
4200
4336
  async save(baseline) {
4201
- const { writeFile, ensureDir } = await import("@frontmcp/utils");
4202
4337
  const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
4203
4338
  await ensureDir(dir);
4204
4339
  await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
@@ -4477,6 +4612,7 @@ function resetRegressionDetector() {
4477
4612
  }
4478
4613
 
4479
4614
  // libs/testing/src/perf/report-generator.ts
4615
+ import { ensureDir as ensureDir2, writeFile as writeFile2 } from "@frontmcp/utils";
4480
4616
  var ReportGenerator = class {
4481
4617
  detector;
4482
4618
  constructor() {
@@ -4732,14 +4868,13 @@ var ReportGenerator = class {
4732
4868
  }
4733
4869
  };
4734
4870
  async function saveReports(measurements, outputDir, baseline, gitInfo) {
4735
- const { writeFile, ensureDir } = await import("@frontmcp/utils");
4736
- await ensureDir(outputDir);
4871
+ await ensureDir2(outputDir);
4737
4872
  const generator = new ReportGenerator();
4738
4873
  const report = generator.generateReport(measurements, baseline, gitInfo);
4739
4874
  const jsonPath = `${outputDir}/report.json`;
4740
4875
  const markdownPath = `${outputDir}/report.md`;
4741
- await writeFile(jsonPath, generator.generateJsonReport(report));
4742
- await writeFile(markdownPath, generator.generateMarkdownReport(report));
4876
+ await writeFile2(jsonPath, generator.generateJsonReport(report));
4877
+ await writeFile2(markdownPath, generator.generateMarkdownReport(report));
4743
4878
  return { jsonPath, markdownPath };
4744
4879
  }
4745
4880
  function createReportGenerator() {