@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/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) {
|
|
@@ -1429,6 +1454,16 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1429
1454
|
name: "@frontmcp/testing",
|
|
1430
1455
|
version: "0.4.0"
|
|
1431
1456
|
};
|
|
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
|
+
}
|
|
1432
1467
|
function extractEntryPaths(data) {
|
|
1433
1468
|
let payload = data;
|
|
1434
1469
|
if (typeof payload === "string") {
|
|
@@ -1515,19 +1550,22 @@ var McpTestClient = class {
|
|
|
1515
1550
|
async connect() {
|
|
1516
1551
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1517
1552
|
this.triedDiscoveredEntryPath = false;
|
|
1553
|
+
this.initResult = null;
|
|
1518
1554
|
this.transport = this.createTransport();
|
|
1519
1555
|
await this.transport.connect();
|
|
1520
1556
|
const maxRetries = 3;
|
|
1521
1557
|
const retryDelayMs = 500;
|
|
1522
1558
|
let lastError;
|
|
1523
1559
|
let reportedEntryPaths;
|
|
1560
|
+
let attemptsMade = 0;
|
|
1524
1561
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1562
|
+
attemptsMade = attempt;
|
|
1525
1563
|
const initResponse = await this.initialize();
|
|
1526
1564
|
if (initResponse.success && initResponse.data) {
|
|
1527
1565
|
this.initResult = initResponse.data;
|
|
1528
1566
|
break;
|
|
1529
1567
|
}
|
|
1530
|
-
lastError = initResponse.error
|
|
1568
|
+
lastError = describeInitializeError(initResponse.error);
|
|
1531
1569
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1532
1570
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1533
1571
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -1539,6 +1577,7 @@ var McpTestClient = class {
|
|
|
1539
1577
|
await this.transport.connect();
|
|
1540
1578
|
continue;
|
|
1541
1579
|
}
|
|
1580
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
1542
1581
|
if (attempt < maxRetries) {
|
|
1543
1582
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1544
1583
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -1547,7 +1586,7 @@ var McpTestClient = class {
|
|
|
1547
1586
|
if (!this.initResult) {
|
|
1548
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.` : "";
|
|
1549
1588
|
throw new Error(
|
|
1550
|
-
`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}`
|
|
1551
1590
|
);
|
|
1552
1591
|
}
|
|
1553
1592
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -1617,18 +1656,47 @@ var McpTestClient = class {
|
|
|
1617
1656
|
return this._authState;
|
|
1618
1657
|
}
|
|
1619
1658
|
/**
|
|
1620
|
-
* 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.
|
|
1621
1665
|
*/
|
|
1622
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 };
|
|
1623
1673
|
this._authState = {
|
|
1624
1674
|
isAnonymous: false,
|
|
1625
1675
|
token,
|
|
1626
1676
|
scopes: this.parseScopesFromToken(token),
|
|
1627
1677
|
user: this.parseUserFromToken(token)
|
|
1628
1678
|
};
|
|
1629
|
-
|
|
1630
|
-
|
|
1679
|
+
const previousTransport = this.transport;
|
|
1680
|
+
if (!previousTransport) {
|
|
1681
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
1682
|
+
return;
|
|
1631
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;
|
|
1698
|
+
}
|
|
1699
|
+
await previousTransport.close().catch(() => void 0);
|
|
1632
1700
|
this.log("debug", "Authentication updated");
|
|
1633
1701
|
}
|
|
1634
1702
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -1662,13 +1730,12 @@ var McpTestClient = class {
|
|
|
1662
1730
|
/**
|
|
1663
1731
|
* List all available tools
|
|
1664
1732
|
*/
|
|
1665
|
-
list: async () =>
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
},
|
|
1733
|
+
list: async () => this.listAllPages(
|
|
1734
|
+
"tools/list",
|
|
1735
|
+
"tools",
|
|
1736
|
+
(cursor) => this.listTools(cursor),
|
|
1737
|
+
(page) => page.tools
|
|
1738
|
+
),
|
|
1672
1739
|
/**
|
|
1673
1740
|
* Call a tool by name with arguments
|
|
1674
1741
|
*/
|
|
@@ -1684,23 +1751,21 @@ var McpTestClient = class {
|
|
|
1684
1751
|
/**
|
|
1685
1752
|
* List all static resources
|
|
1686
1753
|
*/
|
|
1687
|
-
list: async () =>
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
},
|
|
1754
|
+
list: async () => this.listAllPages(
|
|
1755
|
+
"resources/list",
|
|
1756
|
+
"resources",
|
|
1757
|
+
(cursor) => this.listResources(cursor),
|
|
1758
|
+
(page) => page.resources
|
|
1759
|
+
),
|
|
1694
1760
|
/**
|
|
1695
1761
|
* List all resource templates
|
|
1696
1762
|
*/
|
|
1697
|
-
listTemplates: async () =>
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
},
|
|
1763
|
+
listTemplates: async () => this.listAllPages(
|
|
1764
|
+
"resources/templates/list",
|
|
1765
|
+
"resource templates",
|
|
1766
|
+
(cursor) => this.listResourceTemplates(cursor),
|
|
1767
|
+
(page) => page.resourceTemplates
|
|
1768
|
+
),
|
|
1704
1769
|
/**
|
|
1705
1770
|
* Read a resource by URI
|
|
1706
1771
|
*/
|
|
@@ -1728,13 +1793,12 @@ var McpTestClient = class {
|
|
|
1728
1793
|
/**
|
|
1729
1794
|
* List all available prompts
|
|
1730
1795
|
*/
|
|
1731
|
-
list: async () =>
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
},
|
|
1796
|
+
list: async () => this.listAllPages(
|
|
1797
|
+
"prompts/list",
|
|
1798
|
+
"prompts",
|
|
1799
|
+
(cursor) => this.listPrompts(cursor),
|
|
1800
|
+
(page) => page.prompts
|
|
1801
|
+
),
|
|
1738
1802
|
/**
|
|
1739
1803
|
* Get a prompt with arguments
|
|
1740
1804
|
*/
|
|
@@ -2118,8 +2182,33 @@ var McpTestClient = class {
|
|
|
2118
2182
|
clientInfo: this.config.clientInfo
|
|
2119
2183
|
});
|
|
2120
2184
|
}
|
|
2121
|
-
async listTools() {
|
|
2122
|
-
return this.request("tools/list", {});
|
|
2185
|
+
async listTools(cursor) {
|
|
2186
|
+
return this.request("tools/list", cursor ? { cursor } : {});
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* Follow `nextCursor` until the server stops returning one, concatenating the
|
|
2190
|
+
* pages. A server that hands back a cursor it already returned, or never stops
|
|
2191
|
+
* paging, fails the call rather than looping.
|
|
2192
|
+
*/
|
|
2193
|
+
async listAllPages(method, label, fetchPage, itemsOf) {
|
|
2194
|
+
const items = [];
|
|
2195
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
2196
|
+
let cursor;
|
|
2197
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
2198
|
+
const response = await fetchPage(cursor);
|
|
2199
|
+
if (!response.success || !response.data) {
|
|
2200
|
+
throw new Error(`Failed to list ${label}: ${response.error?.message}`);
|
|
2201
|
+
}
|
|
2202
|
+
items.push(...itemsOf(response.data) ?? []);
|
|
2203
|
+
const next = response.data.nextCursor;
|
|
2204
|
+
if (!next) return items;
|
|
2205
|
+
if (seenCursors.has(next)) {
|
|
2206
|
+
throw new Error(`Failed to list ${label}: ${method} returned the cursor "${next}" twice`);
|
|
2207
|
+
}
|
|
2208
|
+
seenCursors.add(next);
|
|
2209
|
+
cursor = next;
|
|
2210
|
+
}
|
|
2211
|
+
throw new Error(`Failed to list ${label}: ${method} did not finish within ${MAX_LIST_PAGES} pages`);
|
|
2123
2212
|
}
|
|
2124
2213
|
async callTool(name, args, progressToken) {
|
|
2125
2214
|
return this.request("tools/call", {
|
|
@@ -2144,17 +2233,17 @@ var McpTestClient = class {
|
|
|
2144
2233
|
timestamp
|
|
2145
2234
|
});
|
|
2146
2235
|
};
|
|
2147
|
-
async listResources() {
|
|
2148
|
-
return this.request("resources/list", {});
|
|
2236
|
+
async listResources(cursor) {
|
|
2237
|
+
return this.request("resources/list", cursor ? { cursor } : {});
|
|
2149
2238
|
}
|
|
2150
|
-
async listResourceTemplates() {
|
|
2151
|
-
return this.request("resources/templates/list", {});
|
|
2239
|
+
async listResourceTemplates(cursor) {
|
|
2240
|
+
return this.request("resources/templates/list", cursor ? { cursor } : {});
|
|
2152
2241
|
}
|
|
2153
2242
|
async readResource(uri) {
|
|
2154
2243
|
return this.request("resources/read", { uri });
|
|
2155
2244
|
}
|
|
2156
|
-
async listPrompts() {
|
|
2157
|
-
return this.request("prompts/list", {});
|
|
2245
|
+
async listPrompts(cursor) {
|
|
2246
|
+
return this.request("prompts/list", cursor ? { cursor } : {});
|
|
2158
2247
|
}
|
|
2159
2248
|
async getPrompt(name, args) {
|
|
2160
2249
|
return this.request("prompts/get", {
|
|
@@ -2231,10 +2320,11 @@ var McpTestClient = class {
|
|
|
2231
2320
|
requestId: id
|
|
2232
2321
|
};
|
|
2233
2322
|
} catch (err) {
|
|
2323
|
+
if (isInterceptedError(err)) throw err;
|
|
2234
2324
|
const durationMs = Date.now() - start;
|
|
2235
2325
|
const error = {
|
|
2236
2326
|
code: -32603,
|
|
2237
|
-
message: err
|
|
2327
|
+
message: errorMessage(err)
|
|
2238
2328
|
};
|
|
2239
2329
|
return {
|
|
2240
2330
|
success: false,
|
|
@@ -2382,12 +2472,12 @@ var McpTestClient = class {
|
|
|
2382
2472
|
const payload = this.decodeJwtPayload(token);
|
|
2383
2473
|
if (!payload) return [];
|
|
2384
2474
|
const scope = payload["scope"];
|
|
2385
|
-
const
|
|
2475
|
+
const scopes2 = payload["scopes"];
|
|
2386
2476
|
if (typeof scope === "string") {
|
|
2387
2477
|
return scope.split(" ");
|
|
2388
2478
|
}
|
|
2389
|
-
if (Array.isArray(
|
|
2390
|
-
return
|
|
2479
|
+
if (Array.isArray(scopes2)) {
|
|
2480
|
+
return scopes2;
|
|
2391
2481
|
}
|
|
2392
2482
|
return [];
|
|
2393
2483
|
} catch {
|
|
@@ -2533,6 +2623,8 @@ var E2E_PORT_RANGES = {
|
|
|
2533
2623
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
2534
2624
|
// Protocol revision E2E tests (50460-50479)
|
|
2535
2625
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
2626
|
+
// Testing library E2E tests (50480-50499)
|
|
2627
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
2536
2628
|
// Mock servers and utilities (50900-50999)
|
|
2537
2629
|
"mock-oauth": { start: 50900, size: 10 },
|
|
2538
2630
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -2541,6 +2633,66 @@ var E2E_PORT_RANGES = {
|
|
|
2541
2633
|
default: { start: 51e3, size: 100 }
|
|
2542
2634
|
};
|
|
2543
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
|
+
}
|
|
2544
2696
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
2545
2697
|
function getPortRange(project) {
|
|
2546
2698
|
const key = project;
|
|
@@ -2551,15 +2703,22 @@ function getPortRange(project) {
|
|
|
2551
2703
|
}
|
|
2552
2704
|
async function reservePort(project, preferredPort) {
|
|
2553
2705
|
const range = getPortRange(project);
|
|
2554
|
-
|
|
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) {
|
|
2555
2719
|
const reservation = await tryReservePort(preferredPort, project);
|
|
2556
2720
|
if (reservation) {
|
|
2557
|
-
return
|
|
2558
|
-
port: preferredPort,
|
|
2559
|
-
release: async () => {
|
|
2560
|
-
await releasePort(preferredPort);
|
|
2561
|
-
}
|
|
2562
|
-
};
|
|
2721
|
+
return handle(preferredPort);
|
|
2563
2722
|
}
|
|
2564
2723
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
2565
2724
|
}
|
|
@@ -2568,30 +2727,19 @@ async function reservePort(project, preferredPort) {
|
|
|
2568
2727
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2569
2728
|
const port = range.start + index % range.size;
|
|
2570
2729
|
index = (index + 1) % range.size;
|
|
2571
|
-
if (reservedPorts.has(port)) {
|
|
2730
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
2572
2731
|
continue;
|
|
2573
2732
|
}
|
|
2574
2733
|
const reservation = await tryReservePort(port, project);
|
|
2575
2734
|
if (reservation) {
|
|
2576
2735
|
projectPortIndex.set(project, index);
|
|
2577
|
-
return
|
|
2578
|
-
port,
|
|
2579
|
-
release: async () => {
|
|
2580
|
-
await releasePort(port);
|
|
2581
|
-
}
|
|
2582
|
-
};
|
|
2736
|
+
return handle(port);
|
|
2583
2737
|
}
|
|
2584
2738
|
}
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
return {
|
|
2590
|
-
port: dynamicPort,
|
|
2591
|
-
release: async () => {
|
|
2592
|
-
await releasePort(dynamicPort);
|
|
2593
|
-
}
|
|
2594
|
-
};
|
|
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);
|
|
2595
2743
|
}
|
|
2596
2744
|
}
|
|
2597
2745
|
throw new Error(
|
|
@@ -2599,9 +2747,13 @@ async function reservePort(project, preferredPort) {
|
|
|
2599
2747
|
);
|
|
2600
2748
|
}
|
|
2601
2749
|
async function tryReservePort(port, project) {
|
|
2750
|
+
if (!acquirePortLock(port)) {
|
|
2751
|
+
return false;
|
|
2752
|
+
}
|
|
2602
2753
|
return new Promise((resolve) => {
|
|
2603
2754
|
const server = (0, import_net.createServer)();
|
|
2604
2755
|
server.once("error", () => {
|
|
2756
|
+
releasePortLock(port);
|
|
2605
2757
|
resolve(false);
|
|
2606
2758
|
});
|
|
2607
2759
|
server.listen(port, () => {
|
|
@@ -2627,31 +2779,6 @@ async function releasePort(port) {
|
|
|
2627
2779
|
});
|
|
2628
2780
|
});
|
|
2629
2781
|
}
|
|
2630
|
-
async function findAvailablePortInRange(start, end) {
|
|
2631
|
-
for (let port = start; port < end; port++) {
|
|
2632
|
-
if (reservedPorts.has(port)) {
|
|
2633
|
-
continue;
|
|
2634
|
-
}
|
|
2635
|
-
const available = await isPortAvailable(port);
|
|
2636
|
-
if (available) {
|
|
2637
|
-
return port;
|
|
2638
|
-
}
|
|
2639
|
-
}
|
|
2640
|
-
return null;
|
|
2641
|
-
}
|
|
2642
|
-
async function isPortAvailable(port) {
|
|
2643
|
-
return new Promise((resolve) => {
|
|
2644
|
-
const server = (0, import_net.createServer)();
|
|
2645
|
-
server.once("error", () => {
|
|
2646
|
-
resolve(false);
|
|
2647
|
-
});
|
|
2648
|
-
server.listen(port, () => {
|
|
2649
|
-
server.close(() => {
|
|
2650
|
-
resolve(true);
|
|
2651
|
-
});
|
|
2652
|
-
});
|
|
2653
|
-
});
|
|
2654
|
-
}
|
|
2655
2782
|
|
|
2656
2783
|
// libs/testing/src/server/test-server.ts
|
|
2657
2784
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -2660,8 +2787,11 @@ var TestServer = class _TestServer {
|
|
|
2660
2787
|
options;
|
|
2661
2788
|
_info;
|
|
2662
2789
|
logs = [];
|
|
2790
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
2663
2791
|
portRelease = null;
|
|
2664
|
-
|
|
2792
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
2793
|
+
portReleaseSocket = null;
|
|
2794
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
2665
2795
|
this.options = {
|
|
2666
2796
|
port,
|
|
2667
2797
|
project: options.project,
|
|
@@ -2673,6 +2803,7 @@ var TestServer = class _TestServer {
|
|
|
2673
2803
|
debug: options.debug ?? DEBUG_SERVER
|
|
2674
2804
|
};
|
|
2675
2805
|
this.portRelease = portRelease ?? null;
|
|
2806
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
2676
2807
|
this._info = {
|
|
2677
2808
|
baseUrl: `http://localhost:${port}`,
|
|
2678
2809
|
port
|
|
@@ -2685,8 +2816,8 @@ var TestServer = class _TestServer {
|
|
|
2685
2816
|
const project = options.project ?? "default";
|
|
2686
2817
|
const maxAttempts = 3;
|
|
2687
2818
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2688
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2689
|
-
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);
|
|
2690
2821
|
try {
|
|
2691
2822
|
await server.startProcess();
|
|
2692
2823
|
return server;
|
|
@@ -2726,7 +2857,7 @@ var TestServer = class _TestServer {
|
|
|
2726
2857
|
}
|
|
2727
2858
|
const maxAttempts = 3;
|
|
2728
2859
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2729
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2860
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2730
2861
|
const serverOptions = {
|
|
2731
2862
|
...options,
|
|
2732
2863
|
port,
|
|
@@ -2734,7 +2865,7 @@ var TestServer = class _TestServer {
|
|
|
2734
2865
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2735
2866
|
cwd: options.cwd ?? process.cwd()
|
|
2736
2867
|
};
|
|
2737
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
2868
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
2738
2869
|
try {
|
|
2739
2870
|
await server.startProcess();
|
|
2740
2871
|
return server;
|
|
@@ -2792,10 +2923,17 @@ var TestServer = class _TestServer {
|
|
|
2792
2923
|
* Stop the test server
|
|
2793
2924
|
*/
|
|
2794
2925
|
async stop() {
|
|
2795
|
-
|
|
2796
|
-
await this.
|
|
2797
|
-
|
|
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;
|
|
2798
2934
|
}
|
|
2935
|
+
}
|
|
2936
|
+
async stopProcess() {
|
|
2799
2937
|
if (this.process) {
|
|
2800
2938
|
this.log("Stopping server...");
|
|
2801
2939
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -2868,7 +3006,7 @@ var TestServer = class _TestServer {
|
|
|
2868
3006
|
* Restart the server
|
|
2869
3007
|
*/
|
|
2870
3008
|
async restart() {
|
|
2871
|
-
await this.
|
|
3009
|
+
await this.stopProcess();
|
|
2872
3010
|
await this.startProcess();
|
|
2873
3011
|
}
|
|
2874
3012
|
/**
|
|
@@ -2898,9 +3036,9 @@ var TestServer = class _TestServer {
|
|
|
2898
3036
|
PORT: String(this.options.port)
|
|
2899
3037
|
};
|
|
2900
3038
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
2901
|
-
if (this.
|
|
2902
|
-
await this.
|
|
2903
|
-
this.
|
|
3039
|
+
if (this.portReleaseSocket) {
|
|
3040
|
+
await this.portReleaseSocket();
|
|
3041
|
+
this.portReleaseSocket = null;
|
|
2904
3042
|
await sleep2(300);
|
|
2905
3043
|
}
|
|
2906
3044
|
this.process = (0, import_child_process.spawn)(this.options.command, [], {
|
|
@@ -2910,9 +3048,8 @@ var TestServer = class _TestServer {
|
|
|
2910
3048
|
detached: true,
|
|
2911
3049
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2912
3050
|
});
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
}
|
|
3051
|
+
this._info.pid = this.process.pid;
|
|
3052
|
+
this._info.shellPid = this.process.pid;
|
|
2916
3053
|
let processExited = false;
|
|
2917
3054
|
let exitCode = null;
|
|
2918
3055
|
let exitError = null;
|
|
@@ -3015,6 +3152,8 @@ ${allLogs}`);
|
|
|
3015
3152
|
});
|
|
3016
3153
|
if (response.ok || response.status === 404) {
|
|
3017
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;
|
|
3018
3157
|
return;
|
|
3019
3158
|
}
|
|
3020
3159
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3059,6 +3198,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3059
3198
|
function sleep2(ms) {
|
|
3060
3199
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3061
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
|
+
}
|
|
3062
3214
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3063
3215
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3064
3216
|
return env;
|
|
@@ -3175,81 +3327,152 @@ function gatewayTokenBinding(serverUrl, entryPath) {
|
|
|
3175
3327
|
}
|
|
3176
3328
|
|
|
3177
3329
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3178
|
-
var
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
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
|
+
}
|
|
3191
3350
|
}
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
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;
|
|
3200
3413
|
if (isDebug) {
|
|
3201
|
-
console.log(`[TestFixture]
|
|
3414
|
+
console.log(`[TestFixture] Server started at ${server.info.baseUrl}`);
|
|
3202
3415
|
}
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
command: serverCommand,
|
|
3208
|
-
env: currentConfig.env,
|
|
3209
|
-
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
3210
|
-
debug: isDebug
|
|
3211
|
-
});
|
|
3212
|
-
serverStartedByUs = true;
|
|
3213
|
-
if (isDebug) {
|
|
3214
|
-
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
3215
|
-
}
|
|
3216
|
-
} catch (error) {
|
|
3217
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3218
|
-
throw new Error(
|
|
3219
|
-
`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.
|
|
3220
3420
|
|
|
3221
|
-
Server entry: ${
|
|
3222
|
-
Project: ${
|
|
3421
|
+
Server entry: ${config.server}
|
|
3422
|
+
Project: ${config.project ?? "default"}
|
|
3223
3423
|
Command: ${serverCommand}
|
|
3224
3424
|
|
|
3225
|
-
Error: ${errMsg}
|
|
3226
|
-
|
|
3227
|
-
}
|
|
3228
|
-
} else {
|
|
3229
|
-
throw new Error(
|
|
3230
|
-
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
3425
|
+
Error: ${errMsg}`,
|
|
3426
|
+
{ cause: error }
|
|
3231
3427
|
);
|
|
3232
3428
|
}
|
|
3429
|
+
} else {
|
|
3430
|
+
throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option');
|
|
3233
3431
|
}
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
}
|
|
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 };
|
|
3239
3436
|
}
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
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
|
+
}
|
|
3247
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 };
|
|
3248
3471
|
const clientInstance = McpTestClient.create({
|
|
3249
|
-
baseUrl: resolveClientBaseUrl(
|
|
3250
|
-
entryPath:
|
|
3251
|
-
transport:
|
|
3252
|
-
publicMode
|
|
3472
|
+
baseUrl: resolveClientBaseUrl(server, clientConfig),
|
|
3473
|
+
entryPath: clientConfig.entryPath,
|
|
3474
|
+
transport: clientConfig.transport ?? "streamable-http",
|
|
3475
|
+
publicMode
|
|
3253
3476
|
}).build();
|
|
3254
3477
|
try {
|
|
3255
3478
|
await clientInstance.connect();
|
|
@@ -3263,17 +3486,24 @@ async function createTestFixtures() {
|
|
|
3263
3486
|
throw err;
|
|
3264
3487
|
}
|
|
3265
3488
|
}
|
|
3266
|
-
const
|
|
3267
|
-
const
|
|
3268
|
-
return {
|
|
3489
|
+
const context = { extraClients: [] };
|
|
3490
|
+
const fixtures = {
|
|
3269
3491
|
mcp: clientInstance,
|
|
3270
|
-
auth,
|
|
3271
|
-
server
|
|
3492
|
+
auth: createAuthFixture(tokenFactory),
|
|
3493
|
+
server: createServerFixture(server, clientConfig, clientInstance, context)
|
|
3272
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());
|
|
3273
3503
|
}
|
|
3274
3504
|
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
3275
|
-
if (testFailed
|
|
3276
|
-
const logs =
|
|
3505
|
+
if (testFailed) {
|
|
3506
|
+
const logs = fixtures.server.getLogs();
|
|
3277
3507
|
if (logs.length > 0) {
|
|
3278
3508
|
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
3279
3509
|
const recentLogs = logs.slice(-50);
|
|
@@ -3284,20 +3514,21 @@ async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
|
3284
3514
|
console.error("[TestFixture] === End Server Logs ===\n");
|
|
3285
3515
|
}
|
|
3286
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
|
+
}
|
|
3287
3523
|
if (fixtures.mcp.isConnected()) {
|
|
3288
3524
|
await fixtures.mcp.disconnect();
|
|
3289
3525
|
}
|
|
3290
3526
|
}
|
|
3291
3527
|
async function cleanupSharedResources() {
|
|
3292
|
-
|
|
3293
|
-
await serverInstance.stop();
|
|
3294
|
-
}
|
|
3295
|
-
serverInstance = null;
|
|
3296
|
-
tokenFactory = null;
|
|
3297
|
-
serverStartedByUs = false;
|
|
3528
|
+
await stopServers();
|
|
3298
3529
|
}
|
|
3299
|
-
function resolveClientBaseUrl(server) {
|
|
3300
|
-
return
|
|
3530
|
+
function resolveClientBaseUrl(server, config) {
|
|
3531
|
+
return config.baseUrl ?? server.info.baseUrl;
|
|
3301
3532
|
}
|
|
3302
3533
|
function createAuthFixture(factory) {
|
|
3303
3534
|
const users = {
|
|
@@ -3343,28 +3574,39 @@ function createAuthFixture(factory) {
|
|
|
3343
3574
|
getAudience: () => factory.getAudience()
|
|
3344
3575
|
};
|
|
3345
3576
|
}
|
|
3346
|
-
function createServerFixture(server) {
|
|
3577
|
+
function createServerFixture(server, config, mcp, context) {
|
|
3347
3578
|
return {
|
|
3348
|
-
|
|
3579
|
+
// Live view: `pid` is filled in once the server is listening and follows restarts
|
|
3580
|
+
get info() {
|
|
3581
|
+
return server.info;
|
|
3582
|
+
},
|
|
3349
3583
|
createClient: async (opts) => {
|
|
3350
|
-
|
|
3351
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3352
|
-
entryPath: opts?.entryPath ??
|
|
3584
|
+
const client = await McpTestClient.create({
|
|
3585
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3586
|
+
entryPath: opts?.entryPath ?? config.entryPath,
|
|
3353
3587
|
transport: opts?.transport ?? "streamable-http",
|
|
3354
3588
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3355
3589
|
clientInfo: opts?.clientInfo,
|
|
3356
|
-
publicMode:
|
|
3590
|
+
publicMode: config.publicMode
|
|
3357
3591
|
}).buildAndConnect();
|
|
3592
|
+
context.extraClients.push(client);
|
|
3593
|
+
return client;
|
|
3358
3594
|
},
|
|
3359
3595
|
createClientBuilder: () => {
|
|
3360
3596
|
const builder = new McpTestClientBuilder({
|
|
3361
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3362
|
-
entryPath:
|
|
3363
|
-
publicMode:
|
|
3597
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3598
|
+
entryPath: config.entryPath,
|
|
3599
|
+
publicMode: config.publicMode
|
|
3364
3600
|
});
|
|
3365
3601
|
return builder;
|
|
3366
3602
|
},
|
|
3367
|
-
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
|
+
},
|
|
3368
3610
|
getLogs: () => server.getLogs(),
|
|
3369
3611
|
clearLogs: () => server.clearLogs()
|
|
3370
3612
|
};
|
|
@@ -3375,12 +3617,12 @@ function resolveServerCommand(server) {
|
|
|
3375
3617
|
}
|
|
3376
3618
|
return `npx tsx ${server}`;
|
|
3377
3619
|
}
|
|
3378
|
-
function runWithFixtures(fn) {
|
|
3379
|
-
return async () => {
|
|
3380
|
-
const fixtures = await
|
|
3620
|
+
function runWithFixtures(fn, chain) {
|
|
3621
|
+
return async (...extra) => {
|
|
3622
|
+
const fixtures = await buildFixtures(chain);
|
|
3381
3623
|
let testFailed = false;
|
|
3382
3624
|
try {
|
|
3383
|
-
await fn(fixtures);
|
|
3625
|
+
await fn(fixtures, ...extra);
|
|
3384
3626
|
} catch (error) {
|
|
3385
3627
|
testFailed = true;
|
|
3386
3628
|
throw error;
|
|
@@ -3389,24 +3631,37 @@ function runWithFixtures(fn) {
|
|
|
3389
3631
|
}
|
|
3390
3632
|
};
|
|
3391
3633
|
}
|
|
3634
|
+
function skipTitle(name, scope) {
|
|
3635
|
+
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
3636
|
+
}
|
|
3392
3637
|
function testWithFixtures(name, fn) {
|
|
3393
|
-
const
|
|
3638
|
+
const chain = scopes.slice();
|
|
3639
|
+
const scope = currentScope();
|
|
3394
3640
|
if (scope.skipped) {
|
|
3395
|
-
it.skip(
|
|
3641
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3396
3642
|
return;
|
|
3397
3643
|
}
|
|
3398
|
-
it(name, runWithFixtures(fn));
|
|
3644
|
+
jestGlobals().it(name, runWithFixtures(fn, chain));
|
|
3399
3645
|
}
|
|
3400
3646
|
function use(config) {
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
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
|
+
}
|
|
3405
3660
|
}
|
|
3406
3661
|
function skip(nameOrCondition, fnOrReason) {
|
|
3407
3662
|
if (typeof nameOrCondition === "boolean") {
|
|
3408
3663
|
if (nameOrCondition) {
|
|
3409
|
-
const scope =
|
|
3664
|
+
const scope = currentScope();
|
|
3410
3665
|
scope.skipped = true;
|
|
3411
3666
|
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3412
3667
|
}
|
|
@@ -3417,53 +3672,90 @@ function skip(nameOrCondition, fnOrReason) {
|
|
|
3417
3672
|
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3418
3673
|
);
|
|
3419
3674
|
}
|
|
3420
|
-
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3675
|
+
jestGlobals().it.skip(nameOrCondition, runWithFixtures(fnOrReason, scopes.slice()));
|
|
3421
3676
|
}
|
|
3422
3677
|
function only(name, fn) {
|
|
3423
|
-
const scope =
|
|
3678
|
+
const scope = currentScope();
|
|
3424
3679
|
if (scope.skipped) {
|
|
3425
|
-
it.skip(
|
|
3680
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, scopes.slice()));
|
|
3426
3681
|
return;
|
|
3427
3682
|
}
|
|
3428
|
-
it.only(name, runWithFixtures(fn));
|
|
3683
|
+
jestGlobals().it.only(name, runWithFixtures(fn, scopes.slice()));
|
|
3429
3684
|
}
|
|
3430
3685
|
function todo(name) {
|
|
3431
|
-
it.todo(name);
|
|
3686
|
+
jestGlobals().it.todo(name);
|
|
3432
3687
|
}
|
|
3433
|
-
function
|
|
3688
|
+
function each(table, ...tagged) {
|
|
3689
|
+
const chain = scopes.slice();
|
|
3690
|
+
const register = jestGlobals().it.each(table, ...tagged);
|
|
3434
3691
|
return (name, fn) => {
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
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
|
|
3443
3710
|
});
|
|
3711
|
+
try {
|
|
3712
|
+
fn(...args);
|
|
3713
|
+
} finally {
|
|
3714
|
+
scopes.pop();
|
|
3715
|
+
}
|
|
3444
3716
|
};
|
|
3445
3717
|
}
|
|
3446
|
-
function
|
|
3447
|
-
return (
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
});
|
|
3718
|
+
function scopedRegister(pick) {
|
|
3719
|
+
return (name, fn, timeout) => {
|
|
3720
|
+
pick()(name, withScope(fn), timeout);
|
|
3721
|
+
};
|
|
3451
3722
|
}
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
}
|
|
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
|
+
);
|
|
3457
3748
|
var test = testWithFixtures;
|
|
3458
3749
|
test.use = use;
|
|
3459
|
-
test.describe =
|
|
3460
|
-
test.beforeAll = beforeAll;
|
|
3461
|
-
test.beforeEach = beforeEach;
|
|
3462
|
-
test.afterEach = afterEach;
|
|
3463
|
-
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));
|
|
3464
3755
|
test.skip = skip;
|
|
3465
3756
|
test.only = only;
|
|
3466
3757
|
test.todo = todo;
|
|
3758
|
+
test.each = each;
|
|
3467
3759
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3468
3760
|
0 && (module.exports = {
|
|
3469
3761
|
cleanupSharedResources,
|