@frontmcp/testing 1.8.5 → 1.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts +2 -2
- package/auth/token-factory.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +12 -1
- package/client/mcp-test-client.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +545 -260
- package/esm/index.mjs +702 -383
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +465 -308
- package/expect.d.ts +12 -11
- package/expect.d.ts.map +1 -1
- package/fixtures/fixture-types.d.ts +22 -4
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +544 -252
- package/fixtures/test-fixture.d.ts +6 -5
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/http-mock/http-mock.d.ts +5 -3
- package/http-mock/http-mock.d.ts.map +1 -1
- package/http-mock/http-mock.types.d.ts +17 -6
- package/http-mock/http-mock.types.d.ts.map +1 -1
- package/http-mock/index.d.ts +1 -1
- package/http-mock/index.d.ts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +710 -384
- package/package.json +6 -6
- package/perf/baseline-store.d.ts +1 -1
- package/perf/baseline-store.d.ts.map +1 -1
- package/perf/index.js +468 -321
- package/perf/perf-test.d.ts.map +1 -1
- package/perf/report-generator.d.ts +1 -1
- package/perf/report-generator.d.ts.map +1 -1
- package/server/port-registry.d.ts +7 -1
- package/server/port-registry.d.ts.map +1 -1
- package/server/test-server.d.ts +10 -1
- package/server/test-server.d.ts.map +1 -1
- package/transport/error-utils.d.ts +15 -0
- package/transport/error-utils.d.ts.map +1 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
package/esm/fixtures/index.mjs
CHANGED
|
@@ -5,16 +5,6 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
5
5
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
-
// libs/testing/src/fixtures/test-fixture.ts
|
|
9
|
-
import {
|
|
10
|
-
afterAll as _afterAll,
|
|
11
|
-
afterEach as _afterEach,
|
|
12
|
-
beforeAll as _beforeAll,
|
|
13
|
-
beforeEach as _beforeEach,
|
|
14
|
-
describe as _describe,
|
|
15
|
-
it as _it
|
|
16
|
-
} from "@jest/globals";
|
|
17
|
-
|
|
18
8
|
// libs/testing/src/auth/token-factory.ts
|
|
19
9
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
20
10
|
var TestTokenFactory = class {
|
|
@@ -73,14 +63,15 @@ var TestTokenFactory = class {
|
|
|
73
63
|
*/
|
|
74
64
|
async createTestToken(options) {
|
|
75
65
|
await this.ensureKeys();
|
|
76
|
-
const
|
|
66
|
+
const nowMs = Date.now();
|
|
67
|
+
const iat = Math.floor(nowMs / 1e3);
|
|
77
68
|
const exp = options.exp ?? 3600;
|
|
78
69
|
const payload = {
|
|
79
70
|
iss: options.iss ?? this.issuer,
|
|
80
71
|
sub: options.sub,
|
|
81
72
|
aud: options.aud ?? this.audience,
|
|
82
|
-
iat
|
|
83
|
-
exp:
|
|
73
|
+
iat,
|
|
74
|
+
exp: Math.ceil(nowMs / 1e3) + exp,
|
|
84
75
|
scope: options.scopes?.join(" "),
|
|
85
76
|
...options.claims
|
|
86
77
|
};
|
|
@@ -105,10 +96,10 @@ var TestTokenFactory = class {
|
|
|
105
96
|
/**
|
|
106
97
|
* Create a regular user token
|
|
107
98
|
*/
|
|
108
|
-
async createUserToken(sub = "user-001",
|
|
99
|
+
async createUserToken(sub = "user-001", scopes2 = ["read", "write"]) {
|
|
109
100
|
return this.createTestToken({
|
|
110
101
|
sub,
|
|
111
|
-
scopes,
|
|
102
|
+
scopes: scopes2,
|
|
112
103
|
claims: {
|
|
113
104
|
email: "user@test.local",
|
|
114
105
|
name: "Test User",
|
|
@@ -119,10 +110,11 @@ var TestTokenFactory = class {
|
|
|
119
110
|
/**
|
|
120
111
|
* Create an anonymous user token
|
|
121
112
|
*/
|
|
122
|
-
async createAnonymousToken() {
|
|
113
|
+
async createAnonymousToken(expiresIn) {
|
|
123
114
|
return this.createTestToken({
|
|
124
115
|
sub: `anon:${Date.now()}`,
|
|
125
116
|
scopes: ["anonymous"],
|
|
117
|
+
exp: expiresIn,
|
|
126
118
|
claims: {
|
|
127
119
|
name: "Anonymous",
|
|
128
120
|
role: "anonymous"
|
|
@@ -471,6 +463,32 @@ function sleep(ms) {
|
|
|
471
463
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
472
464
|
}
|
|
473
465
|
|
|
466
|
+
// libs/testing/src/transport/error-utils.ts
|
|
467
|
+
function errorName(err) {
|
|
468
|
+
if (typeof err !== "object" || err === null) return void 0;
|
|
469
|
+
const name = err.name;
|
|
470
|
+
return typeof name === "string" ? name : void 0;
|
|
471
|
+
}
|
|
472
|
+
function isAbortError(err) {
|
|
473
|
+
return errorName(err) === "AbortError";
|
|
474
|
+
}
|
|
475
|
+
function errorMessage(err, fallback = "Unknown error") {
|
|
476
|
+
if (typeof err === "string" && err) return err;
|
|
477
|
+
if (typeof err === "object" && err !== null) {
|
|
478
|
+
const message = err.message;
|
|
479
|
+
if (typeof message === "string" && message) return message;
|
|
480
|
+
}
|
|
481
|
+
return fallback;
|
|
482
|
+
}
|
|
483
|
+
var interceptedErrors = /* @__PURE__ */ new WeakSet();
|
|
484
|
+
function markInterceptedError(err) {
|
|
485
|
+
interceptedErrors.add(err);
|
|
486
|
+
return err;
|
|
487
|
+
}
|
|
488
|
+
function isInterceptedError(err) {
|
|
489
|
+
return typeof err === "object" && err !== null && interceptedErrors.has(err);
|
|
490
|
+
}
|
|
491
|
+
|
|
474
492
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
475
493
|
var DEFAULT_TIMEOUT = 3e4;
|
|
476
494
|
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
@@ -579,7 +597,7 @@ var StreamableHttpTransport = class {
|
|
|
579
597
|
return mockResponse2;
|
|
580
598
|
}
|
|
581
599
|
case "error":
|
|
582
|
-
throw interceptResult.error;
|
|
600
|
+
throw markInterceptedError(interceptResult.error);
|
|
583
601
|
case "continue":
|
|
584
602
|
message = interceptResult.request;
|
|
585
603
|
break;
|
|
@@ -640,7 +658,7 @@ var StreamableHttpTransport = class {
|
|
|
640
658
|
return jsonResponse;
|
|
641
659
|
} catch (error) {
|
|
642
660
|
clearTimeout(timeoutId);
|
|
643
|
-
if (error
|
|
661
|
+
if (isAbortError(error)) {
|
|
644
662
|
return {
|
|
645
663
|
jsonrpc: "2.0",
|
|
646
664
|
id: message.id ?? null,
|
|
@@ -679,7 +697,7 @@ var StreamableHttpTransport = class {
|
|
|
679
697
|
}
|
|
680
698
|
} catch (error) {
|
|
681
699
|
clearTimeout(timeoutId);
|
|
682
|
-
if (error
|
|
700
|
+
if (!isAbortError(error)) {
|
|
683
701
|
throw error;
|
|
684
702
|
}
|
|
685
703
|
}
|
|
@@ -720,7 +738,7 @@ var StreamableHttpTransport = class {
|
|
|
720
738
|
error: {
|
|
721
739
|
code: -32700,
|
|
722
740
|
message: "Parse error",
|
|
723
|
-
data: error
|
|
741
|
+
data: errorMessage(error)
|
|
724
742
|
}
|
|
725
743
|
};
|
|
726
744
|
}
|
|
@@ -1090,7 +1108,7 @@ var StreamableHttpTransport = class {
|
|
|
1090
1108
|
if (this.config.clientInfo) {
|
|
1091
1109
|
headers["User-Agent"] = `${this.config.clientInfo.name}/${this.config.clientInfo.version}`;
|
|
1092
1110
|
}
|
|
1093
|
-
if (this.authToken
|
|
1111
|
+
if (this.authToken) {
|
|
1094
1112
|
headers["Authorization"] = `Bearer ${this.authToken}`;
|
|
1095
1113
|
}
|
|
1096
1114
|
if (this.sessionId) {
|
|
@@ -1413,6 +1431,16 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1413
1431
|
name: "@frontmcp/testing",
|
|
1414
1432
|
version: "0.4.0"
|
|
1415
1433
|
};
|
|
1434
|
+
var MAX_LIST_PAGES = 1e3;
|
|
1435
|
+
function isAuthFailure(error) {
|
|
1436
|
+
return typeof error?.message === "string" && /^HTTP (401|403)\b/.test(error.message);
|
|
1437
|
+
}
|
|
1438
|
+
function describeInitializeError(error) {
|
|
1439
|
+
const message = error?.message ?? "Unknown error";
|
|
1440
|
+
const data = error?.data;
|
|
1441
|
+
const detail = typeof data === "string" ? data.trim() : "";
|
|
1442
|
+
return detail ? `${message} - ${detail.slice(0, 500)}` : message;
|
|
1443
|
+
}
|
|
1416
1444
|
function extractEntryPaths(data) {
|
|
1417
1445
|
let payload = data;
|
|
1418
1446
|
if (typeof payload === "string") {
|
|
@@ -1499,19 +1527,22 @@ var McpTestClient = class {
|
|
|
1499
1527
|
async connect() {
|
|
1500
1528
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1501
1529
|
this.triedDiscoveredEntryPath = false;
|
|
1530
|
+
this.initResult = null;
|
|
1502
1531
|
this.transport = this.createTransport();
|
|
1503
1532
|
await this.transport.connect();
|
|
1504
1533
|
const maxRetries = 3;
|
|
1505
1534
|
const retryDelayMs = 500;
|
|
1506
1535
|
let lastError;
|
|
1507
1536
|
let reportedEntryPaths;
|
|
1537
|
+
let attemptsMade = 0;
|
|
1508
1538
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1539
|
+
attemptsMade = attempt;
|
|
1509
1540
|
const initResponse = await this.initialize();
|
|
1510
1541
|
if (initResponse.success && initResponse.data) {
|
|
1511
1542
|
this.initResult = initResponse.data;
|
|
1512
1543
|
break;
|
|
1513
1544
|
}
|
|
1514
|
-
lastError = initResponse.error
|
|
1545
|
+
lastError = describeInitializeError(initResponse.error);
|
|
1515
1546
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1516
1547
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1517
1548
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -1523,6 +1554,7 @@ var McpTestClient = class {
|
|
|
1523
1554
|
await this.transport.connect();
|
|
1524
1555
|
continue;
|
|
1525
1556
|
}
|
|
1557
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
1526
1558
|
if (attempt < maxRetries) {
|
|
1527
1559
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1528
1560
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -1531,7 +1563,7 @@ var McpTestClient = class {
|
|
|
1531
1563
|
if (!this.initResult) {
|
|
1532
1564
|
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.` : "";
|
|
1533
1565
|
throw new Error(
|
|
1534
|
-
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${
|
|
1566
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${attemptsMade} attempt${attemptsMade === 1 ? "" : "s"}: ${lastError}.${served}`
|
|
1535
1567
|
);
|
|
1536
1568
|
}
|
|
1537
1569
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -1601,18 +1633,47 @@ var McpTestClient = class {
|
|
|
1601
1633
|
return this._authState;
|
|
1602
1634
|
}
|
|
1603
1635
|
/**
|
|
1604
|
-
* Authenticate with a token
|
|
1636
|
+
* Authenticate with a token.
|
|
1637
|
+
*
|
|
1638
|
+
* When connected, this opens a NEW session with the token (the previous session was created
|
|
1639
|
+
* for the previous identity) and rejects if the server refuses it — for example because the
|
|
1640
|
+
* token is expired or badly signed. On rejection the client keeps its previous identity and
|
|
1641
|
+
* session. The token is kept in the client config, so `reconnect()` keeps using it.
|
|
1605
1642
|
*/
|
|
1606
1643
|
async authenticate(token) {
|
|
1644
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
1645
|
+
throw new Error("authenticate() requires a non-empty token");
|
|
1646
|
+
}
|
|
1647
|
+
const previousAuthState = this._authState;
|
|
1648
|
+
const previousConfigAuth = this.config.auth;
|
|
1649
|
+
this.config.auth = { ...previousConfigAuth, token };
|
|
1607
1650
|
this._authState = {
|
|
1608
1651
|
isAnonymous: false,
|
|
1609
1652
|
token,
|
|
1610
1653
|
scopes: this.parseScopesFromToken(token),
|
|
1611
1654
|
user: this.parseUserFromToken(token)
|
|
1612
1655
|
};
|
|
1613
|
-
|
|
1614
|
-
|
|
1656
|
+
const previousTransport = this.transport;
|
|
1657
|
+
if (!previousTransport) {
|
|
1658
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
1659
|
+
return;
|
|
1615
1660
|
}
|
|
1661
|
+
const previousInit = this.initResult;
|
|
1662
|
+
const previousSessionId = this._sessionId;
|
|
1663
|
+
const previousSessionInfo = this._sessionInfo;
|
|
1664
|
+
try {
|
|
1665
|
+
await this.connect();
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
await this.transport?.close().catch(() => void 0);
|
|
1668
|
+
this.transport = previousTransport;
|
|
1669
|
+
this.initResult = previousInit;
|
|
1670
|
+
this._sessionId = previousSessionId;
|
|
1671
|
+
this._sessionInfo = previousSessionInfo;
|
|
1672
|
+
this.config.auth = previousConfigAuth;
|
|
1673
|
+
this._authState = previousAuthState;
|
|
1674
|
+
throw err;
|
|
1675
|
+
}
|
|
1676
|
+
await previousTransport.close().catch(() => void 0);
|
|
1616
1677
|
this.log("debug", "Authentication updated");
|
|
1617
1678
|
}
|
|
1618
1679
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -1646,13 +1707,12 @@ var McpTestClient = class {
|
|
|
1646
1707
|
/**
|
|
1647
1708
|
* List all available tools
|
|
1648
1709
|
*/
|
|
1649
|
-
list: async () =>
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
},
|
|
1710
|
+
list: async () => this.listAllPages(
|
|
1711
|
+
"tools/list",
|
|
1712
|
+
"tools",
|
|
1713
|
+
(cursor) => this.listTools(cursor),
|
|
1714
|
+
(page) => page.tools
|
|
1715
|
+
),
|
|
1656
1716
|
/**
|
|
1657
1717
|
* Call a tool by name with arguments
|
|
1658
1718
|
*/
|
|
@@ -1668,23 +1728,21 @@ var McpTestClient = class {
|
|
|
1668
1728
|
/**
|
|
1669
1729
|
* List all static resources
|
|
1670
1730
|
*/
|
|
1671
|
-
list: async () =>
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
},
|
|
1731
|
+
list: async () => this.listAllPages(
|
|
1732
|
+
"resources/list",
|
|
1733
|
+
"resources",
|
|
1734
|
+
(cursor) => this.listResources(cursor),
|
|
1735
|
+
(page) => page.resources
|
|
1736
|
+
),
|
|
1678
1737
|
/**
|
|
1679
1738
|
* List all resource templates
|
|
1680
1739
|
*/
|
|
1681
|
-
listTemplates: async () =>
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
},
|
|
1740
|
+
listTemplates: async () => this.listAllPages(
|
|
1741
|
+
"resources/templates/list",
|
|
1742
|
+
"resource templates",
|
|
1743
|
+
(cursor) => this.listResourceTemplates(cursor),
|
|
1744
|
+
(page) => page.resourceTemplates
|
|
1745
|
+
),
|
|
1688
1746
|
/**
|
|
1689
1747
|
* Read a resource by URI
|
|
1690
1748
|
*/
|
|
@@ -1712,13 +1770,12 @@ var McpTestClient = class {
|
|
|
1712
1770
|
/**
|
|
1713
1771
|
* List all available prompts
|
|
1714
1772
|
*/
|
|
1715
|
-
list: async () =>
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
},
|
|
1773
|
+
list: async () => this.listAllPages(
|
|
1774
|
+
"prompts/list",
|
|
1775
|
+
"prompts",
|
|
1776
|
+
(cursor) => this.listPrompts(cursor),
|
|
1777
|
+
(page) => page.prompts
|
|
1778
|
+
),
|
|
1722
1779
|
/**
|
|
1723
1780
|
* Get a prompt with arguments
|
|
1724
1781
|
*/
|
|
@@ -2102,8 +2159,33 @@ var McpTestClient = class {
|
|
|
2102
2159
|
clientInfo: this.config.clientInfo
|
|
2103
2160
|
});
|
|
2104
2161
|
}
|
|
2105
|
-
async listTools() {
|
|
2106
|
-
return this.request("tools/list", {});
|
|
2162
|
+
async listTools(cursor) {
|
|
2163
|
+
return this.request("tools/list", cursor ? { cursor } : {});
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Follow `nextCursor` until the server stops returning one, concatenating the
|
|
2167
|
+
* pages. A server that hands back a cursor it already returned, or never stops
|
|
2168
|
+
* paging, fails the call rather than looping.
|
|
2169
|
+
*/
|
|
2170
|
+
async listAllPages(method, label, fetchPage, itemsOf) {
|
|
2171
|
+
const items = [];
|
|
2172
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
2173
|
+
let cursor;
|
|
2174
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
2175
|
+
const response = await fetchPage(cursor);
|
|
2176
|
+
if (!response.success || !response.data) {
|
|
2177
|
+
throw new Error(`Failed to list ${label}: ${response.error?.message}`);
|
|
2178
|
+
}
|
|
2179
|
+
items.push(...itemsOf(response.data) ?? []);
|
|
2180
|
+
const next = response.data.nextCursor;
|
|
2181
|
+
if (!next) return items;
|
|
2182
|
+
if (seenCursors.has(next)) {
|
|
2183
|
+
throw new Error(`Failed to list ${label}: ${method} returned the cursor "${next}" twice`);
|
|
2184
|
+
}
|
|
2185
|
+
seenCursors.add(next);
|
|
2186
|
+
cursor = next;
|
|
2187
|
+
}
|
|
2188
|
+
throw new Error(`Failed to list ${label}: ${method} did not finish within ${MAX_LIST_PAGES} pages`);
|
|
2107
2189
|
}
|
|
2108
2190
|
async callTool(name, args, progressToken) {
|
|
2109
2191
|
return this.request("tools/call", {
|
|
@@ -2128,17 +2210,17 @@ var McpTestClient = class {
|
|
|
2128
2210
|
timestamp
|
|
2129
2211
|
});
|
|
2130
2212
|
};
|
|
2131
|
-
async listResources() {
|
|
2132
|
-
return this.request("resources/list", {});
|
|
2213
|
+
async listResources(cursor) {
|
|
2214
|
+
return this.request("resources/list", cursor ? { cursor } : {});
|
|
2133
2215
|
}
|
|
2134
|
-
async listResourceTemplates() {
|
|
2135
|
-
return this.request("resources/templates/list", {});
|
|
2216
|
+
async listResourceTemplates(cursor) {
|
|
2217
|
+
return this.request("resources/templates/list", cursor ? { cursor } : {});
|
|
2136
2218
|
}
|
|
2137
2219
|
async readResource(uri) {
|
|
2138
2220
|
return this.request("resources/read", { uri });
|
|
2139
2221
|
}
|
|
2140
|
-
async listPrompts() {
|
|
2141
|
-
return this.request("prompts/list", {});
|
|
2222
|
+
async listPrompts(cursor) {
|
|
2223
|
+
return this.request("prompts/list", cursor ? { cursor } : {});
|
|
2142
2224
|
}
|
|
2143
2225
|
async getPrompt(name, args) {
|
|
2144
2226
|
return this.request("prompts/get", {
|
|
@@ -2215,10 +2297,11 @@ var McpTestClient = class {
|
|
|
2215
2297
|
requestId: id
|
|
2216
2298
|
};
|
|
2217
2299
|
} catch (err) {
|
|
2300
|
+
if (isInterceptedError(err)) throw err;
|
|
2218
2301
|
const durationMs = Date.now() - start;
|
|
2219
2302
|
const error = {
|
|
2220
2303
|
code: -32603,
|
|
2221
|
-
message: err
|
|
2304
|
+
message: errorMessage(err)
|
|
2222
2305
|
};
|
|
2223
2306
|
return {
|
|
2224
2307
|
success: false,
|
|
@@ -2366,12 +2449,12 @@ var McpTestClient = class {
|
|
|
2366
2449
|
const payload = this.decodeJwtPayload(token);
|
|
2367
2450
|
if (!payload) return [];
|
|
2368
2451
|
const scope = payload["scope"];
|
|
2369
|
-
const
|
|
2452
|
+
const scopes2 = payload["scopes"];
|
|
2370
2453
|
if (typeof scope === "string") {
|
|
2371
2454
|
return scope.split(" ");
|
|
2372
2455
|
}
|
|
2373
|
-
if (Array.isArray(
|
|
2374
|
-
return
|
|
2456
|
+
if (Array.isArray(scopes2)) {
|
|
2457
|
+
return scopes2;
|
|
2375
2458
|
}
|
|
2376
2459
|
return [];
|
|
2377
2460
|
} catch {
|
|
@@ -2446,7 +2529,7 @@ var ProgressCollector = class {
|
|
|
2446
2529
|
};
|
|
2447
2530
|
|
|
2448
2531
|
// libs/testing/src/server/test-server.ts
|
|
2449
|
-
import { spawn } from "child_process";
|
|
2532
|
+
import { execFile, spawn } from "child_process";
|
|
2450
2533
|
import { sha256Hex } from "@frontmcp/utils";
|
|
2451
2534
|
|
|
2452
2535
|
// libs/testing/src/errors/index.ts
|
|
@@ -2517,6 +2600,8 @@ var E2E_PORT_RANGES = {
|
|
|
2517
2600
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
2518
2601
|
// Protocol revision E2E tests (50460-50479)
|
|
2519
2602
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
2603
|
+
// Testing library E2E tests (50480-50499)
|
|
2604
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
2520
2605
|
// Mock servers and utilities (50900-50999)
|
|
2521
2606
|
"mock-oauth": { start: 50900, size: 10 },
|
|
2522
2607
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -2525,6 +2610,66 @@ var E2E_PORT_RANGES = {
|
|
|
2525
2610
|
default: { start: 51e3, size: 100 }
|
|
2526
2611
|
};
|
|
2527
2612
|
var reservedPorts = /* @__PURE__ */ new Map();
|
|
2613
|
+
var lockedPorts = /* @__PURE__ */ new Set();
|
|
2614
|
+
var LOCK_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
2615
|
+
function lockDir() {
|
|
2616
|
+
try {
|
|
2617
|
+
const os = __require("node:os");
|
|
2618
|
+
const path = __require("node:path");
|
|
2619
|
+
return path.join(os.tmpdir(), "frontmcp-testing-ports");
|
|
2620
|
+
} catch {
|
|
2621
|
+
return void 0;
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
function pidIsAlive(pid) {
|
|
2625
|
+
try {
|
|
2626
|
+
process.kill(pid, 0);
|
|
2627
|
+
return true;
|
|
2628
|
+
} catch (error) {
|
|
2629
|
+
return error.code === "EPERM";
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
function acquirePortLock(port) {
|
|
2633
|
+
if (lockedPorts.has(port)) return false;
|
|
2634
|
+
let fs;
|
|
2635
|
+
let file;
|
|
2636
|
+
try {
|
|
2637
|
+
fs = __require("node:fs");
|
|
2638
|
+
const dir = lockDir();
|
|
2639
|
+
if (!dir) return true;
|
|
2640
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2641
|
+
file = __require("node:path").join(dir, `${port}.lock`);
|
|
2642
|
+
} catch {
|
|
2643
|
+
return true;
|
|
2644
|
+
}
|
|
2645
|
+
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
2646
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2647
|
+
try {
|
|
2648
|
+
fs.writeFileSync(file, payload, { flag: "wx" });
|
|
2649
|
+
lockedPorts.add(port);
|
|
2650
|
+
return true;
|
|
2651
|
+
} catch (error) {
|
|
2652
|
+
if (error.code !== "EEXIST") return true;
|
|
2653
|
+
try {
|
|
2654
|
+
const held = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2655
|
+
const fresh = typeof held.at === "number" && Date.now() - held.at < LOCK_MAX_AGE_MS;
|
|
2656
|
+
if (fresh && typeof held.pid === "number" && pidIsAlive(held.pid)) return false;
|
|
2657
|
+
fs.unlinkSync(file);
|
|
2658
|
+
} catch {
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
return false;
|
|
2663
|
+
}
|
|
2664
|
+
function releasePortLock(port) {
|
|
2665
|
+
if (!lockedPorts.delete(port)) return;
|
|
2666
|
+
try {
|
|
2667
|
+
const fs = __require("node:fs");
|
|
2668
|
+
const dir = lockDir();
|
|
2669
|
+
if (dir) fs.unlinkSync(__require("node:path").join(dir, `${port}.lock`));
|
|
2670
|
+
} catch {
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2528
2673
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
2529
2674
|
function getPortRange(project) {
|
|
2530
2675
|
const key = project;
|
|
@@ -2535,15 +2680,22 @@ function getPortRange(project) {
|
|
|
2535
2680
|
}
|
|
2536
2681
|
async function reservePort(project, preferredPort) {
|
|
2537
2682
|
const range = getPortRange(project);
|
|
2538
|
-
|
|
2683
|
+
const handle = (port) => ({
|
|
2684
|
+
port,
|
|
2685
|
+
// Frees the socket only, so the server about to be spawned can bind the port; the lock file stays
|
|
2686
|
+
releaseSocket: async () => {
|
|
2687
|
+
await releasePort(port);
|
|
2688
|
+
},
|
|
2689
|
+
release: async () => {
|
|
2690
|
+
await releasePort(port);
|
|
2691
|
+
releasePortLock(port);
|
|
2692
|
+
}
|
|
2693
|
+
});
|
|
2694
|
+
const wantsSpecificPort = preferredPort !== void 0 && Number.isInteger(preferredPort) && preferredPort > 0;
|
|
2695
|
+
if (wantsSpecificPort) {
|
|
2539
2696
|
const reservation = await tryReservePort(preferredPort, project);
|
|
2540
2697
|
if (reservation) {
|
|
2541
|
-
return
|
|
2542
|
-
port: preferredPort,
|
|
2543
|
-
release: async () => {
|
|
2544
|
-
await releasePort(preferredPort);
|
|
2545
|
-
}
|
|
2546
|
-
};
|
|
2698
|
+
return handle(preferredPort);
|
|
2547
2699
|
}
|
|
2548
2700
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
2549
2701
|
}
|
|
@@ -2552,30 +2704,19 @@ async function reservePort(project, preferredPort) {
|
|
|
2552
2704
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2553
2705
|
const port = range.start + index % range.size;
|
|
2554
2706
|
index = (index + 1) % range.size;
|
|
2555
|
-
if (reservedPorts.has(port)) {
|
|
2707
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
2556
2708
|
continue;
|
|
2557
2709
|
}
|
|
2558
2710
|
const reservation = await tryReservePort(port, project);
|
|
2559
2711
|
if (reservation) {
|
|
2560
2712
|
projectPortIndex.set(project, index);
|
|
2561
|
-
return
|
|
2562
|
-
port,
|
|
2563
|
-
release: async () => {
|
|
2564
|
-
await releasePort(port);
|
|
2565
|
-
}
|
|
2566
|
-
};
|
|
2713
|
+
return handle(port);
|
|
2567
2714
|
}
|
|
2568
2715
|
}
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
return {
|
|
2574
|
-
port: dynamicPort,
|
|
2575
|
-
release: async () => {
|
|
2576
|
-
await releasePort(dynamicPort);
|
|
2577
|
-
}
|
|
2578
|
-
};
|
|
2716
|
+
for (let port = 51e3; port < 52e3; port++) {
|
|
2717
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) continue;
|
|
2718
|
+
if (await tryReservePort(port, project)) {
|
|
2719
|
+
return handle(port);
|
|
2579
2720
|
}
|
|
2580
2721
|
}
|
|
2581
2722
|
throw new Error(
|
|
@@ -2583,9 +2724,13 @@ async function reservePort(project, preferredPort) {
|
|
|
2583
2724
|
);
|
|
2584
2725
|
}
|
|
2585
2726
|
async function tryReservePort(port, project) {
|
|
2727
|
+
if (!acquirePortLock(port)) {
|
|
2728
|
+
return false;
|
|
2729
|
+
}
|
|
2586
2730
|
return new Promise((resolve) => {
|
|
2587
2731
|
const server = createServer();
|
|
2588
2732
|
server.once("error", () => {
|
|
2733
|
+
releasePortLock(port);
|
|
2589
2734
|
resolve(false);
|
|
2590
2735
|
});
|
|
2591
2736
|
server.listen(port, () => {
|
|
@@ -2611,31 +2756,6 @@ async function releasePort(port) {
|
|
|
2611
2756
|
});
|
|
2612
2757
|
});
|
|
2613
2758
|
}
|
|
2614
|
-
async function findAvailablePortInRange(start, end) {
|
|
2615
|
-
for (let port = start; port < end; port++) {
|
|
2616
|
-
if (reservedPorts.has(port)) {
|
|
2617
|
-
continue;
|
|
2618
|
-
}
|
|
2619
|
-
const available = await isPortAvailable(port);
|
|
2620
|
-
if (available) {
|
|
2621
|
-
return port;
|
|
2622
|
-
}
|
|
2623
|
-
}
|
|
2624
|
-
return null;
|
|
2625
|
-
}
|
|
2626
|
-
async function isPortAvailable(port) {
|
|
2627
|
-
return new Promise((resolve) => {
|
|
2628
|
-
const server = createServer();
|
|
2629
|
-
server.once("error", () => {
|
|
2630
|
-
resolve(false);
|
|
2631
|
-
});
|
|
2632
|
-
server.listen(port, () => {
|
|
2633
|
-
server.close(() => {
|
|
2634
|
-
resolve(true);
|
|
2635
|
-
});
|
|
2636
|
-
});
|
|
2637
|
-
});
|
|
2638
|
-
}
|
|
2639
2759
|
|
|
2640
2760
|
// libs/testing/src/server/test-server.ts
|
|
2641
2761
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -2644,8 +2764,11 @@ var TestServer = class _TestServer {
|
|
|
2644
2764
|
options;
|
|
2645
2765
|
_info;
|
|
2646
2766
|
logs = [];
|
|
2767
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
2647
2768
|
portRelease = null;
|
|
2648
|
-
|
|
2769
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
2770
|
+
portReleaseSocket = null;
|
|
2771
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
2649
2772
|
this.options = {
|
|
2650
2773
|
port,
|
|
2651
2774
|
project: options.project,
|
|
@@ -2657,6 +2780,7 @@ var TestServer = class _TestServer {
|
|
|
2657
2780
|
debug: options.debug ?? DEBUG_SERVER
|
|
2658
2781
|
};
|
|
2659
2782
|
this.portRelease = portRelease ?? null;
|
|
2783
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
2660
2784
|
this._info = {
|
|
2661
2785
|
baseUrl: `http://localhost:${port}`,
|
|
2662
2786
|
port
|
|
@@ -2669,8 +2793,8 @@ var TestServer = class _TestServer {
|
|
|
2669
2793
|
const project = options.project ?? "default";
|
|
2670
2794
|
const maxAttempts = 3;
|
|
2671
2795
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2672
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2673
|
-
const server = new _TestServer(options, port, release);
|
|
2796
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2797
|
+
const server = new _TestServer(options, port, release, releaseSocket);
|
|
2674
2798
|
try {
|
|
2675
2799
|
await server.startProcess();
|
|
2676
2800
|
return server;
|
|
@@ -2710,7 +2834,7 @@ var TestServer = class _TestServer {
|
|
|
2710
2834
|
}
|
|
2711
2835
|
const maxAttempts = 3;
|
|
2712
2836
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2713
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2837
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2714
2838
|
const serverOptions = {
|
|
2715
2839
|
...options,
|
|
2716
2840
|
port,
|
|
@@ -2718,7 +2842,7 @@ var TestServer = class _TestServer {
|
|
|
2718
2842
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2719
2843
|
cwd: options.cwd ?? process.cwd()
|
|
2720
2844
|
};
|
|
2721
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
2845
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
2722
2846
|
try {
|
|
2723
2847
|
await server.startProcess();
|
|
2724
2848
|
return server;
|
|
@@ -2776,10 +2900,17 @@ var TestServer = class _TestServer {
|
|
|
2776
2900
|
* Stop the test server
|
|
2777
2901
|
*/
|
|
2778
2902
|
async stop() {
|
|
2779
|
-
|
|
2780
|
-
await this.
|
|
2781
|
-
|
|
2903
|
+
try {
|
|
2904
|
+
await this.stopProcess();
|
|
2905
|
+
} finally {
|
|
2906
|
+
if (this.portRelease) {
|
|
2907
|
+
await this.portRelease();
|
|
2908
|
+
this.portRelease = null;
|
|
2909
|
+
}
|
|
2910
|
+
this.portReleaseSocket = null;
|
|
2782
2911
|
}
|
|
2912
|
+
}
|
|
2913
|
+
async stopProcess() {
|
|
2783
2914
|
if (this.process) {
|
|
2784
2915
|
this.log("Stopping server...");
|
|
2785
2916
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -2852,7 +2983,7 @@ var TestServer = class _TestServer {
|
|
|
2852
2983
|
* Restart the server
|
|
2853
2984
|
*/
|
|
2854
2985
|
async restart() {
|
|
2855
|
-
await this.
|
|
2986
|
+
await this.stopProcess();
|
|
2856
2987
|
await this.startProcess();
|
|
2857
2988
|
}
|
|
2858
2989
|
/**
|
|
@@ -2882,9 +3013,9 @@ var TestServer = class _TestServer {
|
|
|
2882
3013
|
PORT: String(this.options.port)
|
|
2883
3014
|
};
|
|
2884
3015
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
2885
|
-
if (this.
|
|
2886
|
-
await this.
|
|
2887
|
-
this.
|
|
3016
|
+
if (this.portReleaseSocket) {
|
|
3017
|
+
await this.portReleaseSocket();
|
|
3018
|
+
this.portReleaseSocket = null;
|
|
2888
3019
|
await sleep2(300);
|
|
2889
3020
|
}
|
|
2890
3021
|
this.process = spawn(this.options.command, [], {
|
|
@@ -2894,9 +3025,8 @@ var TestServer = class _TestServer {
|
|
|
2894
3025
|
detached: true,
|
|
2895
3026
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2896
3027
|
});
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
}
|
|
3028
|
+
this._info.pid = this.process.pid;
|
|
3029
|
+
this._info.shellPid = this.process.pid;
|
|
2900
3030
|
let processExited = false;
|
|
2901
3031
|
let exitCode = null;
|
|
2902
3032
|
let exitError = null;
|
|
@@ -2999,6 +3129,8 @@ ${allLogs}`);
|
|
|
2999
3129
|
});
|
|
3000
3130
|
if (response.ok || response.status === 404) {
|
|
3001
3131
|
this.log(`Server is ready after ${healthCheckAttempts} health check attempts`);
|
|
3132
|
+
const listeningPid = await findListeningPid(this.options.port);
|
|
3133
|
+
if (listeningPid !== void 0) this._info.pid = listeningPid;
|
|
3002
3134
|
return;
|
|
3003
3135
|
}
|
|
3004
3136
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3043,6 +3175,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3043
3175
|
function sleep2(ms) {
|
|
3044
3176
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3045
3177
|
}
|
|
3178
|
+
function findListeningPid(port) {
|
|
3179
|
+
if (process.platform === "win32") return Promise.resolve(void 0);
|
|
3180
|
+
return new Promise((resolve) => {
|
|
3181
|
+
execFile("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeout: 2e3 }, (error, stdout) => {
|
|
3182
|
+
if (error) {
|
|
3183
|
+
resolve(void 0);
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
const pid = parseInt(String(stdout).split("\n")[0] ?? "", 10);
|
|
3187
|
+
resolve(Number.isInteger(pid) && pid > 0 ? pid : void 0);
|
|
3188
|
+
});
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3046
3191
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3047
3192
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3048
3193
|
return env;
|
|
@@ -3159,81 +3304,152 @@ function gatewayTokenBinding(serverUrl, entryPath) {
|
|
|
3159
3304
|
}
|
|
3160
3305
|
|
|
3161
3306
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3162
|
-
var
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3307
|
+
var jestApi;
|
|
3308
|
+
function jestGlobals() {
|
|
3309
|
+
if (jestApi) return jestApi;
|
|
3310
|
+
try {
|
|
3311
|
+
const api = __require("@jest/globals");
|
|
3312
|
+
jestApi = {
|
|
3313
|
+
describe: api["describe"],
|
|
3314
|
+
beforeAll: api["beforeAll"],
|
|
3315
|
+
beforeEach: api["beforeEach"],
|
|
3316
|
+
afterEach: api["afterEach"],
|
|
3317
|
+
afterAll: api["afterAll"],
|
|
3318
|
+
it: api["it"]
|
|
3319
|
+
};
|
|
3320
|
+
return jestApi;
|
|
3321
|
+
} catch (error) {
|
|
3322
|
+
throw new Error(
|
|
3323
|
+
`@frontmcp/testing fixtures ("test", "test.use", ...) can only be used inside a Jest test file: ${error instanceof Error ? error.message : String(error)}`,
|
|
3324
|
+
{ cause: error }
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3175
3327
|
}
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3328
|
+
var scopes = [{ skipped: false, config: {}, configured: false, cleanupRegistered: false }];
|
|
3329
|
+
function currentScope() {
|
|
3330
|
+
return scopes[scopes.length - 1];
|
|
3331
|
+
}
|
|
3332
|
+
var serverEntries = /* @__PURE__ */ new Map();
|
|
3333
|
+
function resolveConfig(chain) {
|
|
3334
|
+
const merged = {};
|
|
3335
|
+
for (const scope of chain) {
|
|
3336
|
+
const { env, ...rest } = scope.config;
|
|
3337
|
+
Object.assign(merged, rest);
|
|
3338
|
+
if (env) merged.env = { ...merged.env, ...env };
|
|
3339
|
+
}
|
|
3340
|
+
return merged;
|
|
3341
|
+
}
|
|
3342
|
+
function ownerScopeOf(chain) {
|
|
3343
|
+
for (let i = chain.length - 1; i > 0; i--) {
|
|
3344
|
+
if (chain[i].configured) return chain[i];
|
|
3345
|
+
}
|
|
3346
|
+
return chain[0];
|
|
3347
|
+
}
|
|
3348
|
+
function serverKey(config) {
|
|
3349
|
+
const env = Object.fromEntries(Object.entries(config.env ?? {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
3350
|
+
return JSON.stringify({
|
|
3351
|
+
server: config.server,
|
|
3352
|
+
baseUrl: config.baseUrl,
|
|
3353
|
+
project: config.project,
|
|
3354
|
+
port: config.port,
|
|
3355
|
+
entryPath: config.entryPath,
|
|
3356
|
+
startupTimeout: config.startupTimeout,
|
|
3357
|
+
logLevel: config.logLevel,
|
|
3358
|
+
auth: config.auth,
|
|
3359
|
+
env
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
function serverEnv(config) {
|
|
3363
|
+
const extra = {};
|
|
3364
|
+
if (config.auth?.mode) extra["FRONTMCP_TEST_AUTH_MODE"] = config.auth.mode;
|
|
3365
|
+
if (config.auth?.type) extra["FRONTMCP_TEST_AUTH_TYPE"] = config.auth.type;
|
|
3366
|
+
if (Object.keys(extra).length === 0 && !config.env) return void 0;
|
|
3367
|
+
return { ...extra, ...config.env };
|
|
3368
|
+
}
|
|
3369
|
+
async function startServerEntry(config, owner) {
|
|
3370
|
+
let server;
|
|
3371
|
+
let startedByUs = false;
|
|
3372
|
+
if (config.baseUrl && !config.server) {
|
|
3373
|
+
server = TestServer.connect(config.baseUrl);
|
|
3374
|
+
} else if (config.server) {
|
|
3375
|
+
const serverCommand = resolveServerCommand(config.server);
|
|
3376
|
+
const isDebug = config.logLevel === "debug" || process.env["DEBUG"] === "1" || process.env["DEBUG_SERVER"] === "1";
|
|
3377
|
+
if (isDebug) {
|
|
3378
|
+
console.log(`[TestFixture] Starting server: ${serverCommand}`);
|
|
3379
|
+
}
|
|
3380
|
+
try {
|
|
3381
|
+
server = await TestServer.start({
|
|
3382
|
+
project: config.project,
|
|
3383
|
+
port: config.port,
|
|
3384
|
+
command: serverCommand,
|
|
3385
|
+
env: serverEnv(config),
|
|
3386
|
+
startupTimeout: config.startupTimeout ?? 3e4,
|
|
3387
|
+
debug: isDebug
|
|
3388
|
+
});
|
|
3389
|
+
startedByUs = true;
|
|
3184
3390
|
if (isDebug) {
|
|
3185
|
-
console.log(`[TestFixture]
|
|
3391
|
+
console.log(`[TestFixture] Server started at ${server.info.baseUrl}`);
|
|
3186
3392
|
}
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
command: serverCommand,
|
|
3192
|
-
env: currentConfig.env,
|
|
3193
|
-
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
3194
|
-
debug: isDebug
|
|
3195
|
-
});
|
|
3196
|
-
serverStartedByUs = true;
|
|
3197
|
-
if (isDebug) {
|
|
3198
|
-
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
3199
|
-
}
|
|
3200
|
-
} catch (error) {
|
|
3201
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3202
|
-
throw new Error(
|
|
3203
|
-
`Failed to start test server.
|
|
3393
|
+
} catch (error) {
|
|
3394
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3395
|
+
throw new Error(
|
|
3396
|
+
`Failed to start test server.
|
|
3204
3397
|
|
|
3205
|
-
Server entry: ${
|
|
3206
|
-
Project: ${
|
|
3398
|
+
Server entry: ${config.server}
|
|
3399
|
+
Project: ${config.project ?? "default"}
|
|
3207
3400
|
Command: ${serverCommand}
|
|
3208
3401
|
|
|
3209
|
-
Error: ${errMsg}
|
|
3210
|
-
|
|
3211
|
-
}
|
|
3212
|
-
} else {
|
|
3213
|
-
throw new Error(
|
|
3214
|
-
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
3402
|
+
Error: ${errMsg}`,
|
|
3403
|
+
{ cause: error }
|
|
3215
3404
|
);
|
|
3216
3405
|
}
|
|
3406
|
+
} else {
|
|
3407
|
+
throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option');
|
|
3217
3408
|
}
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
}
|
|
3409
|
+
const sharedSecret = config.env?.["JWT_SECRET"];
|
|
3410
|
+
const binding = sharedSecret ? gatewayTokenBinding(config.baseUrl ?? server.info.baseUrl, config.entryPath) : void 0;
|
|
3411
|
+
const tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret, ...binding } : {});
|
|
3412
|
+
return { server, startedByUs, tokenFactory, owner };
|
|
3223
3413
|
}
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3414
|
+
function acquireServer(chain) {
|
|
3415
|
+
const config = resolveConfig(chain);
|
|
3416
|
+
const key = serverKey(config);
|
|
3417
|
+
let entry = serverEntries.get(key);
|
|
3418
|
+
if (!entry) {
|
|
3419
|
+
entry = startServerEntry(config, ownerScopeOf(chain));
|
|
3420
|
+
serverEntries.set(key, entry);
|
|
3421
|
+
entry.catch(() => serverEntries.delete(key));
|
|
3422
|
+
}
|
|
3423
|
+
return entry;
|
|
3424
|
+
}
|
|
3425
|
+
async function stopServers(owner) {
|
|
3426
|
+
for (const [key, pending] of Array.from(serverEntries)) {
|
|
3427
|
+
let entry;
|
|
3428
|
+
try {
|
|
3429
|
+
entry = await pending;
|
|
3430
|
+
} catch {
|
|
3431
|
+
serverEntries.delete(key);
|
|
3432
|
+
continue;
|
|
3433
|
+
}
|
|
3434
|
+
if (owner && entry.owner !== owner) continue;
|
|
3435
|
+
serverEntries.delete(key);
|
|
3436
|
+
if (entry.startedByUs) {
|
|
3437
|
+
await entry.server.stop();
|
|
3438
|
+
}
|
|
3231
3439
|
}
|
|
3440
|
+
}
|
|
3441
|
+
var fixtureContexts = /* @__PURE__ */ new WeakMap();
|
|
3442
|
+
async function buildFixtures(chain) {
|
|
3443
|
+
const config = resolveConfig(chain);
|
|
3444
|
+
const entry = await acquireServer(chain);
|
|
3445
|
+
const { server, tokenFactory } = entry;
|
|
3446
|
+
const publicMode = config.publicMode ?? config.auth?.mode === "public";
|
|
3447
|
+
const clientConfig = { ...config, publicMode };
|
|
3232
3448
|
const clientInstance = McpTestClient.create({
|
|
3233
|
-
baseUrl: resolveClientBaseUrl(
|
|
3234
|
-
entryPath:
|
|
3235
|
-
transport:
|
|
3236
|
-
publicMode
|
|
3449
|
+
baseUrl: resolveClientBaseUrl(server, clientConfig),
|
|
3450
|
+
entryPath: clientConfig.entryPath,
|
|
3451
|
+
transport: clientConfig.transport ?? "streamable-http",
|
|
3452
|
+
publicMode
|
|
3237
3453
|
}).build();
|
|
3238
3454
|
try {
|
|
3239
3455
|
await clientInstance.connect();
|
|
@@ -3247,17 +3463,24 @@ async function createTestFixtures() {
|
|
|
3247
3463
|
throw err;
|
|
3248
3464
|
}
|
|
3249
3465
|
}
|
|
3250
|
-
const
|
|
3251
|
-
const
|
|
3252
|
-
return {
|
|
3466
|
+
const context = { extraClients: [] };
|
|
3467
|
+
const fixtures = {
|
|
3253
3468
|
mcp: clientInstance,
|
|
3254
|
-
auth,
|
|
3255
|
-
server
|
|
3469
|
+
auth: createAuthFixture(tokenFactory),
|
|
3470
|
+
server: createServerFixture(server, clientConfig, clientInstance, context)
|
|
3256
3471
|
};
|
|
3472
|
+
fixtureContexts.set(fixtures, context);
|
|
3473
|
+
return fixtures;
|
|
3474
|
+
}
|
|
3475
|
+
async function createTestFixtures() {
|
|
3476
|
+
return buildFixtures(scopes.slice());
|
|
3477
|
+
}
|
|
3478
|
+
async function initializeSharedResources() {
|
|
3479
|
+
await acquireServer(scopes.slice());
|
|
3257
3480
|
}
|
|
3258
3481
|
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
3259
|
-
if (testFailed
|
|
3260
|
-
const logs =
|
|
3482
|
+
if (testFailed) {
|
|
3483
|
+
const logs = fixtures.server.getLogs();
|
|
3261
3484
|
if (logs.length > 0) {
|
|
3262
3485
|
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
3263
3486
|
const recentLogs = logs.slice(-50);
|
|
@@ -3268,20 +3491,21 @@ async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
|
3268
3491
|
console.error("[TestFixture] === End Server Logs ===\n");
|
|
3269
3492
|
}
|
|
3270
3493
|
}
|
|
3494
|
+
const extras = fixtureContexts.get(fixtures)?.extraClients ?? [];
|
|
3495
|
+
for (const client of extras) {
|
|
3496
|
+
if (client.isConnected()) {
|
|
3497
|
+
await client.disconnect().catch(() => void 0);
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3271
3500
|
if (fixtures.mcp.isConnected()) {
|
|
3272
3501
|
await fixtures.mcp.disconnect();
|
|
3273
3502
|
}
|
|
3274
3503
|
}
|
|
3275
3504
|
async function cleanupSharedResources() {
|
|
3276
|
-
|
|
3277
|
-
await serverInstance.stop();
|
|
3278
|
-
}
|
|
3279
|
-
serverInstance = null;
|
|
3280
|
-
tokenFactory = null;
|
|
3281
|
-
serverStartedByUs = false;
|
|
3505
|
+
await stopServers();
|
|
3282
3506
|
}
|
|
3283
|
-
function resolveClientBaseUrl(server) {
|
|
3284
|
-
return
|
|
3507
|
+
function resolveClientBaseUrl(server, config) {
|
|
3508
|
+
return config.baseUrl ?? server.info.baseUrl;
|
|
3285
3509
|
}
|
|
3286
3510
|
function createAuthFixture(factory) {
|
|
3287
3511
|
const users = {
|
|
@@ -3327,28 +3551,39 @@ function createAuthFixture(factory) {
|
|
|
3327
3551
|
getAudience: () => factory.getAudience()
|
|
3328
3552
|
};
|
|
3329
3553
|
}
|
|
3330
|
-
function createServerFixture(server) {
|
|
3554
|
+
function createServerFixture(server, config, mcp, context) {
|
|
3331
3555
|
return {
|
|
3332
|
-
|
|
3556
|
+
// Live view: `pid` is filled in once the server is listening and follows restarts
|
|
3557
|
+
get info() {
|
|
3558
|
+
return server.info;
|
|
3559
|
+
},
|
|
3333
3560
|
createClient: async (opts) => {
|
|
3334
|
-
|
|
3335
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3336
|
-
entryPath: opts?.entryPath ??
|
|
3561
|
+
const client = await McpTestClient.create({
|
|
3562
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3563
|
+
entryPath: opts?.entryPath ?? config.entryPath,
|
|
3337
3564
|
transport: opts?.transport ?? "streamable-http",
|
|
3338
3565
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3339
3566
|
clientInfo: opts?.clientInfo,
|
|
3340
|
-
publicMode:
|
|
3567
|
+
publicMode: config.publicMode
|
|
3341
3568
|
}).buildAndConnect();
|
|
3569
|
+
context.extraClients.push(client);
|
|
3570
|
+
return client;
|
|
3342
3571
|
},
|
|
3343
3572
|
createClientBuilder: () => {
|
|
3344
3573
|
const builder = new McpTestClientBuilder({
|
|
3345
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3346
|
-
entryPath:
|
|
3347
|
-
publicMode:
|
|
3574
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3575
|
+
entryPath: config.entryPath,
|
|
3576
|
+
publicMode: config.publicMode
|
|
3348
3577
|
});
|
|
3349
3578
|
return builder;
|
|
3350
3579
|
},
|
|
3351
|
-
restart: () =>
|
|
3580
|
+
restart: async () => {
|
|
3581
|
+
const reconnect = [mcp, ...context.extraClients].filter((client) => client.isConnected());
|
|
3582
|
+
await server.restart();
|
|
3583
|
+
for (const client of reconnect) {
|
|
3584
|
+
await client.reconnect();
|
|
3585
|
+
}
|
|
3586
|
+
},
|
|
3352
3587
|
getLogs: () => server.getLogs(),
|
|
3353
3588
|
clearLogs: () => server.clearLogs()
|
|
3354
3589
|
};
|
|
@@ -3359,12 +3594,12 @@ function resolveServerCommand(server) {
|
|
|
3359
3594
|
}
|
|
3360
3595
|
return `npx tsx ${server}`;
|
|
3361
3596
|
}
|
|
3362
|
-
function runWithFixtures(fn) {
|
|
3363
|
-
return async () => {
|
|
3364
|
-
const fixtures = await
|
|
3597
|
+
function runWithFixtures(fn, chain) {
|
|
3598
|
+
return async (...extra) => {
|
|
3599
|
+
const fixtures = await buildFixtures(chain);
|
|
3365
3600
|
let testFailed = false;
|
|
3366
3601
|
try {
|
|
3367
|
-
await fn(fixtures);
|
|
3602
|
+
await fn(fixtures, ...extra);
|
|
3368
3603
|
} catch (error) {
|
|
3369
3604
|
testFailed = true;
|
|
3370
3605
|
throw error;
|
|
@@ -3373,24 +3608,37 @@ function runWithFixtures(fn) {
|
|
|
3373
3608
|
}
|
|
3374
3609
|
};
|
|
3375
3610
|
}
|
|
3611
|
+
function skipTitle(name, scope) {
|
|
3612
|
+
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
3613
|
+
}
|
|
3376
3614
|
function testWithFixtures(name, fn) {
|
|
3377
|
-
const
|
|
3615
|
+
const chain = scopes.slice();
|
|
3616
|
+
const scope = currentScope();
|
|
3378
3617
|
if (scope.skipped) {
|
|
3379
|
-
it.skip(
|
|
3618
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3380
3619
|
return;
|
|
3381
3620
|
}
|
|
3382
|
-
it(name, runWithFixtures(fn));
|
|
3621
|
+
jestGlobals().it(name, runWithFixtures(fn, chain));
|
|
3383
3622
|
}
|
|
3384
3623
|
function use(config) {
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3624
|
+
const scope = currentScope();
|
|
3625
|
+
scope.config = {
|
|
3626
|
+
...scope.config,
|
|
3627
|
+
...config,
|
|
3628
|
+
env: config.env ? { ...scope.config.env, ...config.env } : scope.config.env
|
|
3629
|
+
};
|
|
3630
|
+
scope.configured = true;
|
|
3631
|
+
if (!scope.cleanupRegistered) {
|
|
3632
|
+
scope.cleanupRegistered = true;
|
|
3633
|
+
jestGlobals().afterAll(async () => {
|
|
3634
|
+
await stopServers(scope === scopes[0] ? void 0 : scope);
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3389
3637
|
}
|
|
3390
3638
|
function skip(nameOrCondition, fnOrReason) {
|
|
3391
3639
|
if (typeof nameOrCondition === "boolean") {
|
|
3392
3640
|
if (nameOrCondition) {
|
|
3393
|
-
const scope =
|
|
3641
|
+
const scope = currentScope();
|
|
3394
3642
|
scope.skipped = true;
|
|
3395
3643
|
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3396
3644
|
}
|
|
@@ -3401,53 +3649,90 @@ function skip(nameOrCondition, fnOrReason) {
|
|
|
3401
3649
|
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3402
3650
|
);
|
|
3403
3651
|
}
|
|
3404
|
-
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3652
|
+
jestGlobals().it.skip(nameOrCondition, runWithFixtures(fnOrReason, scopes.slice()));
|
|
3405
3653
|
}
|
|
3406
3654
|
function only(name, fn) {
|
|
3407
|
-
const scope =
|
|
3655
|
+
const scope = currentScope();
|
|
3408
3656
|
if (scope.skipped) {
|
|
3409
|
-
it.skip(
|
|
3657
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, scopes.slice()));
|
|
3410
3658
|
return;
|
|
3411
3659
|
}
|
|
3412
|
-
it.only(name, runWithFixtures(fn));
|
|
3660
|
+
jestGlobals().it.only(name, runWithFixtures(fn, scopes.slice()));
|
|
3413
3661
|
}
|
|
3414
3662
|
function todo(name) {
|
|
3415
|
-
it.todo(name);
|
|
3663
|
+
jestGlobals().it.todo(name);
|
|
3416
3664
|
}
|
|
3417
|
-
function
|
|
3665
|
+
function each(table, ...tagged) {
|
|
3666
|
+
const chain = scopes.slice();
|
|
3667
|
+
const register = jestGlobals().it.each(table, ...tagged);
|
|
3418
3668
|
return (name, fn) => {
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3669
|
+
const scope = currentScope();
|
|
3670
|
+
if (scope.skipped) {
|
|
3671
|
+
const skipRegister = jestGlobals().it.skip.each(table, ...tagged);
|
|
3672
|
+
skipRegister(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3673
|
+
return;
|
|
3674
|
+
}
|
|
3675
|
+
register(name, runWithFixtures(fn, chain));
|
|
3676
|
+
};
|
|
3677
|
+
}
|
|
3678
|
+
function withScope(fn) {
|
|
3679
|
+
return (...args) => {
|
|
3680
|
+
const parent = currentScope();
|
|
3681
|
+
scopes.push({
|
|
3682
|
+
skipped: parent.skipped,
|
|
3683
|
+
reason: parent.reason,
|
|
3684
|
+
config: {},
|
|
3685
|
+
configured: false,
|
|
3686
|
+
cleanupRegistered: false
|
|
3427
3687
|
});
|
|
3688
|
+
try {
|
|
3689
|
+
fn(...args);
|
|
3690
|
+
} finally {
|
|
3691
|
+
scopes.pop();
|
|
3692
|
+
}
|
|
3428
3693
|
};
|
|
3429
3694
|
}
|
|
3430
|
-
function
|
|
3431
|
-
return (
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
});
|
|
3695
|
+
function scopedRegister(pick) {
|
|
3696
|
+
return (name, fn, timeout) => {
|
|
3697
|
+
pick()(name, withScope(fn), timeout);
|
|
3698
|
+
};
|
|
3435
3699
|
}
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
}
|
|
3700
|
+
function scopedEach(pick) {
|
|
3701
|
+
return (...eachArgs) => (name, fn, timeout) => {
|
|
3702
|
+
const register = pick()(...eachArgs);
|
|
3703
|
+
register(name, withScope(fn), timeout);
|
|
3704
|
+
};
|
|
3705
|
+
}
|
|
3706
|
+
var describeWithScope = Object.assign(
|
|
3707
|
+
scopedRegister(() => jestGlobals().describe),
|
|
3708
|
+
{
|
|
3709
|
+
// `describe.skip` still evaluates its callback during collection, so it needs a scope too
|
|
3710
|
+
only: Object.assign(
|
|
3711
|
+
scopedRegister(() => jestGlobals().describe.only),
|
|
3712
|
+
{
|
|
3713
|
+
each: scopedEach(() => jestGlobals().describe.only.each)
|
|
3714
|
+
}
|
|
3715
|
+
),
|
|
3716
|
+
skip: Object.assign(
|
|
3717
|
+
scopedRegister(() => jestGlobals().describe.skip),
|
|
3718
|
+
{
|
|
3719
|
+
each: scopedEach(() => jestGlobals().describe.skip.each)
|
|
3720
|
+
}
|
|
3721
|
+
),
|
|
3722
|
+
each: scopedEach(() => jestGlobals().describe.each)
|
|
3723
|
+
}
|
|
3724
|
+
);
|
|
3441
3725
|
var test = testWithFixtures;
|
|
3442
3726
|
test.use = use;
|
|
3443
|
-
test.describe =
|
|
3444
|
-
test.beforeAll = beforeAll;
|
|
3445
|
-
test.beforeEach = beforeEach;
|
|
3446
|
-
test.afterEach = afterEach;
|
|
3447
|
-
test.afterAll = afterAll;
|
|
3727
|
+
test.describe = describeWithScope;
|
|
3728
|
+
test.beforeAll = ((...args) => jestGlobals().beforeAll(...args));
|
|
3729
|
+
test.beforeEach = ((...args) => jestGlobals().beforeEach(...args));
|
|
3730
|
+
test.afterEach = ((...args) => jestGlobals().afterEach(...args));
|
|
3731
|
+
test.afterAll = ((...args) => jestGlobals().afterAll(...args));
|
|
3448
3732
|
test.skip = skip;
|
|
3449
3733
|
test.only = only;
|
|
3450
3734
|
test.todo = todo;
|
|
3735
|
+
test.each = each;
|
|
3451
3736
|
export {
|
|
3452
3737
|
cleanupSharedResources,
|
|
3453
3738
|
cleanupTestFixtures,
|