@frontmcp/testing 1.8.6 → 1.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +487 -224
- package/esm/index.mjs +644 -347
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +407 -272
- 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 +486 -216
- 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 +652 -348
- 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 +410 -285
- 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) {
|
|
@@ -1987,6 +2199,15 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1987
2199
|
version: "0.4.0"
|
|
1988
2200
|
};
|
|
1989
2201
|
var MAX_LIST_PAGES = 1e3;
|
|
2202
|
+
function isAuthFailure(error) {
|
|
2203
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
2204
|
+
}
|
|
2205
|
+
function describeInitializeError(error) {
|
|
2206
|
+
const message = error?.message ?? "Unknown error";
|
|
2207
|
+
const data = error?.data;
|
|
2208
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
2209
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
2210
|
+
}
|
|
1990
2211
|
function extractEntryPaths(data) {
|
|
1991
2212
|
let payload = data;
|
|
1992
2213
|
if (typeof payload === "string") {
|
|
@@ -2073,19 +2294,22 @@ var McpTestClient = class {
|
|
|
2073
2294
|
async connect() {
|
|
2074
2295
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
2075
2296
|
this.triedDiscoveredEntryPath = false;
|
|
2297
|
+
this.initResult = null;
|
|
2076
2298
|
this.transport = this.createTransport();
|
|
2077
2299
|
await this.transport.connect();
|
|
2078
2300
|
const maxRetries = 3;
|
|
2079
2301
|
const retryDelayMs = 500;
|
|
2080
2302
|
let lastError;
|
|
2081
2303
|
let reportedEntryPaths;
|
|
2304
|
+
let attemptsMade = 0;
|
|
2082
2305
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
2306
|
+
attemptsMade = attempt;
|
|
2083
2307
|
const initResponse = await this.initialize();
|
|
2084
2308
|
if (initResponse.success && initResponse.data) {
|
|
2085
2309
|
this.initResult = initResponse.data;
|
|
2086
2310
|
break;
|
|
2087
2311
|
}
|
|
2088
|
-
lastError = initResponse.error
|
|
2312
|
+
lastError = describeInitializeError(initResponse.error);
|
|
2089
2313
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
2090
2314
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
2091
2315
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -2097,6 +2321,7 @@ var McpTestClient = class {
|
|
|
2097
2321
|
await this.transport.connect();
|
|
2098
2322
|
continue;
|
|
2099
2323
|
}
|
|
2324
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
2100
2325
|
if (attempt < maxRetries) {
|
|
2101
2326
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
2102
2327
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -2105,7 +2330,7 @@ var McpTestClient = class {
|
|
|
2105
2330
|
if (!this.initResult) {
|
|
2106
2331
|
const served = reportedEntryPaths?.length ? ` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.` : "";
|
|
2107
2332
|
throw new Error(
|
|
2108
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
2333
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
2109
2334
|
);
|
|
2110
2335
|
}
|
|
2111
2336
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -2175,18 +2400,47 @@ var McpTestClient = class {
|
|
|
2175
2400
|
return this._authState;
|
|
2176
2401
|
}
|
|
2177
2402
|
/**
|
|
2178
|
-
* Authenticate with a token
|
|
2403
|
+
* Authenticate with a token.
|
|
2404
|
+
*
|
|
2405
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
2406
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
2407
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
2408
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
2179
2409
|
*/
|
|
2180
2410
|
async authenticate(token) {
|
|
2411
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
2412
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
2413
|
+
}
|
|
2414
|
+
const previousAuthState = this._authState;
|
|
2415
|
+
const previousConfigAuth = this.config.auth;
|
|
2416
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
2181
2417
|
this._authState = {
|
|
2182
2418
|
isAnonymous: false,
|
|
2183
2419
|
token,
|
|
2184
2420
|
scopes: this.parseScopesFromToken(token),
|
|
2185
2421
|
user: this.parseUserFromToken(token)
|
|
2186
2422
|
};
|
|
2187
|
-
|
|
2188
|
-
|
|
2423
|
+
const previousTransport = this.transport;
|
|
2424
|
+
if (!previousTransport) {
|
|
2425
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
2426
|
+
return;
|
|
2189
2427
|
}
|
|
2428
|
+
const previousInit = this.initResult;
|
|
2429
|
+
const previousSessionId = this._sessionId;
|
|
2430
|
+
const previousSessionInfo = this._sessionInfo;
|
|
2431
|
+
try {
|
|
2432
|
+
await this.connect();
|
|
2433
|
+
} catch (err) {
|
|
2434
|
+
await this.transport?.close().catch(() => void 0);
|
|
2435
|
+
this.transport = previousTransport;
|
|
2436
|
+
this.initResult = previousInit;
|
|
2437
|
+
this._sessionId = previousSessionId;
|
|
2438
|
+
this._sessionInfo = previousSessionInfo;
|
|
2439
|
+
this.config.auth = previousConfigAuth;
|
|
2440
|
+
this._authState = previousAuthState;
|
|
2441
|
+
throw err;
|
|
2442
|
+
}
|
|
2443
|
+
await previousTransport.close().catch(() => void 0);
|
|
2190
2444
|
this.log("debug", "Authentication updated");
|
|
2191
2445
|
}
|
|
2192
2446
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2810,10 +3064,11 @@ var McpTestClient = class {
|
|
|
2810
3064
|
requestId: id
|
|
2811
3065
|
};
|
|
2812
3066
|
} catch (err) {
|
|
3067
|
+
if (isInterceptedError(err)) throw err;
|
|
2813
3068
|
const durationMs = Date.now() - start;
|
|
2814
3069
|
const error = {
|
|
2815
3070
|
code: -32603,
|
|
2816
|
-
message: err
|
|
3071
|
+
message: errorMessage(err)
|
|
2817
3072
|
};
|
|
2818
3073
|
return {
|
|
2819
3074
|
success: false,
|
|
@@ -3040,192 +3295,8 @@ var ProgressCollector = class {
|
|
|
3040
3295
|
}
|
|
3041
3296
|
};
|
|
3042
3297
|
|
|
3043
|
-
// libs/testing/src/auth/token-factory.ts
|
|
3044
|
-
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
3045
|
-
var TestTokenFactory = class {
|
|
3046
|
-
issuer;
|
|
3047
|
-
audience;
|
|
3048
|
-
privateKey = null;
|
|
3049
|
-
publicKey = null;
|
|
3050
|
-
jwk = null;
|
|
3051
|
-
keyId;
|
|
3052
|
-
/** HS256 shared secret (gateway mode). Null → RS256 + JWKS (default). */
|
|
3053
|
-
hmacSecret;
|
|
3054
|
-
constructor(options = {}) {
|
|
3055
|
-
this.issuer = options.issuer ?? "https://test.frontmcp.local";
|
|
3056
|
-
this.audience = options.audience ?? "frontmcp-test";
|
|
3057
|
-
this.keyId = `test-key-${Date.now()}`;
|
|
3058
|
-
this.hmacSecret = options.hmacSecret ? new TextEncoder().encode(options.hmacSecret) : null;
|
|
3059
|
-
}
|
|
3060
|
-
/** True when this factory signs HS256 with a shared secret (gateway mode). */
|
|
3061
|
-
get isHmac() {
|
|
3062
|
-
return this.hmacSecret !== null;
|
|
3063
|
-
}
|
|
3064
|
-
/** The JWS `alg` this factory signs with. */
|
|
3065
|
-
get alg() {
|
|
3066
|
-
return this.isHmac ? "HS256" : "RS256";
|
|
3067
|
-
}
|
|
3068
|
-
/**
|
|
3069
|
-
* The signing key: the shared HMAC secret in gateway mode, otherwise the
|
|
3070
|
-
* generated RSA private key (lazily created via ensureKeys()).
|
|
3071
|
-
*/
|
|
3072
|
-
signingKey() {
|
|
3073
|
-
if (this.hmacSecret) return this.hmacSecret;
|
|
3074
|
-
if (!this.privateKey) {
|
|
3075
|
-
throw new Error("Private key not initialized");
|
|
3076
|
-
}
|
|
3077
|
-
return this.privateKey;
|
|
3078
|
-
}
|
|
3079
|
-
/**
|
|
3080
|
-
* Initialize the key pair (called automatically on first use). No-op in HS256
|
|
3081
|
-
* mode — there is no asymmetric keypair, just the shared secret.
|
|
3082
|
-
*/
|
|
3083
|
-
async ensureKeys() {
|
|
3084
|
-
if (this.isHmac) return;
|
|
3085
|
-
if (this.privateKey && this.publicKey) return;
|
|
3086
|
-
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
3087
|
-
extractable: true
|
|
3088
|
-
});
|
|
3089
|
-
this.privateKey = privateKey;
|
|
3090
|
-
this.publicKey = publicKey;
|
|
3091
|
-
this.jwk = await exportJWK(publicKey);
|
|
3092
|
-
this.jwk.kid = this.keyId;
|
|
3093
|
-
this.jwk.use = "sig";
|
|
3094
|
-
this.jwk.alg = "RS256";
|
|
3095
|
-
}
|
|
3096
|
-
/**
|
|
3097
|
-
* Create a JWT token with the specified claims
|
|
3098
|
-
*/
|
|
3099
|
-
async createTestToken(options) {
|
|
3100
|
-
await this.ensureKeys();
|
|
3101
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3102
|
-
const exp = options.exp ?? 3600;
|
|
3103
|
-
const payload = {
|
|
3104
|
-
iss: options.iss ?? this.issuer,
|
|
3105
|
-
sub: options.sub,
|
|
3106
|
-
aud: options.aud ?? this.audience,
|
|
3107
|
-
iat: now,
|
|
3108
|
-
exp: now + exp,
|
|
3109
|
-
scope: options.scopes?.join(" "),
|
|
3110
|
-
...options.claims
|
|
3111
|
-
};
|
|
3112
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3113
|
-
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3114
|
-
return token;
|
|
3115
|
-
}
|
|
3116
|
-
/**
|
|
3117
|
-
* Create an admin token with full access
|
|
3118
|
-
*/
|
|
3119
|
-
async createAdminToken(sub = "admin-001") {
|
|
3120
|
-
return this.createTestToken({
|
|
3121
|
-
sub,
|
|
3122
|
-
scopes: ["admin:*", "read", "write", "delete"],
|
|
3123
|
-
claims: {
|
|
3124
|
-
email: "admin@test.local",
|
|
3125
|
-
name: "Test Admin",
|
|
3126
|
-
role: "admin"
|
|
3127
|
-
}
|
|
3128
|
-
});
|
|
3129
|
-
}
|
|
3130
|
-
/**
|
|
3131
|
-
* Create a regular user token
|
|
3132
|
-
*/
|
|
3133
|
-
async createUserToken(sub = "user-001", scopes = ["read", "write"]) {
|
|
3134
|
-
return this.createTestToken({
|
|
3135
|
-
sub,
|
|
3136
|
-
scopes,
|
|
3137
|
-
claims: {
|
|
3138
|
-
email: "user@test.local",
|
|
3139
|
-
name: "Test User",
|
|
3140
|
-
role: "user"
|
|
3141
|
-
}
|
|
3142
|
-
});
|
|
3143
|
-
}
|
|
3144
|
-
/**
|
|
3145
|
-
* Create an anonymous user token
|
|
3146
|
-
*/
|
|
3147
|
-
async createAnonymousToken() {
|
|
3148
|
-
return this.createTestToken({
|
|
3149
|
-
sub: `anon:${Date.now()}`,
|
|
3150
|
-
scopes: ["anonymous"],
|
|
3151
|
-
claims: {
|
|
3152
|
-
name: "Anonymous",
|
|
3153
|
-
role: "anonymous"
|
|
3154
|
-
}
|
|
3155
|
-
});
|
|
3156
|
-
}
|
|
3157
|
-
/**
|
|
3158
|
-
* Create an expired token (for testing token expiration)
|
|
3159
|
-
*/
|
|
3160
|
-
async createExpiredToken(options) {
|
|
3161
|
-
await this.ensureKeys();
|
|
3162
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3163
|
-
const payload = {
|
|
3164
|
-
iss: this.issuer,
|
|
3165
|
-
sub: options.sub,
|
|
3166
|
-
aud: this.audience,
|
|
3167
|
-
iat: now - 7200,
|
|
3168
|
-
// 2 hours ago
|
|
3169
|
-
exp: now - 3600
|
|
3170
|
-
// Expired 1 hour ago
|
|
3171
|
-
};
|
|
3172
|
-
const header = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3173
|
-
const token = await new SignJWT(payload).setProtectedHeader(header).sign(this.signingKey());
|
|
3174
|
-
return token;
|
|
3175
|
-
}
|
|
3176
|
-
/**
|
|
3177
|
-
* Create a token with an invalid signature (for testing signature validation)
|
|
3178
|
-
*/
|
|
3179
|
-
createTokenWithInvalidSignature(options) {
|
|
3180
|
-
const now = Math.floor(Date.now() / 1e3);
|
|
3181
|
-
const headerObj = this.isHmac ? { alg: this.alg, typ: "JWT" } : { alg: this.alg, kid: this.keyId };
|
|
3182
|
-
const header = Buffer.from(JSON.stringify(headerObj)).toString("base64url");
|
|
3183
|
-
const payload = Buffer.from(
|
|
3184
|
-
JSON.stringify({
|
|
3185
|
-
iss: this.issuer,
|
|
3186
|
-
sub: options.sub,
|
|
3187
|
-
aud: this.audience,
|
|
3188
|
-
iat: now,
|
|
3189
|
-
exp: now + 3600
|
|
3190
|
-
})
|
|
3191
|
-
).toString("base64url");
|
|
3192
|
-
const signature = Buffer.from("invalid-signature-" + Date.now()).toString("base64url");
|
|
3193
|
-
return `${header}.${payload}.${signature}`;
|
|
3194
|
-
}
|
|
3195
|
-
/**
|
|
3196
|
-
* Get the public JWKS for verifying tokens.
|
|
3197
|
-
*
|
|
3198
|
-
* Only meaningful in RS256 mode — HS256 (gateway) tokens are verified against
|
|
3199
|
-
* the shared secret, not a published public key, so there is no JWKS to serve.
|
|
3200
|
-
*/
|
|
3201
|
-
async getPublicJwks() {
|
|
3202
|
-
if (this.isHmac) {
|
|
3203
|
-
throw new Error("getPublicJwks() is not available in HS256 mode (gateway tokens use a shared secret)");
|
|
3204
|
-
}
|
|
3205
|
-
await this.ensureKeys();
|
|
3206
|
-
if (!this.jwk) {
|
|
3207
|
-
throw new Error("JWK not initialized");
|
|
3208
|
-
}
|
|
3209
|
-
return {
|
|
3210
|
-
keys: [this.jwk]
|
|
3211
|
-
};
|
|
3212
|
-
}
|
|
3213
|
-
/**
|
|
3214
|
-
* Get the issuer URL
|
|
3215
|
-
*/
|
|
3216
|
-
getIssuer() {
|
|
3217
|
-
return this.issuer;
|
|
3218
|
-
}
|
|
3219
|
-
/**
|
|
3220
|
-
* Get the audience
|
|
3221
|
-
*/
|
|
3222
|
-
getAudience() {
|
|
3223
|
-
return this.audience;
|
|
3224
|
-
}
|
|
3225
|
-
};
|
|
3226
|
-
|
|
3227
3298
|
// libs/testing/src/server/test-server.ts
|
|
3228
|
-
import { spawn } from "child_process";
|
|
3299
|
+
import { execFile, spawn } from "child_process";
|
|
3229
3300
|
import { sha256Hex } from "@frontmcp/utils";
|
|
3230
3301
|
|
|
3231
3302
|
// libs/testing/src/errors/index.ts
|
|
@@ -3296,6 +3367,8 @@ var E2E_PORT_RANGES = {
|
|
|
3296
3367
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
3297
3368
|
// Protocol revision E2E tests (50460-50479)
|
|
3298
3369
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
3370
|
+
// Testing library E2E tests (50480-50499)
|
|
3371
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
3299
3372
|
// Mock servers and utilities (50900-50999)
|
|
3300
3373
|
"mock-oauth": { start: 50900, size: 10 },
|
|
3301
3374
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -3304,6 +3377,66 @@ var E2E_PORT_RANGES = {
|
|
|
3304
3377
|
default: { start: 51e3, size: 100 }
|
|
3305
3378
|
};
|
|
3306
3379
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
3380
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
3381
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
3382
|
+
function lockDir() {
|
|
3383
|
+
try {
|
|
3384
|
+
const os = __require("node:os");
|
|
3385
|
+
const path = __require("node:path");
|
|
3386
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
3387
|
+
} catch {
|
|
3388
|
+
return void 0;
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
function pidIsAlive(pid) {
|
|
3392
|
+
try {
|
|
3393
|
+
process.kill(pid, 0);
|
|
3394
|
+
return true;
|
|
3395
|
+
} catch (error) {
|
|
3396
|
+
return error.code === "EPERM";
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
function acquirePortLock(port) {
|
|
3400
|
+
if (lockedPorts.has(port)) return false;
|
|
3401
|
+
let fs;
|
|
3402
|
+
let file;
|
|
3403
|
+
try {
|
|
3404
|
+
fs = __require("node:fs");
|
|
3405
|
+
const dir = lockDir();
|
|
3406
|
+
if (!dir) return true;
|
|
3407
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3408
|
+
file = __require("node:path").join(dir, `${port}.lock`);
|
|
3409
|
+
} catch {
|
|
3410
|
+
return true;
|
|
3411
|
+
}
|
|
3412
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
3413
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
3414
|
+
try {
|
|
3415
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
3416
|
+
lockedPorts.add(port);
|
|
3417
|
+
return true;
|
|
3418
|
+
} catch (error) {
|
|
3419
|
+
if (error.code !== "EEXIST") return true;
|
|
3420
|
+
try {
|
|
3421
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
3422
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
3423
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
3424
|
+
fs.unlinkSync(file);
|
|
3425
|
+
} catch {
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
return false;
|
|
3430
|
+
}
|
|
3431
|
+
function releasePortLock(port) {
|
|
3432
|
+
if (!lockedPorts.delete(port)) return;
|
|
3433
|
+
try {
|
|
3434
|
+
const fs = __require("node:fs");
|
|
3435
|
+
const dir = lockDir();
|
|
3436
|
+
if (dir) fs.unlinkSync(__require("node:path").join(dir, `${port}.lock`));
|
|
3437
|
+
} catch {
|
|
3438
|
+
}
|
|
3439
|
+
}
|
|
3307
3440
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
3308
3441
|
function getPortRange(project) {
|
|
3309
3442
|
const key = project;
|
|
@@ -3314,15 +3447,22 @@ function getPortRange(project) {
|
|
|
3314
3447
|
}
|
|
3315
3448
|
async function reservePort(project, preferredPort) {
|
|
3316
3449
|
const range = getPortRange(project);
|
|
3317
|
-
|
|
3450
|
+
const handle = (port) => ({
|
|
3451
|
+
port,
|
|
3452
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
3453
|
+
releaseSocket: async () => {
|
|
3454
|
+
await releasePort(port);
|
|
3455
|
+
},
|
|
3456
|
+
release: async () => {
|
|
3457
|
+
await releasePort(port);
|
|
3458
|
+
releasePortLock(port);
|
|
3459
|
+
}
|
|
3460
|
+
});
|
|
3461
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
3462
|
+
if (wantsSpecificPort) {
|
|
3318
3463
|
const reservation = await tryReservePort(preferredPort, project);
|
|
3319
3464
|
if (reservation) {
|
|
3320
|
-
return
|
|
3321
|
-
port: preferredPort,
|
|
3322
|
-
release: async () => {
|
|
3323
|
-
await releasePort(preferredPort);
|
|
3324
|
-
}
|
|
3325
|
-
};
|
|
3465
|
+
return handle(preferredPort);
|
|
3326
3466
|
}
|
|
3327
3467
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
3328
3468
|
}
|
|
@@ -3331,30 +3471,19 @@ async function reservePort(project, preferredPort) {
|
|
|
3331
3471
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
3332
3472
|
const port = range.start + index % range.size;
|
|
3333
3473
|
index = (index + 1) % range.size;
|
|
3334
|
-
if (reservedPorts.has(port)) {
|
|
3474
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
3335
3475
|
continue;
|
|
3336
3476
|
}
|
|
3337
3477
|
const reservation = await tryReservePort(port, project);
|
|
3338
3478
|
if (reservation) {
|
|
3339
3479
|
projectPortIndex.set(project, index);
|
|
3340
|
-
return
|
|
3341
|
-
port,
|
|
3342
|
-
release: async () => {
|
|
3343
|
-
await releasePort(port);
|
|
3344
|
-
}
|
|
3345
|
-
};
|
|
3480
|
+
return handle(port);
|
|
3346
3481
|
}
|
|
3347
3482
|
}
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
return {
|
|
3353
|
-
port: dynamicPort,
|
|
3354
|
-
release: async () => {
|
|
3355
|
-
await releasePort(dynamicPort);
|
|
3356
|
-
}
|
|
3357
|
-
};
|
|
3483
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
3484
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
3485
|
+
if (await tryReservePort(port, project)) {
|
|
3486
|
+
return handle(port);
|
|
3358
3487
|
}
|
|
3359
3488
|
}
|
|
3360
3489
|
throw new Error(
|
|
@@ -3362,9 +3491,13 @@ async function reservePort(project, preferredPort) {
|
|
|
3362
3491
|
);
|
|
3363
3492
|
}
|
|
3364
3493
|
async function tryReservePort(port, project) {
|
|
3494
|
+
if (!acquirePortLock(port)) {
|
|
3495
|
+
return false;
|
|
3496
|
+
}
|
|
3365
3497
|
return new Promise((resolve) => {
|
|
3366
3498
|
const server = createServer();
|
|
3367
3499
|
server.once("error", () => {
|
|
3500
|
+
releasePortLock(port);
|
|
3368
3501
|
resolve(false);
|
|
3369
3502
|
});
|
|
3370
3503
|
server.listen(port, () => {
|
|
@@ -3390,31 +3523,6 @@ async function releasePort(port) {
|
|
|
3390
3523
|
});
|
|
3391
3524
|
});
|
|
3392
3525
|
}
|
|
3393
|
-
async function findAvailablePortInRange(start, end) {
|
|
3394
|
-
for (let port = start; port < end; port++) {
|
|
3395
|
-
if (reservedPorts.has(port)) {
|
|
3396
|
-
continue;
|
|
3397
|
-
}
|
|
3398
|
-
const available = await isPortAvailable(port);
|
|
3399
|
-
if (available) {
|
|
3400
|
-
return port;
|
|
3401
|
-
}
|
|
3402
|
-
}
|
|
3403
|
-
return null;
|
|
3404
|
-
}
|
|
3405
|
-
async function isPortAvailable(port) {
|
|
3406
|
-
return new Promise((resolve) => {
|
|
3407
|
-
const server = createServer();
|
|
3408
|
-
server.once("error", () => {
|
|
3409
|
-
resolve(false);
|
|
3410
|
-
});
|
|
3411
|
-
server.listen(port, () => {
|
|
3412
|
-
server.close(() => {
|
|
3413
|
-
resolve(true);
|
|
3414
|
-
});
|
|
3415
|
-
});
|
|
3416
|
-
});
|
|
3417
|
-
}
|
|
3418
3526
|
|
|
3419
3527
|
// libs/testing/src/server/test-server.ts
|
|
3420
3528
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -3423,8 +3531,11 @@ var TestServer = class _TestServer {
|
|
|
3423
3531
|
options;
|
|
3424
3532
|
_info;
|
|
3425
3533
|
logs = [];
|
|
3534
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
3426
3535
|
portRelease = null;
|
|
3427
|
-
|
|
3536
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
3537
|
+
portReleaseSocket = null;
|
|
3538
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
3428
3539
|
this.options = {
|
|
3429
3540
|
port,
|
|
3430
3541
|
project: options.project,
|
|
@@ -3436,6 +3547,7 @@ var TestServer = class _TestServer {
|
|
|
3436
3547
|
debug: options.debug ?? DEBUG_SERVER
|
|
3437
3548
|
};
|
|
3438
3549
|
this.portRelease = portRelease ?? null;
|
|
3550
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
3439
3551
|
this._info = {
|
|
3440
3552
|
baseUrl: `http://localhost:${port}`,
|
|
3441
3553
|
port
|
|
@@ -3448,8 +3560,8 @@ var TestServer = class _TestServer {
|
|
|
3448
3560
|
const project = options.project ?? "default";
|
|
3449
3561
|
const maxAttempts = 3;
|
|
3450
3562
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3451
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3452
|
-
const server = new _TestServer(options, port, release);
|
|
3563
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3564
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
3453
3565
|
try {
|
|
3454
3566
|
await server.startProcess();
|
|
3455
3567
|
return server;
|
|
@@ -3489,7 +3601,7 @@ var TestServer = class _TestServer {
|
|
|
3489
3601
|
}
|
|
3490
3602
|
const maxAttempts = 3;
|
|
3491
3603
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3492
|
-
const { port, release } = await reservePort(project, options.port);
|
|
3604
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
3493
3605
|
const serverOptions = {
|
|
3494
3606
|
...options,
|
|
3495
3607
|
port,
|
|
@@ -3497,7 +3609,7 @@ var TestServer = class _TestServer {
|
|
|
3497
3609
|
command: `npx nx serve ${project} --port ${port}`,
|
|
3498
3610
|
cwd: options.cwd ?? process.cwd()
|
|
3499
3611
|
};
|
|
3500
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
3612
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
3501
3613
|
try {
|
|
3502
3614
|
await server.startProcess();
|
|
3503
3615
|
return server;
|
|
@@ -3555,10 +3667,17 @@ var TestServer = class _TestServer {
|
|
|
3555
3667
|
* Stop the test server
|
|
3556
3668
|
*/
|
|
3557
3669
|
async stop() {
|
|
3558
|
-
|
|
3559
|
-
await this.
|
|
3560
|
-
|
|
3670
|
+
try {
|
|
3671
|
+
await this.stopProcess();
|
|
3672
|
+
} finally {
|
|
3673
|
+
if (this.portRelease) {
|
|
3674
|
+
await this.portRelease();
|
|
3675
|
+
this.portRelease = null;
|
|
3676
|
+
}
|
|
3677
|
+
this.portReleaseSocket = null;
|
|
3561
3678
|
}
|
|
3679
|
+
}
|
|
3680
|
+
async stopProcess() {
|
|
3562
3681
|
if (this.process) {
|
|
3563
3682
|
this.log("Stopping server...");
|
|
3564
3683
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -3631,7 +3750,7 @@ var TestServer = class _TestServer {
|
|
|
3631
3750
|
* Restart the server
|
|
3632
3751
|
*/
|
|
3633
3752
|
async restart() {
|
|
3634
|
-
await this.
|
|
3753
|
+
await this.stopProcess();
|
|
3635
3754
|
await this.startProcess();
|
|
3636
3755
|
}
|
|
3637
3756
|
/**
|
|
@@ -3661,9 +3780,9 @@ var TestServer = class _TestServer {
|
|
|
3661
3780
|
PORT: String(this.options.port)
|
|
3662
3781
|
};
|
|
3663
3782
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
3664
|
-
if (this.
|
|
3665
|
-
await this.
|
|
3666
|
-
this.
|
|
3783
|
+
if (this.portReleaseSocket) {
|
|
3784
|
+
await this.portReleaseSocket();
|
|
3785
|
+
this.portReleaseSocket = null;
|
|
3667
3786
|
await sleep4(300);
|
|
3668
3787
|
}
|
|
3669
3788
|
this.process = spawn(this.options.command, [], {
|
|
@@ -3673,9 +3792,8 @@ var TestServer = class _TestServer {
|
|
|
3673
3792
|
detached: true,
|
|
3674
3793
|
stdio: ["pipe", "pipe", "pipe"]
|
|
3675
3794
|
});
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
}
|
|
3795
|
+
this._info.pid = this.process.pid;
|
|
3796
|
+
this._info.shellPid = this.process.pid;
|
|
3679
3797
|
let processExited = false;
|
|
3680
3798
|
let exitCode = null;
|
|
3681
3799
|
let exitError = null;
|
|
@@ -3778,6 +3896,8 @@ ${allLogs}`);
|
|
|
3778
3896
|
});
|
|
3779
3897
|
if (response.ok || response.status === 404) {
|
|
3780
3898
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3899
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
3900
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3781
3901
|
return;
|
|
3782
3902
|
}
|
|
3783
3903
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3822,6 +3942,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3822
3942
|
function sleep4(ms) {
|
|
3823
3943
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3824
3944
|
}
|
|
3945
|
+
function findListeningPid(port) {
|
|
3946
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3947
|
+
return new Promise((resolve) => {
|
|
3948
|
+
execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
3949
|
+
if (error) {
|
|
3950
|
+
resolve(void 0);
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
3954
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
3955
|
+
});
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3825
3958
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3826
3959
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3827
3960
|
return env;
|
|
@@ -3969,7 +4102,8 @@ Server entry: ${currentConfig.server}
|
|
|
3969
4102
|
Project: ${currentConfig.project ?? "default"}
|
|
3970
4103
|
Command: ${serverCommand}
|
|
3971
4104
|
|
|
3972
|
-
Error: ${errMsg}
|
|
4105
|
+
Error: ${errMsg}`,
|
|
4106
|
+
{ cause: error }
|
|
3973
4107
|
);
|
|
3974
4108
|
}
|
|
3975
4109
|
} else {
|
|
@@ -4159,16 +4293,19 @@ function todo(name) {
|
|
|
4159
4293
|
}
|
|
4160
4294
|
var perfTest = perfTestWithFixtures;
|
|
4161
4295
|
perfTest.use = use;
|
|
4162
|
-
|
|
4163
|
-
perfTest
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4296
|
+
for (const hook of ["describe", "beforeAll", "beforeEach", "afterEach", "afterAll"]) {
|
|
4297
|
+
Object.defineProperty(perfTest, hook, {
|
|
4298
|
+
get: () => globalThis[hook],
|
|
4299
|
+
enumerable: true,
|
|
4300
|
+
configurable: true
|
|
4301
|
+
});
|
|
4302
|
+
}
|
|
4167
4303
|
perfTest.skip = skip;
|
|
4168
4304
|
perfTest.only = only;
|
|
4169
4305
|
perfTest.todo = todo;
|
|
4170
4306
|
|
|
4171
4307
|
// libs/testing/src/perf/baseline-store.ts
|
|
4308
|
+
import { ensureDir, fileExists, readFile, writeFile } from "@frontmcp/utils";
|
|
4172
4309
|
var BASELINE_START_MARKER = "<!-- PERF_BASELINE_START -->";
|
|
4173
4310
|
var BASELINE_END_MARKER = "<!-- PERF_BASELINE_END -->";
|
|
4174
4311
|
var DEFAULT_BASELINE_PATH = "perf-results/baseline.json";
|
|
@@ -4183,7 +4320,6 @@ var BaselineStore = class {
|
|
|
4183
4320
|
*/
|
|
4184
4321
|
async load() {
|
|
4185
4322
|
try {
|
|
4186
|
-
const { readFile, fileExists } = await import("@frontmcp/utils");
|
|
4187
4323
|
if (!await fileExists(this.baselinePath)) {
|
|
4188
4324
|
return null;
|
|
4189
4325
|
}
|
|
@@ -4198,7 +4334,6 @@ var BaselineStore = class {
|
|
|
4198
4334
|
* Save baseline to local file.
|
|
4199
4335
|
*/
|
|
4200
4336
|
async save(baseline) {
|
|
4201
|
-
const { writeFile, ensureDir } = await import("@frontmcp/utils");
|
|
4202
4337
|
const dir = this.baselinePath.substring(0, this.baselinePath.lastIndexOf("/"));
|
|
4203
4338
|
await ensureDir(dir);
|
|
4204
4339
|
await writeFile(this.baselinePath, JSON.stringify(baseline, null, 2));
|
|
@@ -4477,6 +4612,7 @@ function resetRegressionDetector() {
|
|
|
4477
4612
|
}
|
|
4478
4613
|
|
|
4479
4614
|
// libs/testing/src/perf/report-generator.ts
|
|
4615
|
+
import { ensureDir as ensureDir2, writeFile as writeFile2 } from "@frontmcp/utils";
|
|
4480
4616
|
var ReportGenerator = class {
|
|
4481
4617
|
detector;
|
|
4482
4618
|
constructor() {
|
|
@@ -4732,14 +4868,13 @@ var ReportGenerator = class {
|
|
|
4732
4868
|
}
|
|
4733
4869
|
};
|
|
4734
4870
|
async function saveReports(measurements, outputDir, baseline, gitInfo) {
|
|
4735
|
-
|
|
4736
|
-
await ensureDir(outputDir);
|
|
4871
|
+
await ensureDir2(outputDir);
|
|
4737
4872
|
const generator = new ReportGenerator();
|
|
4738
4873
|
const report = generator.generateReport(measurements, baseline, gitInfo);
|
|
4739
4874
|
const jsonPath = `${outputDir}/report.json`;
|
|
4740
4875
|
const markdownPath = `${outputDir}/report.md`;
|
|
4741
|
-
await
|
|
4742
|
-
await
|
|
4876
|
+
await writeFile2(jsonPath, generator.generateJsonReport(report));
|
|
4877
|
+
await writeFile2(markdownPath, generator.generateMarkdownReport(report));
|
|
4743
4878
|
return { jsonPath, markdownPath };
|
|
4744
4879
|
}
|
|
4745
4880
|
function createReportGenerator() {
|