@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/esm/perf/index.mjs
CHANGED
|
@@ -772,6 +772,192 @@ function getMeasurementsForProject(project) {
|
|
|
772
772
|
return getGlobalMeasurementsArray().filter((m) => m.project === project);
|
|
773
773
|
}
|
|
774
774
|
|
|
775
|
+
// libs/testing/src/auth/token-factory.ts
|
|
776
|
+
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
777
|
+
var TestTokenFactory = class {
|
|
778
|
+
issuer;
|
|
779
|
+
audience;
|
|
780
|
+
privateKey = null;
|
|
781
|
+
publicKey = null;
|
|
782
|
+
jwk = null;
|
|
783
|
+
keyId;
|
|
784
|
+
/** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
|
|
785
|
+
hmacSecret;
|
|
786
|
+
constructor(options = {}) {
|
|
787
|
+
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
788
|
+
this.audience = options.audience ?? "frontmcp-test";
|
|
789
|
+
this.keyId = `test-key-${Date.now()}`;
|
|
790
|
+
this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
|
|
791
|
+
}
|
|
792
|
+
/** True when this factory signs HS256 with a shared secret (gateway mode). */
|
|
793
|
+
get isHmac() {
|
|
794
|
+
return this.hmacSecret !== null;
|
|
795
|
+
}
|
|
796
|
+
/** The JWS `alg` this factory signs with. */
|
|
797
|
+
get alg() {
|
|
798
|
+
return this.isHmac ? "HS256" : "RS256";
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* The signing key: the shared HMAC secret in gateway mode, otherwise the
|
|
802
|
+
* generated RSA private key (lazily created via ensureKeys()).
|
|
803
|
+
*/
|
|
804
|
+
signingKey() {
|
|
805
|
+
if (this.hmacSecret) return this.hmacSecret;
|
|
806
|
+
if (!this.privateKey) {
|
|
807
|
+
throw new Error("Private key not initialized");
|
|
808
|
+
}
|
|
809
|
+
return this.privateKey;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Initialize the key pair (called automatically on first use). No-op in HS256
|
|
813
|
+
* mode — there is no asymmetric keypair, just the shared secret.
|
|
814
|
+
*/
|
|
815
|
+
async ensureKeys() {
|
|
816
|
+
if (this.isHmac) return;
|
|
817
|
+
if (this.privateKey && this.publicKey) return;
|
|
818
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
819
|
+
extractable: true
|
|
820
|
+
});
|
|
821
|
+
this.privateKey = privateKey;
|
|
822
|
+
this.publicKey = publicKey;
|
|
823
|
+
this.jwk = await exportJWK(publicKey);
|
|
824
|
+
this.jwk.kid = this.keyId;
|
|
825
|
+
this.jwk.use = "sig";
|
|
826
|
+
this.jwk.alg = "RS256";
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Create a JWT token with the specified claims
|
|
830
|
+
*/
|
|
831
|
+
async createTestToken(options) {
|
|
832
|
+
await this.ensureKeys();
|
|
833
|
+
const nowMs = Date.now();
|
|
834
|
+
const iat = Math.floor(nowMs / 1e3);
|
|
835
|
+
const exp = options.exp ?? 3600;
|
|
836
|
+
const payload = {
|
|
837
|
+
iss: options.iss ?? this.issuer,
|
|
838
|
+
sub: options.sub,
|
|
839
|
+
aud: options.aud ?? this.audience,
|
|
840
|
+
iat,
|
|
841
|
+
exp: Math.ceil(nowMs / 1e3) + exp,
|
|
842
|
+
scope: options.scopes?.join(" "),
|
|
843
|
+
...options.claims
|
|
844
|
+
};
|
|
845
|
+
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
846
|
+
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
847
|
+
return token;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Create an admin token with full access
|
|
851
|
+
*/
|
|
852
|
+
async createAdminToken(sub = "admin-001") {
|
|
853
|
+
return this.createTestToken({
|
|
854
|
+
sub,
|
|
855
|
+
scopes: ["admin:*", "read", "write", "delete"],
|
|
856
|
+
claims: {
|
|
857
|
+
email: "admin@test.local",
|
|
858
|
+
name: "Test Admin",
|
|
859
|
+
role: "admin"
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Create a regular user token
|
|
865
|
+
*/
|
|
866
|
+
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
867
|
+
return this.createTestToken({
|
|
868
|
+
sub,
|
|
869
|
+
scopes,
|
|
870
|
+
claims: {
|
|
871
|
+
email: "user@test.local",
|
|
872
|
+
name: "Test User",
|
|
873
|
+
role: "user"
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Create an anonymous user token
|
|
879
|
+
*/
|
|
880
|
+
async createAnonymousToken(expiresIn) {
|
|
881
|
+
return this.createTestToken({
|
|
882
|
+
sub: `anon:${Date.now()}`,
|
|
883
|
+
scopes: ["anonymous"],
|
|
884
|
+
exp: expiresIn,
|
|
885
|
+
claims: {
|
|
886
|
+
name: "Anonymous",
|
|
887
|
+
role: "anonymous"
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Create an expired token (for testing token expiration)
|
|
893
|
+
*/
|
|
894
|
+
async createExpiredToken(options) {
|
|
895
|
+
await this.ensureKeys();
|
|
896
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
897
|
+
const payload = {
|
|
898
|
+
iss: this.issuer,
|
|
899
|
+
sub: options.sub,
|
|
900
|
+
aud: this.audience,
|
|
901
|
+
iat: now - 7200,
|
|
902
|
+
// 2 hours ago
|
|
903
|
+
exp: now - 3600
|
|
904
|
+
// Expired 1 hour ago
|
|
905
|
+
};
|
|
906
|
+
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
907
|
+
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
908
|
+
return token;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Create a token with an invalid signature (for testing signature validation)
|
|
912
|
+
*/
|
|
913
|
+
createTokenWithInvalidSignature(options) {
|
|
914
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
915
|
+
const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
916
|
+
const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
|
|
917
|
+
const payload = Buffer.from(
|
|
918
|
+
JSON.stringify({
|
|
919
|
+
iss: this.issuer,
|
|
920
|
+
sub: options.sub,
|
|
921
|
+
aud: this.audience,
|
|
922
|
+
iat: now,
|
|
923
|
+
exp: now + 3600
|
|
924
|
+
})
|
|
925
|
+
).toString("base64url");
|
|
926
|
+
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
927
|
+
return `${header}.${payload}.${signature}`;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Get the public JWKS for verifying tokens.
|
|
931
|
+
*
|
|
932
|
+
* Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
|
|
933
|
+
* the shared secret, not a published public key, so there is no JWKS to serve.
|
|
934
|
+
*/
|
|
935
|
+
async getPublicJwks() {
|
|
936
|
+
if (this.isHmac) {
|
|
937
|
+
throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
|
|
938
|
+
}
|
|
939
|
+
await this.ensureKeys();
|
|
940
|
+
if (!this.jwk) {
|
|
941
|
+
throw new Error("JWK not initialized");
|
|
942
|
+
}
|
|
943
|
+
return {
|
|
944
|
+
keys: [this.jwk]
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* Get the issuer URL
|
|
949
|
+
*/
|
|
950
|
+
getIssuer() {
|
|
951
|
+
return this.issuer;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Get the audience
|
|
955
|
+
*/
|
|
956
|
+
getAudience() {
|
|
957
|
+
return this.audience;
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
|
|
775
961
|
// libs/testing/src/interceptor/mock-registry.ts
|
|
776
962
|
var DefaultMockRegistry = class {
|
|
777
963
|
mocks = [];
|
|
@@ -1044,6 +1230,32 @@ function sleep3(ms) {
|
|
|
1044
1230
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1045
1231
|
}
|
|
1046
1232
|
|
|
1233
|
+
// libs/testing/src/transport/error-utils.ts
|
|
1234
|
+
function errorName(err) {
|
|
1235
|
+
if (typeof err !== "object" || err === null) return void 0;
|
|
1236
|
+
const name = err.name;
|
|
1237
|
+
return typeof name === "string" ? name : void 0;
|
|
1238
|
+
}
|
|
1239
|
+
function isAbortError(err) {
|
|
1240
|
+
return errorName(err) === "AbortError";
|
|
1241
|
+
}
|
|
1242
|
+
function errorMessage(err, fallback = "Unknown error") {
|
|
1243
|
+
if (typeof err === "string" && err) return err;
|
|
1244
|
+
if (typeof err === "object" && err !== null) {
|
|
1245
|
+
const message = err.message;
|
|
1246
|
+
if (typeof message === "string" && message) return message;
|
|
1247
|
+
}
|
|
1248
|
+
return fallback;
|
|
1249
|
+
}
|
|
1250
|
+
var interceptedErrors = /* @__PURE__ */ new WeakSet();
|
|
1251
|
+
function markInterceptedError(err) {
|
|
1252
|
+
interceptedErrors.add(err);
|
|
1253
|
+
return err;
|
|
1254
|
+
}
|
|
1255
|
+
function isInterceptedError(err) {
|
|
1256
|
+
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1047
1259
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
1048
1260
|
var DEFAULT_TIMEOUT = 3e4;
|
|
1049
1261
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
@@ -1152,7 +1364,7 @@ var StreamableHttpTransport = class {
|
|
|
1152
1364
|
return mockResponse2;
|
|
1153
1365
|
}
|
|
1154
1366
|
case "error":
|
|
1155
|
-
throw interceptResult.error;
|
|
1367
|
+
throw markInterceptedError(interceptResult.error);
|
|
1156
1368
|
case "continue":
|
|
1157
1369
|
message = interceptResult.request;
|
|
1158
1370
|
break;
|
|
@@ -1213,7 +1425,7 @@ var StreamableHttpTransport = class {
|
|
|
1213
1425
|
return jsonResponse;
|
|
1214
1426
|
} catch (error) {
|
|
1215
1427
|
clearTimeout(timeoutId);
|
|
1216
|
-
if (error
|
|
1428
|
+
if (isAbortError(error)) {
|
|
1217
1429
|
return {
|
|
1218
1430
|
jsonrpc: "2.0",
|
|
1219
1431
|
id: message.id ?? null,
|
|
@@ -1252,7 +1464,7 @@ var StreamableHttpTransport = class {
|
|
|
1252
1464
|
}
|
|
1253
1465
|
} catch (error) {
|
|
1254
1466
|
clearTimeout(timeoutId);
|
|
1255
|
-
if (error
|
|
1467
|
+
if (!isAbortError(error)) {
|
|
1256
1468
|
throw error;
|
|
1257
1469
|
}
|
|
1258
1470
|
}
|
|
@@ -1293,7 +1505,7 @@ var StreamableHttpTransport = class {
|
|
|
1293
1505
|
error: {
|
|
1294
1506
|
code: -32700,
|
|
1295
1507
|
message: "Parse error",
|
|
1296
|
-
data: error
|
|
1508
|
+
data: errorMessage(error)
|
|
1297
1509
|
}
|
|
1298
1510
|
};
|
|
1299
1511
|
}
|
|
@@ -1663,7 +1875,7 @@ var StreamableHttpTransport = class {
|
|
|
1663
1875
|
if (this.config.clientInfo) {
|
|
1664
1876
|
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1665
1877
|
}
|
|
1666
|
-
if (this.authToken
|
|
1878
|
+
if (this.authToken) {
|
|
1667
1879
|
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1668
1880
|
}
|
|
1669
1881
|
if (this.sessionId) {
|
|
@@ -1986,6 +2198,16 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1986
2198
|
name: "@frontmcp/testing",
|
|
1987
2199
|
version: "0.4.0"
|
|
1988
2200
|
};
|
|
2201
|
+
var MAX_LIST_PAGES = 1e3;
|
|
2202
|
+
function isAuthFailure(error) {
|
|
2203
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
2204
|
+
}
|
|
2205
|
+
function describeInitializeError(error) {
|
|
2206
|
+
const message = error?.message ?? "Unknown error";
|
|
2207
|
+
const data = error?.data;
|
|
2208
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
2209
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
2210
|
+
}
|
|
1989
2211
|
function extractEntryPaths(data) {
|
|
1990
2212
|
let payload = data;
|
|
1991
2213
|
if (typeof payload === "string") {
|
|
@@ -2072,19 +2294,22 @@ var McpTestClient = class {
|
|
|
2072
2294
|
async connect() {
|
|
2073
2295
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
2074
2296
|
this.triedDiscoveredEntryPath = false;
|
|
2297
|
+
this.initResult = null;
|
|
2075
2298
|
this.transport = this.createTransport();
|
|
2076
2299
|
await this.transport.connect();
|
|
2077
2300
|
const maxRetries = 3;
|
|
2078
2301
|
const retryDelayMs = 500;
|
|
2079
2302
|
let lastError;
|
|
2080
2303
|
let reportedEntryPaths;
|
|
2304
|
+
let attemptsMade = 0;
|
|
2081
2305
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2306
|
+
attemptsMade = attempt;
|
|
2082
2307
|
const initResponse = await this.initialize();
|
|
2083
2308
|
if (initResponse.success && initResponse.data) {
|
|
2084
2309
|
this.initResult = initResponse.data;
|
|
2085
2310
|
break;
|
|
2086
2311
|
}
|
|
2087
|
-
lastError = initResponse.error
|
|
2312
|
+
lastError = describeInitializeError(initResponse.error);
|
|
2088
2313
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
2089
2314
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
2090
2315
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -2096,6 +2321,7 @@ var McpTestClient = class {
|
|
|
2096
2321
|
await this.transport.connect();
|
|
2097
2322
|
continue;
|
|
2098
2323
|
}
|
|
2324
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
2099
2325
|
if (attempt < maxRetries) {
|
|
2100
2326
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
2101
2327
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -2104,7 +2330,7 @@ var McpTestClient = class {
|
|
|
2104
2330
|
if (!this.initResult) {
|
|
2105
2331
|
const served = reportedEntryPaths?.length ? ` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.` : "";
|
|
2106
2332
|
throw new Error(
|
|
2107
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
2333
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
2108
2334
|
);
|
|
2109
2335
|
}
|
|
2110
2336
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -2174,18 +2400,47 @@ var McpTestClient = class {
|
|
|
2174
2400
|
return this._authState;
|
|
2175
2401
|
}
|
|
2176
2402
|
/**
|
|
2177
|
-
* Authenticate with a token
|
|
2403
|
+
* Authenticate with a token.
|
|
2404
|
+
*
|
|
2405
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
2406
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
2407
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
2408
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
2178
2409
|
*/
|
|
2179
2410
|
async authenticate(token) {
|
|
2411
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
2412
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
2413
|
+
}
|
|
2414
|
+
const previousAuthState = this._authState;
|
|
2415
|
+
const previousConfigAuth = this.config.auth;
|
|
2416
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
2180
2417
|
this._authState = {
|
|
2181
2418
|
isAnonymous: false,
|
|
2182
2419
|
token,
|
|
2183
2420
|
scopes: this.parseScopesFromToken(token),
|
|
2184
2421
|
user: this.parseUserFromToken(token)
|
|
2185
2422
|
};
|
|
2186
|
-
|
|
2187
|
-
|
|
2423
|
+
const previousTransport = this.transport;
|
|
2424
|
+
if (!previousTransport) {
|
|
2425
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
2426
|
+
return;
|
|
2188
2427
|
}
|
|
2428
|
+
const previousInit = this.initResult;
|
|
2429
|
+
const previousSessionId = this._sessionId;
|
|
2430
|
+
const previousSessionInfo = this._sessionInfo;
|
|
2431
|
+
try {
|
|
2432
|
+
await this.connect();
|
|
2433
|
+
} catch (err) {
|
|
2434
|
+
await this.transport?.close().catch(() => void 0);
|
|
2435
|
+
this.transport = previousTransport;
|
|
2436
|
+
this.initResult = previousInit;
|
|
2437
|
+
this._sessionId = previousSessionId;
|
|
2438
|
+
this._sessionInfo = previousSessionInfo;
|
|
2439
|
+
this.config.auth = previousConfigAuth;
|
|
2440
|
+
this._authState = previousAuthState;
|
|
2441
|
+
throw err;
|
|
2442
|
+
}
|
|
2443
|
+
await previousTransport.close().catch(() => void 0);
|
|
2189
2444
|
this.log("debug", "Authentication updated");
|
|
2190
2445
|
}
|
|
2191
2446
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2219,13 +2474,12 @@ var McpTestClient = class {
|
|
|
2219
2474
|
/**
|
|
2220
2475
|
* List all available tools
|
|
2221
2476
|
*/
|
|
2222
|
-
list: async () =>
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
},
|
|
2477
|
+
list: async () => this.listAllPages(
|
|
2478
|
+
"tools/list",
|
|
2479
|
+
"tools",
|
|
2480
|
+
(cursor) => this.listTools(cursor),
|
|
2481
|
+
(page) => page.tools
|
|
2482
|
+
),
|
|
2229
2483
|
/**
|
|
2230
2484
|
* Call a tool by name with arguments
|
|
2231
2485
|
*/
|
|
@@ -2241,23 +2495,21 @@ var McpTestClient = class {
|
|
|
2241
2495
|
/**
|
|
2242
2496
|
* List all static resources
|
|
2243
2497
|
*/
|
|
2244
|
-
list: async () =>
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
},
|
|
2498
|
+
list: async () => this.listAllPages(
|
|
2499
|
+
"resources/list",
|
|
2500
|
+
"resources",
|
|
2501
|
+
(cursor) => this.listResources(cursor),
|
|
2502
|
+
(page) => page.resources
|
|
2503
|
+
),
|
|
2251
2504
|
/**
|
|
2252
2505
|
* List all resource templates
|
|
2253
2506
|
*/
|
|
2254
|
-
listTemplates: async () =>
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
},
|
|
2507
|
+
listTemplates: async () => this.listAllPages(
|
|
2508
|
+
"resources/templates/list",
|
|
2509
|
+
"resource templates",
|
|
2510
|
+
(cursor) => this.listResourceTemplates(cursor),
|
|
2511
|
+
(page) => page.resourceTemplates
|
|
2512
|
+
),
|
|
2261
2513
|
/**
|
|
2262
2514
|
* Read a resource by URI
|
|
2263
2515
|
*/
|
|
@@ -2285,13 +2537,12 @@ var McpTestClient = class {
|
|
|
2285
2537
|
/**
|
|
2286
2538
|
* List all available prompts
|
|
2287
2539
|
*/
|
|
2288
|
-
list: async () =>
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
},
|
|
2540
|
+
list: async () => this.listAllPages(
|
|
2541
|
+
"prompts/list",
|
|
2542
|
+
"prompts",
|
|
2543
|
+
(cursor) => this.listPrompts(cursor),
|
|
2544
|
+
(page) => page.prompts
|
|
2545
|
+
),
|
|
2295
2546
|
/**
|
|
2296
2547
|
* Get a prompt with arguments
|
|
2297
2548
|
*/
|
|
@@ -2675,8 +2926,33 @@ var McpTestClient = class {
|
|
|
2675
2926
|
clientInfo: this.config.clientInfo
|
|
2676
2927
|
});
|
|
2677
2928
|
}
|
|
2678
|
-
async listTools() {
|
|
2679
|
-
return this.request("tools/list", {});
|
|
2929
|
+
async listTools(cursor) {
|
|
2930
|
+
return this.request("tools/list", cursor ? { cursor } : {});
|
|
2931
|
+
}
|
|
2932
|
+
/**
|
|
2933
|
+
* Follow `nextCursor` until the server stops returning one, concatenating the
|
|
2934
|
+
* pages. A server that hands back a cursor it already returned, or never stops
|
|
2935
|
+
* paging, fails the call rather than looping.
|
|
2936
|
+
*/
|
|
2937
|
+
async listAllPages(method, label, fetchPage, itemsOf) {
|
|
2938
|
+
const items = [];
|
|
2939
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
2940
|
+
let cursor;
|
|
2941
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
2942
|
+
const response = await fetchPage(cursor);
|
|
2943
|
+
if (!response.success || !response.data) {
|
|
2944
|
+
throw new Error(`Failed to list ${label}: ${response.error?.message}`);
|
|
2945
|
+
}
|
|
2946
|
+
items.push(...itemsOf(response.data) ?? []);
|
|
2947
|
+
const next = response.data.nextCursor;
|
|
2948
|
+
if (!next) return items;
|
|
2949
|
+
if (seenCursors.has(next)) {
|
|
2950
|
+
throw new Error(`Failed to list ${label}: ${method} returned the cursor "${next}" twice`);
|
|
2951
|
+
}
|
|
2952
|
+
seenCursors.add(next);
|
|
2953
|
+
cursor = next;
|
|
2954
|
+
}
|
|
2955
|
+
throw new Error(`Failed to list ${label}: ${method} did not finish within ${MAX_LIST_PAGES} pages`);
|
|
2680
2956
|
}
|
|
2681
2957
|
async callTool(name, args, progressToken) {
|
|
2682
2958
|
return this.request("tools/call", {
|
|
@@ -2701,17 +2977,17 @@ var McpTestClient = class {
|
|
|
2701
2977
|
timestamp
|
|
2702
2978
|
});
|
|
2703
2979
|
};
|
|
2704
|
-
async listResources() {
|
|
2705
|
-
return this.request("resources/list", {});
|
|
2980
|
+
async listResources(cursor) {
|
|
2981
|
+
return this.request("resources/list", cursor ? { cursor } : {});
|
|
2706
2982
|
}
|
|
2707
|
-
async listResourceTemplates() {
|
|
2708
|
-
return this.request("resources/templates/list", {});
|
|
2983
|
+
async listResourceTemplates(cursor) {
|
|
2984
|
+
return this.request("resources/templates/list", cursor ? { cursor } : {});
|
|
2709
2985
|
}
|
|
2710
2986
|
async readResource(uri) {
|
|
2711
2987
|
return this.request("resources/read", { uri });
|
|
2712
2988
|
}
|
|
2713
|
-
async listPrompts() {
|
|
2714
|
-
return this.request("prompts/list", {});
|
|
2989
|
+
async listPrompts(cursor) {
|
|
2990
|
+
return this.request("prompts/list", cursor ? { cursor } : {});
|
|
2715
2991
|
}
|
|
2716
2992
|
async getPrompt(name, args) {
|
|
2717
2993
|
return this.request("prompts/get", {
|
|
@@ -2788,10 +3064,11 @@ var McpTestClient = class {
|
|
|
2788
3064
|
requestId: id
|
|
2789
3065
|
};
|
|
2790
3066
|
} catch (err) {
|
|
3067
|
+
if (isInterceptedError(err)) throw err;
|
|
2791
3068
|
const durationMs = Date.now() - start;
|
|
2792
3069
|
const error = {
|
|
2793
3070
|
code: -32603,
|
|
2794
|
-
message: err
|
|
3071
|
+
message: errorMessage(err)
|
|
2795
3072
|
};
|
|
2796
3073
|
return {
|
|
2797
3074
|
success: false,
|
|
@@ -3018,192 +3295,8 @@ var ProgressCollector = class {
|
|
|
3018
3295
|
}
|
|
3019
3296
|
};
|
|
3020
3297
|
|
|
3021
|
-
// libs/testing/src/auth/token-factory.ts
|
|
3022
|
-
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
3023
|
-
var TestTokenFactory = class {
|
|
3024
|
-
issuer;
|
|
3025
|
-
audience;
|
|
3026
|
-
privateKey = null;
|
|
3027
|
-
publicKey = null;
|
|
3028
|
-
jwk = null;
|
|
3029
|
-
keyId;
|
|
3030
|
-
/** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
|
|
3031
|
-
hmacSecret;
|
|
3032
|
-
constructor(options = {}) {
|
|
3033
|
-
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
3034
|
-
this.audience = options.audience ?? "frontmcp-test";
|
|
3035
|
-
this.keyId = `test-key-${Date.now()}`;
|
|
3036
|
-
this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
|
|
3037
|
-
}
|
|
3038
|
-
/** True when this factory signs HS256 with a shared secret (gateway mode). */
|
|
3039
|
-
get isHmac() {
|
|
3040
|
-
return this.hmacSecret !== null;
|
|
3041
|
-
}
|
|
3042
|
-
/** The JWS `alg` this factory signs with. */
|
|
3043
|
-
get alg() {
|
|
3044
|
-
return this.isHmac ? "HS256" : "RS256";
|
|
3045
|
-
}
|
|
3046
|
-
/**
|
|
3047
|
-
* The signing key: the shared HMAC secret in gateway mode, otherwise the
|
|
3048
|
-
* generated RSA private key (lazily created via ensureKeys()).
|
|
3049
|
-
*/
|
|
3050
|
-
signingKey() {
|
|
3051
|
-
if (this.hmacSecret) return this.hmacSecret;
|
|
3052
|
-
if (!this.privateKey) {
|
|
3053
|
-
throw new Error("Private key not initialized");
|
|
3054
|
-
}
|
|
3055
|
-
return this.privateKey;
|
|
3056
|
-
}
|
|
3057
|
-
/**
|
|
3058
|
-
* Initialize the key pair (called automatically on first use). No-op in HS256
|
|
3059
|
-
* mode — there is no asymmetric keypair, just the shared secret.
|
|
3060
|
-
*/
|
|
3061
|
-
async ensureKeys() {
|
|
3062
|
-
if (this.isHmac) return;
|
|
3063
|
-
if (this.privateKey && this.publicKey) return;
|
|
3064
|
-
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
3065
|
-
extractable: true
|
|
3066
|
-
});
|
|
3067
|
-
this.privateKey = privateKey;
|
|
3068
|
-
this.publicKey = publicKey;
|
|
3069
|
-
this.jwk = await exportJWK(publicKey);
|
|
3070
|
-
this.jwk.kid = this.keyId;
|
|
3071
|
-
this.jwk.use = "sig";
|
|
3072
|
-
this.jwk.alg = "RS256";
|
|
3073
|
-
}
|
|
3074
|
-
/**
|
|
3075
|
-
* Create a JWT token with the specified claims
|
|
3076
|
-
*/
|
|
3077
|
-
async createTestToken(options) {
|
|
3078
|
-
await this.ensureKeys();
|
|
3079
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3080
|
-
const exp = options.exp ?? 3600;
|
|
3081
|
-
const payload = {
|
|
3082
|
-
iss: options.iss ?? this.issuer,
|
|
3083
|
-
sub: options.sub,
|
|
3084
|
-
aud: options.aud ?? this.audience,
|
|
3085
|
-
iat: now,
|
|
3086
|
-
exp: now + exp,
|
|
3087
|
-
scope: options.scopes?.join(" "),
|
|
3088
|
-
...options.claims
|
|
3089
|
-
};
|
|
3090
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3091
|
-
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3092
|
-
return token;
|
|
3093
|
-
}
|
|
3094
|
-
/**
|
|
3095
|
-
* Create an admin token with full access
|
|
3096
|
-
*/
|
|
3097
|
-
async createAdminToken(sub = "admin-001") {
|
|
3098
|
-
return this.createTestToken({
|
|
3099
|
-
sub,
|
|
3100
|
-
scopes: ["admin:*", "read", "write", "delete"],
|
|
3101
|
-
claims: {
|
|
3102
|
-
email: "admin@test.local",
|
|
3103
|
-
name: "Test Admin",
|
|
3104
|
-
role: "admin"
|
|
3105
|
-
}
|
|
3106
|
-
});
|
|
3107
|
-
}
|
|
3108
|
-
/**
|
|
3109
|
-
* Create a regular user token
|
|
3110
|
-
*/
|
|
3111
|
-
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
3112
|
-
return this.createTestToken({
|
|
3113
|
-
sub,
|
|
3114
|
-
scopes,
|
|
3115
|
-
claims: {
|
|
3116
|
-
email: "user@test.local",
|
|
3117
|
-
name: "Test User",
|
|
3118
|
-
role: "user"
|
|
3119
|
-
}
|
|
3120
|
-
});
|
|
3121
|
-
}
|
|
3122
|
-
/**
|
|
3123
|
-
* Create an anonymous user token
|
|
3124
|
-
*/
|
|
3125
|
-
async createAnonymousToken() {
|
|
3126
|
-
return this.createTestToken({
|
|
3127
|
-
sub: `anon:${Date.now()}`,
|
|
3128
|
-
scopes: ["anonymous"],
|
|
3129
|
-
claims: {
|
|
3130
|
-
name: "Anonymous",
|
|
3131
|
-
role: "anonymous"
|
|
3132
|
-
}
|
|
3133
|
-
});
|
|
3134
|
-
}
|
|
3135
|
-
/**
|
|
3136
|
-
* Create an expired token (for testing token expiration)
|
|
3137
|
-
*/
|
|
3138
|
-
async createExpiredToken(options) {
|
|
3139
|
-
await this.ensureKeys();
|
|
3140
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3141
|
-
const payload = {
|
|
3142
|
-
iss: this.issuer,
|
|
3143
|
-
sub: options.sub,
|
|
3144
|
-
aud: this.audience,
|
|
3145
|
-
iat: now - 7200,
|
|
3146
|
-
// 2 hours ago
|
|
3147
|
-
exp: now - 3600
|
|
3148
|
-
// Expired 1 hour ago
|
|
3149
|
-
};
|
|
3150
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3151
|
-
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3152
|
-
return token;
|
|
3153
|
-
}
|
|
3154
|
-
/**
|
|
3155
|
-
* Create a token with an invalid signature (for testing signature validation)
|
|
3156
|
-
*/
|
|
3157
|
-
createTokenWithInvalidSignature(options) {
|
|
3158
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3159
|
-
const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3160
|
-
const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
|
|
3161
|
-
const payload = Buffer.from(
|
|
3162
|
-
JSON.stringify({
|
|
3163
|
-
iss: this.issuer,
|
|
3164
|
-
sub: options.sub,
|
|
3165
|
-
aud: this.audience,
|
|
3166
|
-
iat: now,
|
|
3167
|
-
exp: now + 3600
|
|
3168
|
-
})
|
|
3169
|
-
).toString("base64url");
|
|
3170
|
-
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
3171
|
-
return `${header}.${payload}.${signature}`;
|
|
3172
|
-
}
|
|
3173
|
-
/**
|
|
3174
|
-
* Get the public JWKS for verifying tokens.
|
|
3175
|
-
*
|
|
3176
|
-
* Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
|
|
3177
|
-
* the shared secret, not a published public key, so there is no JWKS to serve.
|
|
3178
|
-
*/
|
|
3179
|
-
async getPublicJwks() {
|
|
3180
|
-
if (this.isHmac) {
|
|
3181
|
-
throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
|
|
3182
|
-
}
|
|
3183
|
-
await this.ensureKeys();
|
|
3184
|
-
if (!this.jwk) {
|
|
3185
|
-
throw new Error("JWK not initialized");
|
|
3186
|
-
}
|
|
3187
|
-
return {
|
|
3188
|
-
keys: [this.jwk]
|
|
3189
|
-
};
|
|
3190
|
-
}
|
|
3191
|
-
/**
|
|
3192
|
-
* Get the issuer URL
|
|
3193
|
-
*/
|
|
3194
|
-
getIssuer() {
|
|
3195
|
-
return this.issuer;
|
|
3196
|
-
}
|
|
3197
|
-
/**
|
|
3198
|
-
* Get the audience
|
|
3199
|
-
*/
|
|
3200
|
-
getAudience() {
|
|
3201
|
-
return this.audience;
|
|
3202
|
-
}
|
|
3203
|
-
};
|
|
3204
|
-
|
|
3205
3298
|
// libs/testing/src/server/test-server.ts
|
|
3206
|
-
import { spawn } from "child_process";
|
|
3299
|
+
import { execFile, spawn } from "child_process";
|
|
3207
3300
|
import { sha256Hex } from "@frontmcp/utils";
|
|
3208
3301
|
|
|
3209
3302
|
// libs/testing/src/errors/index.ts
|
|
@@ -3274,6 +3367,8 @@ var E2E_PORT_RANGES = {
|
|
|
3274
3367
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
3275
3368
|
// Protocol revision E2E tests (50460-50479)
|
|
3276
3369
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
3370
|
+
// Testing library E2E tests (50480-50499)
|
|
3371
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
3277
3372
|
// Mock servers and utilities (50900-50999)
|
|
3278
3373
|
"mock-oauth": { start: 50900, size: 10 },
|
|
3279
3374
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -3282,6 +3377,66 @@ var E2E_PORT_RANGES = {
|
|
|
3282
3377
|
default: { start: 51e3, size: 100 }
|
|
3283
3378
|
};
|
|
3284
3379
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3380
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
3381
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
3382
|
+
function lockDir() {
|
|
3383
|
+
try {
|
|
3384
|
+
const os = __require("node:os");
|
|
3385
|
+
const path = __require("node:path");
|
|
3386
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
3387
|
+
} catch {
|
|
3388
|
+
return void 0;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
function pidIsAlive(pid) {
|
|
3392
|
+
try {
|
|
3393
|
+
process.kill(pid, 0);
|
|
3394
|
+
return true;
|
|
3395
|
+
} catch (error) {
|
|
3396
|
+
return error.code === "EPERM";
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
function acquirePortLock(port) {
|
|
3400
|
+
if (lockedPorts.has(port)) return false;
|
|
3401
|
+
let fs;
|
|
3402
|
+
let file;
|
|
3403
|
+
try {
|
|
3404
|
+
fs = __require("node:fs");
|
|
3405
|
+
const dir = lockDir();
|
|
3406
|
+
if (!dir) return true;
|
|
3407
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3408
|
+
file = __require("node:path").join(dir, `${port}.lock`);
|
|
3409
|
+
} catch {
|
|
3410
|
+
return true;
|
|
3411
|
+
}
|
|
3412
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
3413
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3414
|
+
try {
|
|
3415
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
3416
|
+
lockedPorts.add(port);
|
|
3417
|
+
return true;
|
|
3418
|
+
} catch (error) {
|
|
3419
|
+
if (error.code !== "EEXIST") return true;
|
|
3420
|
+
try {
|
|
3421
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3422
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
3423
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
3424
|
+
fs.unlinkSync(file);
|
|
3425
|
+
} catch {
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
return false;
|
|
3430
|
+
}
|
|
3431
|
+
function releasePortLock(port) {
|
|
3432
|
+
if (!lockedPorts.delete(port)) return;
|
|
3433
|
+
try {
|
|
3434
|
+
const fs = __require("node:fs");
|
|
3435
|
+
const dir = lockDir();
|
|
3436
|
+
if (dir) fs.unlinkSync(__require("node:path").join(dir, `${port}.lock`));
|
|
3437
|
+
} catch {
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3285
3440
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
3286
3441
|
function getPortRange(project) {
|
|
3287
3442
|
const key = project;
|
|
@@ -3292,15 +3447,22 @@ function getPortRange(project) {
|
|
|
3292
3447
|
}
|
|
3293
3448
|
async function reservePort(project, preferredPort) {
|
|
3294
3449
|
const range = getPortRange(project);
|
|
3295
|
-
|
|
3450
|
+
const handle = (port) => ({
|
|
3451
|
+
port,
|
|
3452
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
3453
|
+
releaseSocket: async () => {
|
|
3454
|
+
await releasePort(port);
|
|
3455
|
+
},
|
|
3456
|
+
release: async () => {
|
|
3457
|
+
await releasePort(port);
|
|
3458
|
+
releasePortLock(port);
|
|
3459
|
+
}
|
|
3460
|
+
});
|
|
3461
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
3462
|
+
if (wantsSpecificPort) {
|
|
3296
3463
|
const reservation = await tryReservePort(preferredPort, project);
|
|
3297
3464
|
if (reservation) {
|
|
3298
|
-
return
|
|
3299
|
-
port: preferredPort,
|
|
3300
|
-
release: async () => {
|
|
3301
|
-
await releasePort(preferredPort);
|
|
3302
|
-
}
|
|
3303
|
-
};
|
|
3465
|
+
return handle(preferredPort);
|
|
3304
3466
|
}
|
|
3305
3467
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
3306
3468
|
}
|
|
@@ -3309,30 +3471,19 @@ async function reservePort(project, preferredPort) {
|
|
|
3309
3471
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3310
3472
|
const port = range.start + index % range.size;
|
|
3311
3473
|
index = (index + 1) % range.size;
|
|
3312
|
-
if (reservedPorts.has(port)) {
|
|
3474
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
3313
3475
|
continue;
|
|
3314
3476
|
}
|
|
3315
3477
|
const reservation = await tryReservePort(port, project);
|
|
3316
3478
|
if (reservation) {
|
|
3317
3479
|
projectPortIndex.set(project, index);
|
|
3318
|
-
return
|
|
3319
|
-
port,
|
|
3320
|
-
release: async () => {
|
|
3321
|
-
await releasePort(port);
|
|
3322
|
-
}
|
|
3323
|
-
};
|
|
3480
|
+
return handle(port);
|
|
3324
3481
|
}
|
|
3325
3482
|
}
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
return {
|
|
3331
|
-
port: dynamicPort,
|
|
3332
|
-
release: async () => {
|
|
3333
|
-
await releasePort(dynamicPort);
|
|
3334
|
-
}
|
|
3335
|
-
};
|
|
3483
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
3484
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
3485
|
+
if (await tryReservePort(port, project)) {
|
|
3486
|
+
return handle(port);
|
|
3336
3487
|
}
|
|
3337
3488
|
}
|
|
3338
3489
|
throw new Error(
|
|
@@ -3340,9 +3491,13 @@ async function reservePort(project, preferredPort) {
|
|
|
3340
3491
|
);
|
|
3341
3492
|
}
|
|
3342
3493
|
async function tryReservePort(port, project) {
|
|
3494
|
+
if (!acquirePortLock(port)) {
|
|
3495
|
+
return false;
|
|
3496
|
+
}
|
|
3343
3497
|
return new Promise((resolve) => {
|
|
3344
3498
|
const server = createServer();
|
|
3345
3499
|
server.once("error", () => {
|
|
3500
|
+
releasePortLock(port);
|
|
3346
3501
|
resolve(false);
|
|
3347
3502
|
});
|
|
3348
3503
|
server.listen(port, () => {
|
|
@@ -3368,31 +3523,6 @@ async function releasePort(port) {
|
|
|
3368
3523
|
});
|
|
3369
3524
|
});
|
|
3370
3525
|
}
|
|
3371
|
-
async function findAvailablePortInRange(start, end) {
|
|
3372
|
-
for (let port = start; port < end; port++) {
|
|
3373
|
-
if (reservedPorts.has(port)) {
|
|
3374
|
-
continue;
|
|
3375
|
-
}
|
|
3376
|
-
const available = await isPortAvailable(port);
|
|
3377
|
-
if (available) {
|
|
3378
|
-
return port;
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
return null;
|
|
3382
|
-
}
|
|
3383
|
-
async function isPortAvailable(port) {
|
|
3384
|
-
return new Promise((resolve) => {
|
|
3385
|
-
const server = createServer();
|
|
3386
|
-
server.once("error", () => {
|
|
3387
|
-
resolve(false);
|
|
3388
|
-
});
|
|
3389
|
-
server.listen(port, () => {
|
|
3390
|
-
server.close(() => {
|
|
3391
|
-
resolve(true);
|
|
3392
|
-
});
|
|
3393
|
-
});
|
|
3394
|
-
});
|
|
3395
|
-
}
|
|
3396
3526
|
|
|
3397
3527
|
// libs/testing/src/server/test-server.ts
|
|
3398
3528
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -3401,8 +3531,11 @@ var TestServer = class _TestServer {
|
|
|
3401
3531
|
options;
|
|
3402
3532
|
_info;
|
|
3403
3533
|
logs = [];
|
|
3534
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
3404
3535
|
portRelease = null;
|
|
3405
|
-
|
|
3536
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
3537
|
+
portReleaseSocket = null;
|
|
3538
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
3406
3539
|
this.options = {
|
|
3407
3540
|
port,
|
|
3408
3541
|
project: options.project,
|
|
@@ -3414,6 +3547,7 @@ var TestServer = class _TestServer {
|
|
|
3414
3547
|
debug: options.debug ?? DEBUG_SERVER
|
|
3415
3548
|
};
|
|
3416
3549
|
this.portRelease = portRelease ?? null;
|
|
3550
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
3417
3551
|
this._info = {
|
|
3418
3552
|
baseUrl: `http://localhost:${port}`,
|
|
3419
3553
|
port
|
|
@@ -3426,8 +3560,8 @@ var TestServer = class _TestServer {
|
|
|
3426
3560
|
const project = options.project ?? "default";
|
|
3427
3561
|
const maxAttempts = 3;
|
|
3428
3562
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3429
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3430
|
-
const server = new _TestServer(options, port, release);
|
|
3563
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3564
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
3431
3565
|
try {
|
|
3432
3566
|
await server.startProcess();
|
|
3433
3567
|
return server;
|
|
@@ -3467,7 +3601,7 @@ var TestServer = class _TestServer {
|
|
|
3467
3601
|
}
|
|
3468
3602
|
const maxAttempts = 3;
|
|
3469
3603
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3470
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3604
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3471
3605
|
const serverOptions = {
|
|
3472
3606
|
...options,
|
|
3473
3607
|
port,
|
|
@@ -3475,7 +3609,7 @@ var TestServer = class _TestServer {
|
|
|
3475
3609
|
command: `npx nx serve ${project} --port ${port}`,
|
|
3476
3610
|
cwd: options.cwd ?? process.cwd()
|
|
3477
3611
|
};
|
|
3478
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
3612
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
3479
3613
|
try {
|
|
3480
3614
|
await server.startProcess();
|
|
3481
3615
|
return server;
|
|
@@ -3533,10 +3667,17 @@ var TestServer = class _TestServer {
|
|
|
3533
3667
|
* Stop the test server
|
|
3534
3668
|
*/
|
|
3535
3669
|
async stop() {
|
|
3536
|
-
|
|
3537
|
-
await this.
|
|
3538
|
-
|
|
3670
|
+
try {
|
|
3671
|
+
await this.stopProcess();
|
|
3672
|
+
} finally {
|
|
3673
|
+
if (this.portRelease) {
|
|
3674
|
+
await this.portRelease();
|
|
3675
|
+
this.portRelease = null;
|
|
3676
|
+
}
|
|
3677
|
+
this.portReleaseSocket = null;
|
|
3539
3678
|
}
|
|
3679
|
+
}
|
|
3680
|
+
async stopProcess() {
|
|
3540
3681
|
if (this.process) {
|
|
3541
3682
|
this.log("Stopping server...");
|
|
3542
3683
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -3609,7 +3750,7 @@ var TestServer = class _TestServer {
|
|
|
3609
3750
|
* Restart the server
|
|
3610
3751
|
*/
|
|
3611
3752
|
async restart() {
|
|
3612
|
-
await this.
|
|
3753
|
+
await this.stopProcess();
|
|
3613
3754
|
await this.startProcess();
|
|
3614
3755
|
}
|
|
3615
3756
|
/**
|
|
@@ -3639,9 +3780,9 @@ var TestServer = class _TestServer {
|
|
|
3639
3780
|
PORT: String(this.options.port)
|
|
3640
3781
|
};
|
|
3641
3782
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
3642
|
-
if (this.
|
|
3643
|
-
await this.
|
|
3644
|
-
this.
|
|
3783
|
+
if (this.portReleaseSocket) {
|
|
3784
|
+
await this.portReleaseSocket();
|
|
3785
|
+
this.portReleaseSocket = null;
|
|
3645
3786
|
await sleep4(300);
|
|
3646
3787
|
}
|
|
3647
3788
|
this.process = spawn(this.options.command, [], {
|
|
@@ -3651,9 +3792,8 @@ var TestServer = class _TestServer {
|
|
|
3651
3792
|
detached: true,
|
|
3652
3793
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3653
3794
|
});
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
}
|
|
3795
|
+
this._info.pid = this.process.pid;
|
|
3796
|
+
this._info.shellPid = this.process.pid;
|
|
3657
3797
|
let processExited = false;
|
|
3658
3798
|
let exitCode = null;
|
|
3659
3799
|
let exitError = null;
|
|
@@ -3756,6 +3896,8 @@ ${allLogs}`);
|
|
|
3756
3896
|
});
|
|
3757
3897
|
if (response.ok || response.status === 404) {
|
|
3758
3898
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3899
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
3900
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3759
3901
|
return;
|
|
3760
3902
|
}
|
|
3761
3903
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3800,6 +3942,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3800
3942
|
function sleep4(ms) {
|
|
3801
3943
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3802
3944
|
}
|
|
3945
|
+
function findListeningPid(port) {
|
|
3946
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3947
|
+
return new Promise((resolve) => {
|
|
3948
|
+
execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
3949
|
+
if (error) {
|
|
3950
|
+
resolve(void 0);
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
3954
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
3955
|
+
});
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3803
3958
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3804
3959
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3805
3960
|
return env;
|
|
@@ -3947,7 +4102,8 @@ Server entry: ${currentConfig.server}
|
|
|
3947
4102
|
Project: ${currentConfig.project ?? "default"}
|
|
3948
4103
|
Command: ${serverCommand}
|
|
3949
4104
|
|
|
3950
|
-
Error: ${errMsg}
|
|
4105
|
+
Error: ${errMsg}`,
|
|
4106
|
+
{ cause: error }
|
|
3951
4107
|
);
|
|
3952
4108
|
}
|
|
3953
4109
|
} else {
|
|
@@ -4137,16 +4293,19 @@ function todo(name) {
|
|
|
4137
4293
|
}
|
|
4138
4294
|
var perfTest = perfTestWithFixtures;
|
|
4139
4295
|
perfTest.use = use;
|
|
4140
|
-
|
|
4141
|
-
perfTest
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4296
|
+
for (const hook of ["describe", "beforeAll", "beforeEach", "afterEach", "afterAll"]) {
|
|
4297
|
+
Object.defineProperty(perfTest, hook, {
|
|
4298
|
+
get: () => globalThis[hook],
|
|
4299
|
+
enumerable: true,
|
|
4300
|
+
configurable: true
|
|
4301
|
+
});
|
|
4302
|
+
}
|
|
4145
4303
|
perfTest.skip = skip;
|
|
4146
4304
|
perfTest.only = only;
|
|
4147
4305
|
perfTest.todo = todo;
|
|
4148
4306
|
|
|
4149
4307
|
// libs/testing/src/perf/baseline-store.ts
|
|
4308
|
+
import { ensureDir, fileExists, readFile, writeFile } from "@frontmcp/utils";
|
|
4150
4309
|
var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
|
|
4151
4310
|
var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
|
|
4152
4311
|
var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
|
|
@@ -4161,7 +4320,6 @@ var BaselineStore = class {
|
|
|
4161
4320
|
*/
|
|
4162
4321
|
async load() {
|
|
4163
4322
|
try {
|
|
4164
|
-
const { readFile, fileExists } = await import("@frontmcp/utils");
|
|
4165
4323
|
if (!await fileExists(this.baselinePath)) {
|
|
4166
4324
|
return null;
|
|
4167
4325
|
}
|
|
@@ -4176,7 +4334,6 @@ var BaselineStore = class {
|
|
|
4176
4334
|
* Save baseline to local file.
|
|
4177
4335
|
*/
|
|
4178
4336
|
async save(baseline) {
|
|
4179
|
-
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
4180
4337
|
const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
|
|
4181
4338
|
await ensureDir(dir);
|
|
4182
4339
|
await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
@@ -4455,6 +4612,7 @@ function resetRegressionDetector() {
|
|
|
4455
4612
|
}
|
|
4456
4613
|
|
|
4457
4614
|
// libs/testing/src/perf/report-generator.ts
|
|
4615
|
+
import { ensureDir as ensureDir2, writeFile as writeFile2 } from "@frontmcp/utils";
|
|
4458
4616
|
var ReportGenerator = class {
|
|
4459
4617
|
detector;
|
|
4460
4618
|
constructor() {
|
|
@@ -4710,14 +4868,13 @@ var ReportGenerator = class {
|
|
|
4710
4868
|
}
|
|
4711
4869
|
};
|
|
4712
4870
|
async function saveReports(measurements, outputDir, baseline, gitInfo) {
|
|
4713
|
-
|
|
4714
|
-
await ensureDir(outputDir);
|
|
4871
|
+
await ensureDir2(outputDir);
|
|
4715
4872
|
const generator = new ReportGenerator();
|
|
4716
4873
|
const report = generator.generateReport(measurements, baseline, gitInfo);
|
|
4717
4874
|
const jsonPath = `${outputDir}/report.json`;
|
|
4718
4875
|
const markdownPath = `${outputDir}/report.md`;
|
|
4719
|
-
await
|
|
4720
|
-
await
|
|
4876
|
+
await writeFile2(jsonPath, generator.generateJsonReport(report));
|
|
4877
|
+
await writeFile2(markdownPath, generator.generateMarkdownReport(report));
|
|
4721
4878
|
return { jsonPath, markdownPath };
|
|
4722
4879
|
}
|
|
4723
4880
|
function createReportGenerator() {
|