@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/fixtures/index.js
CHANGED
|
@@ -28,9 +28,6 @@ __export(fixtures_exports, {
|
|
|
28
28
|
});
|
|
29
29
|
module.exports = __toCommonJS(fixtures_exports);
|
|
30
30
|
|
|
31
|
-
// libs/testing/src/fixtures/test-fixture.ts
|
|
32
|
-
var import_globals = require("@jest/globals");
|
|
33
|
-
|
|
34
31
|
// libs/testing/src/auth/token-factory.ts
|
|
35
32
|
var import_jose = require("jose");
|
|
36
33
|
var TestTokenFactory = class {
|
|
@@ -89,14 +86,15 @@ var TestTokenFactory = class {
|
|
|
89
86
|
*/
|
|
90
87
|
async createTestToken(options) {
|
|
91
88
|
await this.ensureKeys();
|
|
92
|
-
const
|
|
89
|
+
const nowMs = Date.now();
|
|
90
|
+
const iat = Math.floor(nowMs / 1e3);
|
|
93
91
|
const exp = options.exp ?? 3600;
|
|
94
92
|
const payload = {
|
|
95
93
|
iss: options.iss ?? this.issuer,
|
|
96
94
|
sub: options.sub,
|
|
97
95
|
aud: options.aud ?? this.audience,
|
|
98
|
-
iat
|
|
99
|
-
exp:
|
|
96
|
+
iat,
|
|
97
|
+
exp: Math.ceil(nowMs / 1e3) + exp,
|
|
100
98
|
scope: options.scopes?.join(" "),
|
|
101
99
|
...options.claims
|
|
102
100
|
};
|
|
@@ -121,10 +119,10 @@ var TestTokenFactory = class {
|
|
|
121
119
|
/**
|
|
122
120
|
* Create a regular user token
|
|
123
121
|
*/
|
|
124
|
-
async createUserToken(sub = "user-001",
|
|
122
|
+
async createUserToken(sub = "user-001", scopes2 = ["read", "write"]) {
|
|
125
123
|
return this.createTestToken({
|
|
126
124
|
sub,
|
|
127
|
-
scopes,
|
|
125
|
+
scopes: scopes2,
|
|
128
126
|
claims: {
|
|
129
127
|
email: "user@test.local",
|
|
130
128
|
name: "Test User",
|
|
@@ -135,10 +133,11 @@ var TestTokenFactory = class {
|
|
|
135
133
|
/**
|
|
136
134
|
* Create an anonymous user token
|
|
137
135
|
*/
|
|
138
|
-
async createAnonymousToken() {
|
|
136
|
+
async createAnonymousToken(expiresIn) {
|
|
139
137
|
return this.createTestToken({
|
|
140
138
|
sub: `anon:${Date.now()}`,
|
|
141
139
|
scopes: ["anonymous"],
|
|
140
|
+
exp: expiresIn,
|
|
142
141
|
claims: {
|
|
143
142
|
name: "Anonymous",
|
|
144
143
|
role: "anonymous"
|
|
@@ -487,6 +486,32 @@ function sleep(ms) {
|
|
|
487
486
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
488
487
|
}
|
|
489
488
|
|
|
489
|
+
// libs/testing/src/transport/error-utils.ts
|
|
490
|
+
function errorName(err) {
|
|
491
|
+
if (typeof err !== "object" || err === null) return void 0;
|
|
492
|
+
const name = err.name;
|
|
493
|
+
return typeof name === "string" ? name : void 0;
|
|
494
|
+
}
|
|
495
|
+
function isAbortError(err) {
|
|
496
|
+
return errorName(err) === "AbortError";
|
|
497
|
+
}
|
|
498
|
+
function errorMessage(err, fallback = "Unknown error") {
|
|
499
|
+
if (typeof err === "string" && err) return err;
|
|
500
|
+
if (typeof err === "object" && err !== null) {
|
|
501
|
+
const message = err.message;
|
|
502
|
+
if (typeof message === "string" && message) return message;
|
|
503
|
+
}
|
|
504
|
+
return fallback;
|
|
505
|
+
}
|
|
506
|
+
var interceptedErrors = /* @__PURE__ */ new WeakSet();
|
|
507
|
+
function markInterceptedError(err) {
|
|
508
|
+
interceptedErrors.add(err);
|
|
509
|
+
return err;
|
|
510
|
+
}
|
|
511
|
+
function isInterceptedError(err) {
|
|
512
|
+
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
513
|
+
}
|
|
514
|
+
|
|
490
515
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
491
516
|
var DEFAULT_TIMEOUT = 3e4;
|
|
492
517
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
@@ -595,7 +620,7 @@ var StreamableHttpTransport = class {
|
|
|
595
620
|
return mockResponse2;
|
|
596
621
|
}
|
|
597
622
|
case "error":
|
|
598
|
-
throw interceptResult.error;
|
|
623
|
+
throw markInterceptedError(interceptResult.error);
|
|
599
624
|
case "continue":
|
|
600
625
|
message = interceptResult.request;
|
|
601
626
|
break;
|
|
@@ -656,7 +681,7 @@ var StreamableHttpTransport = class {
|
|
|
656
681
|
return jsonResponse;
|
|
657
682
|
} catch (error) {
|
|
658
683
|
clearTimeout(timeoutId);
|
|
659
|
-
if (error
|
|
684
|
+
if (isAbortError(error)) {
|
|
660
685
|
return {
|
|
661
686
|
jsonrpc: "2.0",
|
|
662
687
|
id: message.id ?? null,
|
|
@@ -695,7 +720,7 @@ var StreamableHttpTransport = class {
|
|
|
695
720
|
}
|
|
696
721
|
} catch (error) {
|
|
697
722
|
clearTimeout(timeoutId);
|
|
698
|
-
if (error
|
|
723
|
+
if (!isAbortError(error)) {
|
|
699
724
|
throw error;
|
|
700
725
|
}
|
|
701
726
|
}
|
|
@@ -736,7 +761,7 @@ var StreamableHttpTransport = class {
|
|
|
736
761
|
error: {
|
|
737
762
|
code: -32700,
|
|
738
763
|
message: "Parse error",
|
|
739
|
-
data: error
|
|
764
|
+
data: errorMessage(error)
|
|
740
765
|
}
|
|
741
766
|
};
|
|
742
767
|
}
|
|
@@ -1106,7 +1131,7 @@ var StreamableHttpTransport = class {
|
|
|
1106
1131
|
if (this.config.clientInfo) {
|
|
1107
1132
|
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1108
1133
|
}
|
|
1109
|
-
if (this.authToken
|
|
1134
|
+
if (this.authToken) {
|
|
1110
1135
|
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1111
1136
|
}
|
|
1112
1137
|
if (this.sessionId) {
|
|
@@ -1430,6 +1455,15 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1430
1455
|
version: "0.4.0"
|
|
1431
1456
|
};
|
|
1432
1457
|
var MAX_LIST_PAGES = 1e3;
|
|
1458
|
+
function isAuthFailure(error) {
|
|
1459
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
1460
|
+
}
|
|
1461
|
+
function describeInitializeError(error) {
|
|
1462
|
+
const message = error?.message ?? "Unknown error";
|
|
1463
|
+
const data = error?.data;
|
|
1464
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
1465
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
1466
|
+
}
|
|
1433
1467
|
function extractEntryPaths(data) {
|
|
1434
1468
|
let payload = data;
|
|
1435
1469
|
if (typeof payload === "string") {
|
|
@@ -1516,19 +1550,22 @@ var McpTestClient = class {
|
|
|
1516
1550
|
async connect() {
|
|
1517
1551
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1518
1552
|
this.triedDiscoveredEntryPath = false;
|
|
1553
|
+
this.initResult = null;
|
|
1519
1554
|
this.transport = this.createTransport();
|
|
1520
1555
|
await this.transport.connect();
|
|
1521
1556
|
const maxRetries = 3;
|
|
1522
1557
|
const retryDelayMs = 500;
|
|
1523
1558
|
let lastError;
|
|
1524
1559
|
let reportedEntryPaths;
|
|
1560
|
+
let attemptsMade = 0;
|
|
1525
1561
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1562
|
+
attemptsMade = attempt;
|
|
1526
1563
|
const initResponse = await this.initialize();
|
|
1527
1564
|
if (initResponse.success && initResponse.data) {
|
|
1528
1565
|
this.initResult = initResponse.data;
|
|
1529
1566
|
break;
|
|
1530
1567
|
}
|
|
1531
|
-
lastError = initResponse.error
|
|
1568
|
+
lastError = describeInitializeError(initResponse.error);
|
|
1532
1569
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1533
1570
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1534
1571
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -1540,6 +1577,7 @@ var McpTestClient = class {
|
|
|
1540
1577
|
await this.transport.connect();
|
|
1541
1578
|
continue;
|
|
1542
1579
|
}
|
|
1580
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
1543
1581
|
if (attempt < maxRetries) {
|
|
1544
1582
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1545
1583
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -1548,7 +1586,7 @@ var McpTestClient = class {
|
|
|
1548
1586
|
if (!this.initResult) {
|
|
1549
1587
|
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.` : "";
|
|
1550
1588
|
throw new Error(
|
|
1551
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
1589
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
1552
1590
|
);
|
|
1553
1591
|
}
|
|
1554
1592
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -1618,18 +1656,47 @@ var McpTestClient = class {
|
|
|
1618
1656
|
return this._authState;
|
|
1619
1657
|
}
|
|
1620
1658
|
/**
|
|
1621
|
-
* Authenticate with a token
|
|
1659
|
+
* Authenticate with a token.
|
|
1660
|
+
*
|
|
1661
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
1662
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
1663
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
1664
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
1622
1665
|
*/
|
|
1623
1666
|
async authenticate(token) {
|
|
1667
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
1668
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
1669
|
+
}
|
|
1670
|
+
const previousAuthState = this._authState;
|
|
1671
|
+
const previousConfigAuth = this.config.auth;
|
|
1672
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
1624
1673
|
this._authState = {
|
|
1625
1674
|
isAnonymous: false,
|
|
1626
1675
|
token,
|
|
1627
1676
|
scopes: this.parseScopesFromToken(token),
|
|
1628
1677
|
user: this.parseUserFromToken(token)
|
|
1629
1678
|
};
|
|
1630
|
-
|
|
1631
|
-
|
|
1679
|
+
const previousTransport = this.transport;
|
|
1680
|
+
if (!previousTransport) {
|
|
1681
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
const previousInit = this.initResult;
|
|
1685
|
+
const previousSessionId = this._sessionId;
|
|
1686
|
+
const previousSessionInfo = this._sessionInfo;
|
|
1687
|
+
try {
|
|
1688
|
+
await this.connect();
|
|
1689
|
+
} catch (err) {
|
|
1690
|
+
await this.transport?.close().catch(() => void 0);
|
|
1691
|
+
this.transport = previousTransport;
|
|
1692
|
+
this.initResult = previousInit;
|
|
1693
|
+
this._sessionId = previousSessionId;
|
|
1694
|
+
this._sessionInfo = previousSessionInfo;
|
|
1695
|
+
this.config.auth = previousConfigAuth;
|
|
1696
|
+
this._authState = previousAuthState;
|
|
1697
|
+
throw err;
|
|
1632
1698
|
}
|
|
1699
|
+
await previousTransport.close().catch(() => void 0);
|
|
1633
1700
|
this.log("debug", "Authentication updated");
|
|
1634
1701
|
}
|
|
1635
1702
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2253,10 +2320,11 @@ var McpTestClient = class {
|
|
|
2253
2320
|
requestId: id
|
|
2254
2321
|
};
|
|
2255
2322
|
} catch (err) {
|
|
2323
|
+
if (isInterceptedError(err)) throw err;
|
|
2256
2324
|
const durationMs = Date.now() - start;
|
|
2257
2325
|
const error = {
|
|
2258
2326
|
code: -32603,
|
|
2259
|
-
message: err
|
|
2327
|
+
message: errorMessage(err)
|
|
2260
2328
|
};
|
|
2261
2329
|
return {
|
|
2262
2330
|
success: false,
|
|
@@ -2404,12 +2472,12 @@ var McpTestClient = class {
|
|
|
2404
2472
|
const payload = this.decodeJwtPayload(token);
|
|
2405
2473
|
if (!payload) return [];
|
|
2406
2474
|
const scope = payload["scope"];
|
|
2407
|
-
const
|
|
2475
|
+
const scopes2 = payload["scopes"];
|
|
2408
2476
|
if (typeof scope === "string") {
|
|
2409
2477
|
return scope.split(" ");
|
|
2410
2478
|
}
|
|
2411
|
-
if (Array.isArray(
|
|
2412
|
-
return
|
|
2479
|
+
if (Array.isArray(scopes2)) {
|
|
2480
|
+
return scopes2;
|
|
2413
2481
|
}
|
|
2414
2482
|
return [];
|
|
2415
2483
|
} catch {
|
|
@@ -2555,6 +2623,8 @@ var E2E_PORT_RANGES = {
|
|
|
2555
2623
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
2556
2624
|
// Protocol revision E2E tests (50460-50479)
|
|
2557
2625
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
2626
|
+
// Testing library E2E tests (50480-50499)
|
|
2627
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
2558
2628
|
// Mock servers and utilities (50900-50999)
|
|
2559
2629
|
"mock-oauth": { start: 50900, size: 10 },
|
|
2560
2630
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -2563,6 +2633,66 @@ var E2E_PORT_RANGES = {
|
|
|
2563
2633
|
default: { start: 51e3, size: 100 }
|
|
2564
2634
|
};
|
|
2565
2635
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
2636
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
2637
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
2638
|
+
function lockDir() {
|
|
2639
|
+
try {
|
|
2640
|
+
const os = require("node:os");
|
|
2641
|
+
const path = require("node:path");
|
|
2642
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
2643
|
+
} catch {
|
|
2644
|
+
return void 0;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
function pidIsAlive(pid) {
|
|
2648
|
+
try {
|
|
2649
|
+
process.kill(pid, 0);
|
|
2650
|
+
return true;
|
|
2651
|
+
} catch (error) {
|
|
2652
|
+
return error.code === "EPERM";
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
function acquirePortLock(port) {
|
|
2656
|
+
if (lockedPorts.has(port)) return false;
|
|
2657
|
+
let fs;
|
|
2658
|
+
let file;
|
|
2659
|
+
try {
|
|
2660
|
+
fs = require("node:fs");
|
|
2661
|
+
const dir = lockDir();
|
|
2662
|
+
if (!dir) return true;
|
|
2663
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2664
|
+
file = require("node:path").join(dir, `${port}.lock`);
|
|
2665
|
+
} catch {
|
|
2666
|
+
return true;
|
|
2667
|
+
}
|
|
2668
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
2669
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2670
|
+
try {
|
|
2671
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
2672
|
+
lockedPorts.add(port);
|
|
2673
|
+
return true;
|
|
2674
|
+
} catch (error) {
|
|
2675
|
+
if (error.code !== "EEXIST") return true;
|
|
2676
|
+
try {
|
|
2677
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2678
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
2679
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
2680
|
+
fs.unlinkSync(file);
|
|
2681
|
+
} catch {
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
return false;
|
|
2686
|
+
}
|
|
2687
|
+
function releasePortLock(port) {
|
|
2688
|
+
if (!lockedPorts.delete(port)) return;
|
|
2689
|
+
try {
|
|
2690
|
+
const fs = require("node:fs");
|
|
2691
|
+
const dir = lockDir();
|
|
2692
|
+
if (dir) fs.unlinkSync(require("node:path").join(dir, `${port}.lock`));
|
|
2693
|
+
} catch {
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2566
2696
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
2567
2697
|
function getPortRange(project) {
|
|
2568
2698
|
const key = project;
|
|
@@ -2573,15 +2703,22 @@ function getPortRange(project) {
|
|
|
2573
2703
|
}
|
|
2574
2704
|
async function reservePort(project, preferredPort) {
|
|
2575
2705
|
const range = getPortRange(project);
|
|
2576
|
-
|
|
2706
|
+
const handle = (port) => ({
|
|
2707
|
+
port,
|
|
2708
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
2709
|
+
releaseSocket: async () => {
|
|
2710
|
+
await releasePort(port);
|
|
2711
|
+
},
|
|
2712
|
+
release: async () => {
|
|
2713
|
+
await releasePort(port);
|
|
2714
|
+
releasePortLock(port);
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
2718
|
+
if (wantsSpecificPort) {
|
|
2577
2719
|
const reservation = await tryReservePort(preferredPort, project);
|
|
2578
2720
|
if (reservation) {
|
|
2579
|
-
return
|
|
2580
|
-
port: preferredPort,
|
|
2581
|
-
release: async () => {
|
|
2582
|
-
await releasePort(preferredPort);
|
|
2583
|
-
}
|
|
2584
|
-
};
|
|
2721
|
+
return handle(preferredPort);
|
|
2585
2722
|
}
|
|
2586
2723
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
2587
2724
|
}
|
|
@@ -2590,30 +2727,19 @@ async function reservePort(project, preferredPort) {
|
|
|
2590
2727
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2591
2728
|
const port = range.start + index % range.size;
|
|
2592
2729
|
index = (index + 1) % range.size;
|
|
2593
|
-
if (reservedPorts.has(port)) {
|
|
2730
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
2594
2731
|
continue;
|
|
2595
2732
|
}
|
|
2596
2733
|
const reservation = await tryReservePort(port, project);
|
|
2597
2734
|
if (reservation) {
|
|
2598
2735
|
projectPortIndex.set(project, index);
|
|
2599
|
-
return
|
|
2600
|
-
port,
|
|
2601
|
-
release: async () => {
|
|
2602
|
-
await releasePort(port);
|
|
2603
|
-
}
|
|
2604
|
-
};
|
|
2736
|
+
return handle(port);
|
|
2605
2737
|
}
|
|
2606
2738
|
}
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
return {
|
|
2612
|
-
port: dynamicPort,
|
|
2613
|
-
release: async () => {
|
|
2614
|
-
await releasePort(dynamicPort);
|
|
2615
|
-
}
|
|
2616
|
-
};
|
|
2739
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
2740
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
2741
|
+
if (await tryReservePort(port, project)) {
|
|
2742
|
+
return handle(port);
|
|
2617
2743
|
}
|
|
2618
2744
|
}
|
|
2619
2745
|
throw new Error(
|
|
@@ -2621,9 +2747,13 @@ async function reservePort(project, preferredPort) {
|
|
|
2621
2747
|
);
|
|
2622
2748
|
}
|
|
2623
2749
|
async function tryReservePort(port, project) {
|
|
2750
|
+
if (!acquirePortLock(port)) {
|
|
2751
|
+
return false;
|
|
2752
|
+
}
|
|
2624
2753
|
return new Promise((resolve) => {
|
|
2625
2754
|
const server = (0, import_net.createServer)();
|
|
2626
2755
|
server.once("error", () => {
|
|
2756
|
+
releasePortLock(port);
|
|
2627
2757
|
resolve(false);
|
|
2628
2758
|
});
|
|
2629
2759
|
server.listen(port, () => {
|
|
@@ -2649,31 +2779,6 @@ async function releasePort(port) {
|
|
|
2649
2779
|
});
|
|
2650
2780
|
});
|
|
2651
2781
|
}
|
|
2652
|
-
async function findAvailablePortInRange(start, end) {
|
|
2653
|
-
for (let port = start; port < end; port++) {
|
|
2654
|
-
if (reservedPorts.has(port)) {
|
|
2655
|
-
continue;
|
|
2656
|
-
}
|
|
2657
|
-
const available = await isPortAvailable(port);
|
|
2658
|
-
if (available) {
|
|
2659
|
-
return port;
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
return null;
|
|
2663
|
-
}
|
|
2664
|
-
async function isPortAvailable(port) {
|
|
2665
|
-
return new Promise((resolve) => {
|
|
2666
|
-
const server = (0, import_net.createServer)();
|
|
2667
|
-
server.once("error", () => {
|
|
2668
|
-
resolve(false);
|
|
2669
|
-
});
|
|
2670
|
-
server.listen(port, () => {
|
|
2671
|
-
server.close(() => {
|
|
2672
|
-
resolve(true);
|
|
2673
|
-
});
|
|
2674
|
-
});
|
|
2675
|
-
});
|
|
2676
|
-
}
|
|
2677
2782
|
|
|
2678
2783
|
// libs/testing/src/server/test-server.ts
|
|
2679
2784
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -2682,8 +2787,11 @@ var TestServer = class _TestServer {
|
|
|
2682
2787
|
options;
|
|
2683
2788
|
_info;
|
|
2684
2789
|
logs = [];
|
|
2790
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
2685
2791
|
portRelease = null;
|
|
2686
|
-
|
|
2792
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
2793
|
+
portReleaseSocket = null;
|
|
2794
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
2687
2795
|
this.options = {
|
|
2688
2796
|
port,
|
|
2689
2797
|
project: options.project,
|
|
@@ -2695,6 +2803,7 @@ var TestServer = class _TestServer {
|
|
|
2695
2803
|
debug: options.debug ?? DEBUG_SERVER
|
|
2696
2804
|
};
|
|
2697
2805
|
this.portRelease = portRelease ?? null;
|
|
2806
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
2698
2807
|
this._info = {
|
|
2699
2808
|
baseUrl: `http://localhost:${port}`,
|
|
2700
2809
|
port
|
|
@@ -2707,8 +2816,8 @@ var TestServer = class _TestServer {
|
|
|
2707
2816
|
const project = options.project ?? "default";
|
|
2708
2817
|
const maxAttempts = 3;
|
|
2709
2818
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2710
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2711
|
-
const server = new _TestServer(options, port, release);
|
|
2819
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2820
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
2712
2821
|
try {
|
|
2713
2822
|
await server.startProcess();
|
|
2714
2823
|
return server;
|
|
@@ -2748,7 +2857,7 @@ var TestServer = class _TestServer {
|
|
|
2748
2857
|
}
|
|
2749
2858
|
const maxAttempts = 3;
|
|
2750
2859
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2751
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2860
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2752
2861
|
const serverOptions = {
|
|
2753
2862
|
...options,
|
|
2754
2863
|
port,
|
|
@@ -2756,7 +2865,7 @@ var TestServer = class _TestServer {
|
|
|
2756
2865
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2757
2866
|
cwd: options.cwd ?? process.cwd()
|
|
2758
2867
|
};
|
|
2759
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
2868
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
2760
2869
|
try {
|
|
2761
2870
|
await server.startProcess();
|
|
2762
2871
|
return server;
|
|
@@ -2814,10 +2923,17 @@ var TestServer = class _TestServer {
|
|
|
2814
2923
|
* Stop the test server
|
|
2815
2924
|
*/
|
|
2816
2925
|
async stop() {
|
|
2817
|
-
|
|
2818
|
-
await this.
|
|
2819
|
-
|
|
2926
|
+
try {
|
|
2927
|
+
await this.stopProcess();
|
|
2928
|
+
} finally {
|
|
2929
|
+
if (this.portRelease) {
|
|
2930
|
+
await this.portRelease();
|
|
2931
|
+
this.portRelease = null;
|
|
2932
|
+
}
|
|
2933
|
+
this.portReleaseSocket = null;
|
|
2820
2934
|
}
|
|
2935
|
+
}
|
|
2936
|
+
async stopProcess() {
|
|
2821
2937
|
if (this.process) {
|
|
2822
2938
|
this.log("Stopping server...");
|
|
2823
2939
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -2890,7 +3006,7 @@ var TestServer = class _TestServer {
|
|
|
2890
3006
|
* Restart the server
|
|
2891
3007
|
*/
|
|
2892
3008
|
async restart() {
|
|
2893
|
-
await this.
|
|
3009
|
+
await this.stopProcess();
|
|
2894
3010
|
await this.startProcess();
|
|
2895
3011
|
}
|
|
2896
3012
|
/**
|
|
@@ -2920,9 +3036,9 @@ var TestServer = class _TestServer {
|
|
|
2920
3036
|
PORT: String(this.options.port)
|
|
2921
3037
|
};
|
|
2922
3038
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
2923
|
-
if (this.
|
|
2924
|
-
await this.
|
|
2925
|
-
this.
|
|
3039
|
+
if (this.portReleaseSocket) {
|
|
3040
|
+
await this.portReleaseSocket();
|
|
3041
|
+
this.portReleaseSocket = null;
|
|
2926
3042
|
await sleep2(300);
|
|
2927
3043
|
}
|
|
2928
3044
|
this.process = (0, import_child_process.spawn)(this.options.command, [], {
|
|
@@ -2932,9 +3048,8 @@ var TestServer = class _TestServer {
|
|
|
2932
3048
|
detached: true,
|
|
2933
3049
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2934
3050
|
});
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
}
|
|
3051
|
+
this._info.pid = this.process.pid;
|
|
3052
|
+
this._info.shellPid = this.process.pid;
|
|
2938
3053
|
let processExited = false;
|
|
2939
3054
|
let exitCode = null;
|
|
2940
3055
|
let exitError = null;
|
|
@@ -3037,6 +3152,8 @@ ${allLogs}`);
|
|
|
3037
3152
|
});
|
|
3038
3153
|
if (response.ok || response.status === 404) {
|
|
3039
3154
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3155
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
3156
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3040
3157
|
return;
|
|
3041
3158
|
}
|
|
3042
3159
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3081,6 +3198,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3081
3198
|
function sleep2(ms) {
|
|
3082
3199
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3083
3200
|
}
|
|
3201
|
+
function findListeningPid(port) {
|
|
3202
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3203
|
+
return new Promise((resolve) => {
|
|
3204
|
+
(0, import_child_process.execFile)("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
3205
|
+
if (error) {
|
|
3206
|
+
resolve(void 0);
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
3210
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
3211
|
+
});
|
|
3212
|
+
});
|
|
3213
|
+
}
|
|
3084
3214
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3085
3215
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3086
3216
|
return env;
|
|
@@ -3197,81 +3327,152 @@ function gatewayTokenBinding(serverUrl, entryPath) {
|
|
|
3197
3327
|
}
|
|
3198
3328
|
|
|
3199
3329
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3200
|
-
var
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3330
|
+
var jestApi;
|
|
3331
|
+
function jestGlobals() {
|
|
3332
|
+
if (jestApi) return jestApi;
|
|
3333
|
+
try {
|
|
3334
|
+
const api = require("@jest/globals");
|
|
3335
|
+
jestApi = {
|
|
3336
|
+
describe: api["describe"],
|
|
3337
|
+
beforeAll: api["beforeAll"],
|
|
3338
|
+
beforeEach: api["beforeEach"],
|
|
3339
|
+
afterEach: api["afterEach"],
|
|
3340
|
+
afterAll: api["afterAll"],
|
|
3341
|
+
it: api["it"]
|
|
3342
|
+
};
|
|
3343
|
+
return jestApi;
|
|
3344
|
+
} catch (error) {
|
|
3345
|
+
throw new Error(
|
|
3346
|
+
`@frontmcp/testing fixtures ("test", "test.use", ...) can only be used inside a Jest test file: ${error instanceof Error ? error.message : String(error)}`,
|
|
3347
|
+
{ cause: error }
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3213
3350
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3351
|
+
var scopes = [{ skipped: false, config: {}, configured: false, cleanupRegistered: false }];
|
|
3352
|
+
function currentScope() {
|
|
3353
|
+
return scopes[scopes.length - 1];
|
|
3354
|
+
}
|
|
3355
|
+
var serverEntries = /* @__PURE__ */ new Map();
|
|
3356
|
+
function resolveConfig(chain) {
|
|
3357
|
+
const merged = {};
|
|
3358
|
+
for (const scope of chain) {
|
|
3359
|
+
const { env, ...rest } = scope.config;
|
|
3360
|
+
Object.assign(merged, rest);
|
|
3361
|
+
if (env) merged.env = { ...merged.env, ...env };
|
|
3362
|
+
}
|
|
3363
|
+
return merged;
|
|
3364
|
+
}
|
|
3365
|
+
function ownerScopeOf(chain) {
|
|
3366
|
+
for (let i = chain.length - 1; i > 0; i--) {
|
|
3367
|
+
if (chain[i].configured) return chain[i];
|
|
3368
|
+
}
|
|
3369
|
+
return chain[0];
|
|
3370
|
+
}
|
|
3371
|
+
function serverKey(config) {
|
|
3372
|
+
const env = Object.fromEntries(Object.entries(config.env ?? {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
3373
|
+
return JSON.stringify({
|
|
3374
|
+
server: config.server,
|
|
3375
|
+
baseUrl: config.baseUrl,
|
|
3376
|
+
project: config.project,
|
|
3377
|
+
port: config.port,
|
|
3378
|
+
entryPath: config.entryPath,
|
|
3379
|
+
startupTimeout: config.startupTimeout,
|
|
3380
|
+
logLevel: config.logLevel,
|
|
3381
|
+
auth: config.auth,
|
|
3382
|
+
env
|
|
3383
|
+
});
|
|
3384
|
+
}
|
|
3385
|
+
function serverEnv(config) {
|
|
3386
|
+
const extra = {};
|
|
3387
|
+
if (config.auth?.mode) extra["FRONTMCP_TEST_AUTH_MODE"] = config.auth.mode;
|
|
3388
|
+
if (config.auth?.type) extra["FRONTMCP_TEST_AUTH_TYPE"] = config.auth.type;
|
|
3389
|
+
if (Object.keys(extra).length === 0 && !config.env) return void 0;
|
|
3390
|
+
return { ...extra, ...config.env };
|
|
3391
|
+
}
|
|
3392
|
+
async function startServerEntry(config, owner) {
|
|
3393
|
+
let server;
|
|
3394
|
+
let startedByUs = false;
|
|
3395
|
+
if (config.baseUrl && !config.server) {
|
|
3396
|
+
server = TestServer.connect(config.baseUrl);
|
|
3397
|
+
} else if (config.server) {
|
|
3398
|
+
const serverCommand = resolveServerCommand(config.server);
|
|
3399
|
+
const isDebug = config.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3400
|
+
if (isDebug) {
|
|
3401
|
+
console.log(`[TestFixture] Starting server: ${serverCommand}`);
|
|
3402
|
+
}
|
|
3403
|
+
try {
|
|
3404
|
+
server = await TestServer.start({
|
|
3405
|
+
project: config.project,
|
|
3406
|
+
port: config.port,
|
|
3407
|
+
command: serverCommand,
|
|
3408
|
+
env: serverEnv(config),
|
|
3409
|
+
startupTimeout: config.startupTimeout ?? 3e4,
|
|
3410
|
+
debug: isDebug
|
|
3411
|
+
});
|
|
3412
|
+
startedByUs = true;
|
|
3222
3413
|
if (isDebug) {
|
|
3223
|
-
console.log(`[TestFixture]
|
|
3414
|
+
console.log(`[TestFixture] Server started at ${server.info.baseUrl}`);
|
|
3224
3415
|
}
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
command: serverCommand,
|
|
3230
|
-
env: currentConfig.env,
|
|
3231
|
-
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
3232
|
-
debug: isDebug
|
|
3233
|
-
});
|
|
3234
|
-
serverStartedByUs = true;
|
|
3235
|
-
if (isDebug) {
|
|
3236
|
-
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
3237
|
-
}
|
|
3238
|
-
} catch (error) {
|
|
3239
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3240
|
-
throw new Error(
|
|
3241
|
-
`Failed to start test server.
|
|
3416
|
+
} catch (error) {
|
|
3417
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3418
|
+
throw new Error(
|
|
3419
|
+
`Failed to start test server.
|
|
3242
3420
|
|
|
3243
|
-
Server entry: ${
|
|
3244
|
-
Project: ${
|
|
3421
|
+
Server entry: ${config.server}
|
|
3422
|
+
Project: ${config.project ?? "default"}
|
|
3245
3423
|
Command: ${serverCommand}
|
|
3246
3424
|
|
|
3247
|
-
Error: ${errMsg}
|
|
3248
|
-
|
|
3249
|
-
}
|
|
3250
|
-
} else {
|
|
3251
|
-
throw new Error(
|
|
3252
|
-
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
3425
|
+
Error: ${errMsg}`,
|
|
3426
|
+
{ cause: error }
|
|
3253
3427
|
);
|
|
3254
3428
|
}
|
|
3429
|
+
} else {
|
|
3430
|
+
throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option');
|
|
3255
3431
|
}
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
}
|
|
3432
|
+
const sharedSecret = config.env?.["JWT_SECRET"];
|
|
3433
|
+
const binding = sharedSecret ? gatewayTokenBinding(config.baseUrl ?? server.info.baseUrl, config.entryPath) : void 0;
|
|
3434
|
+
const tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret, ...binding } : {});
|
|
3435
|
+
return { server, startedByUs, tokenFactory, owner };
|
|
3261
3436
|
}
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3437
|
+
function acquireServer(chain) {
|
|
3438
|
+
const config = resolveConfig(chain);
|
|
3439
|
+
const key = serverKey(config);
|
|
3440
|
+
let entry = serverEntries.get(key);
|
|
3441
|
+
if (!entry) {
|
|
3442
|
+
entry = startServerEntry(config, ownerScopeOf(chain));
|
|
3443
|
+
serverEntries.set(key, entry);
|
|
3444
|
+
entry.catch(() => serverEntries.delete(key));
|
|
3445
|
+
}
|
|
3446
|
+
return entry;
|
|
3447
|
+
}
|
|
3448
|
+
async function stopServers(owner) {
|
|
3449
|
+
for (const [key, pending] of Array.from(serverEntries)) {
|
|
3450
|
+
let entry;
|
|
3451
|
+
try {
|
|
3452
|
+
entry = await pending;
|
|
3453
|
+
} catch {
|
|
3454
|
+
serverEntries.delete(key);
|
|
3455
|
+
continue;
|
|
3456
|
+
}
|
|
3457
|
+
if (owner && entry.owner !== owner) continue;
|
|
3458
|
+
serverEntries.delete(key);
|
|
3459
|
+
if (entry.startedByUs) {
|
|
3460
|
+
await entry.server.stop();
|
|
3461
|
+
}
|
|
3269
3462
|
}
|
|
3463
|
+
}
|
|
3464
|
+
var fixtureContexts = /* @__PURE__ */ new WeakMap();
|
|
3465
|
+
async function buildFixtures(chain) {
|
|
3466
|
+
const config = resolveConfig(chain);
|
|
3467
|
+
const entry = await acquireServer(chain);
|
|
3468
|
+
const { server, tokenFactory } = entry;
|
|
3469
|
+
const publicMode = config.publicMode ?? config.auth?.mode === "public";
|
|
3470
|
+
const clientConfig = { ...config, publicMode };
|
|
3270
3471
|
const clientInstance = McpTestClient.create({
|
|
3271
|
-
baseUrl: resolveClientBaseUrl(
|
|
3272
|
-
entryPath:
|
|
3273
|
-
transport:
|
|
3274
|
-
publicMode
|
|
3472
|
+
baseUrl: resolveClientBaseUrl(server, clientConfig),
|
|
3473
|
+
entryPath: clientConfig.entryPath,
|
|
3474
|
+
transport: clientConfig.transport ?? "streamable-http",
|
|
3475
|
+
publicMode
|
|
3275
3476
|
}).build();
|
|
3276
3477
|
try {
|
|
3277
3478
|
await clientInstance.connect();
|
|
@@ -3285,17 +3486,24 @@ async function createTestFixtures() {
|
|
|
3285
3486
|
throw err;
|
|
3286
3487
|
}
|
|
3287
3488
|
}
|
|
3288
|
-
const
|
|
3289
|
-
const
|
|
3290
|
-
return {
|
|
3489
|
+
const context = { extraClients: [] };
|
|
3490
|
+
const fixtures = {
|
|
3291
3491
|
mcp: clientInstance,
|
|
3292
|
-
auth,
|
|
3293
|
-
server
|
|
3492
|
+
auth: createAuthFixture(tokenFactory),
|
|
3493
|
+
server: createServerFixture(server, clientConfig, clientInstance, context)
|
|
3294
3494
|
};
|
|
3495
|
+
fixtureContexts.set(fixtures, context);
|
|
3496
|
+
return fixtures;
|
|
3497
|
+
}
|
|
3498
|
+
async function createTestFixtures() {
|
|
3499
|
+
return buildFixtures(scopes.slice());
|
|
3500
|
+
}
|
|
3501
|
+
async function initializeSharedResources() {
|
|
3502
|
+
await acquireServer(scopes.slice());
|
|
3295
3503
|
}
|
|
3296
3504
|
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
3297
|
-
if (testFailed
|
|
3298
|
-
const logs =
|
|
3505
|
+
if (testFailed) {
|
|
3506
|
+
const logs = fixtures.server.getLogs();
|
|
3299
3507
|
if (logs.length > 0) {
|
|
3300
3508
|
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
3301
3509
|
const recentLogs = logs.slice(-50);
|
|
@@ -3306,20 +3514,21 @@ async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
|
3306
3514
|
console.error("[TestFixture] === End Server Logs ===\n");
|
|
3307
3515
|
}
|
|
3308
3516
|
}
|
|
3517
|
+
const extras = fixtureContexts.get(fixtures)?.extraClients ?? [];
|
|
3518
|
+
for (const client of extras) {
|
|
3519
|
+
if (client.isConnected()) {
|
|
3520
|
+
await client.disconnect().catch(() => void 0);
|
|
3521
|
+
}
|
|
3522
|
+
}
|
|
3309
3523
|
if (fixtures.mcp.isConnected()) {
|
|
3310
3524
|
await fixtures.mcp.disconnect();
|
|
3311
3525
|
}
|
|
3312
3526
|
}
|
|
3313
3527
|
async function cleanupSharedResources() {
|
|
3314
|
-
|
|
3315
|
-
await serverInstance.stop();
|
|
3316
|
-
}
|
|
3317
|
-
serverInstance = null;
|
|
3318
|
-
tokenFactory = null;
|
|
3319
|
-
serverStartedByUs = false;
|
|
3528
|
+
await stopServers();
|
|
3320
3529
|
}
|
|
3321
|
-
function resolveClientBaseUrl(server) {
|
|
3322
|
-
return
|
|
3530
|
+
function resolveClientBaseUrl(server, config) {
|
|
3531
|
+
return config.baseUrl ?? server.info.baseUrl;
|
|
3323
3532
|
}
|
|
3324
3533
|
function createAuthFixture(factory) {
|
|
3325
3534
|
const users = {
|
|
@@ -3365,28 +3574,39 @@ function createAuthFixture(factory) {
|
|
|
3365
3574
|
getAudience: () => factory.getAudience()
|
|
3366
3575
|
};
|
|
3367
3576
|
}
|
|
3368
|
-
function createServerFixture(server) {
|
|
3577
|
+
function createServerFixture(server, config, mcp, context) {
|
|
3369
3578
|
return {
|
|
3370
|
-
|
|
3579
|
+
// Live view: `pid` is filled in once the server is listening and follows restarts
|
|
3580
|
+
get info() {
|
|
3581
|
+
return server.info;
|
|
3582
|
+
},
|
|
3371
3583
|
createClient: async (opts) => {
|
|
3372
|
-
|
|
3373
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3374
|
-
entryPath: opts?.entryPath ??
|
|
3584
|
+
const client = await McpTestClient.create({
|
|
3585
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3586
|
+
entryPath: opts?.entryPath ?? config.entryPath,
|
|
3375
3587
|
transport: opts?.transport ?? "streamable-http",
|
|
3376
3588
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3377
3589
|
clientInfo: opts?.clientInfo,
|
|
3378
|
-
publicMode:
|
|
3590
|
+
publicMode: config.publicMode
|
|
3379
3591
|
}).buildAndConnect();
|
|
3592
|
+
context.extraClients.push(client);
|
|
3593
|
+
return client;
|
|
3380
3594
|
},
|
|
3381
3595
|
createClientBuilder: () => {
|
|
3382
3596
|
const builder = new McpTestClientBuilder({
|
|
3383
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3384
|
-
entryPath:
|
|
3385
|
-
publicMode:
|
|
3597
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3598
|
+
entryPath: config.entryPath,
|
|
3599
|
+
publicMode: config.publicMode
|
|
3386
3600
|
});
|
|
3387
3601
|
return builder;
|
|
3388
3602
|
},
|
|
3389
|
-
restart: () =>
|
|
3603
|
+
restart: async () => {
|
|
3604
|
+
const reconnect = [mcp, ...context.extraClients].filter((client) => client.isConnected());
|
|
3605
|
+
await server.restart();
|
|
3606
|
+
for (const client of reconnect) {
|
|
3607
|
+
await client.reconnect();
|
|
3608
|
+
}
|
|
3609
|
+
},
|
|
3390
3610
|
getLogs: () => server.getLogs(),
|
|
3391
3611
|
clearLogs: () => server.clearLogs()
|
|
3392
3612
|
};
|
|
@@ -3397,12 +3617,12 @@ function resolveServerCommand(server) {
|
|
|
3397
3617
|
}
|
|
3398
3618
|
return `npx tsx ${server}`;
|
|
3399
3619
|
}
|
|
3400
|
-
function runWithFixtures(fn) {
|
|
3401
|
-
return async () => {
|
|
3402
|
-
const fixtures = await
|
|
3620
|
+
function runWithFixtures(fn, chain) {
|
|
3621
|
+
return async (...extra) => {
|
|
3622
|
+
const fixtures = await buildFixtures(chain);
|
|
3403
3623
|
let testFailed = false;
|
|
3404
3624
|
try {
|
|
3405
|
-
await fn(fixtures);
|
|
3625
|
+
await fn(fixtures, ...extra);
|
|
3406
3626
|
} catch (error) {
|
|
3407
3627
|
testFailed = true;
|
|
3408
3628
|
throw error;
|
|
@@ -3411,24 +3631,37 @@ function runWithFixtures(fn) {
|
|
|
3411
3631
|
}
|
|
3412
3632
|
};
|
|
3413
3633
|
}
|
|
3634
|
+
function skipTitle(name, scope) {
|
|
3635
|
+
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
3636
|
+
}
|
|
3414
3637
|
function testWithFixtures(name, fn) {
|
|
3415
|
-
const
|
|
3638
|
+
const chain = scopes.slice();
|
|
3639
|
+
const scope = currentScope();
|
|
3416
3640
|
if (scope.skipped) {
|
|
3417
|
-
it.skip(
|
|
3641
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3418
3642
|
return;
|
|
3419
3643
|
}
|
|
3420
|
-
it(name, runWithFixtures(fn));
|
|
3644
|
+
jestGlobals().it(name, runWithFixtures(fn, chain));
|
|
3421
3645
|
}
|
|
3422
3646
|
function use(config) {
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3647
|
+
const scope = currentScope();
|
|
3648
|
+
scope.config = {
|
|
3649
|
+
...scope.config,
|
|
3650
|
+
...config,
|
|
3651
|
+
env: config.env ? { ...scope.config.env, ...config.env } : scope.config.env
|
|
3652
|
+
};
|
|
3653
|
+
scope.configured = true;
|
|
3654
|
+
if (!scope.cleanupRegistered) {
|
|
3655
|
+
scope.cleanupRegistered = true;
|
|
3656
|
+
jestGlobals().afterAll(async () => {
|
|
3657
|
+
await stopServers(scope === scopes[0] ? void 0 : scope);
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3427
3660
|
}
|
|
3428
3661
|
function skip(nameOrCondition, fnOrReason) {
|
|
3429
3662
|
if (typeof nameOrCondition === "boolean") {
|
|
3430
3663
|
if (nameOrCondition) {
|
|
3431
|
-
const scope =
|
|
3664
|
+
const scope = currentScope();
|
|
3432
3665
|
scope.skipped = true;
|
|
3433
3666
|
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3434
3667
|
}
|
|
@@ -3439,53 +3672,90 @@ function skip(nameOrCondition, fnOrReason) {
|
|
|
3439
3672
|
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3440
3673
|
);
|
|
3441
3674
|
}
|
|
3442
|
-
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3675
|
+
jestGlobals().it.skip(nameOrCondition, runWithFixtures(fnOrReason, scopes.slice()));
|
|
3443
3676
|
}
|
|
3444
3677
|
function only(name, fn) {
|
|
3445
|
-
const scope =
|
|
3678
|
+
const scope = currentScope();
|
|
3446
3679
|
if (scope.skipped) {
|
|
3447
|
-
it.skip(
|
|
3680
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, scopes.slice()));
|
|
3448
3681
|
return;
|
|
3449
3682
|
}
|
|
3450
|
-
it.only(name, runWithFixtures(fn));
|
|
3683
|
+
jestGlobals().it.only(name, runWithFixtures(fn, scopes.slice()));
|
|
3451
3684
|
}
|
|
3452
3685
|
function todo(name) {
|
|
3453
|
-
it.todo(name);
|
|
3686
|
+
jestGlobals().it.todo(name);
|
|
3454
3687
|
}
|
|
3455
|
-
function
|
|
3688
|
+
function each(table, ...tagged) {
|
|
3689
|
+
const chain = scopes.slice();
|
|
3690
|
+
const register = jestGlobals().it.each(table, ...tagged);
|
|
3456
3691
|
return (name, fn) => {
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3692
|
+
const scope = currentScope();
|
|
3693
|
+
if (scope.skipped) {
|
|
3694
|
+
const skipRegister = jestGlobals().it.skip.each(table, ...tagged);
|
|
3695
|
+
skipRegister(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
register(name, runWithFixtures(fn, chain));
|
|
3699
|
+
};
|
|
3700
|
+
}
|
|
3701
|
+
function withScope(fn) {
|
|
3702
|
+
return (...args) => {
|
|
3703
|
+
const parent = currentScope();
|
|
3704
|
+
scopes.push({
|
|
3705
|
+
skipped: parent.skipped,
|
|
3706
|
+
reason: parent.reason,
|
|
3707
|
+
config: {},
|
|
3708
|
+
configured: false,
|
|
3709
|
+
cleanupRegistered: false
|
|
3465
3710
|
});
|
|
3711
|
+
try {
|
|
3712
|
+
fn(...args);
|
|
3713
|
+
} finally {
|
|
3714
|
+
scopes.pop();
|
|
3715
|
+
}
|
|
3466
3716
|
};
|
|
3467
3717
|
}
|
|
3468
|
-
function
|
|
3469
|
-
return (
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
});
|
|
3718
|
+
function scopedRegister(pick) {
|
|
3719
|
+
return (name, fn, timeout) => {
|
|
3720
|
+
pick()(name, withScope(fn), timeout);
|
|
3721
|
+
};
|
|
3473
3722
|
}
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
}
|
|
3723
|
+
function scopedEach(pick) {
|
|
3724
|
+
return (...eachArgs) => (name, fn, timeout) => {
|
|
3725
|
+
const register = pick()(...eachArgs);
|
|
3726
|
+
register(name, withScope(fn), timeout);
|
|
3727
|
+
};
|
|
3728
|
+
}
|
|
3729
|
+
var describeWithScope = Object.assign(
|
|
3730
|
+
scopedRegister(() => jestGlobals().describe),
|
|
3731
|
+
{
|
|
3732
|
+
// `describe.skip` still evaluates its callback during collection, so it needs a scope too
|
|
3733
|
+
only: Object.assign(
|
|
3734
|
+
scopedRegister(() => jestGlobals().describe.only),
|
|
3735
|
+
{
|
|
3736
|
+
each: scopedEach(() => jestGlobals().describe.only.each)
|
|
3737
|
+
}
|
|
3738
|
+
),
|
|
3739
|
+
skip: Object.assign(
|
|
3740
|
+
scopedRegister(() => jestGlobals().describe.skip),
|
|
3741
|
+
{
|
|
3742
|
+
each: scopedEach(() => jestGlobals().describe.skip.each)
|
|
3743
|
+
}
|
|
3744
|
+
),
|
|
3745
|
+
each: scopedEach(() => jestGlobals().describe.each)
|
|
3746
|
+
}
|
|
3747
|
+
);
|
|
3479
3748
|
var test = testWithFixtures;
|
|
3480
3749
|
test.use = use;
|
|
3481
|
-
test.describe =
|
|
3482
|
-
test.beforeAll = beforeAll;
|
|
3483
|
-
test.beforeEach = beforeEach;
|
|
3484
|
-
test.afterEach = afterEach;
|
|
3485
|
-
test.afterAll = afterAll;
|
|
3750
|
+
test.describe = describeWithScope;
|
|
3751
|
+
test.beforeAll = ((...args) => jestGlobals().beforeAll(...args));
|
|
3752
|
+
test.beforeEach = ((...args) => jestGlobals().beforeEach(...args));
|
|
3753
|
+
test.afterEach = ((...args) => jestGlobals().afterEach(...args));
|
|
3754
|
+
test.afterAll = ((...args) => jestGlobals().afterAll(...args));
|
|
3486
3755
|
test.skip = skip;
|
|
3487
3756
|
test.only = only;
|
|
3488
3757
|
test.todo = todo;
|
|
3758
|
+
test.each = each;
|
|
3489
3759
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3490
3760
|
0 && (module.exports = {
|
|
3491
3761
|
cleanupSharedResources,
|