@frontmcp/testing 1.8.5 → 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.
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts +2 -2
- package/auth/token-factory.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +12 -1
- package/client/mcp-test-client.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +545 -260
- package/esm/index.mjs +702 -383
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +465 -308
- package/expect.d.ts +12 -11
- package/expect.d.ts.map +1 -1
- package/fixtures/fixture-types.d.ts +22 -4
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +544 -252
- package/fixtures/test-fixture.d.ts +6 -5
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/http-mock/http-mock.d.ts +5 -3
- package/http-mock/http-mock.d.ts.map +1 -1
- package/http-mock/http-mock.types.d.ts +17 -6
- package/http-mock/http-mock.types.d.ts.map +1 -1
- package/http-mock/index.d.ts +1 -1
- package/http-mock/index.d.ts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +710 -384
- package/package.json +6 -6
- package/perf/baseline-store.d.ts +1 -1
- package/perf/baseline-store.d.ts.map +1 -1
- package/perf/index.js +468 -321
- package/perf/perf-test.d.ts.map +1 -1
- package/perf/report-generator.d.ts +1 -1
- package/perf/report-generator.d.ts.map +1 -1
- package/server/port-registry.d.ts +7 -1
- package/server/port-registry.d.ts.map +1 -1
- package/server/test-server.d.ts +10 -1
- package/server/test-server.d.ts.map +1 -1
- package/transport/error-utils.d.ts +15 -0
- package/transport/error-utils.d.ts.map +1 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
package/perf/index.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __create = Object.create;
|
|
3
2
|
var __defProp = Object.defineProperty;
|
|
4
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
7
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
6
|
var __export = (target, all) => {
|
|
9
7
|
for (var name in all)
|
|
@@ -17,14 +15,6 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
17
15
|
}
|
|
18
16
|
return to;
|
|
19
17
|
};
|
|
20
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
-
mod
|
|
27
|
-
));
|
|
28
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
19
|
|
|
30
20
|
// libs/testing/src/perf/index.ts
|
|
@@ -834,6 +824,192 @@ function getMeasurementsForProject(project) {
|
|
|
834
824
|
return getGlobalMeasurementsArray().filter((m) => m.project === project);
|
|
835
825
|
}
|
|
836
826
|
|
|
827
|
+
// libs/testing/src/auth/token-factory.ts
|
|
828
|
+
var import_jose = require("jose");
|
|
829
|
+
var TestTokenFactory = class {
|
|
830
|
+
issuer;
|
|
831
|
+
audience;
|
|
832
|
+
privateKey = null;
|
|
833
|
+
publicKey = null;
|
|
834
|
+
jwk = null;
|
|
835
|
+
keyId;
|
|
836
|
+
/** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
|
|
837
|
+
hmacSecret;
|
|
838
|
+
constructor(options = {}) {
|
|
839
|
+
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
840
|
+
this.audience = options.audience ?? "frontmcp-test";
|
|
841
|
+
this.keyId = `test-key-${Date.now()}`;
|
|
842
|
+
this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
|
|
843
|
+
}
|
|
844
|
+
/** True when this factory signs HS256 with a shared secret (gateway mode). */
|
|
845
|
+
get isHmac() {
|
|
846
|
+
return this.hmacSecret !== null;
|
|
847
|
+
}
|
|
848
|
+
/** The JWS `alg` this factory signs with. */
|
|
849
|
+
get alg() {
|
|
850
|
+
return this.isHmac ? "HS256" : "RS256";
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The signing key: the shared HMAC secret in gateway mode, otherwise the
|
|
854
|
+
* generated RSA private key (lazily created via ensureKeys()).
|
|
855
|
+
*/
|
|
856
|
+
signingKey() {
|
|
857
|
+
if (this.hmacSecret) return this.hmacSecret;
|
|
858
|
+
if (!this.privateKey) {
|
|
859
|
+
throw new Error("Private key not initialized");
|
|
860
|
+
}
|
|
861
|
+
return this.privateKey;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Initialize the key pair (called automatically on first use). No-op in HS256
|
|
865
|
+
* mode — there is no asymmetric keypair, just the shared secret.
|
|
866
|
+
*/
|
|
867
|
+
async ensureKeys() {
|
|
868
|
+
if (this.isHmac) return;
|
|
869
|
+
if (this.privateKey && this.publicKey) return;
|
|
870
|
+
const { publicKey, privateKey } = await (0, import_jose.generateKeyPair)("RS256", {
|
|
871
|
+
extractable: true
|
|
872
|
+
});
|
|
873
|
+
this.privateKey = privateKey;
|
|
874
|
+
this.publicKey = publicKey;
|
|
875
|
+
this.jwk = await (0, import_jose.exportJWK)(publicKey);
|
|
876
|
+
this.jwk.kid = this.keyId;
|
|
877
|
+
this.jwk.use = "sig";
|
|
878
|
+
this.jwk.alg = "RS256";
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Create a JWT token with the specified claims
|
|
882
|
+
*/
|
|
883
|
+
async createTestToken(options) {
|
|
884
|
+
await this.ensureKeys();
|
|
885
|
+
const nowMs = Date.now();
|
|
886
|
+
const iat = Math.floor(nowMs / 1e3);
|
|
887
|
+
const exp = options.exp ?? 3600;
|
|
888
|
+
const payload = {
|
|
889
|
+
iss: options.iss ?? this.issuer,
|
|
890
|
+
sub: options.sub,
|
|
891
|
+
aud: options.aud ?? this.audience,
|
|
892
|
+
iat,
|
|
893
|
+
exp: Math.ceil(nowMs / 1e3) + exp,
|
|
894
|
+
scope: options.scopes?.join(" "),
|
|
895
|
+
...options.claims
|
|
896
|
+
};
|
|
897
|
+
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
898
|
+
const token = await new import_jose.SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
899
|
+
return token;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Create an admin token with full access
|
|
903
|
+
*/
|
|
904
|
+
async createAdminToken(sub = "admin-001") {
|
|
905
|
+
return this.createTestToken({
|
|
906
|
+
sub,
|
|
907
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
908
|
+
claims: {
|
|
909
|
+
email: "admin@test.local",
|
|
910
|
+
name: "Test Admin",
|
|
911
|
+
role: "admin"
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Create a regular user token
|
|
917
|
+
*/
|
|
918
|
+
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
919
|
+
return this.createTestToken({
|
|
920
|
+
sub,
|
|
921
|
+
scopes,
|
|
922
|
+
claims: {
|
|
923
|
+
email: "user@test.local",
|
|
924
|
+
name: "Test User",
|
|
925
|
+
role: "user"
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Create an anonymous user token
|
|
931
|
+
*/
|
|
932
|
+
async createAnonymousToken(expiresIn) {
|
|
933
|
+
return this.createTestToken({
|
|
934
|
+
sub: `anon:${Date.now()}`,
|
|
935
|
+
scopes: ["anonymous"],
|
|
936
|
+
exp: expiresIn,
|
|
937
|
+
claims: {
|
|
938
|
+
name: "Anonymous",
|
|
939
|
+
role: "anonymous"
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Create an expired token (for testing token expiration)
|
|
945
|
+
*/
|
|
946
|
+
async createExpiredToken(options) {
|
|
947
|
+
await this.ensureKeys();
|
|
948
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
949
|
+
const payload = {
|
|
950
|
+
iss: this.issuer,
|
|
951
|
+
sub: options.sub,
|
|
952
|
+
aud: this.audience,
|
|
953
|
+
iat: now - 7200,
|
|
954
|
+
// 2 hours ago
|
|
955
|
+
exp: now - 3600
|
|
956
|
+
// Expired 1 hour ago
|
|
957
|
+
};
|
|
958
|
+
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
959
|
+
const token = await new import_jose.SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
960
|
+
return token;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Create a token with an invalid signature (for testing signature validation)
|
|
964
|
+
*/
|
|
965
|
+
createTokenWithInvalidSignature(options) {
|
|
966
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
967
|
+
const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
968
|
+
const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
|
|
969
|
+
const payload = Buffer.from(
|
|
970
|
+
JSON.stringify({
|
|
971
|
+
iss: this.issuer,
|
|
972
|
+
sub: options.sub,
|
|
973
|
+
aud: this.audience,
|
|
974
|
+
iat: now,
|
|
975
|
+
exp: now + 3600
|
|
976
|
+
})
|
|
977
|
+
).toString("base64url");
|
|
978
|
+
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
979
|
+
return `${header}.${payload}.${signature}`;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Get the public JWKS for verifying tokens.
|
|
983
|
+
*
|
|
984
|
+
* Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
|
|
985
|
+
* the shared secret, not a published public key, so there is no JWKS to serve.
|
|
986
|
+
*/
|
|
987
|
+
async getPublicJwks() {
|
|
988
|
+
if (this.isHmac) {
|
|
989
|
+
throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
|
|
990
|
+
}
|
|
991
|
+
await this.ensureKeys();
|
|
992
|
+
if (!this.jwk) {
|
|
993
|
+
throw new Error("JWK not initialized");
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
keys: [this.jwk]
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Get the issuer URL
|
|
1001
|
+
*/
|
|
1002
|
+
getIssuer() {
|
|
1003
|
+
return this.issuer;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Get the audience
|
|
1007
|
+
*/
|
|
1008
|
+
getAudience() {
|
|
1009
|
+
return this.audience;
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
|
|
837
1013
|
// libs/testing/src/interceptor/mock-registry.ts
|
|
838
1014
|
var DefaultMockRegistry = class {
|
|
839
1015
|
mocks = [];
|
|
@@ -1106,6 +1282,32 @@ function sleep3(ms) {
|
|
|
1106
1282
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1107
1283
|
}
|
|
1108
1284
|
|
|
1285
|
+
// libs/testing/src/transport/error-utils.ts
|
|
1286
|
+
function errorName(err) {
|
|
1287
|
+
if (typeof err !== "object" || err === null) return void 0;
|
|
1288
|
+
const name = err.name;
|
|
1289
|
+
return typeof name === "string" ? name : void 0;
|
|
1290
|
+
}
|
|
1291
|
+
function isAbortError(err) {
|
|
1292
|
+
return errorName(err) === "AbortError";
|
|
1293
|
+
}
|
|
1294
|
+
function errorMessage(err, fallback = "Unknown error") {
|
|
1295
|
+
if (typeof err === "string" && err) return err;
|
|
1296
|
+
if (typeof err === "object" && err !== null) {
|
|
1297
|
+
const message = err.message;
|
|
1298
|
+
if (typeof message === "string" && message) return message;
|
|
1299
|
+
}
|
|
1300
|
+
return fallback;
|
|
1301
|
+
}
|
|
1302
|
+
var interceptedErrors = /* @__PURE__ */ new WeakSet();
|
|
1303
|
+
function markInterceptedError(err) {
|
|
1304
|
+
interceptedErrors.add(err);
|
|
1305
|
+
return err;
|
|
1306
|
+
}
|
|
1307
|
+
function isInterceptedError(err) {
|
|
1308
|
+
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1109
1311
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
1110
1312
|
var DEFAULT_TIMEOUT = 3e4;
|
|
1111
1313
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
@@ -1214,7 +1416,7 @@ var StreamableHttpTransport = class {
|
|
|
1214
1416
|
return mockResponse2;
|
|
1215
1417
|
}
|
|
1216
1418
|
case "error":
|
|
1217
|
-
throw interceptResult.error;
|
|
1419
|
+
throw markInterceptedError(interceptResult.error);
|
|
1218
1420
|
case "continue":
|
|
1219
1421
|
message = interceptResult.request;
|
|
1220
1422
|
break;
|
|
@@ -1275,7 +1477,7 @@ var StreamableHttpTransport = class {
|
|
|
1275
1477
|
return jsonResponse;
|
|
1276
1478
|
} catch (error) {
|
|
1277
1479
|
clearTimeout(timeoutId);
|
|
1278
|
-
if (error
|
|
1480
|
+
if (isAbortError(error)) {
|
|
1279
1481
|
return {
|
|
1280
1482
|
jsonrpc: "2.0",
|
|
1281
1483
|
id: message.id ?? null,
|
|
@@ -1314,7 +1516,7 @@ var StreamableHttpTransport = class {
|
|
|
1314
1516
|
}
|
|
1315
1517
|
} catch (error) {
|
|
1316
1518
|
clearTimeout(timeoutId);
|
|
1317
|
-
if (error
|
|
1519
|
+
if (!isAbortError(error)) {
|
|
1318
1520
|
throw error;
|
|
1319
1521
|
}
|
|
1320
1522
|
}
|
|
@@ -1355,7 +1557,7 @@ var StreamableHttpTransport = class {
|
|
|
1355
1557
|
error: {
|
|
1356
1558
|
code: -32700,
|
|
1357
1559
|
message: "Parse error",
|
|
1358
|
-
data: error
|
|
1560
|
+
data: errorMessage(error)
|
|
1359
1561
|
}
|
|
1360
1562
|
};
|
|
1361
1563
|
}
|
|
@@ -1725,7 +1927,7 @@ var StreamableHttpTransport = class {
|
|
|
1725
1927
|
if (this.config.clientInfo) {
|
|
1726
1928
|
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1727
1929
|
}
|
|
1728
|
-
if (this.authToken
|
|
1930
|
+
if (this.authToken) {
|
|
1729
1931
|
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1730
1932
|
}
|
|
1731
1933
|
if (this.sessionId) {
|
|
@@ -2048,6 +2250,16 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
2048
2250
|
name: "@frontmcp/testing",
|
|
2049
2251
|
version: "0.4.0"
|
|
2050
2252
|
};
|
|
2253
|
+
var MAX_LIST_PAGES = 1e3;
|
|
2254
|
+
function isAuthFailure(error) {
|
|
2255
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
2256
|
+
}
|
|
2257
|
+
function describeInitializeError(error) {
|
|
2258
|
+
const message = error?.message ?? "Unknown error";
|
|
2259
|
+
const data = error?.data;
|
|
2260
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
2261
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
2262
|
+
}
|
|
2051
2263
|
function extractEntryPaths(data) {
|
|
2052
2264
|
let payload = data;
|
|
2053
2265
|
if (typeof payload === "string") {
|
|
@@ -2134,19 +2346,22 @@ var McpTestClient = class {
|
|
|
2134
2346
|
async connect() {
|
|
2135
2347
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
2136
2348
|
this.triedDiscoveredEntryPath = false;
|
|
2349
|
+
this.initResult = null;
|
|
2137
2350
|
this.transport = this.createTransport();
|
|
2138
2351
|
await this.transport.connect();
|
|
2139
2352
|
const maxRetries = 3;
|
|
2140
2353
|
const retryDelayMs = 500;
|
|
2141
2354
|
let lastError;
|
|
2142
2355
|
let reportedEntryPaths;
|
|
2356
|
+
let attemptsMade = 0;
|
|
2143
2357
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2358
|
+
attemptsMade = attempt;
|
|
2144
2359
|
const initResponse = await this.initialize();
|
|
2145
2360
|
if (initResponse.success && initResponse.data) {
|
|
2146
2361
|
this.initResult = initResponse.data;
|
|
2147
2362
|
break;
|
|
2148
2363
|
}
|
|
2149
|
-
lastError = initResponse.error
|
|
2364
|
+
lastError = describeInitializeError(initResponse.error);
|
|
2150
2365
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
2151
2366
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
2152
2367
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -2158,6 +2373,7 @@ var McpTestClient = class {
|
|
|
2158
2373
|
await this.transport.connect();
|
|
2159
2374
|
continue;
|
|
2160
2375
|
}
|
|
2376
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
2161
2377
|
if (attempt < maxRetries) {
|
|
2162
2378
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
2163
2379
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -2166,7 +2382,7 @@ var McpTestClient = class {
|
|
|
2166
2382
|
if (!this.initResult) {
|
|
2167
2383
|
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.` : "";
|
|
2168
2384
|
throw new Error(
|
|
2169
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
2385
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
2170
2386
|
);
|
|
2171
2387
|
}
|
|
2172
2388
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -2236,18 +2452,47 @@ var McpTestClient = class {
|
|
|
2236
2452
|
return this._authState;
|
|
2237
2453
|
}
|
|
2238
2454
|
/**
|
|
2239
|
-
* Authenticate with a token
|
|
2455
|
+
* Authenticate with a token.
|
|
2456
|
+
*
|
|
2457
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
2458
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
2459
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
2460
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
2240
2461
|
*/
|
|
2241
2462
|
async authenticate(token) {
|
|
2463
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
2464
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
2465
|
+
}
|
|
2466
|
+
const previousAuthState = this._authState;
|
|
2467
|
+
const previousConfigAuth = this.config.auth;
|
|
2468
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
2242
2469
|
this._authState = {
|
|
2243
2470
|
isAnonymous: false,
|
|
2244
2471
|
token,
|
|
2245
2472
|
scopes: this.parseScopesFromToken(token),
|
|
2246
2473
|
user: this.parseUserFromToken(token)
|
|
2247
2474
|
};
|
|
2248
|
-
|
|
2249
|
-
|
|
2475
|
+
const previousTransport = this.transport;
|
|
2476
|
+
if (!previousTransport) {
|
|
2477
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
2478
|
+
return;
|
|
2250
2479
|
}
|
|
2480
|
+
const previousInit = this.initResult;
|
|
2481
|
+
const previousSessionId = this._sessionId;
|
|
2482
|
+
const previousSessionInfo = this._sessionInfo;
|
|
2483
|
+
try {
|
|
2484
|
+
await this.connect();
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
await this.transport?.close().catch(() => void 0);
|
|
2487
|
+
this.transport = previousTransport;
|
|
2488
|
+
this.initResult = previousInit;
|
|
2489
|
+
this._sessionId = previousSessionId;
|
|
2490
|
+
this._sessionInfo = previousSessionInfo;
|
|
2491
|
+
this.config.auth = previousConfigAuth;
|
|
2492
|
+
this._authState = previousAuthState;
|
|
2493
|
+
throw err;
|
|
2494
|
+
}
|
|
2495
|
+
await previousTransport.close().catch(() => void 0);
|
|
2251
2496
|
this.log("debug", "Authentication updated");
|
|
2252
2497
|
}
|
|
2253
2498
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2281,13 +2526,12 @@ var McpTestClient = class {
|
|
|
2281
2526
|
/**
|
|
2282
2527
|
* List all available tools
|
|
2283
2528
|
*/
|
|
2284
|
-
list: async () =>
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
},
|
|
2529
|
+
list: async () => this.listAllPages(
|
|
2530
|
+
"tools/list",
|
|
2531
|
+
"tools",
|
|
2532
|
+
(cursor) => this.listTools(cursor),
|
|
2533
|
+
(page) => page.tools
|
|
2534
|
+
),
|
|
2291
2535
|
/**
|
|
2292
2536
|
* Call a tool by name with arguments
|
|
2293
2537
|
*/
|
|
@@ -2303,23 +2547,21 @@ var McpTestClient = class {
|
|
|
2303
2547
|
/**
|
|
2304
2548
|
* List all static resources
|
|
2305
2549
|
*/
|
|
2306
|
-
list: async () =>
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
},
|
|
2550
|
+
list: async () => this.listAllPages(
|
|
2551
|
+
"resources/list",
|
|
2552
|
+
"resources",
|
|
2553
|
+
(cursor) => this.listResources(cursor),
|
|
2554
|
+
(page) => page.resources
|
|
2555
|
+
),
|
|
2313
2556
|
/**
|
|
2314
2557
|
* List all resource templates
|
|
2315
2558
|
*/
|
|
2316
|
-
listTemplates: async () =>
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
},
|
|
2559
|
+
listTemplates: async () => this.listAllPages(
|
|
2560
|
+
"resources/templates/list",
|
|
2561
|
+
"resource templates",
|
|
2562
|
+
(cursor) => this.listResourceTemplates(cursor),
|
|
2563
|
+
(page) => page.resourceTemplates
|
|
2564
|
+
),
|
|
2323
2565
|
/**
|
|
2324
2566
|
* Read a resource by URI
|
|
2325
2567
|
*/
|
|
@@ -2347,13 +2589,12 @@ var McpTestClient = class {
|
|
|
2347
2589
|
/**
|
|
2348
2590
|
* List all available prompts
|
|
2349
2591
|
*/
|
|
2350
|
-
list: async () =>
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
},
|
|
2592
|
+
list: async () => this.listAllPages(
|
|
2593
|
+
"prompts/list",
|
|
2594
|
+
"prompts",
|
|
2595
|
+
(cursor) => this.listPrompts(cursor),
|
|
2596
|
+
(page) => page.prompts
|
|
2597
|
+
),
|
|
2357
2598
|
/**
|
|
2358
2599
|
* Get a prompt with arguments
|
|
2359
2600
|
*/
|
|
@@ -2737,8 +2978,33 @@ var McpTestClient = class {
|
|
|
2737
2978
|
clientInfo: this.config.clientInfo
|
|
2738
2979
|
});
|
|
2739
2980
|
}
|
|
2740
|
-
async listTools() {
|
|
2741
|
-
return this.request("tools/list", {});
|
|
2981
|
+
async listTools(cursor) {
|
|
2982
|
+
return this.request("tools/list", cursor ? { cursor } : {});
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* Follow `nextCursor` until the server stops returning one, concatenating the
|
|
2986
|
+
* pages. A server that hands back a cursor it already returned, or never stops
|
|
2987
|
+
* paging, fails the call rather than looping.
|
|
2988
|
+
*/
|
|
2989
|
+
async listAllPages(method, label, fetchPage, itemsOf) {
|
|
2990
|
+
const items = [];
|
|
2991
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
2992
|
+
let cursor;
|
|
2993
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
2994
|
+
const response = await fetchPage(cursor);
|
|
2995
|
+
if (!response.success || !response.data) {
|
|
2996
|
+
throw new Error(`Failed to list ${label}: ${response.error?.message}`);
|
|
2997
|
+
}
|
|
2998
|
+
items.push(...itemsOf(response.data) ?? []);
|
|
2999
|
+
const next = response.data.nextCursor;
|
|
3000
|
+
if (!next) return items;
|
|
3001
|
+
if (seenCursors.has(next)) {
|
|
3002
|
+
throw new Error(`Failed to list ${label}: ${method} returned the cursor "${next}" twice`);
|
|
3003
|
+
}
|
|
3004
|
+
seenCursors.add(next);
|
|
3005
|
+
cursor = next;
|
|
3006
|
+
}
|
|
3007
|
+
throw new Error(`Failed to list ${label}: ${method} did not finish within ${MAX_LIST_PAGES} pages`);
|
|
2742
3008
|
}
|
|
2743
3009
|
async callTool(name, args, progressToken) {
|
|
2744
3010
|
return this.request("tools/call", {
|
|
@@ -2763,17 +3029,17 @@ var McpTestClient = class {
|
|
|
2763
3029
|
timestamp
|
|
2764
3030
|
});
|
|
2765
3031
|
};
|
|
2766
|
-
async listResources() {
|
|
2767
|
-
return this.request("resources/list", {});
|
|
3032
|
+
async listResources(cursor) {
|
|
3033
|
+
return this.request("resources/list", cursor ? { cursor } : {});
|
|
2768
3034
|
}
|
|
2769
|
-
async listResourceTemplates() {
|
|
2770
|
-
return this.request("resources/templates/list", {});
|
|
3035
|
+
async listResourceTemplates(cursor) {
|
|
3036
|
+
return this.request("resources/templates/list", cursor ? { cursor } : {});
|
|
2771
3037
|
}
|
|
2772
3038
|
async readResource(uri) {
|
|
2773
3039
|
return this.request("resources/read", { uri });
|
|
2774
3040
|
}
|
|
2775
|
-
async listPrompts() {
|
|
2776
|
-
return this.request("prompts/list", {});
|
|
3041
|
+
async listPrompts(cursor) {
|
|
3042
|
+
return this.request("prompts/list", cursor ? { cursor } : {});
|
|
2777
3043
|
}
|
|
2778
3044
|
async getPrompt(name, args) {
|
|
2779
3045
|
return this.request("prompts/get", {
|
|
@@ -2850,10 +3116,11 @@ var McpTestClient = class {
|
|
|
2850
3116
|
requestId: id
|
|
2851
3117
|
};
|
|
2852
3118
|
} catch (err) {
|
|
3119
|
+
if (isInterceptedError(err)) throw err;
|
|
2853
3120
|
const durationMs = Date.now() - start;
|
|
2854
3121
|
const error = {
|
|
2855
3122
|
code: -32603,
|
|
2856
|
-
message: err
|
|
3123
|
+
message: errorMessage(err)
|
|
2857
3124
|
};
|
|
2858
3125
|
return {
|
|
2859
3126
|
success: false,
|
|
@@ -3080,190 +3347,6 @@ var ProgressCollector = class {
|
|
|
3080
3347
|
}
|
|
3081
3348
|
};
|
|
3082
3349
|
|
|
3083
|
-
// libs/testing/src/auth/token-factory.ts
|
|
3084
|
-
var import_jose = require("jose");
|
|
3085
|
-
var TestTokenFactory = class {
|
|
3086
|
-
issuer;
|
|
3087
|
-
audience;
|
|
3088
|
-
privateKey = null;
|
|
3089
|
-
publicKey = null;
|
|
3090
|
-
jwk = null;
|
|
3091
|
-
keyId;
|
|
3092
|
-
/** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
|
|
3093
|
-
hmacSecret;
|
|
3094
|
-
constructor(options = {}) {
|
|
3095
|
-
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
3096
|
-
this.audience = options.audience ?? "frontmcp-test";
|
|
3097
|
-
this.keyId = `test-key-${Date.now()}`;
|
|
3098
|
-
this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
|
|
3099
|
-
}
|
|
3100
|
-
/** True when this factory signs HS256 with a shared secret (gateway mode). */
|
|
3101
|
-
get isHmac() {
|
|
3102
|
-
return this.hmacSecret !== null;
|
|
3103
|
-
}
|
|
3104
|
-
/** The JWS `alg` this factory signs with. */
|
|
3105
|
-
get alg() {
|
|
3106
|
-
return this.isHmac ? "HS256" : "RS256";
|
|
3107
|
-
}
|
|
3108
|
-
/**
|
|
3109
|
-
* The signing key: the shared HMAC secret in gateway mode, otherwise the
|
|
3110
|
-
* generated RSA private key (lazily created via ensureKeys()).
|
|
3111
|
-
*/
|
|
3112
|
-
signingKey() {
|
|
3113
|
-
if (this.hmacSecret) return this.hmacSecret;
|
|
3114
|
-
if (!this.privateKey) {
|
|
3115
|
-
throw new Error("Private key not initialized");
|
|
3116
|
-
}
|
|
3117
|
-
return this.privateKey;
|
|
3118
|
-
}
|
|
3119
|
-
/**
|
|
3120
|
-
* Initialize the key pair (called automatically on first use). No-op in HS256
|
|
3121
|
-
* mode — there is no asymmetric keypair, just the shared secret.
|
|
3122
|
-
*/
|
|
3123
|
-
async ensureKeys() {
|
|
3124
|
-
if (this.isHmac) return;
|
|
3125
|
-
if (this.privateKey && this.publicKey) return;
|
|
3126
|
-
const { publicKey, privateKey } = await (0, import_jose.generateKeyPair)("RS256", {
|
|
3127
|
-
extractable: true
|
|
3128
|
-
});
|
|
3129
|
-
this.privateKey = privateKey;
|
|
3130
|
-
this.publicKey = publicKey;
|
|
3131
|
-
this.jwk = await (0, import_jose.exportJWK)(publicKey);
|
|
3132
|
-
this.jwk.kid = this.keyId;
|
|
3133
|
-
this.jwk.use = "sig";
|
|
3134
|
-
this.jwk.alg = "RS256";
|
|
3135
|
-
}
|
|
3136
|
-
/**
|
|
3137
|
-
* Create a JWT token with the specified claims
|
|
3138
|
-
*/
|
|
3139
|
-
async createTestToken(options) {
|
|
3140
|
-
await this.ensureKeys();
|
|
3141
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3142
|
-
const exp = options.exp ?? 3600;
|
|
3143
|
-
const payload = {
|
|
3144
|
-
iss: options.iss ?? this.issuer,
|
|
3145
|
-
sub: options.sub,
|
|
3146
|
-
aud: options.aud ?? this.audience,
|
|
3147
|
-
iat: now,
|
|
3148
|
-
exp: now + exp,
|
|
3149
|
-
scope: options.scopes?.join(" "),
|
|
3150
|
-
...options.claims
|
|
3151
|
-
};
|
|
3152
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3153
|
-
const token = await new import_jose.SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3154
|
-
return token;
|
|
3155
|
-
}
|
|
3156
|
-
/**
|
|
3157
|
-
* Create an admin token with full access
|
|
3158
|
-
*/
|
|
3159
|
-
async createAdminToken(sub = "admin-001") {
|
|
3160
|
-
return this.createTestToken({
|
|
3161
|
-
sub,
|
|
3162
|
-
scopes: ["admin:*", "read", "write", "delete"],
|
|
3163
|
-
claims: {
|
|
3164
|
-
email: "admin@test.local",
|
|
3165
|
-
name: "Test Admin",
|
|
3166
|
-
role: "admin"
|
|
3167
|
-
}
|
|
3168
|
-
});
|
|
3169
|
-
}
|
|
3170
|
-
/**
|
|
3171
|
-
* Create a regular user token
|
|
3172
|
-
*/
|
|
3173
|
-
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
3174
|
-
return this.createTestToken({
|
|
3175
|
-
sub,
|
|
3176
|
-
scopes,
|
|
3177
|
-
claims: {
|
|
3178
|
-
email: "user@test.local",
|
|
3179
|
-
name: "Test User",
|
|
3180
|
-
role: "user"
|
|
3181
|
-
}
|
|
3182
|
-
});
|
|
3183
|
-
}
|
|
3184
|
-
/**
|
|
3185
|
-
* Create an anonymous user token
|
|
3186
|
-
*/
|
|
3187
|
-
async createAnonymousToken() {
|
|
3188
|
-
return this.createTestToken({
|
|
3189
|
-
sub: `anon:${Date.now()}`,
|
|
3190
|
-
scopes: ["anonymous"],
|
|
3191
|
-
claims: {
|
|
3192
|
-
name: "Anonymous",
|
|
3193
|
-
role: "anonymous"
|
|
3194
|
-
}
|
|
3195
|
-
});
|
|
3196
|
-
}
|
|
3197
|
-
/**
|
|
3198
|
-
* Create an expired token (for testing token expiration)
|
|
3199
|
-
*/
|
|
3200
|
-
async createExpiredToken(options) {
|
|
3201
|
-
await this.ensureKeys();
|
|
3202
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3203
|
-
const payload = {
|
|
3204
|
-
iss: this.issuer,
|
|
3205
|
-
sub: options.sub,
|
|
3206
|
-
aud: this.audience,
|
|
3207
|
-
iat: now - 7200,
|
|
3208
|
-
// 2 hours ago
|
|
3209
|
-
exp: now - 3600
|
|
3210
|
-
// Expired 1 hour ago
|
|
3211
|
-
};
|
|
3212
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3213
|
-
const token = await new import_jose.SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3214
|
-
return token;
|
|
3215
|
-
}
|
|
3216
|
-
/**
|
|
3217
|
-
* Create a token with an invalid signature (for testing signature validation)
|
|
3218
|
-
*/
|
|
3219
|
-
createTokenWithInvalidSignature(options) {
|
|
3220
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3221
|
-
const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3222
|
-
const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
|
|
3223
|
-
const payload = Buffer.from(
|
|
3224
|
-
JSON.stringify({
|
|
3225
|
-
iss: this.issuer,
|
|
3226
|
-
sub: options.sub,
|
|
3227
|
-
aud: this.audience,
|
|
3228
|
-
iat: now,
|
|
3229
|
-
exp: now + 3600
|
|
3230
|
-
})
|
|
3231
|
-
).toString("base64url");
|
|
3232
|
-
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
3233
|
-
return `${header}.${payload}.${signature}`;
|
|
3234
|
-
}
|
|
3235
|
-
/**
|
|
3236
|
-
* Get the public JWKS for verifying tokens.
|
|
3237
|
-
*
|
|
3238
|
-
* Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
|
|
3239
|
-
* the shared secret, not a published public key, so there is no JWKS to serve.
|
|
3240
|
-
*/
|
|
3241
|
-
async getPublicJwks() {
|
|
3242
|
-
if (this.isHmac) {
|
|
3243
|
-
throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
|
|
3244
|
-
}
|
|
3245
|
-
await this.ensureKeys();
|
|
3246
|
-
if (!this.jwk) {
|
|
3247
|
-
throw new Error("JWK not initialized");
|
|
3248
|
-
}
|
|
3249
|
-
return {
|
|
3250
|
-
keys: [this.jwk]
|
|
3251
|
-
};
|
|
3252
|
-
}
|
|
3253
|
-
/**
|
|
3254
|
-
* Get the issuer URL
|
|
3255
|
-
*/
|
|
3256
|
-
getIssuer() {
|
|
3257
|
-
return this.issuer;
|
|
3258
|
-
}
|
|
3259
|
-
/**
|
|
3260
|
-
* Get the audience
|
|
3261
|
-
*/
|
|
3262
|
-
getAudience() {
|
|
3263
|
-
return this.audience;
|
|
3264
|
-
}
|
|
3265
|
-
};
|
|
3266
|
-
|
|
3267
3350
|
// libs/testing/src/server/test-server.ts
|
|
3268
3351
|
var import_child_process = require("child_process");
|
|
3269
3352
|
var import_utils = require("@frontmcp/utils");
|
|
@@ -3336,6 +3419,8 @@ var E2E_PORT_RANGES = {
|
|
|
3336
3419
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
3337
3420
|
// Protocol revision E2E tests (50460-50479)
|
|
3338
3421
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
3422
|
+
// Testing library E2E tests (50480-50499)
|
|
3423
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
3339
3424
|
// Mock servers and utilities (50900-50999)
|
|
3340
3425
|
"mock-oauth": { start: 50900, size: 10 },
|
|
3341
3426
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -3344,6 +3429,66 @@ var E2E_PORT_RANGES = {
|
|
|
3344
3429
|
default: { start: 51e3, size: 100 }
|
|
3345
3430
|
};
|
|
3346
3431
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3432
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
3433
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
3434
|
+
function lockDir() {
|
|
3435
|
+
try {
|
|
3436
|
+
const os = require("node:os");
|
|
3437
|
+
const path = require("node:path");
|
|
3438
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
3439
|
+
} catch {
|
|
3440
|
+
return void 0;
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
function pidIsAlive(pid) {
|
|
3444
|
+
try {
|
|
3445
|
+
process.kill(pid, 0);
|
|
3446
|
+
return true;
|
|
3447
|
+
} catch (error) {
|
|
3448
|
+
return error.code === "EPERM";
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
function acquirePortLock(port) {
|
|
3452
|
+
if (lockedPorts.has(port)) return false;
|
|
3453
|
+
let fs;
|
|
3454
|
+
let file;
|
|
3455
|
+
try {
|
|
3456
|
+
fs = require("node:fs");
|
|
3457
|
+
const dir = lockDir();
|
|
3458
|
+
if (!dir) return true;
|
|
3459
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3460
|
+
file = require("node:path").join(dir, `${port}.lock`);
|
|
3461
|
+
} catch {
|
|
3462
|
+
return true;
|
|
3463
|
+
}
|
|
3464
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
3465
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3466
|
+
try {
|
|
3467
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
3468
|
+
lockedPorts.add(port);
|
|
3469
|
+
return true;
|
|
3470
|
+
} catch (error) {
|
|
3471
|
+
if (error.code !== "EEXIST") return true;
|
|
3472
|
+
try {
|
|
3473
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3474
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
3475
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
3476
|
+
fs.unlinkSync(file);
|
|
3477
|
+
} catch {
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
}
|
|
3481
|
+
return false;
|
|
3482
|
+
}
|
|
3483
|
+
function releasePortLock(port) {
|
|
3484
|
+
if (!lockedPorts.delete(port)) return;
|
|
3485
|
+
try {
|
|
3486
|
+
const fs = require("node:fs");
|
|
3487
|
+
const dir = lockDir();
|
|
3488
|
+
if (dir) fs.unlinkSync(require("node:path").join(dir, `${port}.lock`));
|
|
3489
|
+
} catch {
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3347
3492
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
3348
3493
|
function getPortRange(project) {
|
|
3349
3494
|
const key = project;
|
|
@@ -3354,15 +3499,22 @@ function getPortRange(project) {
|
|
|
3354
3499
|
}
|
|
3355
3500
|
async function reservePort(project, preferredPort) {
|
|
3356
3501
|
const range = getPortRange(project);
|
|
3357
|
-
|
|
3502
|
+
const handle = (port) => ({
|
|
3503
|
+
port,
|
|
3504
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
3505
|
+
releaseSocket: async () => {
|
|
3506
|
+
await releasePort(port);
|
|
3507
|
+
},
|
|
3508
|
+
release: async () => {
|
|
3509
|
+
await releasePort(port);
|
|
3510
|
+
releasePortLock(port);
|
|
3511
|
+
}
|
|
3512
|
+
});
|
|
3513
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
3514
|
+
if (wantsSpecificPort) {
|
|
3358
3515
|
const reservation = await tryReservePort(preferredPort, project);
|
|
3359
3516
|
if (reservation) {
|
|
3360
|
-
return
|
|
3361
|
-
port: preferredPort,
|
|
3362
|
-
release: async () => {
|
|
3363
|
-
await releasePort(preferredPort);
|
|
3364
|
-
}
|
|
3365
|
-
};
|
|
3517
|
+
return handle(preferredPort);
|
|
3366
3518
|
}
|
|
3367
3519
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
3368
3520
|
}
|
|
@@ -3371,30 +3523,19 @@ async function reservePort(project, preferredPort) {
|
|
|
3371
3523
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3372
3524
|
const port = range.start + index % range.size;
|
|
3373
3525
|
index = (index + 1) % range.size;
|
|
3374
|
-
if (reservedPorts.has(port)) {
|
|
3526
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
3375
3527
|
continue;
|
|
3376
3528
|
}
|
|
3377
3529
|
const reservation = await tryReservePort(port, project);
|
|
3378
3530
|
if (reservation) {
|
|
3379
3531
|
projectPortIndex.set(project, index);
|
|
3380
|
-
return
|
|
3381
|
-
port,
|
|
3382
|
-
release: async () => {
|
|
3383
|
-
await releasePort(port);
|
|
3384
|
-
}
|
|
3385
|
-
};
|
|
3532
|
+
return handle(port);
|
|
3386
3533
|
}
|
|
3387
3534
|
}
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
return {
|
|
3393
|
-
port: dynamicPort,
|
|
3394
|
-
release: async () => {
|
|
3395
|
-
await releasePort(dynamicPort);
|
|
3396
|
-
}
|
|
3397
|
-
};
|
|
3535
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
3536
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
3537
|
+
if (await tryReservePort(port, project)) {
|
|
3538
|
+
return handle(port);
|
|
3398
3539
|
}
|
|
3399
3540
|
}
|
|
3400
3541
|
throw new Error(
|
|
@@ -3402,9 +3543,13 @@ async function reservePort(project, preferredPort) {
|
|
|
3402
3543
|
);
|
|
3403
3544
|
}
|
|
3404
3545
|
async function tryReservePort(port, project) {
|
|
3546
|
+
if (!acquirePortLock(port)) {
|
|
3547
|
+
return false;
|
|
3548
|
+
}
|
|
3405
3549
|
return new Promise((resolve) => {
|
|
3406
3550
|
const server = (0, import_net.createServer)();
|
|
3407
3551
|
server.once("error", () => {
|
|
3552
|
+
releasePortLock(port);
|
|
3408
3553
|
resolve(false);
|
|
3409
3554
|
});
|
|
3410
3555
|
server.listen(port, () => {
|
|
@@ -3430,31 +3575,6 @@ async function releasePort(port) {
|
|
|
3430
3575
|
});
|
|
3431
3576
|
});
|
|
3432
3577
|
}
|
|
3433
|
-
async function findAvailablePortInRange(start, end) {
|
|
3434
|
-
for (let port = start; port < end; port++) {
|
|
3435
|
-
if (reservedPorts.has(port)) {
|
|
3436
|
-
continue;
|
|
3437
|
-
}
|
|
3438
|
-
const available = await isPortAvailable(port);
|
|
3439
|
-
if (available) {
|
|
3440
|
-
return port;
|
|
3441
|
-
}
|
|
3442
|
-
}
|
|
3443
|
-
return null;
|
|
3444
|
-
}
|
|
3445
|
-
async function isPortAvailable(port) {
|
|
3446
|
-
return new Promise((resolve) => {
|
|
3447
|
-
const server = (0, import_net.createServer)();
|
|
3448
|
-
server.once("error", () => {
|
|
3449
|
-
resolve(false);
|
|
3450
|
-
});
|
|
3451
|
-
server.listen(port, () => {
|
|
3452
|
-
server.close(() => {
|
|
3453
|
-
resolve(true);
|
|
3454
|
-
});
|
|
3455
|
-
});
|
|
3456
|
-
});
|
|
3457
|
-
}
|
|
3458
3578
|
|
|
3459
3579
|
// libs/testing/src/server/test-server.ts
|
|
3460
3580
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -3463,8 +3583,11 @@ var TestServer = class _TestServer {
|
|
|
3463
3583
|
options;
|
|
3464
3584
|
_info;
|
|
3465
3585
|
logs = [];
|
|
3586
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
3466
3587
|
portRelease = null;
|
|
3467
|
-
|
|
3588
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
3589
|
+
portReleaseSocket = null;
|
|
3590
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
3468
3591
|
this.options = {
|
|
3469
3592
|
port,
|
|
3470
3593
|
project: options.project,
|
|
@@ -3476,6 +3599,7 @@ var TestServer = class _TestServer {
|
|
|
3476
3599
|
debug: options.debug ?? DEBUG_SERVER
|
|
3477
3600
|
};
|
|
3478
3601
|
this.portRelease = portRelease ?? null;
|
|
3602
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
3479
3603
|
this._info = {
|
|
3480
3604
|
baseUrl: `http://localhost:${port}`,
|
|
3481
3605
|
port
|
|
@@ -3488,8 +3612,8 @@ var TestServer = class _TestServer {
|
|
|
3488
3612
|
const project = options.project ?? "default";
|
|
3489
3613
|
const maxAttempts = 3;
|
|
3490
3614
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3491
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3492
|
-
const server = new _TestServer(options, port, release);
|
|
3615
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3616
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
3493
3617
|
try {
|
|
3494
3618
|
await server.startProcess();
|
|
3495
3619
|
return server;
|
|
@@ -3529,7 +3653,7 @@ var TestServer = class _TestServer {
|
|
|
3529
3653
|
}
|
|
3530
3654
|
const maxAttempts = 3;
|
|
3531
3655
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3532
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3656
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3533
3657
|
const serverOptions = {
|
|
3534
3658
|
...options,
|
|
3535
3659
|
port,
|
|
@@ -3537,7 +3661,7 @@ var TestServer = class _TestServer {
|
|
|
3537
3661
|
command: `npx nx serve ${project} --port ${port}`,
|
|
3538
3662
|
cwd: options.cwd ?? process.cwd()
|
|
3539
3663
|
};
|
|
3540
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
3664
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
3541
3665
|
try {
|
|
3542
3666
|
await server.startProcess();
|
|
3543
3667
|
return server;
|
|
@@ -3595,10 +3719,17 @@ var TestServer = class _TestServer {
|
|
|
3595
3719
|
* Stop the test server
|
|
3596
3720
|
*/
|
|
3597
3721
|
async stop() {
|
|
3598
|
-
|
|
3599
|
-
await this.
|
|
3600
|
-
|
|
3722
|
+
try {
|
|
3723
|
+
await this.stopProcess();
|
|
3724
|
+
} finally {
|
|
3725
|
+
if (this.portRelease) {
|
|
3726
|
+
await this.portRelease();
|
|
3727
|
+
this.portRelease = null;
|
|
3728
|
+
}
|
|
3729
|
+
this.portReleaseSocket = null;
|
|
3601
3730
|
}
|
|
3731
|
+
}
|
|
3732
|
+
async stopProcess() {
|
|
3602
3733
|
if (this.process) {
|
|
3603
3734
|
this.log("Stopping server...");
|
|
3604
3735
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -3671,7 +3802,7 @@ var TestServer = class _TestServer {
|
|
|
3671
3802
|
* Restart the server
|
|
3672
3803
|
*/
|
|
3673
3804
|
async restart() {
|
|
3674
|
-
await this.
|
|
3805
|
+
await this.stopProcess();
|
|
3675
3806
|
await this.startProcess();
|
|
3676
3807
|
}
|
|
3677
3808
|
/**
|
|
@@ -3701,9 +3832,9 @@ var TestServer = class _TestServer {
|
|
|
3701
3832
|
PORT: String(this.options.port)
|
|
3702
3833
|
};
|
|
3703
3834
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
3704
|
-
if (this.
|
|
3705
|
-
await this.
|
|
3706
|
-
this.
|
|
3835
|
+
if (this.portReleaseSocket) {
|
|
3836
|
+
await this.portReleaseSocket();
|
|
3837
|
+
this.portReleaseSocket = null;
|
|
3707
3838
|
await sleep4(300);
|
|
3708
3839
|
}
|
|
3709
3840
|
this.process = (0, import_child_process.spawn)(this.options.command, [], {
|
|
@@ -3713,9 +3844,8 @@ var TestServer = class _TestServer {
|
|
|
3713
3844
|
detached: true,
|
|
3714
3845
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3715
3846
|
});
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
}
|
|
3847
|
+
this._info.pid = this.process.pid;
|
|
3848
|
+
this._info.shellPid = this.process.pid;
|
|
3719
3849
|
let processExited = false;
|
|
3720
3850
|
let exitCode = null;
|
|
3721
3851
|
let exitError = null;
|
|
@@ -3818,6 +3948,8 @@ ${allLogs}`);
|
|
|
3818
3948
|
});
|
|
3819
3949
|
if (response.ok || response.status === 404) {
|
|
3820
3950
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3951
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
3952
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3821
3953
|
return;
|
|
3822
3954
|
}
|
|
3823
3955
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3862,6 +3994,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3862
3994
|
function sleep4(ms) {
|
|
3863
3995
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3864
3996
|
}
|
|
3997
|
+
function findListeningPid(port) {
|
|
3998
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3999
|
+
return new Promise((resolve) => {
|
|
4000
|
+
(0, import_child_process.execFile)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
4001
|
+
if (error) {
|
|
4002
|
+
resolve(void 0);
|
|
4003
|
+
return;
|
|
4004
|
+
}
|
|
4005
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
4006
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
4007
|
+
});
|
|
4008
|
+
});
|
|
4009
|
+
}
|
|
3865
4010
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3866
4011
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3867
4012
|
return env;
|
|
@@ -4009,7 +4154,8 @@ Server entry: ${currentConfig.server}
|
|
|
4009
4154
|
Project: ${currentConfig.project ?? "default"}
|
|
4010
4155
|
Command: ${serverCommand}
|
|
4011
4156
|
|
|
4012
|
-
Error: ${errMsg}
|
|
4157
|
+
Error: ${errMsg}`,
|
|
4158
|
+
{ cause: error }
|
|
4013
4159
|
);
|
|
4014
4160
|
}
|
|
4015
4161
|
} else {
|
|
@@ -4199,16 +4345,19 @@ function todo(name) {
|
|
|
4199
4345
|
}
|
|
4200
4346
|
var perfTest = perfTestWithFixtures;
|
|
4201
4347
|
perfTest.use = use;
|
|
4202
|
-
|
|
4203
|
-
perfTest
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4348
|
+
for (const hook of ["describe", "beforeAll", "beforeEach", "afterEach", "afterAll"]) {
|
|
4349
|
+
Object.defineProperty(perfTest, hook, {
|
|
4350
|
+
get: () => globalThis[hook],
|
|
4351
|
+
enumerable: true,
|
|
4352
|
+
configurable: true
|
|
4353
|
+
});
|
|
4354
|
+
}
|
|
4207
4355
|
perfTest.skip = skip;
|
|
4208
4356
|
perfTest.only = only;
|
|
4209
4357
|
perfTest.todo = todo;
|
|
4210
4358
|
|
|
4211
4359
|
// libs/testing/src/perf/baseline-store.ts
|
|
4360
|
+
var import_utils2 = require("@frontmcp/utils");
|
|
4212
4361
|
var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
|
|
4213
4362
|
var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
|
|
4214
4363
|
var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
|
|
@@ -4223,11 +4372,10 @@ var BaselineStore = class {
|
|
|
4223
4372
|
*/
|
|
4224
4373
|
async load() {
|
|
4225
4374
|
try {
|
|
4226
|
-
|
|
4227
|
-
if (!await fileExists(this.baselinePath)) {
|
|
4375
|
+
if (!await (0, import_utils2.fileExists)(this.baselinePath)) {
|
|
4228
4376
|
return null;
|
|
4229
4377
|
}
|
|
4230
|
-
const content = await readFile(this.baselinePath);
|
|
4378
|
+
const content = await (0, import_utils2.readFile)(this.baselinePath);
|
|
4231
4379
|
this.baseline = JSON.parse(content);
|
|
4232
4380
|
return this.baseline;
|
|
4233
4381
|
} catch {
|
|
@@ -4238,10 +4386,9 @@ var BaselineStore = class {
|
|
|
4238
4386
|
* Save baseline to local file.
|
|
4239
4387
|
*/
|
|
4240
4388
|
async save(baseline) {
|
|
4241
|
-
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
4242
4389
|
const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
|
|
4243
|
-
await ensureDir(dir);
|
|
4244
|
-
await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
4390
|
+
await (0, import_utils2.ensureDir)(dir);
|
|
4391
|
+
await (0, import_utils2.writeFile)(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
4245
4392
|
this.baseline = baseline;
|
|
4246
4393
|
}
|
|
4247
4394
|
/**
|
|
@@ -4517,6 +4664,7 @@ function resetRegressionDetector() {
|
|
|
4517
4664
|
}
|
|
4518
4665
|
|
|
4519
4666
|
// libs/testing/src/perf/report-generator.ts
|
|
4667
|
+
var import_utils3 = require("@frontmcp/utils");
|
|
4520
4668
|
var ReportGenerator = class {
|
|
4521
4669
|
detector;
|
|
4522
4670
|
constructor() {
|
|
@@ -4772,14 +4920,13 @@ var ReportGenerator = class {
|
|
|
4772
4920
|
}
|
|
4773
4921
|
};
|
|
4774
4922
|
async function saveReports(measurements, outputDir, baseline, gitInfo) {
|
|
4775
|
-
|
|
4776
|
-
await ensureDir(outputDir);
|
|
4923
|
+
await (0, import_utils3.ensureDir)(outputDir);
|
|
4777
4924
|
const generator = new ReportGenerator();
|
|
4778
4925
|
const report = generator.generateReport(measurements, baseline, gitInfo);
|
|
4779
4926
|
const jsonPath = `${outputDir}/report.json`;
|
|
4780
4927
|
const markdownPath = `${outputDir}/report.md`;
|
|
4781
|
-
await writeFile(jsonPath, generator.generateJsonReport(report));
|
|
4782
|
-
await writeFile(markdownPath, generator.generateMarkdownReport(report));
|
|
4928
|
+
await (0, import_utils3.writeFile)(jsonPath, generator.generateJsonReport(report));
|
|
4929
|
+
await (0, import_utils3.writeFile)(markdownPath, generator.generateMarkdownReport(report));
|
|
4783
4930
|
return { jsonPath, markdownPath };
|
|
4784
4931
|
}
|
|
4785
4932
|
function createReportGenerator() {
|