@frontmcp/testing 1.8.6 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts +2 -2
- package/auth/token-factory.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +6 -1
- package/client/mcp-test-client.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +993 -237
- package/esm/index.mjs +1151 -360
- package/esm/matchers/index.mjs +3 -0
- package/esm/package.json +8 -6
- package/esm/perf/index.mjs +874 -280
- package/esm/perf/perf-setup.mjs +3 -0
- package/esm/playwright/index.mjs +3 -0
- package/esm/setup.mjs +3 -0
- package/expect.d.ts +12 -11
- package/expect.d.ts.map +1 -1
- package/fixtures/fixture-types.d.ts +41 -10
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.d.ts +1 -1
- package/fixtures/index.d.ts.map +1 -1
- package/fixtures/index.js +989 -229
- package/fixtures/test-fixture.d.ts +6 -5
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/http-mock/http-mock.d.ts +5 -3
- package/http-mock/http-mock.d.ts.map +1 -1
- package/http-mock/http-mock.types.d.ts +17 -6
- package/http-mock/http-mock.types.d.ts.map +1 -1
- package/http-mock/index.d.ts +1 -1
- package/http-mock/index.d.ts.map +1 -1
- package/index.d.ts +3 -2
- package/index.d.ts.map +1 -1
- package/index.js +1157 -361
- package/package.json +8 -6
- package/perf/baseline-store.d.ts +1 -1
- package/perf/baseline-store.d.ts.map +1 -1
- package/perf/index.js +874 -293
- package/perf/perf-test.d.ts.map +1 -1
- package/perf/report-generator.d.ts +1 -1
- package/perf/report-generator.d.ts.map +1 -1
- package/server/port-registry.d.ts +7 -1
- package/server/port-registry.d.ts.map +1 -1
- package/server/test-server.d.ts +10 -1
- package/server/test-server.d.ts.map +1 -1
- package/transport/error-utils.d.ts +15 -0
- package/transport/error-utils.d.ts.map +1 -0
- package/transport/index.d.ts +1 -0
- package/transport/index.d.ts.map +1 -1
- package/transport/sse.transport.d.ts +80 -0
- package/transport/sse.transport.d.ts.map +1 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
package/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,8 +1282,479 @@ function sleep3(ms) {
|
|
|
1106
1282
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1107
1283
|
}
|
|
1108
1284
|
|
|
1109
|
-
// libs/testing/src/transport/
|
|
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
|
+
|
|
1311
|
+
// libs/testing/src/transport/sse.transport.ts
|
|
1110
1312
|
var DEFAULT_TIMEOUT = 3e4;
|
|
1313
|
+
var SseTransport = class {
|
|
1314
|
+
baseUrl;
|
|
1315
|
+
entryPath;
|
|
1316
|
+
timeout;
|
|
1317
|
+
authHeaders;
|
|
1318
|
+
publicMode;
|
|
1319
|
+
debug;
|
|
1320
|
+
clientInfo;
|
|
1321
|
+
notificationHandler;
|
|
1322
|
+
interceptors;
|
|
1323
|
+
elicitationHandler;
|
|
1324
|
+
state = "disconnected";
|
|
1325
|
+
authToken;
|
|
1326
|
+
sessionId;
|
|
1327
|
+
messageEndpoint;
|
|
1328
|
+
stream;
|
|
1329
|
+
pending = /* @__PURE__ */ new Map();
|
|
1330
|
+
connectionCount = 0;
|
|
1331
|
+
reconnectCount = 0;
|
|
1332
|
+
lastRequestHeaders = {};
|
|
1333
|
+
constructor(config) {
|
|
1334
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
1335
|
+
this.entryPath = config.entryPath;
|
|
1336
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
1337
|
+
this.authHeaders = config.auth?.headers ?? {};
|
|
1338
|
+
this.authToken = config.auth?.token;
|
|
1339
|
+
this.publicMode = config.publicMode ?? false;
|
|
1340
|
+
this.debug = config.debug ?? false;
|
|
1341
|
+
this.clientInfo = config.clientInfo;
|
|
1342
|
+
this.notificationHandler = config.notificationHandler;
|
|
1343
|
+
this.interceptors = config.interceptors;
|
|
1344
|
+
this.elicitationHandler = config.elicitationHandler;
|
|
1345
|
+
}
|
|
1346
|
+
async connect() {
|
|
1347
|
+
this.state = "connecting";
|
|
1348
|
+
this.connectionCount++;
|
|
1349
|
+
try {
|
|
1350
|
+
if (!this.publicMode && !this.authToken) {
|
|
1351
|
+
await this.requestAnonymousToken();
|
|
1352
|
+
}
|
|
1353
|
+
await this.openStream();
|
|
1354
|
+
this.state = "connected";
|
|
1355
|
+
this.log(`Connected; messages go to ${this.messageEndpoint}`);
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
this.state = "error";
|
|
1358
|
+
this.stream?.abort();
|
|
1359
|
+
this.stream = void 0;
|
|
1360
|
+
throw error;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
async request(message) {
|
|
1364
|
+
this.ensureConnected();
|
|
1365
|
+
const startTime = Date.now();
|
|
1366
|
+
if (this.interceptors) {
|
|
1367
|
+
const intercepted = await this.interceptors.processRequest(message, {
|
|
1368
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
1369
|
+
transport: "sse",
|
|
1370
|
+
sessionId: this.sessionId
|
|
1371
|
+
});
|
|
1372
|
+
switch (intercepted.type) {
|
|
1373
|
+
case "mock":
|
|
1374
|
+
return await this.interceptors.processResponse(
|
|
1375
|
+
message,
|
|
1376
|
+
intercepted.response,
|
|
1377
|
+
Date.now() - startTime
|
|
1378
|
+
);
|
|
1379
|
+
case "error":
|
|
1380
|
+
throw markInterceptedError(intercepted.error);
|
|
1381
|
+
case "continue":
|
|
1382
|
+
message = intercepted.request;
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
let response;
|
|
1387
|
+
if (message.id === void 0) {
|
|
1388
|
+
await this.post(JSON.stringify(message));
|
|
1389
|
+
response = { jsonrpc: "2.0", id: null, result: void 0 };
|
|
1390
|
+
} else {
|
|
1391
|
+
response = await this.postAndAwait(message.id, JSON.stringify(message));
|
|
1392
|
+
}
|
|
1393
|
+
if (this.interceptors) {
|
|
1394
|
+
response = await this.interceptors.processResponse(message, response, Date.now() - startTime);
|
|
1395
|
+
}
|
|
1396
|
+
return response;
|
|
1397
|
+
}
|
|
1398
|
+
async notify(message) {
|
|
1399
|
+
this.ensureConnected();
|
|
1400
|
+
const result = await this.post(JSON.stringify(message));
|
|
1401
|
+
if (!result.ok) this.log(`HTTP ${result.status} on notification: ${result.body}`);
|
|
1402
|
+
}
|
|
1403
|
+
async sendRaw(data) {
|
|
1404
|
+
this.ensureConnected();
|
|
1405
|
+
let id;
|
|
1406
|
+
try {
|
|
1407
|
+
const parsed = JSON.parse(data);
|
|
1408
|
+
if (typeof parsed.id === "string" || typeof parsed.id === "number") id = parsed.id;
|
|
1409
|
+
} catch {
|
|
1410
|
+
}
|
|
1411
|
+
if (id !== void 0) return this.postAndAwait(id, data);
|
|
1412
|
+
const result = await this.post(data);
|
|
1413
|
+
if (!result.ok) {
|
|
1414
|
+
return { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error", data: result.body } };
|
|
1415
|
+
}
|
|
1416
|
+
return { jsonrpc: "2.0", id: null, result: void 0 };
|
|
1417
|
+
}
|
|
1418
|
+
async close() {
|
|
1419
|
+
this.closeStream("Transport closed");
|
|
1420
|
+
this.state = "disconnected";
|
|
1421
|
+
this.sessionId = void 0;
|
|
1422
|
+
this.messageEndpoint = void 0;
|
|
1423
|
+
this.log("SSE transport closed");
|
|
1424
|
+
}
|
|
1425
|
+
isConnected() {
|
|
1426
|
+
return this.state === "connected";
|
|
1427
|
+
}
|
|
1428
|
+
getState() {
|
|
1429
|
+
return this.state;
|
|
1430
|
+
}
|
|
1431
|
+
getSessionId() {
|
|
1432
|
+
return this.sessionId;
|
|
1433
|
+
}
|
|
1434
|
+
setAuthToken(token) {
|
|
1435
|
+
this.authToken = token;
|
|
1436
|
+
}
|
|
1437
|
+
setTimeout(ms) {
|
|
1438
|
+
this.timeout = ms;
|
|
1439
|
+
}
|
|
1440
|
+
getMessageEndpoint() {
|
|
1441
|
+
return this.messageEndpoint;
|
|
1442
|
+
}
|
|
1443
|
+
getConnectionCount() {
|
|
1444
|
+
return this.connectionCount;
|
|
1445
|
+
}
|
|
1446
|
+
getReconnectCount() {
|
|
1447
|
+
return this.reconnectCount;
|
|
1448
|
+
}
|
|
1449
|
+
getLastRequestHeaders() {
|
|
1450
|
+
return { ...this.lastRequestHeaders };
|
|
1451
|
+
}
|
|
1452
|
+
setInterceptors(interceptors2) {
|
|
1453
|
+
this.interceptors = interceptors2;
|
|
1454
|
+
}
|
|
1455
|
+
getInterceptors() {
|
|
1456
|
+
return this.interceptors;
|
|
1457
|
+
}
|
|
1458
|
+
setElicitationHandler(handler) {
|
|
1459
|
+
this.elicitationHandler = handler;
|
|
1460
|
+
}
|
|
1461
|
+
async simulateDisconnect() {
|
|
1462
|
+
this.closeStream("Simulated disconnect");
|
|
1463
|
+
this.state = "disconnected";
|
|
1464
|
+
this.sessionId = void 0;
|
|
1465
|
+
this.messageEndpoint = void 0;
|
|
1466
|
+
}
|
|
1467
|
+
async waitForReconnect(timeoutMs) {
|
|
1468
|
+
this.reconnectCount++;
|
|
1469
|
+
let timer;
|
|
1470
|
+
try {
|
|
1471
|
+
await Promise.race([
|
|
1472
|
+
this.connect(),
|
|
1473
|
+
new Promise((_, reject) => {
|
|
1474
|
+
timer = setTimeout(() => reject(new Error("Timeout waiting for reconnection")), timeoutMs);
|
|
1475
|
+
})
|
|
1476
|
+
]);
|
|
1477
|
+
} finally {
|
|
1478
|
+
clearTimeout(timer);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1482
|
+
// STREAM
|
|
1483
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1484
|
+
/** Open `GET <entry>/sse` and wait for the `endpoint` event. */
|
|
1485
|
+
async openStream() {
|
|
1486
|
+
const controller = new AbortController();
|
|
1487
|
+
this.stream = controller;
|
|
1488
|
+
const url = this.sseUrl();
|
|
1489
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
1490
|
+
this.log(`GET ${url}`);
|
|
1491
|
+
const connectTimer = setTimeout(() => controller.abort(), this.timeout);
|
|
1492
|
+
let response;
|
|
1493
|
+
try {
|
|
1494
|
+
response = await fetch(url, {
|
|
1495
|
+
method: "GET",
|
|
1496
|
+
headers: { ...headers, Accept: "text/event-stream" },
|
|
1497
|
+
signal: controller.signal
|
|
1498
|
+
});
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
clearTimeout(connectTimer);
|
|
1501
|
+
throw new Error(
|
|
1502
|
+
isAbortError(error) ? `SSE connection to ${url} timed out after ${this.timeout}ms` : `SSE connection to ${url} failed: ${errorMessage(error)}`,
|
|
1503
|
+
{ cause: error }
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
1507
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
1508
|
+
clearTimeout(connectTimer);
|
|
1509
|
+
const body = await response.text().catch(() => "");
|
|
1510
|
+
throw new Error(
|
|
1511
|
+
`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 } } })).`
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
const endpoint = new Promise((resolve, reject) => {
|
|
1515
|
+
const reader = response.body?.getReader();
|
|
1516
|
+
if (!reader) {
|
|
1517
|
+
reject(new Error(`SSE connection to ${url} has no body`));
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
void this.readStream(controller, reader, resolve, reject);
|
|
1521
|
+
});
|
|
1522
|
+
try {
|
|
1523
|
+
const data = await endpoint;
|
|
1524
|
+
const messageUrl = new URL(data, url);
|
|
1525
|
+
this.messageEndpoint = messageUrl.toString();
|
|
1526
|
+
this.sessionId = messageUrl.searchParams.get("sessionId") ?? void 0;
|
|
1527
|
+
} finally {
|
|
1528
|
+
clearTimeout(connectTimer);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
async readStream(controller, reader, onEndpoint, onEndpointError) {
|
|
1532
|
+
const decoder = new TextDecoder();
|
|
1533
|
+
let buffer = "";
|
|
1534
|
+
let endpointSeen = false;
|
|
1535
|
+
try {
|
|
1536
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
1537
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
1538
|
+
buffer = remainder;
|
|
1539
|
+
for (const text of completeEvents) {
|
|
1540
|
+
const event = parseSSEEvent(text);
|
|
1541
|
+
if (!event) continue;
|
|
1542
|
+
if (event.type === "endpoint") {
|
|
1543
|
+
if (!endpointSeen) {
|
|
1544
|
+
endpointSeen = true;
|
|
1545
|
+
onEndpoint(event.data);
|
|
1546
|
+
}
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
await this.handleMessage(event.data);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
if (!controller.signal.aborted) this.log("SSE stream failed:", error);
|
|
1554
|
+
} finally {
|
|
1555
|
+
reader.releaseLock();
|
|
1556
|
+
}
|
|
1557
|
+
if (!endpointSeen) {
|
|
1558
|
+
onEndpointError(
|
|
1559
|
+
new Error(
|
|
1560
|
+
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`
|
|
1561
|
+
)
|
|
1562
|
+
);
|
|
1563
|
+
}
|
|
1564
|
+
if (this.stream === controller) {
|
|
1565
|
+
this.stream = void 0;
|
|
1566
|
+
this.rejectPending("SSE stream closed by the server");
|
|
1567
|
+
if (this.state === "connected") this.state = "disconnected";
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
async handleMessage(data) {
|
|
1571
|
+
let message;
|
|
1572
|
+
try {
|
|
1573
|
+
message = JSON.parse(data);
|
|
1574
|
+
} catch {
|
|
1575
|
+
this.log("Ignoring SSE event that is not JSON:", data);
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (!message || typeof message !== "object") return;
|
|
1579
|
+
if (typeof message.method === "string") {
|
|
1580
|
+
const request = message;
|
|
1581
|
+
if (request.id === void 0 || request.id === null) {
|
|
1582
|
+
this.notificationHandler?.(request);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
await this.answerServerRequest(request);
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
if (message.id !== void 0 && message.id !== null) {
|
|
1589
|
+
const pending = this.pending.get(message.id);
|
|
1590
|
+
if (pending) {
|
|
1591
|
+
clearTimeout(pending.timer);
|
|
1592
|
+
this.pending.delete(message.id);
|
|
1593
|
+
pending.resolve(message);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
/** Server→client requests: elicitation goes to the handler, `ping` is answered, the rest refused. */
|
|
1598
|
+
async answerServerRequest(request) {
|
|
1599
|
+
const id = request.id;
|
|
1600
|
+
let reply;
|
|
1601
|
+
if (request.method === "elicitation/create") {
|
|
1602
|
+
reply = { jsonrpc: "2.0", id, result: await this.elicit(request.params) };
|
|
1603
|
+
} else if (request.method === "ping") {
|
|
1604
|
+
reply = { jsonrpc: "2.0", id, result: {} };
|
|
1605
|
+
} else {
|
|
1606
|
+
reply = { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${request.method}` } };
|
|
1607
|
+
}
|
|
1608
|
+
const result = await this.post(JSON.stringify(reply));
|
|
1609
|
+
if (!result.ok) this.log(`HTTP ${result.status} answering ${request.method}: ${result.body}`);
|
|
1610
|
+
}
|
|
1611
|
+
async elicit(params) {
|
|
1612
|
+
if (!this.elicitationHandler) return { action: "decline" };
|
|
1613
|
+
try {
|
|
1614
|
+
return await this.elicitationHandler(params);
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
this.log("Elicitation handler error:", error);
|
|
1617
|
+
return { action: "cancel" };
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
closeStream(reason) {
|
|
1621
|
+
const controller = this.stream;
|
|
1622
|
+
this.stream = void 0;
|
|
1623
|
+
controller?.abort();
|
|
1624
|
+
this.rejectPending(reason);
|
|
1625
|
+
}
|
|
1626
|
+
rejectPending(reason) {
|
|
1627
|
+
for (const [id, pending] of this.pending) {
|
|
1628
|
+
clearTimeout(pending.timer);
|
|
1629
|
+
pending.resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: reason } });
|
|
1630
|
+
}
|
|
1631
|
+
this.pending.clear();
|
|
1632
|
+
}
|
|
1633
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1634
|
+
// POSTING
|
|
1635
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1636
|
+
/** POST a message and resolve with the response the stream carries for `id`. */
|
|
1637
|
+
async postAndAwait(id, body) {
|
|
1638
|
+
const answer = new Promise((resolve) => {
|
|
1639
|
+
const timer = setTimeout(() => {
|
|
1640
|
+
this.pending.delete(id);
|
|
1641
|
+
resolve({ jsonrpc: "2.0", id, error: { code: -32e3, message: `Request timeout after ${this.timeout}ms` } });
|
|
1642
|
+
}, this.timeout);
|
|
1643
|
+
this.pending.set(id, { resolve, timer });
|
|
1644
|
+
});
|
|
1645
|
+
const result = await this.post(body);
|
|
1646
|
+
if (!result.ok) {
|
|
1647
|
+
const pending = this.pending.get(id);
|
|
1648
|
+
if (pending) {
|
|
1649
|
+
clearTimeout(pending.timer);
|
|
1650
|
+
this.pending.delete(id);
|
|
1651
|
+
}
|
|
1652
|
+
return {
|
|
1653
|
+
jsonrpc: "2.0",
|
|
1654
|
+
id,
|
|
1655
|
+
error: { code: -32e3, message: `HTTP ${result.status}: ${result.statusText}`, data: result.body }
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
return answer;
|
|
1659
|
+
}
|
|
1660
|
+
async post(body) {
|
|
1661
|
+
const endpoint = this.messageEndpoint;
|
|
1662
|
+
if (!endpoint) throw new Error("SSE transport has no message endpoint. Call connect() first.");
|
|
1663
|
+
const headers = this.buildHeaders();
|
|
1664
|
+
this.lastRequestHeaders = headers;
|
|
1665
|
+
this.log(`POST ${endpoint}`, body);
|
|
1666
|
+
const controller = new AbortController();
|
|
1667
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
1668
|
+
try {
|
|
1669
|
+
const response = await fetch(endpoint, { method: "POST", headers, body, signal: controller.signal });
|
|
1670
|
+
const text = await response.text();
|
|
1671
|
+
return { ok: response.ok, status: response.status, statusText: response.statusText, body: text };
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
if (isAbortError(error)) {
|
|
1674
|
+
return { ok: false, status: 0, statusText: `timeout after ${this.timeout}ms`, body: "" };
|
|
1675
|
+
}
|
|
1676
|
+
throw error;
|
|
1677
|
+
} finally {
|
|
1678
|
+
clearTimeout(timer);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1682
|
+
// HELPERS
|
|
1683
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
1684
|
+
async requestAnonymousToken() {
|
|
1685
|
+
const url = new URL(this.baseUrl);
|
|
1686
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/oauth/token`;
|
|
1687
|
+
url.search = "";
|
|
1688
|
+
const tokenUrl = url.toString();
|
|
1689
|
+
try {
|
|
1690
|
+
const response = await fetch(tokenUrl, {
|
|
1691
|
+
method: "POST",
|
|
1692
|
+
headers: { "Content-Type": "application/json" },
|
|
1693
|
+
body: JSON.stringify({ grant_type: "anonymous", client_id: crypto.randomUUID(), resource: this.baseUrl })
|
|
1694
|
+
});
|
|
1695
|
+
if (!response.ok) {
|
|
1696
|
+
this.log(`No anonymous token (HTTP ${response.status})`);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
const token = await response.json();
|
|
1700
|
+
if (token.access_token) this.authToken = token.access_token;
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
this.log("Error requesting anonymous token:", error);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
/** `<baseUrl><entryPath>/sse`, built from URL parts so query params on `baseUrl` survive. */
|
|
1706
|
+
sseUrl() {
|
|
1707
|
+
const url = new URL(this.baseUrl);
|
|
1708
|
+
const entry = (this.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1709
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1710
|
+
url.pathname = entry ? `${basePath}/${entry}/sse` : `${basePath}/sse`;
|
|
1711
|
+
return url.toString();
|
|
1712
|
+
}
|
|
1713
|
+
buildHeaders() {
|
|
1714
|
+
const headers = {
|
|
1715
|
+
"Content-Type": "application/json",
|
|
1716
|
+
Accept: "application/json, text/event-stream"
|
|
1717
|
+
};
|
|
1718
|
+
if (this.clientInfo) headers["User-Agent"] = `${this.clientInfo.name}/${this.clientInfo.version}`;
|
|
1719
|
+
if (this.authToken) headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1720
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
1721
|
+
Object.assign(headers, this.authHeaders);
|
|
1722
|
+
return headers;
|
|
1723
|
+
}
|
|
1724
|
+
ensureConnected() {
|
|
1725
|
+
if (this.state !== "connected") {
|
|
1726
|
+
throw new Error("Transport not connected. Call connect() first.");
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
log(message, data) {
|
|
1730
|
+
if (this.debug) {
|
|
1731
|
+
console.log(`[SSE] ${message}`, data ?? "");
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
function splitSSEEvents(buffer) {
|
|
1736
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1737
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1738
|
+
const completeEvents = normalized.split("\n\n");
|
|
1739
|
+
const remainder = completeEvents.pop() ?? "";
|
|
1740
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1741
|
+
}
|
|
1742
|
+
function parseSSEEvent(text) {
|
|
1743
|
+
let type = "message";
|
|
1744
|
+
const data = [];
|
|
1745
|
+
for (const line of text.split("\n")) {
|
|
1746
|
+
if (line.startsWith(":")) continue;
|
|
1747
|
+
const colon = line.indexOf(":");
|
|
1748
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
1749
|
+
const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
1750
|
+
if (field === "event") type = value;
|
|
1751
|
+
else if (field === "data") data.push(value);
|
|
1752
|
+
}
|
|
1753
|
+
return data.length > 0 ? { type, data: data.join("\n") } : void 0;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// libs/testing/src/transport/streamable-http.transport.ts
|
|
1757
|
+
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1111
1758
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
1112
1759
|
var StreamableHttpTransport = class {
|
|
1113
1760
|
config;
|
|
@@ -1129,7 +1776,7 @@ var StreamableHttpTransport = class {
|
|
|
1129
1776
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
1130
1777
|
// Remove trailing slash
|
|
1131
1778
|
entryPath: config.entryPath,
|
|
1132
|
-
timeout: config.timeout ??
|
|
1779
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT2,
|
|
1133
1780
|
auth: config.auth ?? {},
|
|
1134
1781
|
publicMode: config.publicMode ?? false,
|
|
1135
1782
|
debug: config.debug ?? false,
|
|
@@ -1214,7 +1861,7 @@ var StreamableHttpTransport = class {
|
|
|
1214
1861
|
return mockResponse2;
|
|
1215
1862
|
}
|
|
1216
1863
|
case "error":
|
|
1217
|
-
throw interceptResult.error;
|
|
1864
|
+
throw markInterceptedError(interceptResult.error);
|
|
1218
1865
|
case "continue":
|
|
1219
1866
|
message = interceptResult.request;
|
|
1220
1867
|
break;
|
|
@@ -1275,7 +1922,7 @@ var StreamableHttpTransport = class {
|
|
|
1275
1922
|
return jsonResponse;
|
|
1276
1923
|
} catch (error) {
|
|
1277
1924
|
clearTimeout(timeoutId);
|
|
1278
|
-
if (error
|
|
1925
|
+
if (isAbortError(error)) {
|
|
1279
1926
|
return {
|
|
1280
1927
|
jsonrpc: "2.0",
|
|
1281
1928
|
id: message.id ?? null,
|
|
@@ -1314,7 +1961,7 @@ var StreamableHttpTransport = class {
|
|
|
1314
1961
|
}
|
|
1315
1962
|
} catch (error) {
|
|
1316
1963
|
clearTimeout(timeoutId);
|
|
1317
|
-
if (error
|
|
1964
|
+
if (!isAbortError(error)) {
|
|
1318
1965
|
throw error;
|
|
1319
1966
|
}
|
|
1320
1967
|
}
|
|
@@ -1355,7 +2002,7 @@ var StreamableHttpTransport = class {
|
|
|
1355
2002
|
error: {
|
|
1356
2003
|
code: -32700,
|
|
1357
2004
|
message: "Parse error",
|
|
1358
|
-
data: error
|
|
2005
|
+
data: errorMessage(error)
|
|
1359
2006
|
}
|
|
1360
2007
|
};
|
|
1361
2008
|
}
|
|
@@ -1469,7 +2116,7 @@ var StreamableHttpTransport = class {
|
|
|
1469
2116
|
let buffer = "";
|
|
1470
2117
|
try {
|
|
1471
2118
|
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
1472
|
-
const { completeEvents, remainder } =
|
|
2119
|
+
const { completeEvents, remainder } = splitSSEEvents2(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
1473
2120
|
buffer = remainder;
|
|
1474
2121
|
for (const eventText of completeEvents) {
|
|
1475
2122
|
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
@@ -1725,7 +2372,7 @@ var StreamableHttpTransport = class {
|
|
|
1725
2372
|
if (this.config.clientInfo) {
|
|
1726
2373
|
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1727
2374
|
}
|
|
1728
|
-
if (this.authToken
|
|
2375
|
+
if (this.authToken) {
|
|
1729
2376
|
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1730
2377
|
}
|
|
1731
2378
|
if (this.sessionId) {
|
|
@@ -1803,7 +2450,7 @@ var StreamableHttpTransport = class {
|
|
|
1803
2450
|
};
|
|
1804
2451
|
}
|
|
1805
2452
|
};
|
|
1806
|
-
function
|
|
2453
|
+
function splitSSEEvents2(buffer) {
|
|
1807
2454
|
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1808
2455
|
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1809
2456
|
const completeEvents = normalized.split("\n\n");
|
|
@@ -2041,7 +2688,7 @@ var McpTestClientBuilder = class {
|
|
|
2041
2688
|
};
|
|
2042
2689
|
|
|
2043
2690
|
// libs/testing/src/client/mcp-test-client.ts
|
|
2044
|
-
var
|
|
2691
|
+
var DEFAULT_TIMEOUT3 = 3e4;
|
|
2045
2692
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
2046
2693
|
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
2047
2694
|
var DEFAULT_CLIENT_INFO = {
|
|
@@ -2049,6 +2696,15 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
2049
2696
|
version: "0.4.0"
|
|
2050
2697
|
};
|
|
2051
2698
|
var MAX_LIST_PAGES = 1e3;
|
|
2699
|
+
function isAuthFailure(error) {
|
|
2700
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
2701
|
+
}
|
|
2702
|
+
function describeInitializeError(error) {
|
|
2703
|
+
const message = error?.message ?? "Unknown error";
|
|
2704
|
+
const data = error?.data;
|
|
2705
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
2706
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
2707
|
+
}
|
|
2052
2708
|
function extractEntryPaths(data) {
|
|
2053
2709
|
let payload = data;
|
|
2054
2710
|
if (typeof payload === "string") {
|
|
@@ -2102,7 +2758,7 @@ var McpTestClient = class {
|
|
|
2102
2758
|
transport: config.transport ?? "streamable-http",
|
|
2103
2759
|
auth: config.auth ?? {},
|
|
2104
2760
|
publicMode: config.publicMode ?? false,
|
|
2105
|
-
timeout: config.timeout ??
|
|
2761
|
+
timeout: config.timeout ?? DEFAULT_TIMEOUT3,
|
|
2106
2762
|
debug: config.debug ?? false,
|
|
2107
2763
|
protocolVersion: config.protocolVersion ?? DEFAULT_PROTOCOL_VERSION,
|
|
2108
2764
|
clientInfo: config.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
@@ -2135,19 +2791,22 @@ var McpTestClient = class {
|
|
|
2135
2791
|
async connect() {
|
|
2136
2792
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
2137
2793
|
this.triedDiscoveredEntryPath = false;
|
|
2794
|
+
this.initResult = null;
|
|
2138
2795
|
this.transport = this.createTransport();
|
|
2139
2796
|
await this.transport.connect();
|
|
2140
2797
|
const maxRetries = 3;
|
|
2141
2798
|
const retryDelayMs = 500;
|
|
2142
2799
|
let lastError;
|
|
2143
2800
|
let reportedEntryPaths;
|
|
2801
|
+
let attemptsMade = 0;
|
|
2144
2802
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2803
|
+
attemptsMade = attempt;
|
|
2145
2804
|
const initResponse = await this.initialize();
|
|
2146
2805
|
if (initResponse.success && initResponse.data) {
|
|
2147
2806
|
this.initResult = initResponse.data;
|
|
2148
2807
|
break;
|
|
2149
2808
|
}
|
|
2150
|
-
lastError = initResponse.error
|
|
2809
|
+
lastError = describeInitializeError(initResponse.error);
|
|
2151
2810
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
2152
2811
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
2153
2812
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -2159,6 +2818,7 @@ var McpTestClient = class {
|
|
|
2159
2818
|
await this.transport.connect();
|
|
2160
2819
|
continue;
|
|
2161
2820
|
}
|
|
2821
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
2162
2822
|
if (attempt < maxRetries) {
|
|
2163
2823
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
2164
2824
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -2167,7 +2827,7 @@ var McpTestClient = class {
|
|
|
2167
2827
|
if (!this.initResult) {
|
|
2168
2828
|
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.` : "";
|
|
2169
2829
|
throw new Error(
|
|
2170
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
2830
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
2171
2831
|
);
|
|
2172
2832
|
}
|
|
2173
2833
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -2237,18 +2897,47 @@ var McpTestClient = class {
|
|
|
2237
2897
|
return this._authState;
|
|
2238
2898
|
}
|
|
2239
2899
|
/**
|
|
2240
|
-
* Authenticate with a token
|
|
2900
|
+
* Authenticate with a token.
|
|
2901
|
+
*
|
|
2902
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
2903
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
2904
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
2905
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
2241
2906
|
*/
|
|
2242
2907
|
async authenticate(token) {
|
|
2908
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
2909
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
2910
|
+
}
|
|
2911
|
+
const previousAuthState = this._authState;
|
|
2912
|
+
const previousConfigAuth = this.config.auth;
|
|
2913
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
2243
2914
|
this._authState = {
|
|
2244
2915
|
isAnonymous: false,
|
|
2245
2916
|
token,
|
|
2246
2917
|
scopes: this.parseScopesFromToken(token),
|
|
2247
2918
|
user: this.parseUserFromToken(token)
|
|
2248
2919
|
};
|
|
2249
|
-
|
|
2250
|
-
|
|
2920
|
+
const previousTransport = this.transport;
|
|
2921
|
+
if (!previousTransport) {
|
|
2922
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
2923
|
+
return;
|
|
2251
2924
|
}
|
|
2925
|
+
const previousInit = this.initResult;
|
|
2926
|
+
const previousSessionId = this._sessionId;
|
|
2927
|
+
const previousSessionInfo = this._sessionInfo;
|
|
2928
|
+
try {
|
|
2929
|
+
await this.connect();
|
|
2930
|
+
} catch (err) {
|
|
2931
|
+
await this.transport?.close().catch(() => void 0);
|
|
2932
|
+
this.transport = previousTransport;
|
|
2933
|
+
this.initResult = previousInit;
|
|
2934
|
+
this._sessionId = previousSessionId;
|
|
2935
|
+
this._sessionInfo = previousSessionInfo;
|
|
2936
|
+
this.config.auth = previousConfigAuth;
|
|
2937
|
+
this._authState = previousAuthState;
|
|
2938
|
+
throw err;
|
|
2939
|
+
}
|
|
2940
|
+
await previousTransport.close().catch(() => void 0);
|
|
2252
2941
|
this.log("debug", "Authentication updated");
|
|
2253
2942
|
}
|
|
2254
2943
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2834,8 +3523,19 @@ var McpTestClient = class {
|
|
|
2834
3523
|
elicitationHandler: this._elicitationHandler,
|
|
2835
3524
|
notificationHandler: this.recordNotification
|
|
2836
3525
|
});
|
|
2837
|
-
case "sse":
|
|
2838
|
-
|
|
3526
|
+
case "sse":
|
|
3527
|
+
return new SseTransport({
|
|
3528
|
+
baseUrl,
|
|
3529
|
+
entryPath: this.config.entryPath,
|
|
3530
|
+
timeout: this.config.timeout,
|
|
3531
|
+
auth: this.config.auth,
|
|
3532
|
+
publicMode: this.config.publicMode,
|
|
3533
|
+
debug: this.config.debug,
|
|
3534
|
+
interceptors: this._interceptors,
|
|
3535
|
+
clientInfo: this.config.clientInfo,
|
|
3536
|
+
elicitationHandler: this._elicitationHandler,
|
|
3537
|
+
notificationHandler: this.recordNotification
|
|
3538
|
+
});
|
|
2839
3539
|
default:
|
|
2840
3540
|
throw new Error(`Unknown transport type: ${this.config.transport}`);
|
|
2841
3541
|
}
|
|
@@ -2872,10 +3572,11 @@ var McpTestClient = class {
|
|
|
2872
3572
|
requestId: id
|
|
2873
3573
|
};
|
|
2874
3574
|
} catch (err) {
|
|
3575
|
+
if (isInterceptedError(err)) throw err;
|
|
2875
3576
|
const durationMs = Date.now() - start;
|
|
2876
3577
|
const error = {
|
|
2877
3578
|
code: -32603,
|
|
2878
|
-
message: err
|
|
3579
|
+
message: errorMessage(err)
|
|
2879
3580
|
};
|
|
2880
3581
|
return {
|
|
2881
3582
|
success: false,
|
|
@@ -3102,190 +3803,6 @@ var ProgressCollector = class {
|
|
|
3102
3803
|
}
|
|
3103
3804
|
};
|
|
3104
3805
|
|
|
3105
|
-
// libs/testing/src/auth/token-factory.ts
|
|
3106
|
-
var import_jose = require("jose");
|
|
3107
|
-
var TestTokenFactory = class {
|
|
3108
|
-
issuer;
|
|
3109
|
-
audience;
|
|
3110
|
-
privateKey = null;
|
|
3111
|
-
publicKey = null;
|
|
3112
|
-
jwk = null;
|
|
3113
|
-
keyId;
|
|
3114
|
-
/** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
|
|
3115
|
-
hmacSecret;
|
|
3116
|
-
constructor(options = {}) {
|
|
3117
|
-
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
3118
|
-
this.audience = options.audience ?? "frontmcp-test";
|
|
3119
|
-
this.keyId = `test-key-${Date.now()}`;
|
|
3120
|
-
this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
|
|
3121
|
-
}
|
|
3122
|
-
/** True when this factory signs HS256 with a shared secret (gateway mode). */
|
|
3123
|
-
get isHmac() {
|
|
3124
|
-
return this.hmacSecret !== null;
|
|
3125
|
-
}
|
|
3126
|
-
/** The JWS `alg` this factory signs with. */
|
|
3127
|
-
get alg() {
|
|
3128
|
-
return this.isHmac ? "HS256" : "RS256";
|
|
3129
|
-
}
|
|
3130
|
-
/**
|
|
3131
|
-
* The signing key: the shared HMAC secret in gateway mode, otherwise the
|
|
3132
|
-
* generated RSA private key (lazily created via ensureKeys()).
|
|
3133
|
-
*/
|
|
3134
|
-
signingKey() {
|
|
3135
|
-
if (this.hmacSecret) return this.hmacSecret;
|
|
3136
|
-
if (!this.privateKey) {
|
|
3137
|
-
throw new Error("Private key not initialized");
|
|
3138
|
-
}
|
|
3139
|
-
return this.privateKey;
|
|
3140
|
-
}
|
|
3141
|
-
/**
|
|
3142
|
-
* Initialize the key pair (called automatically on first use). No-op in HS256
|
|
3143
|
-
* mode — there is no asymmetric keypair, just the shared secret.
|
|
3144
|
-
*/
|
|
3145
|
-
async ensureKeys() {
|
|
3146
|
-
if (this.isHmac) return;
|
|
3147
|
-
if (this.privateKey && this.publicKey) return;
|
|
3148
|
-
const { publicKey, privateKey } = await (0, import_jose.generateKeyPair)("RS256", {
|
|
3149
|
-
extractable: true
|
|
3150
|
-
});
|
|
3151
|
-
this.privateKey = privateKey;
|
|
3152
|
-
this.publicKey = publicKey;
|
|
3153
|
-
this.jwk = await (0, import_jose.exportJWK)(publicKey);
|
|
3154
|
-
this.jwk.kid = this.keyId;
|
|
3155
|
-
this.jwk.use = "sig";
|
|
3156
|
-
this.jwk.alg = "RS256";
|
|
3157
|
-
}
|
|
3158
|
-
/**
|
|
3159
|
-
* Create a JWT token with the specified claims
|
|
3160
|
-
*/
|
|
3161
|
-
async createTestToken(options) {
|
|
3162
|
-
await this.ensureKeys();
|
|
3163
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3164
|
-
const exp = options.exp ?? 3600;
|
|
3165
|
-
const payload = {
|
|
3166
|
-
iss: options.iss ?? this.issuer,
|
|
3167
|
-
sub: options.sub,
|
|
3168
|
-
aud: options.aud ?? this.audience,
|
|
3169
|
-
iat: now,
|
|
3170
|
-
exp: now + exp,
|
|
3171
|
-
scope: options.scopes?.join(" "),
|
|
3172
|
-
...options.claims
|
|
3173
|
-
};
|
|
3174
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3175
|
-
const token = await new import_jose.SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3176
|
-
return token;
|
|
3177
|
-
}
|
|
3178
|
-
/**
|
|
3179
|
-
* Create an admin token with full access
|
|
3180
|
-
*/
|
|
3181
|
-
async createAdminToken(sub = "admin-001") {
|
|
3182
|
-
return this.createTestToken({
|
|
3183
|
-
sub,
|
|
3184
|
-
scopes: ["admin:*", "read", "write", "delete"],
|
|
3185
|
-
claims: {
|
|
3186
|
-
email: "admin@test.local",
|
|
3187
|
-
name: "Test Admin",
|
|
3188
|
-
role: "admin"
|
|
3189
|
-
}
|
|
3190
|
-
});
|
|
3191
|
-
}
|
|
3192
|
-
/**
|
|
3193
|
-
* Create a regular user token
|
|
3194
|
-
*/
|
|
3195
|
-
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
3196
|
-
return this.createTestToken({
|
|
3197
|
-
sub,
|
|
3198
|
-
scopes,
|
|
3199
|
-
claims: {
|
|
3200
|
-
email: "user@test.local",
|
|
3201
|
-
name: "Test User",
|
|
3202
|
-
role: "user"
|
|
3203
|
-
}
|
|
3204
|
-
});
|
|
3205
|
-
}
|
|
3206
|
-
/**
|
|
3207
|
-
* Create an anonymous user token
|
|
3208
|
-
*/
|
|
3209
|
-
async createAnonymousToken() {
|
|
3210
|
-
return this.createTestToken({
|
|
3211
|
-
sub: `anon:${Date.now()}`,
|
|
3212
|
-
scopes: ["anonymous"],
|
|
3213
|
-
claims: {
|
|
3214
|
-
name: "Anonymous",
|
|
3215
|
-
role: "anonymous"
|
|
3216
|
-
}
|
|
3217
|
-
});
|
|
3218
|
-
}
|
|
3219
|
-
/**
|
|
3220
|
-
* Create an expired token (for testing token expiration)
|
|
3221
|
-
*/
|
|
3222
|
-
async createExpiredToken(options) {
|
|
3223
|
-
await this.ensureKeys();
|
|
3224
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3225
|
-
const payload = {
|
|
3226
|
-
iss: this.issuer,
|
|
3227
|
-
sub: options.sub,
|
|
3228
|
-
aud: this.audience,
|
|
3229
|
-
iat: now - 7200,
|
|
3230
|
-
// 2 hours ago
|
|
3231
|
-
exp: now - 3600
|
|
3232
|
-
// Expired 1 hour ago
|
|
3233
|
-
};
|
|
3234
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3235
|
-
const token = await new import_jose.SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3236
|
-
return token;
|
|
3237
|
-
}
|
|
3238
|
-
/**
|
|
3239
|
-
* Create a token with an invalid signature (for testing signature validation)
|
|
3240
|
-
*/
|
|
3241
|
-
createTokenWithInvalidSignature(options) {
|
|
3242
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3243
|
-
const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3244
|
-
const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
|
|
3245
|
-
const payload = Buffer.from(
|
|
3246
|
-
JSON.stringify({
|
|
3247
|
-
iss: this.issuer,
|
|
3248
|
-
sub: options.sub,
|
|
3249
|
-
aud: this.audience,
|
|
3250
|
-
iat: now,
|
|
3251
|
-
exp: now + 3600
|
|
3252
|
-
})
|
|
3253
|
-
).toString("base64url");
|
|
3254
|
-
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
3255
|
-
return `${header}.${payload}.${signature}`;
|
|
3256
|
-
}
|
|
3257
|
-
/**
|
|
3258
|
-
* Get the public JWKS for verifying tokens.
|
|
3259
|
-
*
|
|
3260
|
-
* Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
|
|
3261
|
-
* the shared secret, not a published public key, so there is no JWKS to serve.
|
|
3262
|
-
*/
|
|
3263
|
-
async getPublicJwks() {
|
|
3264
|
-
if (this.isHmac) {
|
|
3265
|
-
throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
|
|
3266
|
-
}
|
|
3267
|
-
await this.ensureKeys();
|
|
3268
|
-
if (!this.jwk) {
|
|
3269
|
-
throw new Error("JWK not initialized");
|
|
3270
|
-
}
|
|
3271
|
-
return {
|
|
3272
|
-
keys: [this.jwk]
|
|
3273
|
-
};
|
|
3274
|
-
}
|
|
3275
|
-
/**
|
|
3276
|
-
* Get the issuer URL
|
|
3277
|
-
*/
|
|
3278
|
-
getIssuer() {
|
|
3279
|
-
return this.issuer;
|
|
3280
|
-
}
|
|
3281
|
-
/**
|
|
3282
|
-
* Get the audience
|
|
3283
|
-
*/
|
|
3284
|
-
getAudience() {
|
|
3285
|
-
return this.audience;
|
|
3286
|
-
}
|
|
3287
|
-
};
|
|
3288
|
-
|
|
3289
3806
|
// libs/testing/src/server/test-server.ts
|
|
3290
3807
|
var import_child_process = require("child_process");
|
|
3291
3808
|
var import_utils = require("@frontmcp/utils");
|
|
@@ -3358,6 +3875,8 @@ var E2E_PORT_RANGES = {
|
|
|
3358
3875
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
3359
3876
|
// Protocol revision E2E tests (50460-50479)
|
|
3360
3877
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
3878
|
+
// Testing library E2E tests (50480-50499)
|
|
3879
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
3361
3880
|
// Mock servers and utilities (50900-50999)
|
|
3362
3881
|
"mock-oauth": { start: 50900, size: 10 },
|
|
3363
3882
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -3366,6 +3885,66 @@ var E2E_PORT_RANGES = {
|
|
|
3366
3885
|
default: { start: 51e3, size: 100 }
|
|
3367
3886
|
};
|
|
3368
3887
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3888
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
3889
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
3890
|
+
function lockDir() {
|
|
3891
|
+
try {
|
|
3892
|
+
const os = require("node:os");
|
|
3893
|
+
const path = require("node:path");
|
|
3894
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
3895
|
+
} catch {
|
|
3896
|
+
return void 0;
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
function pidIsAlive(pid) {
|
|
3900
|
+
try {
|
|
3901
|
+
process.kill(pid, 0);
|
|
3902
|
+
return true;
|
|
3903
|
+
} catch (error) {
|
|
3904
|
+
return error.code === "EPERM";
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
function acquirePortLock(port) {
|
|
3908
|
+
if (lockedPorts.has(port)) return false;
|
|
3909
|
+
let fs;
|
|
3910
|
+
let file;
|
|
3911
|
+
try {
|
|
3912
|
+
fs = require("node:fs");
|
|
3913
|
+
const dir = lockDir();
|
|
3914
|
+
if (!dir) return true;
|
|
3915
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3916
|
+
file = require("node:path").join(dir, `${port}.lock`);
|
|
3917
|
+
} catch {
|
|
3918
|
+
return true;
|
|
3919
|
+
}
|
|
3920
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
3921
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3922
|
+
try {
|
|
3923
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
3924
|
+
lockedPorts.add(port);
|
|
3925
|
+
return true;
|
|
3926
|
+
} catch (error) {
|
|
3927
|
+
if (error.code !== "EEXIST") return true;
|
|
3928
|
+
try {
|
|
3929
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3930
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
3931
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
3932
|
+
fs.unlinkSync(file);
|
|
3933
|
+
} catch {
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
return false;
|
|
3938
|
+
}
|
|
3939
|
+
function releasePortLock(port) {
|
|
3940
|
+
if (!lockedPorts.delete(port)) return;
|
|
3941
|
+
try {
|
|
3942
|
+
const fs = require("node:fs");
|
|
3943
|
+
const dir = lockDir();
|
|
3944
|
+
if (dir) fs.unlinkSync(require("node:path").join(dir, `${port}.lock`));
|
|
3945
|
+
} catch {
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3369
3948
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
3370
3949
|
function getPortRange(project) {
|
|
3371
3950
|
const key = project;
|
|
@@ -3376,15 +3955,22 @@ function getPortRange(project) {
|
|
|
3376
3955
|
}
|
|
3377
3956
|
async function reservePort(project, preferredPort) {
|
|
3378
3957
|
const range = getPortRange(project);
|
|
3379
|
-
|
|
3958
|
+
const handle = (port) => ({
|
|
3959
|
+
port,
|
|
3960
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
3961
|
+
releaseSocket: async () => {
|
|
3962
|
+
await releasePort(port);
|
|
3963
|
+
},
|
|
3964
|
+
release: async () => {
|
|
3965
|
+
await releasePort(port);
|
|
3966
|
+
releasePortLock(port);
|
|
3967
|
+
}
|
|
3968
|
+
});
|
|
3969
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
3970
|
+
if (wantsSpecificPort) {
|
|
3380
3971
|
const reservation = await tryReservePort(preferredPort, project);
|
|
3381
3972
|
if (reservation) {
|
|
3382
|
-
return
|
|
3383
|
-
port: preferredPort,
|
|
3384
|
-
release: async () => {
|
|
3385
|
-
await releasePort(preferredPort);
|
|
3386
|
-
}
|
|
3387
|
-
};
|
|
3973
|
+
return handle(preferredPort);
|
|
3388
3974
|
}
|
|
3389
3975
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
3390
3976
|
}
|
|
@@ -3393,30 +3979,19 @@ async function reservePort(project, preferredPort) {
|
|
|
3393
3979
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3394
3980
|
const port = range.start + index % range.size;
|
|
3395
3981
|
index = (index + 1) % range.size;
|
|
3396
|
-
if (reservedPorts.has(port)) {
|
|
3982
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
3397
3983
|
continue;
|
|
3398
3984
|
}
|
|
3399
3985
|
const reservation = await tryReservePort(port, project);
|
|
3400
3986
|
if (reservation) {
|
|
3401
3987
|
projectPortIndex.set(project, index);
|
|
3402
|
-
return
|
|
3403
|
-
port,
|
|
3404
|
-
release: async () => {
|
|
3405
|
-
await releasePort(port);
|
|
3406
|
-
}
|
|
3407
|
-
};
|
|
3988
|
+
return handle(port);
|
|
3408
3989
|
}
|
|
3409
3990
|
}
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
return {
|
|
3415
|
-
port: dynamicPort,
|
|
3416
|
-
release: async () => {
|
|
3417
|
-
await releasePort(dynamicPort);
|
|
3418
|
-
}
|
|
3419
|
-
};
|
|
3991
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
3992
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
3993
|
+
if (await tryReservePort(port, project)) {
|
|
3994
|
+
return handle(port);
|
|
3420
3995
|
}
|
|
3421
3996
|
}
|
|
3422
3997
|
throw new Error(
|
|
@@ -3424,9 +3999,13 @@ async function reservePort(project, preferredPort) {
|
|
|
3424
3999
|
);
|
|
3425
4000
|
}
|
|
3426
4001
|
async function tryReservePort(port, project) {
|
|
4002
|
+
if (!acquirePortLock(port)) {
|
|
4003
|
+
return false;
|
|
4004
|
+
}
|
|
3427
4005
|
return new Promise((resolve) => {
|
|
3428
4006
|
const server = (0, import_net.createServer)();
|
|
3429
4007
|
server.once("error", () => {
|
|
4008
|
+
releasePortLock(port);
|
|
3430
4009
|
resolve(false);
|
|
3431
4010
|
});
|
|
3432
4011
|
server.listen(port, () => {
|
|
@@ -3452,31 +4031,6 @@ async function releasePort(port) {
|
|
|
3452
4031
|
});
|
|
3453
4032
|
});
|
|
3454
4033
|
}
|
|
3455
|
-
async function findAvailablePortInRange(start, end) {
|
|
3456
|
-
for (let port = start; port < end; port++) {
|
|
3457
|
-
if (reservedPorts.has(port)) {
|
|
3458
|
-
continue;
|
|
3459
|
-
}
|
|
3460
|
-
const available = await isPortAvailable(port);
|
|
3461
|
-
if (available) {
|
|
3462
|
-
return port;
|
|
3463
|
-
}
|
|
3464
|
-
}
|
|
3465
|
-
return null;
|
|
3466
|
-
}
|
|
3467
|
-
async function isPortAvailable(port) {
|
|
3468
|
-
return new Promise((resolve) => {
|
|
3469
|
-
const server = (0, import_net.createServer)();
|
|
3470
|
-
server.once("error", () => {
|
|
3471
|
-
resolve(false);
|
|
3472
|
-
});
|
|
3473
|
-
server.listen(port, () => {
|
|
3474
|
-
server.close(() => {
|
|
3475
|
-
resolve(true);
|
|
3476
|
-
});
|
|
3477
|
-
});
|
|
3478
|
-
});
|
|
3479
|
-
}
|
|
3480
4034
|
|
|
3481
4035
|
// libs/testing/src/server/test-server.ts
|
|
3482
4036
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -3485,8 +4039,11 @@ var TestServer = class _TestServer {
|
|
|
3485
4039
|
options;
|
|
3486
4040
|
_info;
|
|
3487
4041
|
logs = [];
|
|
4042
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
3488
4043
|
portRelease = null;
|
|
3489
|
-
|
|
4044
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
4045
|
+
portReleaseSocket = null;
|
|
4046
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
3490
4047
|
this.options = {
|
|
3491
4048
|
port,
|
|
3492
4049
|
project: options.project,
|
|
@@ -3498,6 +4055,7 @@ var TestServer = class _TestServer {
|
|
|
3498
4055
|
debug: options.debug ?? DEBUG_SERVER
|
|
3499
4056
|
};
|
|
3500
4057
|
this.portRelease = portRelease ?? null;
|
|
4058
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
3501
4059
|
this._info = {
|
|
3502
4060
|
baseUrl: `http://localhost:${port}`,
|
|
3503
4061
|
port
|
|
@@ -3510,8 +4068,8 @@ var TestServer = class _TestServer {
|
|
|
3510
4068
|
const project = options.project ?? "default";
|
|
3511
4069
|
const maxAttempts = 3;
|
|
3512
4070
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3513
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3514
|
-
const server = new _TestServer(options, port, release);
|
|
4071
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
4072
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
3515
4073
|
try {
|
|
3516
4074
|
await server.startProcess();
|
|
3517
4075
|
return server;
|
|
@@ -3551,7 +4109,7 @@ var TestServer = class _TestServer {
|
|
|
3551
4109
|
}
|
|
3552
4110
|
const maxAttempts = 3;
|
|
3553
4111
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3554
|
-
const { port, release } = await reservePort(project, options.port);
|
|
4112
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3555
4113
|
const serverOptions = {
|
|
3556
4114
|
...options,
|
|
3557
4115
|
port,
|
|
@@ -3559,7 +4117,7 @@ var TestServer = class _TestServer {
|
|
|
3559
4117
|
command: `npx nx serve ${project} --port ${port}`,
|
|
3560
4118
|
cwd: options.cwd ?? process.cwd()
|
|
3561
4119
|
};
|
|
3562
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
4120
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
3563
4121
|
try {
|
|
3564
4122
|
await server.startProcess();
|
|
3565
4123
|
return server;
|
|
@@ -3617,10 +4175,17 @@ var TestServer = class _TestServer {
|
|
|
3617
4175
|
* Stop the test server
|
|
3618
4176
|
*/
|
|
3619
4177
|
async stop() {
|
|
3620
|
-
|
|
3621
|
-
await this.
|
|
3622
|
-
|
|
4178
|
+
try {
|
|
4179
|
+
await this.stopProcess();
|
|
4180
|
+
} finally {
|
|
4181
|
+
if (this.portRelease) {
|
|
4182
|
+
await this.portRelease();
|
|
4183
|
+
this.portRelease = null;
|
|
4184
|
+
}
|
|
4185
|
+
this.portReleaseSocket = null;
|
|
3623
4186
|
}
|
|
4187
|
+
}
|
|
4188
|
+
async stopProcess() {
|
|
3624
4189
|
if (this.process) {
|
|
3625
4190
|
this.log("Stopping server...");
|
|
3626
4191
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -3693,7 +4258,7 @@ var TestServer = class _TestServer {
|
|
|
3693
4258
|
* Restart the server
|
|
3694
4259
|
*/
|
|
3695
4260
|
async restart() {
|
|
3696
|
-
await this.
|
|
4261
|
+
await this.stopProcess();
|
|
3697
4262
|
await this.startProcess();
|
|
3698
4263
|
}
|
|
3699
4264
|
/**
|
|
@@ -3723,9 +4288,9 @@ var TestServer = class _TestServer {
|
|
|
3723
4288
|
PORT: String(this.options.port)
|
|
3724
4289
|
};
|
|
3725
4290
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
3726
|
-
if (this.
|
|
3727
|
-
await this.
|
|
3728
|
-
this.
|
|
4291
|
+
if (this.portReleaseSocket) {
|
|
4292
|
+
await this.portReleaseSocket();
|
|
4293
|
+
this.portReleaseSocket = null;
|
|
3729
4294
|
await sleep4(300);
|
|
3730
4295
|
}
|
|
3731
4296
|
this.process = (0, import_child_process.spawn)(this.options.command, [], {
|
|
@@ -3735,9 +4300,8 @@ var TestServer = class _TestServer {
|
|
|
3735
4300
|
detached: true,
|
|
3736
4301
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3737
4302
|
});
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
}
|
|
4303
|
+
this._info.pid = this.process.pid;
|
|
4304
|
+
this._info.shellPid = this.process.pid;
|
|
3741
4305
|
let processExited = false;
|
|
3742
4306
|
let exitCode = null;
|
|
3743
4307
|
let exitError = null;
|
|
@@ -3840,6 +4404,8 @@ ${allLogs}`);
|
|
|
3840
4404
|
});
|
|
3841
4405
|
if (response.ok || response.status === 404) {
|
|
3842
4406
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
4407
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
4408
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3843
4409
|
return;
|
|
3844
4410
|
}
|
|
3845
4411
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3884,6 +4450,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3884
4450
|
function sleep4(ms) {
|
|
3885
4451
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3886
4452
|
}
|
|
4453
|
+
function findListeningPid(port) {
|
|
4454
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
4455
|
+
return new Promise((resolve) => {
|
|
4456
|
+
(0, import_child_process.execFile)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
4457
|
+
if (error) {
|
|
4458
|
+
resolve(void 0);
|
|
4459
|
+
return;
|
|
4460
|
+
}
|
|
4461
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
4462
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
4463
|
+
});
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
3887
4466
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3888
4467
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3889
4468
|
return env;
|
|
@@ -4031,7 +4610,8 @@ Server entry: ${currentConfig.server}
|
|
|
4031
4610
|
Project: ${currentConfig.project ?? "default"}
|
|
4032
4611
|
Command: ${serverCommand}
|
|
4033
4612
|
|
|
4034
|
-
Error: ${errMsg}
|
|
4613
|
+
Error: ${errMsg}`,
|
|
4614
|
+
{ cause: error }
|
|
4035
4615
|
);
|
|
4036
4616
|
}
|
|
4037
4617
|
} else {
|
|
@@ -4221,16 +4801,19 @@ function todo(name) {
|
|
|
4221
4801
|
}
|
|
4222
4802
|
var perfTest = perfTestWithFixtures;
|
|
4223
4803
|
perfTest.use = use;
|
|
4224
|
-
|
|
4225
|
-
perfTest
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4804
|
+
for (const hook of ["describe", "beforeAll", "beforeEach", "afterEach", "afterAll"]) {
|
|
4805
|
+
Object.defineProperty(perfTest, hook, {
|
|
4806
|
+
get: () => globalThis[hook],
|
|
4807
|
+
enumerable: true,
|
|
4808
|
+
configurable: true
|
|
4809
|
+
});
|
|
4810
|
+
}
|
|
4229
4811
|
perfTest.skip = skip;
|
|
4230
4812
|
perfTest.only = only;
|
|
4231
4813
|
perfTest.todo = todo;
|
|
4232
4814
|
|
|
4233
4815
|
// libs/testing/src/perf/baseline-store.ts
|
|
4816
|
+
var import_utils2 = require("@frontmcp/utils");
|
|
4234
4817
|
var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
|
|
4235
4818
|
var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
|
|
4236
4819
|
var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
|
|
@@ -4245,11 +4828,10 @@ var BaselineStore = class {
|
|
|
4245
4828
|
*/
|
|
4246
4829
|
async load() {
|
|
4247
4830
|
try {
|
|
4248
|
-
|
|
4249
|
-
if (!await fileExists(this.baselinePath)) {
|
|
4831
|
+
if (!await (0, import_utils2.fileExists)(this.baselinePath)) {
|
|
4250
4832
|
return null;
|
|
4251
4833
|
}
|
|
4252
|
-
const content = await readFile(this.baselinePath);
|
|
4834
|
+
const content = await (0, import_utils2.readFile)(this.baselinePath);
|
|
4253
4835
|
this.baseline = JSON.parse(content);
|
|
4254
4836
|
return this.baseline;
|
|
4255
4837
|
} catch {
|
|
@@ -4260,10 +4842,9 @@ var BaselineStore = class {
|
|
|
4260
4842
|
* Save baseline to local file.
|
|
4261
4843
|
*/
|
|
4262
4844
|
async save(baseline) {
|
|
4263
|
-
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
4264
4845
|
const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
|
|
4265
|
-
await ensureDir(dir);
|
|
4266
|
-
await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
4846
|
+
await (0, import_utils2.ensureDir)(dir);
|
|
4847
|
+
await (0, import_utils2.writeFile)(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
4267
4848
|
this.baseline = baseline;
|
|
4268
4849
|
}
|
|
4269
4850
|
/**
|
|
@@ -4539,6 +5120,7 @@ function resetRegressionDetector() {
|
|
|
4539
5120
|
}
|
|
4540
5121
|
|
|
4541
5122
|
// libs/testing/src/perf/report-generator.ts
|
|
5123
|
+
var import_utils3 = require("@frontmcp/utils");
|
|
4542
5124
|
var ReportGenerator = class {
|
|
4543
5125
|
detector;
|
|
4544
5126
|
constructor() {
|
|
@@ -4794,14 +5376,13 @@ var ReportGenerator = class {
|
|
|
4794
5376
|
}
|
|
4795
5377
|
};
|
|
4796
5378
|
async function saveReports(measurements, outputDir, baseline, gitInfo) {
|
|
4797
|
-
|
|
4798
|
-
await ensureDir(outputDir);
|
|
5379
|
+
await (0, import_utils3.ensureDir)(outputDir);
|
|
4799
5380
|
const generator = new ReportGenerator();
|
|
4800
5381
|
const report = generator.generateReport(measurements, baseline, gitInfo);
|
|
4801
5382
|
const jsonPath = `${outputDir}/report.json`;
|
|
4802
5383
|
const markdownPath = `${outputDir}/report.md`;
|
|
4803
|
-
await writeFile(jsonPath, generator.generateJsonReport(report));
|
|
4804
|
-
await writeFile(markdownPath, generator.generateMarkdownReport(report));
|
|
5384
|
+
await (0, import_utils3.writeFile)(jsonPath, generator.generateJsonReport(report));
|
|
5385
|
+
await (0, import_utils3.writeFile)(markdownPath, generator.generateMarkdownReport(report));
|
|
4805
5386
|
return { jsonPath, markdownPath };
|
|
4806
5387
|
}
|
|
4807
5388
|
function createReportGenerator() {
|