@frontmcp/testing 1.8.6 → 1.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth/mock-oauth-server.d.ts.map +1 -1
- package/auth/token-factory.d.ts +2 -2
- package/auth/token-factory.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +6 -1
- package/client/mcp-test-client.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +487 -224
- package/esm/index.mjs +644 -347
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +407 -272
- package/expect.d.ts +12 -11
- package/expect.d.ts.map +1 -1
- package/fixtures/fixture-types.d.ts +22 -4
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +486 -216
- package/fixtures/test-fixture.d.ts +6 -5
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/http-mock/http-mock.d.ts +5 -3
- package/http-mock/http-mock.d.ts.map +1 -1
- package/http-mock/http-mock.types.d.ts +17 -6
- package/http-mock/http-mock.types.d.ts.map +1 -1
- package/http-mock/index.d.ts +1 -1
- package/http-mock/index.d.ts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +652 -348
- package/package.json +6 -6
- package/perf/baseline-store.d.ts +1 -1
- package/perf/baseline-store.d.ts.map +1 -1
- package/perf/index.js +410 -285
- package/perf/perf-test.d.ts.map +1 -1
- package/perf/report-generator.d.ts +1 -1
- package/perf/report-generator.d.ts.map +1 -1
- package/server/port-registry.d.ts +7 -1
- package/server/port-registry.d.ts.map +1 -1
- package/server/test-server.d.ts +10 -1
- package/server/test-server.d.ts.map +1 -1
- package/transport/error-utils.d.ts +15 -0
- package/transport/error-utils.d.ts.map +1 -0
- package/transport/streamable-http.transport.d.ts.map +1 -1
package/esm/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) {
|
|
@@ -1414,6 +1432,15 @@ var DEFAULT_CLIENT_INFO = {
|
|
|
1414
1432
|
version: "0.4.0"
|
|
1415
1433
|
};
|
|
1416
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
|
+
}
|
|
1417
1444
|
function extractEntryPaths(data) {
|
|
1418
1445
|
let payload = data;
|
|
1419
1446
|
if (typeof payload === "string") {
|
|
@@ -1500,19 +1527,22 @@ var McpTestClient = class {
|
|
|
1500
1527
|
async connect() {
|
|
1501
1528
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1502
1529
|
this.triedDiscoveredEntryPath = false;
|
|
1530
|
+
this.initResult = null;
|
|
1503
1531
|
this.transport = this.createTransport();
|
|
1504
1532
|
await this.transport.connect();
|
|
1505
1533
|
const maxRetries = 3;
|
|
1506
1534
|
const retryDelayMs = 500;
|
|
1507
1535
|
let lastError;
|
|
1508
1536
|
let reportedEntryPaths;
|
|
1537
|
+
let attemptsMade = 0;
|
|
1509
1538
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1539
|
+
attemptsMade = attempt;
|
|
1510
1540
|
const initResponse = await this.initialize();
|
|
1511
1541
|
if (initResponse.success && initResponse.data) {
|
|
1512
1542
|
this.initResult = initResponse.data;
|
|
1513
1543
|
break;
|
|
1514
1544
|
}
|
|
1515
|
-
lastError = initResponse.error
|
|
1545
|
+
lastError = describeInitializeError(initResponse.error);
|
|
1516
1546
|
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1517
1547
|
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1518
1548
|
this.triedDiscoveredEntryPath = true;
|
|
@@ -1524,6 +1554,7 @@ var McpTestClient = class {
|
|
|
1524
1554
|
await this.transport.connect();
|
|
1525
1555
|
continue;
|
|
1526
1556
|
}
|
|
1557
|
+
if (isAuthFailure(initResponse.error)) break;
|
|
1527
1558
|
if (attempt < maxRetries) {
|
|
1528
1559
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1529
1560
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
@@ -1532,7 +1563,7 @@ var McpTestClient = class {
|
|
|
1532
1563
|
if (!this.initResult) {
|
|
1533
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.` : "";
|
|
1534
1565
|
throw new Error(
|
|
1535
|
-
`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}`
|
|
1536
1567
|
);
|
|
1537
1568
|
}
|
|
1538
1569
|
this._sessionId = this.transport.getSessionId();
|
|
@@ -1602,18 +1633,47 @@ var McpTestClient = class {
|
|
|
1602
1633
|
return this._authState;
|
|
1603
1634
|
}
|
|
1604
1635
|
/**
|
|
1605
|
-
* 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.
|
|
1606
1642
|
*/
|
|
1607
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 };
|
|
1608
1650
|
this._authState = {
|
|
1609
1651
|
isAnonymous: false,
|
|
1610
1652
|
token,
|
|
1611
1653
|
scopes: this.parseScopesFromToken(token),
|
|
1612
1654
|
user: this.parseUserFromToken(token)
|
|
1613
1655
|
};
|
|
1614
|
-
|
|
1615
|
-
|
|
1656
|
+
const previousTransport = this.transport;
|
|
1657
|
+
if (!previousTransport) {
|
|
1658
|
+
this.log("debug", "Authentication updated (not connected yet)");
|
|
1659
|
+
return;
|
|
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;
|
|
1616
1675
|
}
|
|
1676
|
+
await previousTransport.close().catch(() => void 0);
|
|
1617
1677
|
this.log("debug", "Authentication updated");
|
|
1618
1678
|
}
|
|
1619
1679
|
// ═══════════════════════════════════════════════════════════════════
|
|
@@ -2237,10 +2297,11 @@ var McpTestClient = class {
|
|
|
2237
2297
|
requestId: id
|
|
2238
2298
|
};
|
|
2239
2299
|
} catch (err) {
|
|
2300
|
+
if (isInterceptedError(err)) throw err;
|
|
2240
2301
|
const durationMs = Date.now() - start;
|
|
2241
2302
|
const error = {
|
|
2242
2303
|
code: -32603,
|
|
2243
|
-
message: err
|
|
2304
|
+
message: errorMessage(err)
|
|
2244
2305
|
};
|
|
2245
2306
|
return {
|
|
2246
2307
|
success: false,
|
|
@@ -2388,12 +2449,12 @@ var McpTestClient = class {
|
|
|
2388
2449
|
const payload = this.decodeJwtPayload(token);
|
|
2389
2450
|
if (!payload) return [];
|
|
2390
2451
|
const scope = payload["scope"];
|
|
2391
|
-
const
|
|
2452
|
+
const scopes2 = payload["scopes"];
|
|
2392
2453
|
if (typeof scope === "string") {
|
|
2393
2454
|
return scope.split(" ");
|
|
2394
2455
|
}
|
|
2395
|
-
if (Array.isArray(
|
|
2396
|
-
return
|
|
2456
|
+
if (Array.isArray(scopes2)) {
|
|
2457
|
+
return scopes2;
|
|
2397
2458
|
}
|
|
2398
2459
|
return [];
|
|
2399
2460
|
} catch {
|
|
@@ -2468,7 +2529,7 @@ var ProgressCollector = class {
|
|
|
2468
2529
|
};
|
|
2469
2530
|
|
|
2470
2531
|
// libs/testing/src/server/test-server.ts
|
|
2471
|
-
import { spawn } from "child_process";
|
|
2532
|
+
import { execFile, spawn } from "child_process";
|
|
2472
2533
|
import { sha256Hex } from "@frontmcp/utils";
|
|
2473
2534
|
|
|
2474
2535
|
// libs/testing/src/errors/index.ts
|
|
@@ -2539,6 +2600,8 @@ var E2E_PORT_RANGES = {
|
|
|
2539
2600
|
"demo-e2e-distributed": { start: 50440, size: 20 },
|
|
2540
2601
|
// Protocol revision E2E tests (50460-50479)
|
|
2541
2602
|
"demo-e2e-protocol-20260728": { start: 50460, size: 20 },
|
|
2603
|
+
// Testing library E2E tests (50480-50499)
|
|
2604
|
+
"demo-e2e-testing": { start: 50480, size: 20 },
|
|
2542
2605
|
// Mock servers and utilities (50900-50999)
|
|
2543
2606
|
"mock-oauth": { start: 50900, size: 10 },
|
|
2544
2607
|
"mock-api": { start: 50910, size: 10 },
|
|
@@ -2547,6 +2610,66 @@ var E2E_PORT_RANGES = {
|
|
|
2547
2610
|
default: { start: 51e3, size: 100 }
|
|
2548
2611
|
};
|
|
2549
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
|
+
}
|
|
2550
2673
|
var projectPortIndex = /* @__PURE__ */ new Map();
|
|
2551
2674
|
function getPortRange(project) {
|
|
2552
2675
|
const key = project;
|
|
@@ -2557,15 +2680,22 @@ function getPortRange(project) {
|
|
|
2557
2680
|
}
|
|
2558
2681
|
async function reservePort(project, preferredPort) {
|
|
2559
2682
|
const range = getPortRange(project);
|
|
2560
|
-
|
|
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) {
|
|
2561
2696
|
const reservation = await tryReservePort(preferredPort, project);
|
|
2562
2697
|
if (reservation) {
|
|
2563
|
-
return
|
|
2564
|
-
port: preferredPort,
|
|
2565
|
-
release: async () => {
|
|
2566
|
-
await releasePort(preferredPort);
|
|
2567
|
-
}
|
|
2568
|
-
};
|
|
2698
|
+
return handle(preferredPort);
|
|
2569
2699
|
}
|
|
2570
2700
|
console.warn(`[PortRegistry] Preferred port ${preferredPort} not available for ${project}, allocating from range`);
|
|
2571
2701
|
}
|
|
@@ -2574,30 +2704,19 @@ async function reservePort(project, preferredPort) {
|
|
|
2574
2704
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
2575
2705
|
const port = range.start + index % range.size;
|
|
2576
2706
|
index = (index + 1) % range.size;
|
|
2577
|
-
if (reservedPorts.has(port)) {
|
|
2707
|
+
if (reservedPorts.has(port) || lockedPorts.has(port)) {
|
|
2578
2708
|
continue;
|
|
2579
2709
|
}
|
|
2580
2710
|
const reservation = await tryReservePort(port, project);
|
|
2581
2711
|
if (reservation) {
|
|
2582
2712
|
projectPortIndex.set(project, index);
|
|
2583
|
-
return
|
|
2584
|
-
port,
|
|
2585
|
-
release: async () => {
|
|
2586
|
-
await releasePort(port);
|
|
2587
|
-
}
|
|
2588
|
-
};
|
|
2713
|
+
return handle(port);
|
|
2589
2714
|
}
|
|
2590
2715
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
return {
|
|
2596
|
-
port: dynamicPort,
|
|
2597
|
-
release: async () => {
|
|
2598
|
-
await releasePort(dynamicPort);
|
|
2599
|
-
}
|
|
2600
|
-
};
|
|
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);
|
|
2601
2720
|
}
|
|
2602
2721
|
}
|
|
2603
2722
|
throw new Error(
|
|
@@ -2605,9 +2724,13 @@ async function reservePort(project, preferredPort) {
|
|
|
2605
2724
|
);
|
|
2606
2725
|
}
|
|
2607
2726
|
async function tryReservePort(port, project) {
|
|
2727
|
+
if (!acquirePortLock(port)) {
|
|
2728
|
+
return false;
|
|
2729
|
+
}
|
|
2608
2730
|
return new Promise((resolve) => {
|
|
2609
2731
|
const server = createServer();
|
|
2610
2732
|
server.once("error", () => {
|
|
2733
|
+
releasePortLock(port);
|
|
2611
2734
|
resolve(false);
|
|
2612
2735
|
});
|
|
2613
2736
|
server.listen(port, () => {
|
|
@@ -2633,31 +2756,6 @@ async function releasePort(port) {
|
|
|
2633
2756
|
});
|
|
2634
2757
|
});
|
|
2635
2758
|
}
|
|
2636
|
-
async function findAvailablePortInRange(start, end) {
|
|
2637
|
-
for (let port = start; port < end; port++) {
|
|
2638
|
-
if (reservedPorts.has(port)) {
|
|
2639
|
-
continue;
|
|
2640
|
-
}
|
|
2641
|
-
const available = await isPortAvailable(port);
|
|
2642
|
-
if (available) {
|
|
2643
|
-
return port;
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
return null;
|
|
2647
|
-
}
|
|
2648
|
-
async function isPortAvailable(port) {
|
|
2649
|
-
return new Promise((resolve) => {
|
|
2650
|
-
const server = createServer();
|
|
2651
|
-
server.once("error", () => {
|
|
2652
|
-
resolve(false);
|
|
2653
|
-
});
|
|
2654
|
-
server.listen(port, () => {
|
|
2655
|
-
server.close(() => {
|
|
2656
|
-
resolve(true);
|
|
2657
|
-
});
|
|
2658
|
-
});
|
|
2659
|
-
});
|
|
2660
|
-
}
|
|
2661
2759
|
|
|
2662
2760
|
// libs/testing/src/server/test-server.ts
|
|
2663
2761
|
var DEBUG_SERVER = process.env["DEBUG_SERVER"] === "1" || process.env["DEBUG"] === "1";
|
|
@@ -2666,8 +2764,11 @@ var TestServer = class _TestServer {
|
|
|
2666
2764
|
options;
|
|
2667
2765
|
_info;
|
|
2668
2766
|
logs = [];
|
|
2767
|
+
/** Frees the port reservation (socket and cross-process lock); held until stop() */
|
|
2669
2768
|
portRelease = null;
|
|
2670
|
-
|
|
2769
|
+
/** Frees only the reservation socket, just before the server is spawned */
|
|
2770
|
+
portReleaseSocket = null;
|
|
2771
|
+
constructor(options, port, portRelease, portReleaseSocket) {
|
|
2671
2772
|
this.options = {
|
|
2672
2773
|
port,
|
|
2673
2774
|
project: options.project,
|
|
@@ -2679,6 +2780,7 @@ var TestServer = class _TestServer {
|
|
|
2679
2780
|
debug: options.debug ?? DEBUG_SERVER
|
|
2680
2781
|
};
|
|
2681
2782
|
this.portRelease = portRelease ?? null;
|
|
2783
|
+
this.portReleaseSocket = portReleaseSocket ?? portRelease ?? null;
|
|
2682
2784
|
this._info = {
|
|
2683
2785
|
baseUrl: `http://localhost:${port}`,
|
|
2684
2786
|
port
|
|
@@ -2691,8 +2793,8 @@ var TestServer = class _TestServer {
|
|
|
2691
2793
|
const project = options.project ?? "default";
|
|
2692
2794
|
const maxAttempts = 3;
|
|
2693
2795
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2694
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2695
|
-
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);
|
|
2696
2798
|
try {
|
|
2697
2799
|
await server.startProcess();
|
|
2698
2800
|
return server;
|
|
@@ -2732,7 +2834,7 @@ var TestServer = class _TestServer {
|
|
|
2732
2834
|
}
|
|
2733
2835
|
const maxAttempts = 3;
|
|
2734
2836
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2735
|
-
const { port, release } = await reservePort(project, options.port);
|
|
2837
|
+
const { port, release, releaseSocket } = await reservePort(project, options.port);
|
|
2736
2838
|
const serverOptions = {
|
|
2737
2839
|
...options,
|
|
2738
2840
|
port,
|
|
@@ -2740,7 +2842,7 @@ var TestServer = class _TestServer {
|
|
|
2740
2842
|
command: `npx nx serve ${project} --port ${port}`,
|
|
2741
2843
|
cwd: options.cwd ?? process.cwd()
|
|
2742
2844
|
};
|
|
2743
|
-
const server = new _TestServer(serverOptions, port, release);
|
|
2845
|
+
const server = new _TestServer(serverOptions, port, release, releaseSocket);
|
|
2744
2846
|
try {
|
|
2745
2847
|
await server.startProcess();
|
|
2746
2848
|
return server;
|
|
@@ -2798,10 +2900,17 @@ var TestServer = class _TestServer {
|
|
|
2798
2900
|
* Stop the test server
|
|
2799
2901
|
*/
|
|
2800
2902
|
async stop() {
|
|
2801
|
-
|
|
2802
|
-
await this.
|
|
2803
|
-
|
|
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;
|
|
2804
2911
|
}
|
|
2912
|
+
}
|
|
2913
|
+
async stopProcess() {
|
|
2805
2914
|
if (this.process) {
|
|
2806
2915
|
this.log("Stopping server...");
|
|
2807
2916
|
if (this.process.exitCode !== null || this.process.signalCode !== null) {
|
|
@@ -2874,7 +2983,7 @@ var TestServer = class _TestServer {
|
|
|
2874
2983
|
* Restart the server
|
|
2875
2984
|
*/
|
|
2876
2985
|
async restart() {
|
|
2877
|
-
await this.
|
|
2986
|
+
await this.stopProcess();
|
|
2878
2987
|
await this.startProcess();
|
|
2879
2988
|
}
|
|
2880
2989
|
/**
|
|
@@ -2904,9 +3013,9 @@ var TestServer = class _TestServer {
|
|
|
2904
3013
|
PORT: String(this.options.port)
|
|
2905
3014
|
};
|
|
2906
3015
|
const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
|
|
2907
|
-
if (this.
|
|
2908
|
-
await this.
|
|
2909
|
-
this.
|
|
3016
|
+
if (this.portReleaseSocket) {
|
|
3017
|
+
await this.portReleaseSocket();
|
|
3018
|
+
this.portReleaseSocket = null;
|
|
2910
3019
|
await sleep2(300);
|
|
2911
3020
|
}
|
|
2912
3021
|
this.process = spawn(this.options.command, [], {
|
|
@@ -2916,9 +3025,8 @@ var TestServer = class _TestServer {
|
|
|
2916
3025
|
detached: true,
|
|
2917
3026
|
stdio: ["pipe", "pipe", "pipe"]
|
|
2918
3027
|
});
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
}
|
|
3028
|
+
this._info.pid = this.process.pid;
|
|
3029
|
+
this._info.shellPid = this.process.pid;
|
|
2922
3030
|
let processExited = false;
|
|
2923
3031
|
let exitCode = null;
|
|
2924
3032
|
let exitError = null;
|
|
@@ -3021,6 +3129,8 @@ ${allLogs}`);
|
|
|
3021
3129
|
});
|
|
3022
3130
|
if (response.ok || response.status === 404) {
|
|
3023
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;
|
|
3024
3134
|
return;
|
|
3025
3135
|
}
|
|
3026
3136
|
lastHealthCheckError = `HTTP ${response.status}: ${response.statusText}`;
|
|
@@ -3065,6 +3175,19 @@ TIP: Set DEBUG_SERVER=1 or DEBUG=1 environment variable for verbose output`
|
|
|
3065
3175
|
function sleep2(ms) {
|
|
3066
3176
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3067
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
|
+
}
|
|
3068
3191
|
function withWorkspaceProtocolFallback(env, cwd) {
|
|
3069
3192
|
if (findInstalledProtocolPackageDir(cwd)) {
|
|
3070
3193
|
return env;
|
|
@@ -3181,81 +3304,152 @@ function gatewayTokenBinding(serverUrl, entryPath) {
|
|
|
3181
3304
|
}
|
|
3182
3305
|
|
|
3183
3306
|
// libs/testing/src/fixtures/test-fixture.ts
|
|
3184
|
-
var
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
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
|
+
}
|
|
3197
3327
|
}
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
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;
|
|
3206
3390
|
if (isDebug) {
|
|
3207
|
-
console.log(`[TestFixture]
|
|
3391
|
+
console.log(`[TestFixture] Server started at ${server.info.baseUrl}`);
|
|
3208
3392
|
}
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
command: serverCommand,
|
|
3214
|
-
env: currentConfig.env,
|
|
3215
|
-
startupTimeout: currentConfig.startupTimeout ?? 3e4,
|
|
3216
|
-
debug: isDebug
|
|
3217
|
-
});
|
|
3218
|
-
serverStartedByUs = true;
|
|
3219
|
-
if (isDebug) {
|
|
3220
|
-
console.log(`[TestFixture] Server started at ${serverInstance.info.baseUrl}`);
|
|
3221
|
-
}
|
|
3222
|
-
} catch (error) {
|
|
3223
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
3224
|
-
throw new Error(
|
|
3225
|
-
`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.
|
|
3226
3397
|
|
|
3227
|
-
Server entry: ${
|
|
3228
|
-
Project: ${
|
|
3398
|
+
Server entry: ${config.server}
|
|
3399
|
+
Project: ${config.project ?? "default"}
|
|
3229
3400
|
Command: ${serverCommand}
|
|
3230
3401
|
|
|
3231
|
-
Error: ${errMsg}
|
|
3232
|
-
|
|
3233
|
-
}
|
|
3234
|
-
} else {
|
|
3235
|
-
throw new Error(
|
|
3236
|
-
'test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option'
|
|
3402
|
+
Error: ${errMsg}`,
|
|
3403
|
+
{ cause: error }
|
|
3237
3404
|
);
|
|
3238
3405
|
}
|
|
3406
|
+
} else {
|
|
3407
|
+
throw new Error('test.use() requires either "server" (entry file path) or "baseUrl" (for external server) option');
|
|
3239
3408
|
}
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
}
|
|
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 };
|
|
3245
3413
|
}
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
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
|
+
}
|
|
3253
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 };
|
|
3254
3448
|
const clientInstance = McpTestClient.create({
|
|
3255
|
-
baseUrl: resolveClientBaseUrl(
|
|
3256
|
-
entryPath:
|
|
3257
|
-
transport:
|
|
3258
|
-
publicMode
|
|
3449
|
+
baseUrl: resolveClientBaseUrl(server, clientConfig),
|
|
3450
|
+
entryPath: clientConfig.entryPath,
|
|
3451
|
+
transport: clientConfig.transport ?? "streamable-http",
|
|
3452
|
+
publicMode
|
|
3259
3453
|
}).build();
|
|
3260
3454
|
try {
|
|
3261
3455
|
await clientInstance.connect();
|
|
@@ -3269,17 +3463,24 @@ async function createTestFixtures() {
|
|
|
3269
3463
|
throw err;
|
|
3270
3464
|
}
|
|
3271
3465
|
}
|
|
3272
|
-
const
|
|
3273
|
-
const
|
|
3274
|
-
return {
|
|
3466
|
+
const context = { extraClients: [] };
|
|
3467
|
+
const fixtures = {
|
|
3275
3468
|
mcp: clientInstance,
|
|
3276
|
-
auth,
|
|
3277
|
-
server
|
|
3469
|
+
auth: createAuthFixture(tokenFactory),
|
|
3470
|
+
server: createServerFixture(server, clientConfig, clientInstance, context)
|
|
3278
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());
|
|
3279
3480
|
}
|
|
3280
3481
|
async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
3281
|
-
if (testFailed
|
|
3282
|
-
const logs =
|
|
3482
|
+
if (testFailed) {
|
|
3483
|
+
const logs = fixtures.server.getLogs();
|
|
3283
3484
|
if (logs.length > 0) {
|
|
3284
3485
|
console.error("\n[TestFixture] === Server Logs (test failed) ===");
|
|
3285
3486
|
const recentLogs = logs.slice(-50);
|
|
@@ -3290,20 +3491,21 @@ async function cleanupTestFixtures(fixtures, testFailed = false) {
|
|
|
3290
3491
|
console.error("[TestFixture] === End Server Logs ===\n");
|
|
3291
3492
|
}
|
|
3292
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
|
+
}
|
|
3293
3500
|
if (fixtures.mcp.isConnected()) {
|
|
3294
3501
|
await fixtures.mcp.disconnect();
|
|
3295
3502
|
}
|
|
3296
3503
|
}
|
|
3297
3504
|
async function cleanupSharedResources() {
|
|
3298
|
-
|
|
3299
|
-
await serverInstance.stop();
|
|
3300
|
-
}
|
|
3301
|
-
serverInstance = null;
|
|
3302
|
-
tokenFactory = null;
|
|
3303
|
-
serverStartedByUs = false;
|
|
3505
|
+
await stopServers();
|
|
3304
3506
|
}
|
|
3305
|
-
function resolveClientBaseUrl(server) {
|
|
3306
|
-
return
|
|
3507
|
+
function resolveClientBaseUrl(server, config) {
|
|
3508
|
+
return config.baseUrl ?? server.info.baseUrl;
|
|
3307
3509
|
}
|
|
3308
3510
|
function createAuthFixture(factory) {
|
|
3309
3511
|
const users = {
|
|
@@ -3349,28 +3551,39 @@ function createAuthFixture(factory) {
|
|
|
3349
3551
|
getAudience: () => factory.getAudience()
|
|
3350
3552
|
};
|
|
3351
3553
|
}
|
|
3352
|
-
function createServerFixture(server) {
|
|
3554
|
+
function createServerFixture(server, config, mcp, context) {
|
|
3353
3555
|
return {
|
|
3354
|
-
|
|
3556
|
+
// Live view: `pid` is filled in once the server is listening and follows restarts
|
|
3557
|
+
get info() {
|
|
3558
|
+
return server.info;
|
|
3559
|
+
},
|
|
3355
3560
|
createClient: async (opts) => {
|
|
3356
|
-
|
|
3357
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3358
|
-
entryPath: opts?.entryPath ??
|
|
3561
|
+
const client = await McpTestClient.create({
|
|
3562
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3563
|
+
entryPath: opts?.entryPath ?? config.entryPath,
|
|
3359
3564
|
transport: opts?.transport ?? "streamable-http",
|
|
3360
3565
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3361
3566
|
clientInfo: opts?.clientInfo,
|
|
3362
|
-
publicMode:
|
|
3567
|
+
publicMode: config.publicMode
|
|
3363
3568
|
}).buildAndConnect();
|
|
3569
|
+
context.extraClients.push(client);
|
|
3570
|
+
return client;
|
|
3364
3571
|
},
|
|
3365
3572
|
createClientBuilder: () => {
|
|
3366
3573
|
const builder = new McpTestClientBuilder({
|
|
3367
|
-
baseUrl: resolveClientBaseUrl(server),
|
|
3368
|
-
entryPath:
|
|
3369
|
-
publicMode:
|
|
3574
|
+
baseUrl: resolveClientBaseUrl(server, config),
|
|
3575
|
+
entryPath: config.entryPath,
|
|
3576
|
+
publicMode: config.publicMode
|
|
3370
3577
|
});
|
|
3371
3578
|
return builder;
|
|
3372
3579
|
},
|
|
3373
|
-
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
|
+
},
|
|
3374
3587
|
getLogs: () => server.getLogs(),
|
|
3375
3588
|
clearLogs: () => server.clearLogs()
|
|
3376
3589
|
};
|
|
@@ -3381,12 +3594,12 @@ function resolveServerCommand(server) {
|
|
|
3381
3594
|
}
|
|
3382
3595
|
return `npx tsx ${server}`;
|
|
3383
3596
|
}
|
|
3384
|
-
function runWithFixtures(fn) {
|
|
3385
|
-
return async () => {
|
|
3386
|
-
const fixtures = await
|
|
3597
|
+
function runWithFixtures(fn, chain) {
|
|
3598
|
+
return async (...extra) => {
|
|
3599
|
+
const fixtures = await buildFixtures(chain);
|
|
3387
3600
|
let testFailed = false;
|
|
3388
3601
|
try {
|
|
3389
|
-
await fn(fixtures);
|
|
3602
|
+
await fn(fixtures, ...extra);
|
|
3390
3603
|
} catch (error) {
|
|
3391
3604
|
testFailed = true;
|
|
3392
3605
|
throw error;
|
|
@@ -3395,24 +3608,37 @@ function runWithFixtures(fn) {
|
|
|
3395
3608
|
}
|
|
3396
3609
|
};
|
|
3397
3610
|
}
|
|
3611
|
+
function skipTitle(name, scope) {
|
|
3612
|
+
return scope.reason ? `${name} (skipped: ${scope.reason})` : name;
|
|
3613
|
+
}
|
|
3398
3614
|
function testWithFixtures(name, fn) {
|
|
3399
|
-
const
|
|
3615
|
+
const chain = scopes.slice();
|
|
3616
|
+
const scope = currentScope();
|
|
3400
3617
|
if (scope.skipped) {
|
|
3401
|
-
it.skip(
|
|
3618
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, chain));
|
|
3402
3619
|
return;
|
|
3403
3620
|
}
|
|
3404
|
-
it(name, runWithFixtures(fn));
|
|
3621
|
+
jestGlobals().it(name, runWithFixtures(fn, chain));
|
|
3405
3622
|
}
|
|
3406
3623
|
function use(config) {
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
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
|
+
}
|
|
3411
3637
|
}
|
|
3412
3638
|
function skip(nameOrCondition, fnOrReason) {
|
|
3413
3639
|
if (typeof nameOrCondition === "boolean") {
|
|
3414
3640
|
if (nameOrCondition) {
|
|
3415
|
-
const scope =
|
|
3641
|
+
const scope = currentScope();
|
|
3416
3642
|
scope.skipped = true;
|
|
3417
3643
|
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3418
3644
|
}
|
|
@@ -3423,53 +3649,90 @@ function skip(nameOrCondition, fnOrReason) {
|
|
|
3423
3649
|
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3424
3650
|
);
|
|
3425
3651
|
}
|
|
3426
|
-
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3652
|
+
jestGlobals().it.skip(nameOrCondition, runWithFixtures(fnOrReason, scopes.slice()));
|
|
3427
3653
|
}
|
|
3428
3654
|
function only(name, fn) {
|
|
3429
|
-
const scope =
|
|
3655
|
+
const scope = currentScope();
|
|
3430
3656
|
if (scope.skipped) {
|
|
3431
|
-
it.skip(
|
|
3657
|
+
jestGlobals().it.skip(skipTitle(name, scope), runWithFixtures(fn, scopes.slice()));
|
|
3432
3658
|
return;
|
|
3433
3659
|
}
|
|
3434
|
-
it.only(name, runWithFixtures(fn));
|
|
3660
|
+
jestGlobals().it.only(name, runWithFixtures(fn, scopes.slice()));
|
|
3435
3661
|
}
|
|
3436
3662
|
function todo(name) {
|
|
3437
|
-
it.todo(name);
|
|
3663
|
+
jestGlobals().it.todo(name);
|
|
3438
3664
|
}
|
|
3439
|
-
function
|
|
3665
|
+
function each(table, ...tagged) {
|
|
3666
|
+
const chain = scopes.slice();
|
|
3667
|
+
const register = jestGlobals().it.each(table, ...tagged);
|
|
3440
3668
|
return (name, fn) => {
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
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
|
|
3449
3687
|
});
|
|
3688
|
+
try {
|
|
3689
|
+
fn(...args);
|
|
3690
|
+
} finally {
|
|
3691
|
+
scopes.pop();
|
|
3692
|
+
}
|
|
3450
3693
|
};
|
|
3451
3694
|
}
|
|
3452
|
-
function
|
|
3453
|
-
return (
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
});
|
|
3695
|
+
function scopedRegister(pick) {
|
|
3696
|
+
return (name, fn, timeout) => {
|
|
3697
|
+
pick()(name, withScope(fn), timeout);
|
|
3698
|
+
};
|
|
3457
3699
|
}
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
}
|
|
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
|
+
);
|
|
3463
3725
|
var test = testWithFixtures;
|
|
3464
3726
|
test.use = use;
|
|
3465
|
-
test.describe =
|
|
3466
|
-
test.beforeAll = beforeAll;
|
|
3467
|
-
test.beforeEach = beforeEach;
|
|
3468
|
-
test.afterEach = afterEach;
|
|
3469
|
-
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));
|
|
3470
3732
|
test.skip = skip;
|
|
3471
3733
|
test.only = only;
|
|
3472
3734
|
test.todo = todo;
|
|
3735
|
+
test.each = each;
|
|
3473
3736
|
export {
|
|
3474
3737
|
cleanupSharedResources,
|
|
3475
3738
|
cleanupTestFixtures,
|