@frontmcp/testing 1.7.2 → 1.8.1
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/client/index.d.ts +1 -1
- package/client/index.d.ts.map +1 -1
- package/client/mcp-test-client.builder.d.ts +2 -1
- package/client/mcp-test-client.builder.d.ts.map +1 -1
- package/client/mcp-test-client.d.ts +11 -3
- package/client/mcp-test-client.d.ts.map +1 -1
- package/client/mcp-test-client.types.d.ts +17 -3
- package/client/mcp-test-client.types.d.ts.map +1 -1
- package/esm/fixtures/index.mjs +670 -458
- package/esm/index.mjs +780 -569
- package/esm/matchers/index.mjs +5 -6
- package/esm/package.json +6 -6
- package/esm/perf/index.mjs +600 -426
- package/esm/setup.mjs +5 -6
- package/fixtures/fixture-types.d.ts +34 -3
- package/fixtures/fixture-types.d.ts.map +1 -1
- package/fixtures/index.js +670 -458
- package/fixtures/test-fixture.d.ts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js +780 -569
- package/matchers/index.js +5 -6
- package/matchers/matcher-types.d.ts +3 -2
- package/matchers/matcher-types.d.ts.map +1 -1
- package/matchers/mcp-matchers.d.ts +1 -1
- package/matchers/mcp-matchers.d.ts.map +1 -1
- package/package.json +6 -6
- package/perf/index.js +600 -426
- package/setup.js +5 -6
- package/transport/index.d.ts +1 -1
- package/transport/index.d.ts.map +1 -1
- package/transport/streamable-http.transport.d.ts +18 -2
- package/transport/streamable-http.transport.d.ts.map +1 -1
- package/transport/transport.interface.d.ts +17 -1
- package/transport/transport.interface.d.ts.map +1 -1
package/esm/fixtures/index.mjs
CHANGED
|
@@ -199,236 +199,281 @@ var TestTokenFactory = class {
|
|
|
199
199
|
}
|
|
200
200
|
};
|
|
201
201
|
|
|
202
|
-
// libs/testing/src/
|
|
203
|
-
var
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
name: "mcp-ext-apps",
|
|
214
|
-
version: "1.0"
|
|
215
|
-
};
|
|
216
|
-
case "claude":
|
|
217
|
-
return {
|
|
218
|
-
name: "claude-desktop",
|
|
219
|
-
version: "1.0"
|
|
220
|
-
};
|
|
221
|
-
case "cursor":
|
|
222
|
-
return {
|
|
223
|
-
name: "cursor",
|
|
224
|
-
version: "1.0"
|
|
225
|
-
};
|
|
226
|
-
case "continue":
|
|
227
|
-
return {
|
|
228
|
-
name: "continue",
|
|
229
|
-
version: "1.0"
|
|
230
|
-
};
|
|
231
|
-
case "cody":
|
|
232
|
-
return {
|
|
233
|
-
name: "cody",
|
|
234
|
-
version: "1.0"
|
|
235
|
-
};
|
|
236
|
-
case "gemini":
|
|
237
|
-
return {
|
|
238
|
-
name: "gemini",
|
|
239
|
-
version: "1.0"
|
|
240
|
-
};
|
|
241
|
-
case "generic-mcp":
|
|
242
|
-
return {
|
|
243
|
-
name: "generic-mcp-client",
|
|
244
|
-
version: "1.0"
|
|
245
|
-
};
|
|
246
|
-
case "unknown":
|
|
247
|
-
default:
|
|
248
|
-
return {
|
|
249
|
-
name: "mcp-test-client",
|
|
250
|
-
version: "1.0"
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
function getPlatformCapabilities(platform) {
|
|
255
|
-
const baseCapabilities = {
|
|
256
|
-
sampling: {},
|
|
257
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
258
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
259
|
-
elicitation: {
|
|
260
|
-
form: {}
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
if (platform === "ext-apps") {
|
|
202
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
203
|
+
var DefaultMockRegistry = class {
|
|
204
|
+
mocks = [];
|
|
205
|
+
add(mock) {
|
|
206
|
+
const entry = {
|
|
207
|
+
definition: mock,
|
|
208
|
+
callCount: 0,
|
|
209
|
+
calls: [],
|
|
210
|
+
remainingUses: mock.times ?? Infinity
|
|
211
|
+
};
|
|
212
|
+
this.mocks.push(entry);
|
|
264
213
|
return {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
214
|
+
remove: () => {
|
|
215
|
+
const index = this.mocks.indexOf(entry);
|
|
216
|
+
if (index !== -1) {
|
|
217
|
+
this.mocks.splice(index, 1);
|
|
269
218
|
}
|
|
270
|
-
}
|
|
219
|
+
},
|
|
220
|
+
callCount: () => entry.callCount,
|
|
221
|
+
calls: () => [...entry.calls]
|
|
271
222
|
};
|
|
272
223
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
277
|
-
var McpTestClientBuilder = class {
|
|
278
|
-
config;
|
|
279
|
-
constructor(config) {
|
|
280
|
-
this.config = { ...config };
|
|
224
|
+
clear() {
|
|
225
|
+
this.mocks = [];
|
|
281
226
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
227
|
+
getAll() {
|
|
228
|
+
return this.mocks.map((e) => e.definition);
|
|
229
|
+
}
|
|
230
|
+
match(request) {
|
|
231
|
+
for (const entry of this.mocks) {
|
|
232
|
+
if (entry.remainingUses <= 0) continue;
|
|
233
|
+
const { definition } = entry;
|
|
234
|
+
if (definition.method !== request.method) continue;
|
|
235
|
+
if (definition.params !== void 0) {
|
|
236
|
+
const params = request.params ?? {};
|
|
237
|
+
if (typeof definition.params === "function") {
|
|
238
|
+
if (!definition.params(params)) continue;
|
|
239
|
+
} else {
|
|
240
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
entry.callCount++;
|
|
244
|
+
entry.calls.push(request);
|
|
245
|
+
entry.remainingUses--;
|
|
246
|
+
return definition;
|
|
247
|
+
}
|
|
248
|
+
return void 0;
|
|
288
249
|
}
|
|
289
250
|
/**
|
|
290
|
-
*
|
|
251
|
+
* Check if request params match the mock params definition
|
|
291
252
|
*/
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
253
|
+
paramsMatch(expected, actual) {
|
|
254
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
255
|
+
if (!(key in actual)) return false;
|
|
256
|
+
const actualValue = actual[key];
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
if (!Array.isArray(actualValue)) return false;
|
|
259
|
+
if (value.length !== actualValue.length) return false;
|
|
260
|
+
for (let i = 0; i < value.length; i++) {
|
|
261
|
+
const expectedItem = value[i];
|
|
262
|
+
const actualItem = actualValue[i];
|
|
263
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
264
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
265
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
} else if (actualItem !== expectedItem) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} else if (typeof value === "object" && value !== null) {
|
|
273
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
274
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
} else if (actualValue !== value) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
295
282
|
}
|
|
283
|
+
};
|
|
284
|
+
var mockResponse = {
|
|
296
285
|
/**
|
|
297
|
-
*
|
|
286
|
+
* Create a successful JSON-RPC response
|
|
298
287
|
*/
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
288
|
+
success(result, id = 1) {
|
|
289
|
+
return {
|
|
290
|
+
jsonrpc: "2.0",
|
|
291
|
+
id,
|
|
292
|
+
result
|
|
303
293
|
};
|
|
304
|
-
|
|
305
|
-
}
|
|
294
|
+
},
|
|
306
295
|
/**
|
|
307
|
-
*
|
|
296
|
+
* Create an error JSON-RPC response
|
|
308
297
|
*/
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
298
|
+
error(code, message, data, id = 1) {
|
|
299
|
+
return {
|
|
300
|
+
jsonrpc: "2.0",
|
|
301
|
+
id,
|
|
302
|
+
error: { code, message, data }
|
|
303
|
+
};
|
|
304
|
+
},
|
|
313
305
|
/**
|
|
314
|
-
*
|
|
306
|
+
* Create a tool result response
|
|
315
307
|
*/
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
308
|
+
toolResult(content, id = 1) {
|
|
309
|
+
return {
|
|
310
|
+
jsonrpc: "2.0",
|
|
311
|
+
id,
|
|
312
|
+
result: { content }
|
|
313
|
+
};
|
|
314
|
+
},
|
|
320
315
|
/**
|
|
321
|
-
*
|
|
316
|
+
* Create a tools/list response
|
|
322
317
|
*/
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
318
|
+
toolsList(tools, id = 1) {
|
|
319
|
+
return {
|
|
320
|
+
jsonrpc: "2.0",
|
|
321
|
+
id,
|
|
322
|
+
result: { tools }
|
|
323
|
+
};
|
|
324
|
+
},
|
|
327
325
|
/**
|
|
328
|
-
*
|
|
329
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
330
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
326
|
+
* Create a resources/list response
|
|
331
327
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
328
|
+
resourcesList(resources, id = 1) {
|
|
329
|
+
return {
|
|
330
|
+
jsonrpc: "2.0",
|
|
331
|
+
id,
|
|
332
|
+
result: { resources }
|
|
333
|
+
};
|
|
334
|
+
},
|
|
336
335
|
/**
|
|
337
|
-
*
|
|
336
|
+
* Create a resources/read response
|
|
338
337
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
338
|
+
resourceRead(contents, id = 1) {
|
|
339
|
+
return {
|
|
340
|
+
jsonrpc: "2.0",
|
|
341
|
+
id,
|
|
342
|
+
result: { contents }
|
|
343
|
+
};
|
|
344
|
+
},
|
|
343
345
|
/**
|
|
344
|
-
*
|
|
346
|
+
* Common MCP errors
|
|
345
347
|
*/
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
348
|
+
errors: {
|
|
349
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
350
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
351
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
352
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
353
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
354
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
355
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
360
|
+
var DefaultInterceptorChain = class {
|
|
361
|
+
request = [];
|
|
362
|
+
response = [];
|
|
363
|
+
mocks;
|
|
364
|
+
constructor() {
|
|
365
|
+
this.mocks = new DefaultMockRegistry();
|
|
349
366
|
}
|
|
350
367
|
/**
|
|
351
|
-
*
|
|
352
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
353
|
-
*
|
|
354
|
-
* Platform-specific behavior:
|
|
355
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
356
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
357
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
358
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
359
|
-
* - Other platforms follow similar patterns
|
|
360
|
-
*
|
|
361
|
-
* @example
|
|
362
|
-
* ```typescript
|
|
363
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
364
|
-
* .withPlatform('openai')
|
|
365
|
-
* .buildAndConnect();
|
|
366
|
-
*
|
|
367
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
368
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
369
|
-
* .withPlatform('ext-apps')
|
|
370
|
-
* .buildAndConnect();
|
|
371
|
-
* ```
|
|
368
|
+
* Add a request interceptor
|
|
372
369
|
*/
|
|
373
|
-
|
|
374
|
-
this.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
370
|
+
addRequestInterceptor(interceptor) {
|
|
371
|
+
this.request.push(interceptor);
|
|
372
|
+
return () => {
|
|
373
|
+
const index = this.request.indexOf(interceptor);
|
|
374
|
+
if (index !== -1) {
|
|
375
|
+
this.request.splice(index, 1);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
378
|
}
|
|
379
379
|
/**
|
|
380
|
-
*
|
|
381
|
-
* Use this for fine-grained control over capabilities sent during initialization.
|
|
382
|
-
*
|
|
383
|
-
* @example
|
|
384
|
-
* ```typescript
|
|
385
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
386
|
-
* .withCapabilities({
|
|
387
|
-
* sampling: {},
|
|
388
|
-
* experimental: {
|
|
389
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
390
|
-
* }
|
|
391
|
-
* })
|
|
392
|
-
* .buildAndConnect();
|
|
393
|
-
* ```
|
|
380
|
+
* Add a response interceptor
|
|
394
381
|
*/
|
|
395
|
-
|
|
396
|
-
this.
|
|
397
|
-
return
|
|
382
|
+
addResponseInterceptor(interceptor) {
|
|
383
|
+
this.response.push(interceptor);
|
|
384
|
+
return () => {
|
|
385
|
+
const index = this.response.indexOf(interceptor);
|
|
386
|
+
if (index !== -1) {
|
|
387
|
+
this.response.splice(index, 1);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
398
390
|
}
|
|
399
391
|
/**
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
406
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
407
|
-
* .buildAndConnect();
|
|
408
|
-
* ```
|
|
392
|
+
* Process a request through the interceptor chain
|
|
393
|
+
* Returns either:
|
|
394
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
395
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
396
|
+
* - { type: 'error', error } - throw error
|
|
409
397
|
*/
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
398
|
+
async processRequest(request, meta) {
|
|
399
|
+
let currentRequest = request;
|
|
400
|
+
const mockDef = this.mocks.match(request);
|
|
401
|
+
if (mockDef) {
|
|
402
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
403
|
+
await sleep(mockDef.delay);
|
|
404
|
+
}
|
|
405
|
+
let mockResponse2;
|
|
406
|
+
if (typeof mockDef.response === "function") {
|
|
407
|
+
mockResponse2 = await mockDef.response(request);
|
|
408
|
+
} else {
|
|
409
|
+
mockResponse2 = mockDef.response;
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
type: "mock",
|
|
413
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
for (const interceptor of this.request) {
|
|
417
|
+
const ctx = {
|
|
418
|
+
request: currentRequest,
|
|
419
|
+
meta
|
|
420
|
+
};
|
|
421
|
+
const result = await interceptor(ctx);
|
|
422
|
+
switch (result.action) {
|
|
423
|
+
case "passthrough":
|
|
424
|
+
break;
|
|
425
|
+
case "modify":
|
|
426
|
+
currentRequest = result.request;
|
|
427
|
+
break;
|
|
428
|
+
case "mock":
|
|
429
|
+
return {
|
|
430
|
+
type: "mock",
|
|
431
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
432
|
+
};
|
|
433
|
+
case "error":
|
|
434
|
+
return { type: "error", error: result.error };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { type: "continue", request: currentRequest };
|
|
413
438
|
}
|
|
414
439
|
/**
|
|
415
|
-
*
|
|
440
|
+
* Process a response through the interceptor chain
|
|
416
441
|
*/
|
|
417
|
-
|
|
418
|
-
|
|
442
|
+
async processResponse(request, response, durationMs) {
|
|
443
|
+
let currentResponse = response;
|
|
444
|
+
for (const interceptor of this.response) {
|
|
445
|
+
const ctx = {
|
|
446
|
+
request,
|
|
447
|
+
response: currentResponse,
|
|
448
|
+
durationMs
|
|
449
|
+
};
|
|
450
|
+
const result = await interceptor(ctx);
|
|
451
|
+
switch (result.action) {
|
|
452
|
+
case "passthrough":
|
|
453
|
+
break;
|
|
454
|
+
case "modify":
|
|
455
|
+
currentResponse = result.response;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return currentResponse;
|
|
419
460
|
}
|
|
420
461
|
/**
|
|
421
|
-
*
|
|
462
|
+
* Clear all interceptors and mocks
|
|
422
463
|
*/
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
464
|
+
clear() {
|
|
465
|
+
this.request = [];
|
|
466
|
+
this.response = [];
|
|
467
|
+
this.mocks.clear();
|
|
427
468
|
}
|
|
428
469
|
};
|
|
470
|
+
function sleep(ms) {
|
|
471
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
472
|
+
}
|
|
429
473
|
|
|
430
474
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
431
475
|
var DEFAULT_TIMEOUT = 3e4;
|
|
476
|
+
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
432
477
|
var StreamableHttpTransport = class {
|
|
433
478
|
config;
|
|
434
479
|
state = "disconnected";
|
|
@@ -440,10 +485,15 @@ var StreamableHttpTransport = class {
|
|
|
440
485
|
interceptors;
|
|
441
486
|
publicMode;
|
|
442
487
|
elicitationHandler;
|
|
488
|
+
notificationHandler;
|
|
489
|
+
notificationStream;
|
|
490
|
+
notificationProtocolVersion = "";
|
|
491
|
+
notificationStreamReopenTimer;
|
|
443
492
|
constructor(config) {
|
|
444
493
|
this.config = {
|
|
445
494
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
446
495
|
// Remove trailing slash
|
|
496
|
+
entryPath: config.entryPath,
|
|
447
497
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
448
498
|
auth: config.auth ?? {},
|
|
449
499
|
publicMode: config.publicMode ?? false,
|
|
@@ -455,6 +505,7 @@ var StreamableHttpTransport = class {
|
|
|
455
505
|
this.interceptors = config.interceptors;
|
|
456
506
|
this.publicMode = config.publicMode ?? false;
|
|
457
507
|
this.elicitationHandler = config.elicitationHandler;
|
|
508
|
+
this.notificationHandler = config.notificationHandler;
|
|
458
509
|
}
|
|
459
510
|
async connect() {
|
|
460
511
|
this.state = "connecting";
|
|
@@ -536,7 +587,7 @@ var StreamableHttpTransport = class {
|
|
|
536
587
|
}
|
|
537
588
|
const headers = this.buildHeaders();
|
|
538
589
|
this.lastRequestHeaders = headers;
|
|
539
|
-
const url =
|
|
590
|
+
const url = this.mcpUrl();
|
|
540
591
|
this.log(`POST ${url}`, message);
|
|
541
592
|
const controller = new AbortController();
|
|
542
593
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -606,7 +657,7 @@ var StreamableHttpTransport = class {
|
|
|
606
657
|
this.ensureConnected();
|
|
607
658
|
const headers = this.buildHeaders();
|
|
608
659
|
this.lastRequestHeaders = headers;
|
|
609
|
-
const url =
|
|
660
|
+
const url = this.mcpUrl();
|
|
610
661
|
this.log(`POST ${url} (notification)`, message);
|
|
611
662
|
const controller = new AbortController();
|
|
612
663
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -637,7 +688,7 @@ var StreamableHttpTransport = class {
|
|
|
637
688
|
this.ensureConnected();
|
|
638
689
|
const headers = this.buildHeaders();
|
|
639
690
|
this.lastRequestHeaders = headers;
|
|
640
|
-
const url =
|
|
691
|
+
const url = this.mcpUrl();
|
|
641
692
|
this.log(`POST ${url} (raw)`, data);
|
|
642
693
|
const controller = new AbortController();
|
|
643
694
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -674,7 +725,12 @@ var StreamableHttpTransport = class {
|
|
|
674
725
|
};
|
|
675
726
|
}
|
|
676
727
|
}
|
|
728
|
+
async openNotificationStream(protocolVersion) {
|
|
729
|
+
this.notificationProtocolVersion = protocolVersion;
|
|
730
|
+
await this.connectNotificationStream();
|
|
731
|
+
}
|
|
677
732
|
async close() {
|
|
733
|
+
this.closeNotificationStream();
|
|
678
734
|
this.state = "disconnected";
|
|
679
735
|
this.sessionId = void 0;
|
|
680
736
|
this.log("StreamableHTTP transport closed");
|
|
@@ -713,6 +769,7 @@ var StreamableHttpTransport = class {
|
|
|
713
769
|
return { ...this.lastRequestHeaders };
|
|
714
770
|
}
|
|
715
771
|
async simulateDisconnect() {
|
|
772
|
+
this.closeNotificationStream();
|
|
716
773
|
this.state = "disconnected";
|
|
717
774
|
this.sessionId = void 0;
|
|
718
775
|
}
|
|
@@ -731,6 +788,72 @@ var StreamableHttpTransport = class {
|
|
|
731
788
|
// ═══════════════════════════════════════════════════════════════════
|
|
732
789
|
// PRIVATE HELPERS
|
|
733
790
|
// ═══════════════════════════════════════════════════════════════════
|
|
791
|
+
closeNotificationStream() {
|
|
792
|
+
clearTimeout(this.notificationStreamReopenTimer);
|
|
793
|
+
this.notificationStream?.abort();
|
|
794
|
+
this.notificationStream = void 0;
|
|
795
|
+
}
|
|
796
|
+
/** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
|
|
797
|
+
async connectNotificationStream() {
|
|
798
|
+
if (!this.sessionId || this.notificationStream) return;
|
|
799
|
+
const controller = new AbortController();
|
|
800
|
+
this.notificationStream = controller;
|
|
801
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
802
|
+
const established = fetch(this.mcpUrl(), {
|
|
803
|
+
method: "GET",
|
|
804
|
+
headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
|
|
805
|
+
signal: controller.signal
|
|
806
|
+
}).then(
|
|
807
|
+
async (response) => {
|
|
808
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
809
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
810
|
+
await response.body?.cancel();
|
|
811
|
+
this.log(`Server offers no notification stream (HTTP ${response.status})`);
|
|
812
|
+
this.releaseNotificationStream(controller);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
void this.readNotificationStream(controller, response.body.getReader());
|
|
816
|
+
},
|
|
817
|
+
(error) => {
|
|
818
|
+
this.log("Failed to open the notification stream:", error);
|
|
819
|
+
this.releaseNotificationStream(controller);
|
|
820
|
+
}
|
|
821
|
+
);
|
|
822
|
+
let establishTimer;
|
|
823
|
+
const establishDeadline = new Promise((resolve) => {
|
|
824
|
+
establishTimer = setTimeout(resolve, this.config.timeout);
|
|
825
|
+
});
|
|
826
|
+
await Promise.race([established, establishDeadline]);
|
|
827
|
+
clearTimeout(establishTimer);
|
|
828
|
+
}
|
|
829
|
+
releaseNotificationStream(controller) {
|
|
830
|
+
if (this.notificationStream === controller) this.notificationStream = void 0;
|
|
831
|
+
}
|
|
832
|
+
async readNotificationStream(controller, reader) {
|
|
833
|
+
const decoder = new TextDecoder();
|
|
834
|
+
let buffer = "";
|
|
835
|
+
try {
|
|
836
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
837
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
838
|
+
buffer = remainder;
|
|
839
|
+
for (const eventText of completeEvents) {
|
|
840
|
+
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
841
|
+
await this.handleSSEEvent(event);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} catch (error) {
|
|
846
|
+
this.log("Notification stream ended:", error);
|
|
847
|
+
} finally {
|
|
848
|
+
reader.releaseLock();
|
|
849
|
+
}
|
|
850
|
+
if (controller.signal.aborted || this.notificationStream !== controller) return;
|
|
851
|
+
this.releaseNotificationStream(controller);
|
|
852
|
+
this.notificationStreamReopenTimer = setTimeout(
|
|
853
|
+
() => void this.connectNotificationStream(),
|
|
854
|
+
NOTIFICATION_STREAM_REOPEN_DELAY_MS
|
|
855
|
+
);
|
|
856
|
+
}
|
|
734
857
|
/**
|
|
735
858
|
* Handle SSE response with elicitation support.
|
|
736
859
|
*
|
|
@@ -868,6 +991,13 @@ var StreamableHttpTransport = class {
|
|
|
868
991
|
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
869
992
|
};
|
|
870
993
|
}
|
|
994
|
+
if ("method" in parsed && parsed.id === void 0) {
|
|
995
|
+
this.notificationHandler?.(parsed);
|
|
996
|
+
return {
|
|
997
|
+
isFinal: false,
|
|
998
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
871
1001
|
if ("result" in parsed || "error" in parsed) {
|
|
872
1002
|
return { isFinal: true, response: parsed };
|
|
873
1003
|
}
|
|
@@ -920,7 +1050,7 @@ var StreamableHttpTransport = class {
|
|
|
920
1050
|
*/
|
|
921
1051
|
async sendElicitationResponse(requestId, response) {
|
|
922
1052
|
const headers = this.buildHeaders();
|
|
923
|
-
const url =
|
|
1053
|
+
const url = this.mcpUrl();
|
|
924
1054
|
const rpcResponse = {
|
|
925
1055
|
jsonrpc: "2.0",
|
|
926
1056
|
id: requestId,
|
|
@@ -940,6 +1070,18 @@ var StreamableHttpTransport = class {
|
|
|
940
1070
|
this.log("Failed to send elicitation response:", error);
|
|
941
1071
|
}
|
|
942
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
1075
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
1076
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
1077
|
+
*/
|
|
1078
|
+
mcpUrl() {
|
|
1079
|
+
const url = new URL(this.config.baseUrl);
|
|
1080
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1081
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1082
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
1083
|
+
return url.toString();
|
|
1084
|
+
}
|
|
943
1085
|
buildHeaders() {
|
|
944
1086
|
const headers = {
|
|
945
1087
|
"Content-Type": "application/json",
|
|
@@ -1026,286 +1168,266 @@ var StreamableHttpTransport = class {
|
|
|
1026
1168
|
};
|
|
1027
1169
|
}
|
|
1028
1170
|
};
|
|
1171
|
+
function splitSSEEvents(buffer) {
|
|
1172
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1173
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1174
|
+
const completeEvents = normalized.split("\n\n");
|
|
1175
|
+
const remainder = completeEvents.pop() ?? "";
|
|
1176
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1177
|
+
}
|
|
1029
1178
|
|
|
1030
|
-
// libs/testing/src/
|
|
1031
|
-
var
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1179
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1180
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1181
|
+
function getPlatformClientInfo(platform) {
|
|
1182
|
+
switch (platform) {
|
|
1183
|
+
case "openai":
|
|
1184
|
+
return {
|
|
1185
|
+
name: "ChatGPT",
|
|
1186
|
+
version: "1.0"
|
|
1187
|
+
};
|
|
1188
|
+
case "ext-apps":
|
|
1189
|
+
return {
|
|
1190
|
+
name: "mcp-ext-apps",
|
|
1191
|
+
version: "1.0"
|
|
1192
|
+
};
|
|
1193
|
+
case "claude":
|
|
1194
|
+
return {
|
|
1195
|
+
name: "claude-desktop",
|
|
1196
|
+
version: "1.0"
|
|
1197
|
+
};
|
|
1198
|
+
case "cursor":
|
|
1199
|
+
return {
|
|
1200
|
+
name: "cursor",
|
|
1201
|
+
version: "1.0"
|
|
1202
|
+
};
|
|
1203
|
+
case "continue":
|
|
1204
|
+
return {
|
|
1205
|
+
name: "continue",
|
|
1206
|
+
version: "1.0"
|
|
1207
|
+
};
|
|
1208
|
+
case "cody":
|
|
1209
|
+
return {
|
|
1210
|
+
name: "cody",
|
|
1211
|
+
version: "1.0"
|
|
1212
|
+
};
|
|
1213
|
+
case "gemini":
|
|
1214
|
+
return {
|
|
1215
|
+
name: "gemini",
|
|
1216
|
+
version: "1.0"
|
|
1217
|
+
};
|
|
1218
|
+
case "generic-mcp":
|
|
1219
|
+
return {
|
|
1220
|
+
name: "generic-mcp-client",
|
|
1221
|
+
version: "1.0"
|
|
1222
|
+
};
|
|
1223
|
+
case "unknown":
|
|
1224
|
+
default:
|
|
1225
|
+
return {
|
|
1226
|
+
name: "mcp-test-client",
|
|
1227
|
+
version: "1.0"
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
function getPlatformCapabilities(platform) {
|
|
1232
|
+
const baseCapabilities = {
|
|
1233
|
+
sampling: {},
|
|
1234
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1235
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1236
|
+
elicitation: {
|
|
1237
|
+
form: {}
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
if (platform === "ext-apps") {
|
|
1041
1241
|
return {
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1242
|
+
...baseCapabilities,
|
|
1243
|
+
experimental: {
|
|
1244
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1245
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1046
1246
|
}
|
|
1047
|
-
}
|
|
1048
|
-
callCount: () => entry.callCount,
|
|
1049
|
-
calls: () => [...entry.calls]
|
|
1247
|
+
}
|
|
1050
1248
|
};
|
|
1051
1249
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
if (entry.remainingUses <= 0) continue;
|
|
1061
|
-
const { definition } = entry;
|
|
1062
|
-
if (definition.method !== request.method) continue;
|
|
1063
|
-
if (definition.params !== void 0) {
|
|
1064
|
-
const params = request.params ?? {};
|
|
1065
|
-
if (typeof definition.params === "function") {
|
|
1066
|
-
if (!definition.params(params)) continue;
|
|
1067
|
-
} else {
|
|
1068
|
-
if (!this.paramsMatch(definition.params, params)) continue;
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
entry.callCount++;
|
|
1072
|
-
entry.calls.push(request);
|
|
1073
|
-
entry.remainingUses--;
|
|
1074
|
-
return definition;
|
|
1075
|
-
}
|
|
1076
|
-
return void 0;
|
|
1250
|
+
return baseCapabilities;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1254
|
+
var McpTestClientBuilder = class {
|
|
1255
|
+
config;
|
|
1256
|
+
constructor(config) {
|
|
1257
|
+
this.config = { ...config };
|
|
1077
1258
|
}
|
|
1078
1259
|
/**
|
|
1079
|
-
*
|
|
1260
|
+
* Set the authentication configuration
|
|
1080
1261
|
*/
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
const actualValue = actual[key];
|
|
1085
|
-
if (Array.isArray(value)) {
|
|
1086
|
-
if (!Array.isArray(actualValue)) return false;
|
|
1087
|
-
if (value.length !== actualValue.length) return false;
|
|
1088
|
-
for (let i = 0; i < value.length; i++) {
|
|
1089
|
-
const expectedItem = value[i];
|
|
1090
|
-
const actualItem = actualValue[i];
|
|
1091
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1092
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1093
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1094
|
-
return false;
|
|
1095
|
-
}
|
|
1096
|
-
} else if (actualItem !== expectedItem) {
|
|
1097
|
-
return false;
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1101
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1102
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
1103
|
-
return false;
|
|
1104
|
-
}
|
|
1105
|
-
} else if (actualValue !== value) {
|
|
1106
|
-
return false;
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
return true;
|
|
1262
|
+
withAuth(auth) {
|
|
1263
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1264
|
+
return this;
|
|
1110
1265
|
}
|
|
1111
|
-
};
|
|
1112
|
-
var mockResponse = {
|
|
1113
1266
|
/**
|
|
1114
|
-
*
|
|
1267
|
+
* Set the bearer token for authentication
|
|
1115
1268
|
*/
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
result
|
|
1121
|
-
};
|
|
1122
|
-
},
|
|
1269
|
+
withToken(token) {
|
|
1270
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1271
|
+
return this;
|
|
1272
|
+
}
|
|
1123
1273
|
/**
|
|
1124
|
-
*
|
|
1274
|
+
* Add custom headers to all requests
|
|
1125
1275
|
*/
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
error: { code, message, data }
|
|
1276
|
+
withHeaders(headers) {
|
|
1277
|
+
this.config.auth = {
|
|
1278
|
+
...this.config.auth,
|
|
1279
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1131
1280
|
};
|
|
1132
|
-
|
|
1281
|
+
return this;
|
|
1282
|
+
}
|
|
1133
1283
|
/**
|
|
1134
|
-
*
|
|
1284
|
+
* Set the transport type
|
|
1135
1285
|
*/
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
result: { content }
|
|
1141
|
-
};
|
|
1142
|
-
},
|
|
1286
|
+
withTransport(transport) {
|
|
1287
|
+
this.config.transport = transport;
|
|
1288
|
+
return this;
|
|
1289
|
+
}
|
|
1143
1290
|
/**
|
|
1144
|
-
*
|
|
1291
|
+
* Set the request timeout in milliseconds
|
|
1145
1292
|
*/
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
result: { tools }
|
|
1151
|
-
};
|
|
1152
|
-
},
|
|
1293
|
+
withTimeout(timeoutMs) {
|
|
1294
|
+
this.config.timeout = timeoutMs;
|
|
1295
|
+
return this;
|
|
1296
|
+
}
|
|
1153
1297
|
/**
|
|
1154
|
-
*
|
|
1298
|
+
* Enable debug logging
|
|
1155
1299
|
*/
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
result: { resources }
|
|
1161
|
-
};
|
|
1162
|
-
},
|
|
1300
|
+
withDebug(enabled = true) {
|
|
1301
|
+
this.config.debug = enabled;
|
|
1302
|
+
return this;
|
|
1303
|
+
}
|
|
1163
1304
|
/**
|
|
1164
|
-
*
|
|
1305
|
+
* Enable public mode - skip authentication entirely.
|
|
1306
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1307
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1165
1308
|
*/
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
result: { contents }
|
|
1171
|
-
};
|
|
1172
|
-
},
|
|
1309
|
+
withPublicMode(enabled = true) {
|
|
1310
|
+
this.config.publicMode = enabled;
|
|
1311
|
+
return this;
|
|
1312
|
+
}
|
|
1173
1313
|
/**
|
|
1174
|
-
*
|
|
1314
|
+
* Set the MCP protocol version to request (default '2025-06-18').
|
|
1315
|
+
* '2026-07-28' has no initialize handshake, so `build()` throws for it.
|
|
1175
1316
|
*/
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1180
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1181
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1182
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1183
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1317
|
+
withProtocolVersion(version) {
|
|
1318
|
+
this.config.protocolVersion = version;
|
|
1319
|
+
return this;
|
|
1184
1320
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
mocks;
|
|
1192
|
-
constructor() {
|
|
1193
|
-
this.mocks = new DefaultMockRegistry();
|
|
1321
|
+
/**
|
|
1322
|
+
* Set the client info sent during initialization
|
|
1323
|
+
*/
|
|
1324
|
+
withClientInfo(info) {
|
|
1325
|
+
this.config.clientInfo = info;
|
|
1326
|
+
return this;
|
|
1194
1327
|
}
|
|
1195
1328
|
/**
|
|
1196
|
-
*
|
|
1329
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1330
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1331
|
+
*
|
|
1332
|
+
* Platform-specific behavior:
|
|
1333
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1334
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1335
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1336
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1337
|
+
* - Other platforms follow similar patterns
|
|
1338
|
+
*
|
|
1339
|
+
* @example
|
|
1340
|
+
* ```typescript
|
|
1341
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1342
|
+
* .withPlatform('openai')
|
|
1343
|
+
* .buildAndConnect();
|
|
1344
|
+
*
|
|
1345
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1346
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1347
|
+
* .withPlatform('ext-apps')
|
|
1348
|
+
* .buildAndConnect();
|
|
1349
|
+
* ```
|
|
1197
1350
|
*/
|
|
1198
|
-
|
|
1199
|
-
this.
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
this.request.splice(index, 1);
|
|
1204
|
-
}
|
|
1205
|
-
};
|
|
1351
|
+
withPlatform(platform) {
|
|
1352
|
+
this.config.platform = platform;
|
|
1353
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1354
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1355
|
+
return this;
|
|
1206
1356
|
}
|
|
1207
1357
|
/**
|
|
1208
|
-
*
|
|
1358
|
+
* Set custom client capabilities for MCP initialization.
|
|
1359
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1360
|
+
*
|
|
1361
|
+
* @example
|
|
1362
|
+
* ```typescript
|
|
1363
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1364
|
+
* .withCapabilities({
|
|
1365
|
+
* sampling: {},
|
|
1366
|
+
* experimental: {
|
|
1367
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1368
|
+
* }
|
|
1369
|
+
* })
|
|
1370
|
+
* .buildAndConnect();
|
|
1371
|
+
* ```
|
|
1209
1372
|
*/
|
|
1210
|
-
|
|
1211
|
-
this.
|
|
1212
|
-
return
|
|
1213
|
-
const index = this.response.indexOf(interceptor);
|
|
1214
|
-
if (index !== -1) {
|
|
1215
|
-
this.response.splice(index, 1);
|
|
1216
|
-
}
|
|
1217
|
-
};
|
|
1373
|
+
withCapabilities(capabilities) {
|
|
1374
|
+
this.config.capabilities = capabilities;
|
|
1375
|
+
return this;
|
|
1218
1376
|
}
|
|
1219
1377
|
/**
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1378
|
+
* Set query parameters to append to the connection URL.
|
|
1379
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1380
|
+
*
|
|
1381
|
+
* @example
|
|
1382
|
+
* ```typescript
|
|
1383
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1384
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1385
|
+
* .buildAndConnect();
|
|
1386
|
+
* ```
|
|
1225
1387
|
*/
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
if (mockDef) {
|
|
1230
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1231
|
-
await sleep(mockDef.delay);
|
|
1232
|
-
}
|
|
1233
|
-
let mockResponse2;
|
|
1234
|
-
if (typeof mockDef.response === "function") {
|
|
1235
|
-
mockResponse2 = await mockDef.response(request);
|
|
1236
|
-
} else {
|
|
1237
|
-
mockResponse2 = mockDef.response;
|
|
1238
|
-
}
|
|
1239
|
-
return {
|
|
1240
|
-
type: "mock",
|
|
1241
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1242
|
-
};
|
|
1243
|
-
}
|
|
1244
|
-
for (const interceptor of this.request) {
|
|
1245
|
-
const ctx = {
|
|
1246
|
-
request: currentRequest,
|
|
1247
|
-
meta
|
|
1248
|
-
};
|
|
1249
|
-
const result = await interceptor(ctx);
|
|
1250
|
-
switch (result.action) {
|
|
1251
|
-
case "passthrough":
|
|
1252
|
-
break;
|
|
1253
|
-
case "modify":
|
|
1254
|
-
currentRequest = result.request;
|
|
1255
|
-
break;
|
|
1256
|
-
case "mock":
|
|
1257
|
-
return {
|
|
1258
|
-
type: "mock",
|
|
1259
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1260
|
-
};
|
|
1261
|
-
case "error":
|
|
1262
|
-
return { type: "error", error: result.error };
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
return { type: "continue", request: currentRequest };
|
|
1388
|
+
withQueryParams(params) {
|
|
1389
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1390
|
+
return this;
|
|
1266
1391
|
}
|
|
1267
1392
|
/**
|
|
1268
|
-
*
|
|
1393
|
+
* Build the McpTestClient instance (does not connect)
|
|
1269
1394
|
*/
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
for (const interceptor of this.response) {
|
|
1273
|
-
const ctx = {
|
|
1274
|
-
request,
|
|
1275
|
-
response: currentResponse,
|
|
1276
|
-
durationMs
|
|
1277
|
-
};
|
|
1278
|
-
const result = await interceptor(ctx);
|
|
1279
|
-
switch (result.action) {
|
|
1280
|
-
case "passthrough":
|
|
1281
|
-
break;
|
|
1282
|
-
case "modify":
|
|
1283
|
-
currentResponse = result.response;
|
|
1284
|
-
break;
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
return currentResponse;
|
|
1395
|
+
build() {
|
|
1396
|
+
return new McpTestClient(this.config);
|
|
1288
1397
|
}
|
|
1289
1398
|
/**
|
|
1290
|
-
*
|
|
1399
|
+
* Build the McpTestClient and connect to the server
|
|
1291
1400
|
*/
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1401
|
+
async buildAndConnect() {
|
|
1402
|
+
const client = this.build();
|
|
1403
|
+
await client.connect();
|
|
1404
|
+
return client;
|
|
1296
1405
|
}
|
|
1297
1406
|
};
|
|
1298
|
-
function sleep(ms) {
|
|
1299
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1300
|
-
}
|
|
1301
1407
|
|
|
1302
1408
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1303
1409
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1304
1410
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1411
|
+
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1305
1412
|
var DEFAULT_CLIENT_INFO = {
|
|
1306
1413
|
name: "@frontmcp/testing",
|
|
1307
1414
|
version: "0.4.0"
|
|
1308
1415
|
};
|
|
1416
|
+
function extractEntryPaths(data) {
|
|
1417
|
+
let payload = data;
|
|
1418
|
+
if (typeof payload === "string") {
|
|
1419
|
+
try {
|
|
1420
|
+
payload = JSON.parse(payload);
|
|
1421
|
+
} catch {
|
|
1422
|
+
return void 0;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1426
|
+
const paths = payload.entryPaths;
|
|
1427
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1428
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1429
|
+
return strings.length > 0 ? strings : void 0;
|
|
1430
|
+
}
|
|
1309
1431
|
var McpTestClient = class {
|
|
1310
1432
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1311
1433
|
config;
|
|
@@ -1314,6 +1436,8 @@ var McpTestClient = class {
|
|
|
1314
1436
|
requestIdCounter = 0;
|
|
1315
1437
|
_lastRequestId = 0;
|
|
1316
1438
|
_sessionId;
|
|
1439
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1440
|
+
triedDiscoveredEntryPath = false;
|
|
1317
1441
|
_sessionInfo = null;
|
|
1318
1442
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1319
1443
|
// Logging and tracing
|
|
@@ -1321,6 +1445,8 @@ var McpTestClient = class {
|
|
|
1321
1445
|
_traces = [];
|
|
1322
1446
|
_notifications = [];
|
|
1323
1447
|
_progressUpdates = [];
|
|
1448
|
+
collectingProgress = false;
|
|
1449
|
+
progressTokenCounter = 0;
|
|
1324
1450
|
// Interceptor chain
|
|
1325
1451
|
_interceptors;
|
|
1326
1452
|
// Elicitation handler for server→client elicit requests
|
|
@@ -1329,8 +1455,14 @@ var McpTestClient = class {
|
|
|
1329
1455
|
// CONSTRUCTOR & FACTORY
|
|
1330
1456
|
// ═══════════════════════════════════════════════════════════════════
|
|
1331
1457
|
constructor(config) {
|
|
1458
|
+
if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
|
|
1459
|
+
throw new Error(
|
|
1460
|
+
`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1332
1463
|
this.config = {
|
|
1333
1464
|
baseUrl: config.baseUrl,
|
|
1465
|
+
entryPath: config.entryPath,
|
|
1334
1466
|
transport: config.transport ?? "streamable-http",
|
|
1335
1467
|
auth: config.auth ?? {},
|
|
1336
1468
|
publicMode: config.publicMode ?? false,
|
|
@@ -1366,11 +1498,13 @@ var McpTestClient = class {
|
|
|
1366
1498
|
*/
|
|
1367
1499
|
async connect() {
|
|
1368
1500
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1501
|
+
this.triedDiscoveredEntryPath = false;
|
|
1369
1502
|
this.transport = this.createTransport();
|
|
1370
1503
|
await this.transport.connect();
|
|
1371
1504
|
const maxRetries = 3;
|
|
1372
1505
|
const retryDelayMs = 500;
|
|
1373
1506
|
let lastError;
|
|
1507
|
+
let reportedEntryPaths;
|
|
1374
1508
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1375
1509
|
const initResponse = await this.initialize();
|
|
1376
1510
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1378,13 +1512,27 @@ var McpTestClient = class {
|
|
|
1378
1512
|
break;
|
|
1379
1513
|
}
|
|
1380
1514
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1515
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1516
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1517
|
+
this.triedDiscoveredEntryPath = true;
|
|
1518
|
+
const discovered = reportedEntryPaths[0];
|
|
1519
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1520
|
+
this.config.entryPath = discovered;
|
|
1521
|
+
await this.transport.close();
|
|
1522
|
+
this.transport = this.createTransport();
|
|
1523
|
+
await this.transport.connect();
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1381
1526
|
if (attempt < maxRetries) {
|
|
1382
1527
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1383
1528
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1384
1529
|
}
|
|
1385
1530
|
}
|
|
1386
1531
|
if (!this.initResult) {
|
|
1387
|
-
|
|
1532
|
+
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
|
+
throw new Error(
|
|
1534
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1535
|
+
);
|
|
1388
1536
|
}
|
|
1389
1537
|
this._sessionId = this.transport.getSessionId();
|
|
1390
1538
|
this._sessionInfo = {
|
|
@@ -1397,6 +1545,7 @@ var McpTestClient = class {
|
|
|
1397
1545
|
jsonrpc: "2.0",
|
|
1398
1546
|
method: "notifications/initialized"
|
|
1399
1547
|
});
|
|
1548
|
+
await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
|
|
1400
1549
|
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
1401
1550
|
return this.initResult;
|
|
1402
1551
|
}
|
|
@@ -1507,8 +1656,8 @@ var McpTestClient = class {
|
|
|
1507
1656
|
/**
|
|
1508
1657
|
* Call a tool by name with arguments
|
|
1509
1658
|
*/
|
|
1510
|
-
call: async (name, args) => {
|
|
1511
|
-
const response = await this.callTool(name, args);
|
|
1659
|
+
call: async (name, args, options = {}) => {
|
|
1660
|
+
const response = await this.callTool(name, args, this.progressTokenFor(options));
|
|
1512
1661
|
return this.wrapToolResult(response);
|
|
1513
1662
|
}
|
|
1514
1663
|
};
|
|
@@ -1650,6 +1799,7 @@ var McpTestClient = class {
|
|
|
1650
1799
|
* Collect progress notifications specifically
|
|
1651
1800
|
*/
|
|
1652
1801
|
collectProgress: () => {
|
|
1802
|
+
this.collectingProgress = true;
|
|
1653
1803
|
return new ProgressCollector(this._progressUpdates);
|
|
1654
1804
|
},
|
|
1655
1805
|
/**
|
|
@@ -1955,12 +2105,29 @@ var McpTestClient = class {
|
|
|
1955
2105
|
async listTools() {
|
|
1956
2106
|
return this.request("tools/list", {});
|
|
1957
2107
|
}
|
|
1958
|
-
async callTool(name, args) {
|
|
2108
|
+
async callTool(name, args, progressToken) {
|
|
1959
2109
|
return this.request("tools/call", {
|
|
1960
2110
|
name,
|
|
1961
|
-
arguments: args ?? {}
|
|
2111
|
+
arguments: args ?? {},
|
|
2112
|
+
...progressToken !== void 0 && { _meta: { progressToken } }
|
|
1962
2113
|
});
|
|
1963
2114
|
}
|
|
2115
|
+
progressTokenFor(options) {
|
|
2116
|
+
if (options.progressToken !== void 0) return options.progressToken;
|
|
2117
|
+
return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
|
|
2118
|
+
}
|
|
2119
|
+
recordNotification = (notification) => {
|
|
2120
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
2121
|
+
this._notifications.push({ method: notification.method, params: notification.params, timestamp });
|
|
2122
|
+
if (notification.method !== "notifications/progress") return;
|
|
2123
|
+
const { progress, total, progressToken } = notification.params ?? {};
|
|
2124
|
+
this._progressUpdates.push({
|
|
2125
|
+
progress: Number(progress),
|
|
2126
|
+
total: typeof total === "number" ? total : void 0,
|
|
2127
|
+
progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
|
|
2128
|
+
timestamp
|
|
2129
|
+
});
|
|
2130
|
+
};
|
|
1964
2131
|
async listResources() {
|
|
1965
2132
|
return this.request("resources/list", {});
|
|
1966
2133
|
}
|
|
@@ -1982,6 +2149,11 @@ var McpTestClient = class {
|
|
|
1982
2149
|
// ═══════════════════════════════════════════════════════════════════
|
|
1983
2150
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
1984
2151
|
// ═══════════════════════════════════════════════════════════════════
|
|
2152
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2153
|
+
mcpPathLabel() {
|
|
2154
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2155
|
+
return entry ? `/${entry}` : "/";
|
|
2156
|
+
}
|
|
1985
2157
|
createTransport() {
|
|
1986
2158
|
let baseUrl = this.config.baseUrl;
|
|
1987
2159
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -1995,13 +2167,15 @@ var McpTestClient = class {
|
|
|
1995
2167
|
case "streamable-http":
|
|
1996
2168
|
return new StreamableHttpTransport({
|
|
1997
2169
|
baseUrl,
|
|
2170
|
+
entryPath: this.config.entryPath,
|
|
1998
2171
|
timeout: this.config.timeout,
|
|
1999
2172
|
auth: this.config.auth,
|
|
2000
2173
|
publicMode: this.config.publicMode,
|
|
2001
2174
|
debug: this.config.debug,
|
|
2002
2175
|
interceptors: this._interceptors,
|
|
2003
2176
|
clientInfo: this.config.clientInfo,
|
|
2004
|
-
elicitationHandler: this._elicitationHandler
|
|
2177
|
+
elicitationHandler: this._elicitationHandler,
|
|
2178
|
+
notificationHandler: this.recordNotification
|
|
2005
2179
|
});
|
|
2006
2180
|
case "sse":
|
|
2007
2181
|
throw new Error("SSE transport not yet implemented");
|
|
@@ -2986,13 +3160,17 @@ var currentConfig = {};
|
|
|
2986
3160
|
var serverInstance = null;
|
|
2987
3161
|
var tokenFactory = null;
|
|
2988
3162
|
var serverStartedByUs = false;
|
|
3163
|
+
var skipScopes = [{ skipped: false }];
|
|
3164
|
+
function currentSkipScope() {
|
|
3165
|
+
return skipScopes[skipScopes.length - 1];
|
|
3166
|
+
}
|
|
2989
3167
|
async function initializeSharedResources() {
|
|
2990
3168
|
if (!tokenFactory) {
|
|
2991
3169
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
2992
3170
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
2993
3171
|
}
|
|
2994
3172
|
if (!serverInstance) {
|
|
2995
|
-
if (currentConfig.baseUrl) {
|
|
3173
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
2996
3174
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
2997
3175
|
serverStartedByUs = false;
|
|
2998
3176
|
} else if (currentConfig.server) {
|
|
@@ -3042,7 +3220,8 @@ async function createTestFixtures() {
|
|
|
3042
3220
|
throw new Error("Token factory not initialized");
|
|
3043
3221
|
}
|
|
3044
3222
|
const clientInstance = McpTestClient.create({
|
|
3045
|
-
baseUrl: serverInstance
|
|
3223
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
3224
|
+
entryPath: currentConfig.entryPath,
|
|
3046
3225
|
transport: currentConfig.transport ?? "streamable-http",
|
|
3047
3226
|
publicMode: currentConfig.publicMode
|
|
3048
3227
|
}).build();
|
|
@@ -3091,6 +3270,9 @@ async function cleanupSharedResources() {
|
|
|
3091
3270
|
tokenFactory = null;
|
|
3092
3271
|
serverStartedByUs = false;
|
|
3093
3272
|
}
|
|
3273
|
+
function resolveClientBaseUrl(server) {
|
|
3274
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
3275
|
+
}
|
|
3094
3276
|
function createAuthFixture(factory) {
|
|
3095
3277
|
const users = {
|
|
3096
3278
|
admin: {
|
|
@@ -3140,7 +3322,8 @@ function createServerFixture(server) {
|
|
|
3140
3322
|
info: server.info,
|
|
3141
3323
|
createClient: async (opts) => {
|
|
3142
3324
|
return McpTestClient.create({
|
|
3143
|
-
baseUrl: server
|
|
3325
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3326
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
3144
3327
|
transport: opts?.transport ?? "streamable-http",
|
|
3145
3328
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3146
3329
|
clientInfo: opts?.clientInfo,
|
|
@@ -3149,7 +3332,8 @@ function createServerFixture(server) {
|
|
|
3149
3332
|
},
|
|
3150
3333
|
createClientBuilder: () => {
|
|
3151
3334
|
const builder = new McpTestClientBuilder({
|
|
3152
|
-
baseUrl: server
|
|
3335
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3336
|
+
entryPath: currentConfig.entryPath,
|
|
3153
3337
|
publicMode: currentConfig.publicMode
|
|
3154
3338
|
});
|
|
3155
3339
|
return builder;
|
|
@@ -3165,8 +3349,8 @@ function resolveServerCommand(server) {
|
|
|
3165
3349
|
}
|
|
3166
3350
|
return `npx tsx ${server}`;
|
|
3167
3351
|
}
|
|
3168
|
-
function
|
|
3169
|
-
|
|
3352
|
+
function runWithFixtures(fn) {
|
|
3353
|
+
return async () => {
|
|
3170
3354
|
const fixtures = await createTestFixtures();
|
|
3171
3355
|
let testFailed = false;
|
|
3172
3356
|
try {
|
|
@@ -3177,7 +3361,15 @@ function testWithFixtures(name, fn) {
|
|
|
3177
3361
|
} finally {
|
|
3178
3362
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
3179
3363
|
}
|
|
3180
|
-
}
|
|
3364
|
+
};
|
|
3365
|
+
}
|
|
3366
|
+
function testWithFixtures(name, fn) {
|
|
3367
|
+
const scope = currentSkipScope();
|
|
3368
|
+
if (scope.skipped) {
|
|
3369
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3370
|
+
return;
|
|
3371
|
+
}
|
|
3372
|
+
it(name, runWithFixtures(fn));
|
|
3181
3373
|
}
|
|
3182
3374
|
function use(config) {
|
|
3183
3375
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -3185,40 +3377,60 @@ function use(config) {
|
|
|
3185
3377
|
await cleanupSharedResources();
|
|
3186
3378
|
});
|
|
3187
3379
|
}
|
|
3188
|
-
function skip(
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
} catch (error) {
|
|
3195
|
-
testFailed = true;
|
|
3196
|
-
throw error;
|
|
3197
|
-
} finally {
|
|
3198
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3380
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
3381
|
+
if (typeof nameOrCondition === "boolean") {
|
|
3382
|
+
if (nameOrCondition) {
|
|
3383
|
+
const scope = currentSkipScope();
|
|
3384
|
+
scope.skipped = true;
|
|
3385
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3199
3386
|
}
|
|
3200
|
-
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
if (typeof fnOrReason !== "function") {
|
|
3390
|
+
throw new TypeError(
|
|
3391
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3392
|
+
);
|
|
3393
|
+
}
|
|
3394
|
+
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3201
3395
|
}
|
|
3202
3396
|
function only(name, fn) {
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
testFailed = true;
|
|
3210
|
-
throw error;
|
|
3211
|
-
} finally {
|
|
3212
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3213
|
-
}
|
|
3214
|
-
});
|
|
3397
|
+
const scope = currentSkipScope();
|
|
3398
|
+
if (scope.skipped) {
|
|
3399
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
it.only(name, runWithFixtures(fn));
|
|
3215
3403
|
}
|
|
3216
3404
|
function todo(name) {
|
|
3217
3405
|
it.todo(name);
|
|
3218
3406
|
}
|
|
3407
|
+
function withSkipScope(register) {
|
|
3408
|
+
return (name, fn) => {
|
|
3409
|
+
register(name, () => {
|
|
3410
|
+
const parent = currentSkipScope();
|
|
3411
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
3412
|
+
try {
|
|
3413
|
+
fn();
|
|
3414
|
+
} finally {
|
|
3415
|
+
skipScopes.pop();
|
|
3416
|
+
}
|
|
3417
|
+
});
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
function eachWithSkipScope(each) {
|
|
3421
|
+
return ((...eachArgs) => {
|
|
3422
|
+
const register = each(...eachArgs);
|
|
3423
|
+
return withSkipScope(register);
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
|
|
3427
|
+
only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
|
|
3428
|
+
skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
|
|
3429
|
+
each: eachWithSkipScope(describe.each)
|
|
3430
|
+
});
|
|
3219
3431
|
var test = testWithFixtures;
|
|
3220
3432
|
test.use = use;
|
|
3221
|
-
test.describe =
|
|
3433
|
+
test.describe = describeWithSkipScope;
|
|
3222
3434
|
test.beforeAll = beforeAll;
|
|
3223
3435
|
test.beforeEach = beforeEach;
|
|
3224
3436
|
test.afterEach = afterEach;
|