@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/fixtures/index.js
CHANGED
|
@@ -215,236 +215,281 @@ var TestTokenFactory = class {
|
|
|
215
215
|
}
|
|
216
216
|
};
|
|
217
217
|
|
|
218
|
-
// libs/testing/src/
|
|
219
|
-
var
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
name: "mcp-ext-apps",
|
|
230
|
-
version: "1.0"
|
|
231
|
-
};
|
|
232
|
-
case "claude":
|
|
233
|
-
return {
|
|
234
|
-
name: "claude-desktop",
|
|
235
|
-
version: "1.0"
|
|
236
|
-
};
|
|
237
|
-
case "cursor":
|
|
238
|
-
return {
|
|
239
|
-
name: "cursor",
|
|
240
|
-
version: "1.0"
|
|
241
|
-
};
|
|
242
|
-
case "continue":
|
|
243
|
-
return {
|
|
244
|
-
name: "continue",
|
|
245
|
-
version: "1.0"
|
|
246
|
-
};
|
|
247
|
-
case "cody":
|
|
248
|
-
return {
|
|
249
|
-
name: "cody",
|
|
250
|
-
version: "1.0"
|
|
251
|
-
};
|
|
252
|
-
case "gemini":
|
|
253
|
-
return {
|
|
254
|
-
name: "gemini",
|
|
255
|
-
version: "1.0"
|
|
256
|
-
};
|
|
257
|
-
case "generic-mcp":
|
|
258
|
-
return {
|
|
259
|
-
name: "generic-mcp-client",
|
|
260
|
-
version: "1.0"
|
|
261
|
-
};
|
|
262
|
-
case "unknown":
|
|
263
|
-
default:
|
|
264
|
-
return {
|
|
265
|
-
name: "mcp-test-client",
|
|
266
|
-
version: "1.0"
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
function getPlatformCapabilities(platform) {
|
|
271
|
-
const baseCapabilities = {
|
|
272
|
-
sampling: {},
|
|
273
|
-
// Include elicitation.form by default for testing elicitation workflows
|
|
274
|
-
// Note: MCP SDK expects form to be an object, not boolean
|
|
275
|
-
elicitation: {
|
|
276
|
-
form: {}
|
|
277
|
-
}
|
|
278
|
-
};
|
|
279
|
-
if (platform === "ext-apps") {
|
|
218
|
+
// libs/testing/src/interceptor/mock-registry.ts
|
|
219
|
+
var DefaultMockRegistry = class {
|
|
220
|
+
mocks = [];
|
|
221
|
+
add(mock) {
|
|
222
|
+
const entry = {
|
|
223
|
+
definition: mock,
|
|
224
|
+
callCount: 0,
|
|
225
|
+
calls: [],
|
|
226
|
+
remainingUses: mock.times ?? Infinity
|
|
227
|
+
};
|
|
228
|
+
this.mocks.push(entry);
|
|
280
229
|
return {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
230
|
+
remove: () => {
|
|
231
|
+
const index = this.mocks.indexOf(entry);
|
|
232
|
+
if (index !== -1) {
|
|
233
|
+
this.mocks.splice(index, 1);
|
|
285
234
|
}
|
|
286
|
-
}
|
|
235
|
+
},
|
|
236
|
+
callCount: () => entry.callCount,
|
|
237
|
+
calls: () => [...entry.calls]
|
|
287
238
|
};
|
|
288
239
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
293
|
-
var McpTestClientBuilder = class {
|
|
294
|
-
config;
|
|
295
|
-
constructor(config) {
|
|
296
|
-
this.config = { ...config };
|
|
240
|
+
clear() {
|
|
241
|
+
this.mocks = [];
|
|
297
242
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
243
|
+
getAll() {
|
|
244
|
+
return this.mocks.map((e) => e.definition);
|
|
245
|
+
}
|
|
246
|
+
match(request) {
|
|
247
|
+
for (const entry of this.mocks) {
|
|
248
|
+
if (entry.remainingUses <= 0) continue;
|
|
249
|
+
const { definition } = entry;
|
|
250
|
+
if (definition.method !== request.method) continue;
|
|
251
|
+
if (definition.params !== void 0) {
|
|
252
|
+
const params = request.params ?? {};
|
|
253
|
+
if (typeof definition.params === "function") {
|
|
254
|
+
if (!definition.params(params)) continue;
|
|
255
|
+
} else {
|
|
256
|
+
if (!this.paramsMatch(definition.params, params)) continue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
entry.callCount++;
|
|
260
|
+
entry.calls.push(request);
|
|
261
|
+
entry.remainingUses--;
|
|
262
|
+
return definition;
|
|
263
|
+
}
|
|
264
|
+
return void 0;
|
|
304
265
|
}
|
|
305
266
|
/**
|
|
306
|
-
*
|
|
267
|
+
* Check if request params match the mock params definition
|
|
307
268
|
*/
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
269
|
+
paramsMatch(expected, actual) {
|
|
270
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
271
|
+
if (!(key in actual)) return false;
|
|
272
|
+
const actualValue = actual[key];
|
|
273
|
+
if (Array.isArray(value)) {
|
|
274
|
+
if (!Array.isArray(actualValue)) return false;
|
|
275
|
+
if (value.length !== actualValue.length) return false;
|
|
276
|
+
for (let i = 0; i < value.length; i++) {
|
|
277
|
+
const expectedItem = value[i];
|
|
278
|
+
const actualItem = actualValue[i];
|
|
279
|
+
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
280
|
+
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
281
|
+
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
} else if (actualItem !== expectedItem) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} else if (typeof value === "object" && value !== null) {
|
|
289
|
+
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
290
|
+
if (!this.paramsMatch(value, actualValue)) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
} else if (actualValue !== value) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
311
298
|
}
|
|
299
|
+
};
|
|
300
|
+
var mockResponse = {
|
|
312
301
|
/**
|
|
313
|
-
*
|
|
302
|
+
* Create a successful JSON-RPC response
|
|
314
303
|
*/
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
304
|
+
success(result, id = 1) {
|
|
305
|
+
return {
|
|
306
|
+
jsonrpc: "2.0",
|
|
307
|
+
id,
|
|
308
|
+
result
|
|
319
309
|
};
|
|
320
|
-
|
|
321
|
-
}
|
|
310
|
+
},
|
|
322
311
|
/**
|
|
323
|
-
*
|
|
312
|
+
* Create an error JSON-RPC response
|
|
324
313
|
*/
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
314
|
+
error(code, message, data, id = 1) {
|
|
315
|
+
return {
|
|
316
|
+
jsonrpc: "2.0",
|
|
317
|
+
id,
|
|
318
|
+
error: { code, message, data }
|
|
319
|
+
};
|
|
320
|
+
},
|
|
329
321
|
/**
|
|
330
|
-
*
|
|
322
|
+
* Create a tool result response
|
|
331
323
|
*/
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
324
|
+
toolResult(content, id = 1) {
|
|
325
|
+
return {
|
|
326
|
+
jsonrpc: "2.0",
|
|
327
|
+
id,
|
|
328
|
+
result: { content }
|
|
329
|
+
};
|
|
330
|
+
},
|
|
336
331
|
/**
|
|
337
|
-
*
|
|
332
|
+
* Create a tools/list response
|
|
338
333
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
334
|
+
toolsList(tools, id = 1) {
|
|
335
|
+
return {
|
|
336
|
+
jsonrpc: "2.0",
|
|
337
|
+
id,
|
|
338
|
+
result: { tools }
|
|
339
|
+
};
|
|
340
|
+
},
|
|
343
341
|
/**
|
|
344
|
-
*
|
|
345
|
-
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
346
|
-
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
342
|
+
* Create a resources/list response
|
|
347
343
|
*/
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
344
|
+
resourcesList(resources, id = 1) {
|
|
345
|
+
return {
|
|
346
|
+
jsonrpc: "2.0",
|
|
347
|
+
id,
|
|
348
|
+
result: { resources }
|
|
349
|
+
};
|
|
350
|
+
},
|
|
352
351
|
/**
|
|
353
|
-
*
|
|
352
|
+
* Create a resources/read response
|
|
354
353
|
*/
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
354
|
+
resourceRead(contents, id = 1) {
|
|
355
|
+
return {
|
|
356
|
+
jsonrpc: "2.0",
|
|
357
|
+
id,
|
|
358
|
+
result: { contents }
|
|
359
|
+
};
|
|
360
|
+
},
|
|
359
361
|
/**
|
|
360
|
-
*
|
|
362
|
+
* Common MCP errors
|
|
361
363
|
*/
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
364
|
+
errors: {
|
|
365
|
+
methodNotFound: (method, id = 1) => mockResponse.error(-32601, `Method not found: ${method}`, void 0, id),
|
|
366
|
+
invalidParams: (message, id = 1) => mockResponse.error(-32602, message, void 0, id),
|
|
367
|
+
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
368
|
+
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
369
|
+
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
370
|
+
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
371
|
+
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// libs/testing/src/interceptor/interceptor-chain.ts
|
|
376
|
+
var DefaultInterceptorChain = class {
|
|
377
|
+
request = [];
|
|
378
|
+
response = [];
|
|
379
|
+
mocks;
|
|
380
|
+
constructor() {
|
|
381
|
+
this.mocks = new DefaultMockRegistry();
|
|
365
382
|
}
|
|
366
383
|
/**
|
|
367
|
-
*
|
|
368
|
-
* Automatically configures clientInfo and capabilities for platform detection.
|
|
369
|
-
*
|
|
370
|
-
* Platform-specific behavior:
|
|
371
|
-
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
372
|
-
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
373
|
-
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
374
|
-
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
375
|
-
* - Other platforms follow similar patterns
|
|
376
|
-
*
|
|
377
|
-
* @example
|
|
378
|
-
* ```typescript
|
|
379
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
380
|
-
* .withPlatform('openai')
|
|
381
|
-
* .buildAndConnect();
|
|
382
|
-
*
|
|
383
|
-
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
384
|
-
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
385
|
-
* .withPlatform('ext-apps')
|
|
386
|
-
* .buildAndConnect();
|
|
387
|
-
* ```
|
|
384
|
+
* Add a request interceptor
|
|
388
385
|
*/
|
|
389
|
-
|
|
390
|
-
this.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
386
|
+
addRequestInterceptor(interceptor) {
|
|
387
|
+
this.request.push(interceptor);
|
|
388
|
+
return () => {
|
|
389
|
+
const index = this.request.indexOf(interceptor);
|
|
390
|
+
if (index !== -1) {
|
|
391
|
+
this.request.splice(index, 1);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
394
|
}
|
|
395
395
|
/**
|
|
396
|
-
*
|
|
397
|
-
* Use this for fine-grained control over capabilities sent during initialization.
|
|
398
|
-
*
|
|
399
|
-
* @example
|
|
400
|
-
* ```typescript
|
|
401
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
402
|
-
* .withCapabilities({
|
|
403
|
-
* sampling: {},
|
|
404
|
-
* experimental: {
|
|
405
|
-
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
406
|
-
* }
|
|
407
|
-
* })
|
|
408
|
-
* .buildAndConnect();
|
|
409
|
-
* ```
|
|
396
|
+
* Add a response interceptor
|
|
410
397
|
*/
|
|
411
|
-
|
|
412
|
-
this.
|
|
413
|
-
return
|
|
398
|
+
addResponseInterceptor(interceptor) {
|
|
399
|
+
this.response.push(interceptor);
|
|
400
|
+
return () => {
|
|
401
|
+
const index = this.response.indexOf(interceptor);
|
|
402
|
+
if (index !== -1) {
|
|
403
|
+
this.response.splice(index, 1);
|
|
404
|
+
}
|
|
405
|
+
};
|
|
414
406
|
}
|
|
415
407
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* const client = await McpTestClient.create({ baseUrl })
|
|
422
|
-
* .withQueryParams({ mode: 'skills_only' })
|
|
423
|
-
* .buildAndConnect();
|
|
424
|
-
* ```
|
|
408
|
+
* Process a request through the interceptor chain
|
|
409
|
+
* Returns either:
|
|
410
|
+
* - { type: 'continue', request } - continue with (possibly modified) request
|
|
411
|
+
* - { type: 'mock', response } - return mock response immediately
|
|
412
|
+
* - { type: 'error', error } - throw error
|
|
425
413
|
*/
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
414
|
+
async processRequest(request, meta) {
|
|
415
|
+
let currentRequest = request;
|
|
416
|
+
const mockDef = this.mocks.match(request);
|
|
417
|
+
if (mockDef) {
|
|
418
|
+
if (mockDef.delay && mockDef.delay > 0) {
|
|
419
|
+
await sleep(mockDef.delay);
|
|
420
|
+
}
|
|
421
|
+
let mockResponse2;
|
|
422
|
+
if (typeof mockDef.response === "function") {
|
|
423
|
+
mockResponse2 = await mockDef.response(request);
|
|
424
|
+
} else {
|
|
425
|
+
mockResponse2 = mockDef.response;
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
type: "mock",
|
|
429
|
+
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
for (const interceptor of this.request) {
|
|
433
|
+
const ctx = {
|
|
434
|
+
request: currentRequest,
|
|
435
|
+
meta
|
|
436
|
+
};
|
|
437
|
+
const result = await interceptor(ctx);
|
|
438
|
+
switch (result.action) {
|
|
439
|
+
case "passthrough":
|
|
440
|
+
break;
|
|
441
|
+
case "modify":
|
|
442
|
+
currentRequest = result.request;
|
|
443
|
+
break;
|
|
444
|
+
case "mock":
|
|
445
|
+
return {
|
|
446
|
+
type: "mock",
|
|
447
|
+
response: { ...result.response, id: request.id ?? result.response.id }
|
|
448
|
+
};
|
|
449
|
+
case "error":
|
|
450
|
+
return { type: "error", error: result.error };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return { type: "continue", request: currentRequest };
|
|
429
454
|
}
|
|
430
455
|
/**
|
|
431
|
-
*
|
|
456
|
+
* Process a response through the interceptor chain
|
|
432
457
|
*/
|
|
433
|
-
|
|
434
|
-
|
|
458
|
+
async processResponse(request, response, durationMs) {
|
|
459
|
+
let currentResponse = response;
|
|
460
|
+
for (const interceptor of this.response) {
|
|
461
|
+
const ctx = {
|
|
462
|
+
request,
|
|
463
|
+
response: currentResponse,
|
|
464
|
+
durationMs
|
|
465
|
+
};
|
|
466
|
+
const result = await interceptor(ctx);
|
|
467
|
+
switch (result.action) {
|
|
468
|
+
case "passthrough":
|
|
469
|
+
break;
|
|
470
|
+
case "modify":
|
|
471
|
+
currentResponse = result.response;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return currentResponse;
|
|
435
476
|
}
|
|
436
477
|
/**
|
|
437
|
-
*
|
|
478
|
+
* Clear all interceptors and mocks
|
|
438
479
|
*/
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
480
|
+
clear() {
|
|
481
|
+
this.request = [];
|
|
482
|
+
this.response = [];
|
|
483
|
+
this.mocks.clear();
|
|
443
484
|
}
|
|
444
485
|
};
|
|
486
|
+
function sleep(ms) {
|
|
487
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
488
|
+
}
|
|
445
489
|
|
|
446
490
|
// libs/testing/src/transport/streamable-http.transport.ts
|
|
447
491
|
var DEFAULT_TIMEOUT = 3e4;
|
|
492
|
+
var NOTIFICATION_STREAM_REOPEN_DELAY_MS = 250;
|
|
448
493
|
var StreamableHttpTransport = class {
|
|
449
494
|
config;
|
|
450
495
|
state = "disconnected";
|
|
@@ -456,10 +501,15 @@ var StreamableHttpTransport = class {
|
|
|
456
501
|
interceptors;
|
|
457
502
|
publicMode;
|
|
458
503
|
elicitationHandler;
|
|
504
|
+
notificationHandler;
|
|
505
|
+
notificationStream;
|
|
506
|
+
notificationProtocolVersion = "";
|
|
507
|
+
notificationStreamReopenTimer;
|
|
459
508
|
constructor(config) {
|
|
460
509
|
this.config = {
|
|
461
510
|
baseUrl: config.baseUrl.replace(/\/$/, ""),
|
|
462
511
|
// Remove trailing slash
|
|
512
|
+
entryPath: config.entryPath,
|
|
463
513
|
timeout: config.timeout ?? DEFAULT_TIMEOUT,
|
|
464
514
|
auth: config.auth ?? {},
|
|
465
515
|
publicMode: config.publicMode ?? false,
|
|
@@ -471,6 +521,7 @@ var StreamableHttpTransport = class {
|
|
|
471
521
|
this.interceptors = config.interceptors;
|
|
472
522
|
this.publicMode = config.publicMode ?? false;
|
|
473
523
|
this.elicitationHandler = config.elicitationHandler;
|
|
524
|
+
this.notificationHandler = config.notificationHandler;
|
|
474
525
|
}
|
|
475
526
|
async connect() {
|
|
476
527
|
this.state = "connecting";
|
|
@@ -552,7 +603,7 @@ var StreamableHttpTransport = class {
|
|
|
552
603
|
}
|
|
553
604
|
const headers = this.buildHeaders();
|
|
554
605
|
this.lastRequestHeaders = headers;
|
|
555
|
-
const url =
|
|
606
|
+
const url = this.mcpUrl();
|
|
556
607
|
this.log(`POST ${url}`, message);
|
|
557
608
|
const controller = new AbortController();
|
|
558
609
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -622,7 +673,7 @@ var StreamableHttpTransport = class {
|
|
|
622
673
|
this.ensureConnected();
|
|
623
674
|
const headers = this.buildHeaders();
|
|
624
675
|
this.lastRequestHeaders = headers;
|
|
625
|
-
const url =
|
|
676
|
+
const url = this.mcpUrl();
|
|
626
677
|
this.log(`POST ${url} (notification)`, message);
|
|
627
678
|
const controller = new AbortController();
|
|
628
679
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -653,7 +704,7 @@ var StreamableHttpTransport = class {
|
|
|
653
704
|
this.ensureConnected();
|
|
654
705
|
const headers = this.buildHeaders();
|
|
655
706
|
this.lastRequestHeaders = headers;
|
|
656
|
-
const url =
|
|
707
|
+
const url = this.mcpUrl();
|
|
657
708
|
this.log(`POST ${url} (raw)`, data);
|
|
658
709
|
const controller = new AbortController();
|
|
659
710
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
@@ -690,7 +741,12 @@ var StreamableHttpTransport = class {
|
|
|
690
741
|
};
|
|
691
742
|
}
|
|
692
743
|
}
|
|
744
|
+
async openNotificationStream(protocolVersion) {
|
|
745
|
+
this.notificationProtocolVersion = protocolVersion;
|
|
746
|
+
await this.connectNotificationStream();
|
|
747
|
+
}
|
|
693
748
|
async close() {
|
|
749
|
+
this.closeNotificationStream();
|
|
694
750
|
this.state = "disconnected";
|
|
695
751
|
this.sessionId = void 0;
|
|
696
752
|
this.log("StreamableHTTP transport closed");
|
|
@@ -729,6 +785,7 @@ var StreamableHttpTransport = class {
|
|
|
729
785
|
return { ...this.lastRequestHeaders };
|
|
730
786
|
}
|
|
731
787
|
async simulateDisconnect() {
|
|
788
|
+
this.closeNotificationStream();
|
|
732
789
|
this.state = "disconnected";
|
|
733
790
|
this.sessionId = void 0;
|
|
734
791
|
}
|
|
@@ -747,6 +804,72 @@ var StreamableHttpTransport = class {
|
|
|
747
804
|
// ═══════════════════════════════════════════════════════════════════
|
|
748
805
|
// PRIVATE HELPERS
|
|
749
806
|
// ═══════════════════════════════════════════════════════════════════
|
|
807
|
+
closeNotificationStream() {
|
|
808
|
+
clearTimeout(this.notificationStreamReopenTimer);
|
|
809
|
+
this.notificationStream?.abort();
|
|
810
|
+
this.notificationStream = void 0;
|
|
811
|
+
}
|
|
812
|
+
/** Opens the stream without holding up the caller past the request timeout; close() aborts it at any point. */
|
|
813
|
+
async connectNotificationStream() {
|
|
814
|
+
if (!this.sessionId || this.notificationStream) return;
|
|
815
|
+
const controller = new AbortController();
|
|
816
|
+
this.notificationStream = controller;
|
|
817
|
+
const { "Content-Type": _contentType, ...headers } = this.buildHeaders();
|
|
818
|
+
const established = fetch(this.mcpUrl(), {
|
|
819
|
+
method: "GET",
|
|
820
|
+
headers: { ...headers, Accept: "text/event-stream", "MCP-Protocol-Version": this.notificationProtocolVersion },
|
|
821
|
+
signal: controller.signal
|
|
822
|
+
}).then(
|
|
823
|
+
async (response) => {
|
|
824
|
+
const isEventStream = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
825
|
+
if (!response.ok || !response.body || !isEventStream) {
|
|
826
|
+
await response.body?.cancel();
|
|
827
|
+
this.log(`Server offers no notification stream (HTTP ${response.status})`);
|
|
828
|
+
this.releaseNotificationStream(controller);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
void this.readNotificationStream(controller, response.body.getReader());
|
|
832
|
+
},
|
|
833
|
+
(error) => {
|
|
834
|
+
this.log("Failed to open the notification stream:", error);
|
|
835
|
+
this.releaseNotificationStream(controller);
|
|
836
|
+
}
|
|
837
|
+
);
|
|
838
|
+
let establishTimer;
|
|
839
|
+
const establishDeadline = new Promise((resolve) => {
|
|
840
|
+
establishTimer = setTimeout(resolve, this.config.timeout);
|
|
841
|
+
});
|
|
842
|
+
await Promise.race([established, establishDeadline]);
|
|
843
|
+
clearTimeout(establishTimer);
|
|
844
|
+
}
|
|
845
|
+
releaseNotificationStream(controller) {
|
|
846
|
+
if (this.notificationStream === controller) this.notificationStream = void 0;
|
|
847
|
+
}
|
|
848
|
+
async readNotificationStream(controller, reader) {
|
|
849
|
+
const decoder = new TextDecoder();
|
|
850
|
+
let buffer = "";
|
|
851
|
+
try {
|
|
852
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
853
|
+
const { completeEvents, remainder } = splitSSEEvents(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
854
|
+
buffer = remainder;
|
|
855
|
+
for (const eventText of completeEvents) {
|
|
856
|
+
for (const event of this.parseSSEEvents(eventText, void 0).events) {
|
|
857
|
+
await this.handleSSEEvent(event);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
} catch (error) {
|
|
862
|
+
this.log("Notification stream ended:", error);
|
|
863
|
+
} finally {
|
|
864
|
+
reader.releaseLock();
|
|
865
|
+
}
|
|
866
|
+
if (controller.signal.aborted || this.notificationStream !== controller) return;
|
|
867
|
+
this.releaseNotificationStream(controller);
|
|
868
|
+
this.notificationStreamReopenTimer = setTimeout(
|
|
869
|
+
() => void this.connectNotificationStream(),
|
|
870
|
+
NOTIFICATION_STREAM_REOPEN_DELAY_MS
|
|
871
|
+
);
|
|
872
|
+
}
|
|
750
873
|
/**
|
|
751
874
|
* Handle SSE response with elicitation support.
|
|
752
875
|
*
|
|
@@ -884,6 +1007,13 @@ var StreamableHttpTransport = class {
|
|
|
884
1007
|
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
885
1008
|
};
|
|
886
1009
|
}
|
|
1010
|
+
if ("method" in parsed && parsed.id === void 0) {
|
|
1011
|
+
this.notificationHandler?.(parsed);
|
|
1012
|
+
return {
|
|
1013
|
+
isFinal: false,
|
|
1014
|
+
response: { jsonrpc: "2.0", id: null, result: void 0 }
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
887
1017
|
if ("result" in parsed || "error" in parsed) {
|
|
888
1018
|
return { isFinal: true, response: parsed };
|
|
889
1019
|
}
|
|
@@ -936,7 +1066,7 @@ var StreamableHttpTransport = class {
|
|
|
936
1066
|
*/
|
|
937
1067
|
async sendElicitationResponse(requestId, response) {
|
|
938
1068
|
const headers = this.buildHeaders();
|
|
939
|
-
const url =
|
|
1069
|
+
const url = this.mcpUrl();
|
|
940
1070
|
const rpcResponse = {
|
|
941
1071
|
jsonrpc: "2.0",
|
|
942
1072
|
id: requestId,
|
|
@@ -956,6 +1086,18 @@ var StreamableHttpTransport = class {
|
|
|
956
1086
|
this.log("Failed to send elicitation response:", error);
|
|
957
1087
|
}
|
|
958
1088
|
}
|
|
1089
|
+
/**
|
|
1090
|
+
* The MCP endpoint URL. `entryPath` is where the server mounts MCP; the
|
|
1091
|
+
* trailing slash is kept because both hosts normalize it and the original
|
|
1092
|
+
* behaviour (`${baseUrl}/`) relied on it (issue #543).
|
|
1093
|
+
*/
|
|
1094
|
+
mcpUrl() {
|
|
1095
|
+
const url = new URL(this.config.baseUrl);
|
|
1096
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
1097
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1098
|
+
url.pathname = entry ? `${basePath}/${entry}` : `${basePath}/`;
|
|
1099
|
+
return url.toString();
|
|
1100
|
+
}
|
|
959
1101
|
buildHeaders() {
|
|
960
1102
|
const headers = {
|
|
961
1103
|
"Content-Type": "application/json",
|
|
@@ -1042,286 +1184,266 @@ var StreamableHttpTransport = class {
|
|
|
1042
1184
|
};
|
|
1043
1185
|
}
|
|
1044
1186
|
};
|
|
1187
|
+
function splitSSEEvents(buffer) {
|
|
1188
|
+
const endsWithCarriageReturn = buffer.endsWith("\r");
|
|
1189
|
+
const normalized = (endsWithCarriageReturn ? buffer.slice(0, -1) : buffer).replace(/\r\n?/g, "\n");
|
|
1190
|
+
const completeEvents = normalized.split("\n\n");
|
|
1191
|
+
const remainder = completeEvents.pop() ?? "";
|
|
1192
|
+
return { completeEvents, remainder: endsWithCarriageReturn ? `${remainder}\r` : remainder };
|
|
1193
|
+
}
|
|
1045
1194
|
|
|
1046
|
-
// libs/testing/src/
|
|
1047
|
-
var
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1195
|
+
// libs/testing/src/platform/platform-client-info.ts
|
|
1196
|
+
var MCP_APPS_EXTENSION_KEY = "io.modelcontextprotocol/ui";
|
|
1197
|
+
function getPlatformClientInfo(platform) {
|
|
1198
|
+
switch (platform) {
|
|
1199
|
+
case "openai":
|
|
1200
|
+
return {
|
|
1201
|
+
name: "ChatGPT",
|
|
1202
|
+
version: "1.0"
|
|
1203
|
+
};
|
|
1204
|
+
case "ext-apps":
|
|
1205
|
+
return {
|
|
1206
|
+
name: "mcp-ext-apps",
|
|
1207
|
+
version: "1.0"
|
|
1208
|
+
};
|
|
1209
|
+
case "claude":
|
|
1210
|
+
return {
|
|
1211
|
+
name: "claude-desktop",
|
|
1212
|
+
version: "1.0"
|
|
1213
|
+
};
|
|
1214
|
+
case "cursor":
|
|
1215
|
+
return {
|
|
1216
|
+
name: "cursor",
|
|
1217
|
+
version: "1.0"
|
|
1218
|
+
};
|
|
1219
|
+
case "continue":
|
|
1220
|
+
return {
|
|
1221
|
+
name: "continue",
|
|
1222
|
+
version: "1.0"
|
|
1223
|
+
};
|
|
1224
|
+
case "cody":
|
|
1225
|
+
return {
|
|
1226
|
+
name: "cody",
|
|
1227
|
+
version: "1.0"
|
|
1228
|
+
};
|
|
1229
|
+
case "gemini":
|
|
1230
|
+
return {
|
|
1231
|
+
name: "gemini",
|
|
1232
|
+
version: "1.0"
|
|
1233
|
+
};
|
|
1234
|
+
case "generic-mcp":
|
|
1235
|
+
return {
|
|
1236
|
+
name: "generic-mcp-client",
|
|
1237
|
+
version: "1.0"
|
|
1238
|
+
};
|
|
1239
|
+
case "unknown":
|
|
1240
|
+
default:
|
|
1241
|
+
return {
|
|
1242
|
+
name: "mcp-test-client",
|
|
1243
|
+
version: "1.0"
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
function getPlatformCapabilities(platform) {
|
|
1248
|
+
const baseCapabilities = {
|
|
1249
|
+
sampling: {},
|
|
1250
|
+
// Include elicitation.form by default for testing elicitation workflows
|
|
1251
|
+
// Note: MCP SDK expects form to be an object, not boolean
|
|
1252
|
+
elicitation: {
|
|
1253
|
+
form: {}
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
if (platform === "ext-apps") {
|
|
1057
1257
|
return {
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1258
|
+
...baseCapabilities,
|
|
1259
|
+
experimental: {
|
|
1260
|
+
[MCP_APPS_EXTENSION_KEY]: {
|
|
1261
|
+
mimeTypes: ["text/html;profile=mcp-app"]
|
|
1062
1262
|
}
|
|
1063
|
-
}
|
|
1064
|
-
callCount: () => entry.callCount,
|
|
1065
|
-
calls: () => [...entry.calls]
|
|
1263
|
+
}
|
|
1066
1264
|
};
|
|
1067
1265
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
if (entry.remainingUses <= 0) continue;
|
|
1077
|
-
const { definition } = entry;
|
|
1078
|
-
if (definition.method !== request.method) continue;
|
|
1079
|
-
if (definition.params !== void 0) {
|
|
1080
|
-
const params = request.params ?? {};
|
|
1081
|
-
if (typeof definition.params === "function") {
|
|
1082
|
-
if (!definition.params(params)) continue;
|
|
1083
|
-
} else {
|
|
1084
|
-
if (!this.paramsMatch(definition.params, params)) continue;
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
entry.callCount++;
|
|
1088
|
-
entry.calls.push(request);
|
|
1089
|
-
entry.remainingUses--;
|
|
1090
|
-
return definition;
|
|
1091
|
-
}
|
|
1092
|
-
return void 0;
|
|
1266
|
+
return baseCapabilities;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// libs/testing/src/client/mcp-test-client.builder.ts
|
|
1270
|
+
var McpTestClientBuilder = class {
|
|
1271
|
+
config;
|
|
1272
|
+
constructor(config) {
|
|
1273
|
+
this.config = { ...config };
|
|
1093
1274
|
}
|
|
1094
1275
|
/**
|
|
1095
|
-
*
|
|
1276
|
+
* Set the authentication configuration
|
|
1096
1277
|
*/
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const actualValue = actual[key];
|
|
1101
|
-
if (Array.isArray(value)) {
|
|
1102
|
-
if (!Array.isArray(actualValue)) return false;
|
|
1103
|
-
if (value.length !== actualValue.length) return false;
|
|
1104
|
-
for (let i = 0; i < value.length; i++) {
|
|
1105
|
-
const expectedItem = value[i];
|
|
1106
|
-
const actualItem = actualValue[i];
|
|
1107
|
-
if (typeof expectedItem === "object" && expectedItem !== null) {
|
|
1108
|
-
if (typeof actualItem !== "object" || actualItem === null) return false;
|
|
1109
|
-
if (!this.paramsMatch(expectedItem, actualItem)) {
|
|
1110
|
-
return false;
|
|
1111
|
-
}
|
|
1112
|
-
} else if (actualItem !== expectedItem) {
|
|
1113
|
-
return false;
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
} else if (typeof value === "object" && value !== null) {
|
|
1117
|
-
if (typeof actualValue !== "object" || actualValue === null) return false;
|
|
1118
|
-
if (!this.paramsMatch(value, actualValue)) {
|
|
1119
|
-
return false;
|
|
1120
|
-
}
|
|
1121
|
-
} else if (actualValue !== value) {
|
|
1122
|
-
return false;
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
return true;
|
|
1278
|
+
withAuth(auth) {
|
|
1279
|
+
this.config.auth = { ...this.config.auth, ...auth };
|
|
1280
|
+
return this;
|
|
1126
1281
|
}
|
|
1127
|
-
};
|
|
1128
|
-
var mockResponse = {
|
|
1129
1282
|
/**
|
|
1130
|
-
*
|
|
1283
|
+
* Set the bearer token for authentication
|
|
1131
1284
|
*/
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
result
|
|
1137
|
-
};
|
|
1138
|
-
},
|
|
1285
|
+
withToken(token) {
|
|
1286
|
+
this.config.auth = { ...this.config.auth, token };
|
|
1287
|
+
return this;
|
|
1288
|
+
}
|
|
1139
1289
|
/**
|
|
1140
|
-
*
|
|
1290
|
+
* Add custom headers to all requests
|
|
1141
1291
|
*/
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
error: { code, message, data }
|
|
1292
|
+
withHeaders(headers) {
|
|
1293
|
+
this.config.auth = {
|
|
1294
|
+
...this.config.auth,
|
|
1295
|
+
headers: { ...this.config.auth?.headers, ...headers }
|
|
1147
1296
|
};
|
|
1148
|
-
|
|
1297
|
+
return this;
|
|
1298
|
+
}
|
|
1149
1299
|
/**
|
|
1150
|
-
*
|
|
1300
|
+
* Set the transport type
|
|
1151
1301
|
*/
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
result: { content }
|
|
1157
|
-
};
|
|
1158
|
-
},
|
|
1302
|
+
withTransport(transport) {
|
|
1303
|
+
this.config.transport = transport;
|
|
1304
|
+
return this;
|
|
1305
|
+
}
|
|
1159
1306
|
/**
|
|
1160
|
-
*
|
|
1307
|
+
* Set the request timeout in milliseconds
|
|
1161
1308
|
*/
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
result: { tools }
|
|
1167
|
-
};
|
|
1168
|
-
},
|
|
1309
|
+
withTimeout(timeoutMs) {
|
|
1310
|
+
this.config.timeout = timeoutMs;
|
|
1311
|
+
return this;
|
|
1312
|
+
}
|
|
1169
1313
|
/**
|
|
1170
|
-
*
|
|
1314
|
+
* Enable debug logging
|
|
1171
1315
|
*/
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
result: { resources }
|
|
1177
|
-
};
|
|
1178
|
-
},
|
|
1316
|
+
withDebug(enabled = true) {
|
|
1317
|
+
this.config.debug = enabled;
|
|
1318
|
+
return this;
|
|
1319
|
+
}
|
|
1179
1320
|
/**
|
|
1180
|
-
*
|
|
1321
|
+
* Enable public mode - skip authentication entirely.
|
|
1322
|
+
* When true, no Authorization header is sent and anonymous token is not requested.
|
|
1323
|
+
* Use this for testing public/unauthenticated endpoints in CI/CD pipelines.
|
|
1181
1324
|
*/
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
result: { contents }
|
|
1187
|
-
};
|
|
1188
|
-
},
|
|
1325
|
+
withPublicMode(enabled = true) {
|
|
1326
|
+
this.config.publicMode = enabled;
|
|
1327
|
+
return this;
|
|
1328
|
+
}
|
|
1189
1329
|
/**
|
|
1190
|
-
*
|
|
1330
|
+
* Set the MCP protocol version to request (default '2025-06-18').
|
|
1331
|
+
* '2026-07-28' has no initialize handshake, so `build()` throws for it.
|
|
1191
1332
|
*/
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
internalError: (message, id = 1) => mockResponse.error(-32603, message, void 0, id),
|
|
1196
|
-
resourceNotFound: (uri, id = 1) => mockResponse.error(-32002, `Resource not found: ${uri}`, { uri }, id),
|
|
1197
|
-
toolNotFound: (name, id = 1) => mockResponse.error(-32601, `Tool not found: ${name}`, { name }, id),
|
|
1198
|
-
unauthorized: (id = 1) => mockResponse.error(-32001, "Unauthorized", void 0, id),
|
|
1199
|
-
forbidden: (id = 1) => mockResponse.error(-32003, "Forbidden", void 0, id)
|
|
1333
|
+
withProtocolVersion(version) {
|
|
1334
|
+
this.config.protocolVersion = version;
|
|
1335
|
+
return this;
|
|
1200
1336
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
mocks;
|
|
1208
|
-
constructor() {
|
|
1209
|
-
this.mocks = new DefaultMockRegistry();
|
|
1337
|
+
/**
|
|
1338
|
+
* Set the client info sent during initialization
|
|
1339
|
+
*/
|
|
1340
|
+
withClientInfo(info) {
|
|
1341
|
+
this.config.clientInfo = info;
|
|
1342
|
+
return this;
|
|
1210
1343
|
}
|
|
1211
1344
|
/**
|
|
1212
|
-
*
|
|
1345
|
+
* Set the platform type for testing platform-specific meta keys.
|
|
1346
|
+
* Automatically configures clientInfo and capabilities for platform detection.
|
|
1347
|
+
*
|
|
1348
|
+
* Platform-specific behavior:
|
|
1349
|
+
* - `openai`: Uses openai/* meta keys, sets User-Agent to "ChatGPT/1.0"
|
|
1350
|
+
* - `ext-apps`: Uses ui/* meta keys per SEP-1865, sets io.modelcontextprotocol/ui capability
|
|
1351
|
+
* - `claude`: Uses frontmcp/* + ui/* keys, sets User-Agent to "claude-desktop/1.0"
|
|
1352
|
+
* - `cursor`: Uses frontmcp/* + ui/* keys, sets User-Agent to "cursor/1.0"
|
|
1353
|
+
* - Other platforms follow similar patterns
|
|
1354
|
+
*
|
|
1355
|
+
* @example
|
|
1356
|
+
* ```typescript
|
|
1357
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1358
|
+
* .withPlatform('openai')
|
|
1359
|
+
* .buildAndConnect();
|
|
1360
|
+
*
|
|
1361
|
+
* // ext-apps automatically sets the io.modelcontextprotocol/ui capability
|
|
1362
|
+
* const extAppsClient = await McpTestClient.create({ baseUrl })
|
|
1363
|
+
* .withPlatform('ext-apps')
|
|
1364
|
+
* .buildAndConnect();
|
|
1365
|
+
* ```
|
|
1213
1366
|
*/
|
|
1214
|
-
|
|
1215
|
-
this.
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
this.request.splice(index, 1);
|
|
1220
|
-
}
|
|
1221
|
-
};
|
|
1367
|
+
withPlatform(platform) {
|
|
1368
|
+
this.config.platform = platform;
|
|
1369
|
+
this.config.clientInfo = getPlatformClientInfo(platform);
|
|
1370
|
+
this.config.capabilities = getPlatformCapabilities(platform);
|
|
1371
|
+
return this;
|
|
1222
1372
|
}
|
|
1223
1373
|
/**
|
|
1224
|
-
*
|
|
1374
|
+
* Set custom client capabilities for MCP initialization.
|
|
1375
|
+
* Use this for fine-grained control over capabilities sent during initialization.
|
|
1376
|
+
*
|
|
1377
|
+
* @example
|
|
1378
|
+
* ```typescript
|
|
1379
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1380
|
+
* .withCapabilities({
|
|
1381
|
+
* sampling: {},
|
|
1382
|
+
* experimental: {
|
|
1383
|
+
* 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] }
|
|
1384
|
+
* }
|
|
1385
|
+
* })
|
|
1386
|
+
* .buildAndConnect();
|
|
1387
|
+
* ```
|
|
1225
1388
|
*/
|
|
1226
|
-
|
|
1227
|
-
this.
|
|
1228
|
-
return
|
|
1229
|
-
const index = this.response.indexOf(interceptor);
|
|
1230
|
-
if (index !== -1) {
|
|
1231
|
-
this.response.splice(index, 1);
|
|
1232
|
-
}
|
|
1233
|
-
};
|
|
1389
|
+
withCapabilities(capabilities) {
|
|
1390
|
+
this.config.capabilities = capabilities;
|
|
1391
|
+
return this;
|
|
1234
1392
|
}
|
|
1235
1393
|
/**
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1394
|
+
* Set query parameters to append to the connection URL.
|
|
1395
|
+
* Useful for testing mode switches like `?mode=skills_only`.
|
|
1396
|
+
*
|
|
1397
|
+
* @example
|
|
1398
|
+
* ```typescript
|
|
1399
|
+
* const client = await McpTestClient.create({ baseUrl })
|
|
1400
|
+
* .withQueryParams({ mode: 'skills_only' })
|
|
1401
|
+
* .buildAndConnect();
|
|
1402
|
+
* ```
|
|
1241
1403
|
*/
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
if (mockDef) {
|
|
1246
|
-
if (mockDef.delay && mockDef.delay > 0) {
|
|
1247
|
-
await sleep(mockDef.delay);
|
|
1248
|
-
}
|
|
1249
|
-
let mockResponse2;
|
|
1250
|
-
if (typeof mockDef.response === "function") {
|
|
1251
|
-
mockResponse2 = await mockDef.response(request);
|
|
1252
|
-
} else {
|
|
1253
|
-
mockResponse2 = mockDef.response;
|
|
1254
|
-
}
|
|
1255
|
-
return {
|
|
1256
|
-
type: "mock",
|
|
1257
|
-
response: { ...mockResponse2, id: request.id ?? mockResponse2.id }
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
for (const interceptor of this.request) {
|
|
1261
|
-
const ctx = {
|
|
1262
|
-
request: currentRequest,
|
|
1263
|
-
meta
|
|
1264
|
-
};
|
|
1265
|
-
const result = await interceptor(ctx);
|
|
1266
|
-
switch (result.action) {
|
|
1267
|
-
case "passthrough":
|
|
1268
|
-
break;
|
|
1269
|
-
case "modify":
|
|
1270
|
-
currentRequest = result.request;
|
|
1271
|
-
break;
|
|
1272
|
-
case "mock":
|
|
1273
|
-
return {
|
|
1274
|
-
type: "mock",
|
|
1275
|
-
response: { ...result.response, id: request.id ?? result.response.id }
|
|
1276
|
-
};
|
|
1277
|
-
case "error":
|
|
1278
|
-
return { type: "error", error: result.error };
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
return { type: "continue", request: currentRequest };
|
|
1404
|
+
withQueryParams(params) {
|
|
1405
|
+
this.config.queryParams = { ...this.config.queryParams, ...params };
|
|
1406
|
+
return this;
|
|
1282
1407
|
}
|
|
1283
1408
|
/**
|
|
1284
|
-
*
|
|
1409
|
+
* Build the McpTestClient instance (does not connect)
|
|
1285
1410
|
*/
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
for (const interceptor of this.response) {
|
|
1289
|
-
const ctx = {
|
|
1290
|
-
request,
|
|
1291
|
-
response: currentResponse,
|
|
1292
|
-
durationMs
|
|
1293
|
-
};
|
|
1294
|
-
const result = await interceptor(ctx);
|
|
1295
|
-
switch (result.action) {
|
|
1296
|
-
case "passthrough":
|
|
1297
|
-
break;
|
|
1298
|
-
case "modify":
|
|
1299
|
-
currentResponse = result.response;
|
|
1300
|
-
break;
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
return currentResponse;
|
|
1411
|
+
build() {
|
|
1412
|
+
return new McpTestClient(this.config);
|
|
1304
1413
|
}
|
|
1305
1414
|
/**
|
|
1306
|
-
*
|
|
1415
|
+
* Build the McpTestClient and connect to the server
|
|
1307
1416
|
*/
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1417
|
+
async buildAndConnect() {
|
|
1418
|
+
const client = this.build();
|
|
1419
|
+
await client.connect();
|
|
1420
|
+
return client;
|
|
1312
1421
|
}
|
|
1313
1422
|
};
|
|
1314
|
-
function sleep(ms) {
|
|
1315
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1316
|
-
}
|
|
1317
1423
|
|
|
1318
1424
|
// libs/testing/src/client/mcp-test-client.ts
|
|
1319
1425
|
var DEFAULT_TIMEOUT2 = 3e4;
|
|
1320
1426
|
var DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
1427
|
+
var UNSUPPORTED_PROTOCOL_VERSIONS = ["2026-07-28"];
|
|
1321
1428
|
var DEFAULT_CLIENT_INFO = {
|
|
1322
1429
|
name: "@frontmcp/testing",
|
|
1323
1430
|
version: "0.4.0"
|
|
1324
1431
|
};
|
|
1432
|
+
function extractEntryPaths(data) {
|
|
1433
|
+
let payload = data;
|
|
1434
|
+
if (typeof payload === "string") {
|
|
1435
|
+
try {
|
|
1436
|
+
payload = JSON.parse(payload);
|
|
1437
|
+
} catch {
|
|
1438
|
+
return void 0;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (!payload || typeof payload !== "object") return void 0;
|
|
1442
|
+
const paths = payload.entryPaths;
|
|
1443
|
+
if (!Array.isArray(paths)) return void 0;
|
|
1444
|
+
const strings = paths.filter((p) => typeof p === "string");
|
|
1445
|
+
return strings.length > 0 ? strings : void 0;
|
|
1446
|
+
}
|
|
1325
1447
|
var McpTestClient = class {
|
|
1326
1448
|
// Platform, capabilities, and queryParams are optional - only set when needed
|
|
1327
1449
|
config;
|
|
@@ -1330,6 +1452,8 @@ var McpTestClient = class {
|
|
|
1330
1452
|
requestIdCounter = 0;
|
|
1331
1453
|
_lastRequestId = 0;
|
|
1332
1454
|
_sessionId;
|
|
1455
|
+
/** #543 — discovery is attempted at most once per connect. */
|
|
1456
|
+
triedDiscoveredEntryPath = false;
|
|
1333
1457
|
_sessionInfo = null;
|
|
1334
1458
|
_authState = { isAnonymous: true, scopes: [] };
|
|
1335
1459
|
// Logging and tracing
|
|
@@ -1337,6 +1461,8 @@ var McpTestClient = class {
|
|
|
1337
1461
|
_traces = [];
|
|
1338
1462
|
_notifications = [];
|
|
1339
1463
|
_progressUpdates = [];
|
|
1464
|
+
collectingProgress = false;
|
|
1465
|
+
progressTokenCounter = 0;
|
|
1340
1466
|
// Interceptor chain
|
|
1341
1467
|
_interceptors;
|
|
1342
1468
|
// Elicitation handler for server→client elicit requests
|
|
@@ -1345,8 +1471,14 @@ var McpTestClient = class {
|
|
|
1345
1471
|
// CONSTRUCTOR & FACTORY
|
|
1346
1472
|
// ═══════════════════════════════════════════════════════════════════
|
|
1347
1473
|
constructor(config) {
|
|
1474
|
+
if (config.protocolVersion && UNSUPPORTED_PROTOCOL_VERSIONS.includes(config.protocolVersion)) {
|
|
1475
|
+
throw new Error(
|
|
1476
|
+
`McpTestClient does not support protocol version ${config.protocolVersion}: it has no initialize handshake. Use ${DEFAULT_PROTOCOL_VERSION} or earlier.`
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1348
1479
|
this.config = {
|
|
1349
1480
|
baseUrl: config.baseUrl,
|
|
1481
|
+
entryPath: config.entryPath,
|
|
1350
1482
|
transport: config.transport ?? "streamable-http",
|
|
1351
1483
|
auth: config.auth ?? {},
|
|
1352
1484
|
publicMode: config.publicMode ?? false,
|
|
@@ -1382,11 +1514,13 @@ var McpTestClient = class {
|
|
|
1382
1514
|
*/
|
|
1383
1515
|
async connect() {
|
|
1384
1516
|
this.log("debug", `Connecting to ${this.config.baseUrl}...`);
|
|
1517
|
+
this.triedDiscoveredEntryPath = false;
|
|
1385
1518
|
this.transport = this.createTransport();
|
|
1386
1519
|
await this.transport.connect();
|
|
1387
1520
|
const maxRetries = 3;
|
|
1388
1521
|
const retryDelayMs = 500;
|
|
1389
1522
|
let lastError;
|
|
1523
|
+
let reportedEntryPaths;
|
|
1390
1524
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1391
1525
|
const initResponse = await this.initialize();
|
|
1392
1526
|
if (initResponse.success && initResponse.data) {
|
|
@@ -1394,13 +1528,27 @@ var McpTestClient = class {
|
|
|
1394
1528
|
break;
|
|
1395
1529
|
}
|
|
1396
1530
|
lastError = initResponse.error?.message ?? "Unknown error";
|
|
1531
|
+
reportedEntryPaths = extractEntryPaths(initResponse.error?.data) ?? reportedEntryPaths;
|
|
1532
|
+
if (reportedEntryPaths?.length === 1 && !this.triedDiscoveredEntryPath) {
|
|
1533
|
+
this.triedDiscoveredEntryPath = true;
|
|
1534
|
+
const discovered = reportedEntryPaths[0];
|
|
1535
|
+
this.log("info", `Server serves MCP at ${discovered}; reconnecting there (configured: ${this.mcpPathLabel()})`);
|
|
1536
|
+
this.config.entryPath = discovered;
|
|
1537
|
+
await this.transport.close();
|
|
1538
|
+
this.transport = this.createTransport();
|
|
1539
|
+
await this.transport.connect();
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1397
1542
|
if (attempt < maxRetries) {
|
|
1398
1543
|
this.log("debug", `MCP init attempt ${attempt} failed (${lastError}), retrying in ${retryDelayMs}ms...`);
|
|
1399
1544
|
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
1400
1545
|
}
|
|
1401
1546
|
}
|
|
1402
1547
|
if (!this.initResult) {
|
|
1403
|
-
|
|
1548
|
+
const served = reportedEntryPaths?.length ? ` The server reports it serves MCP at ${reportedEntryPaths.join(", ")}; set \`entryPath\` in test.use() (or \`http.entryPath\` on the server) to match.` : "";
|
|
1549
|
+
throw new Error(
|
|
1550
|
+
`Failed to initialize MCP connection to ${this.config.baseUrl}${this.mcpPathLabel()} after ${maxRetries} attempts: ${lastError}.${served}`
|
|
1551
|
+
);
|
|
1404
1552
|
}
|
|
1405
1553
|
this._sessionId = this.transport.getSessionId();
|
|
1406
1554
|
this._sessionInfo = {
|
|
@@ -1413,6 +1561,7 @@ var McpTestClient = class {
|
|
|
1413
1561
|
jsonrpc: "2.0",
|
|
1414
1562
|
method: "notifications/initialized"
|
|
1415
1563
|
});
|
|
1564
|
+
await this.transport.openNotificationStream?.(this.initResult.protocolVersion);
|
|
1416
1565
|
this.log("info", `Connected to ${this.initResult.serverInfo?.name ?? "MCP Server"}`);
|
|
1417
1566
|
return this.initResult;
|
|
1418
1567
|
}
|
|
@@ -1523,8 +1672,8 @@ var McpTestClient = class {
|
|
|
1523
1672
|
/**
|
|
1524
1673
|
* Call a tool by name with arguments
|
|
1525
1674
|
*/
|
|
1526
|
-
call: async (name, args) => {
|
|
1527
|
-
const response = await this.callTool(name, args);
|
|
1675
|
+
call: async (name, args, options = {}) => {
|
|
1676
|
+
const response = await this.callTool(name, args, this.progressTokenFor(options));
|
|
1528
1677
|
return this.wrapToolResult(response);
|
|
1529
1678
|
}
|
|
1530
1679
|
};
|
|
@@ -1666,6 +1815,7 @@ var McpTestClient = class {
|
|
|
1666
1815
|
* Collect progress notifications specifically
|
|
1667
1816
|
*/
|
|
1668
1817
|
collectProgress: () => {
|
|
1818
|
+
this.collectingProgress = true;
|
|
1669
1819
|
return new ProgressCollector(this._progressUpdates);
|
|
1670
1820
|
},
|
|
1671
1821
|
/**
|
|
@@ -1971,12 +2121,29 @@ var McpTestClient = class {
|
|
|
1971
2121
|
async listTools() {
|
|
1972
2122
|
return this.request("tools/list", {});
|
|
1973
2123
|
}
|
|
1974
|
-
async callTool(name, args) {
|
|
2124
|
+
async callTool(name, args, progressToken) {
|
|
1975
2125
|
return this.request("tools/call", {
|
|
1976
2126
|
name,
|
|
1977
|
-
arguments: args ?? {}
|
|
2127
|
+
arguments: args ?? {},
|
|
2128
|
+
...progressToken !== void 0 && { _meta: { progressToken } }
|
|
1978
2129
|
});
|
|
1979
2130
|
}
|
|
2131
|
+
progressTokenFor(options) {
|
|
2132
|
+
if (options.progressToken !== void 0) return options.progressToken;
|
|
2133
|
+
return this.collectingProgress ? `progress-${++this.progressTokenCounter}` : void 0;
|
|
2134
|
+
}
|
|
2135
|
+
recordNotification = (notification) => {
|
|
2136
|
+
const timestamp = /* @__PURE__ */ new Date();
|
|
2137
|
+
this._notifications.push({ method: notification.method, params: notification.params, timestamp });
|
|
2138
|
+
if (notification.method !== "notifications/progress") return;
|
|
2139
|
+
const { progress, total, progressToken } = notification.params ?? {};
|
|
2140
|
+
this._progressUpdates.push({
|
|
2141
|
+
progress: Number(progress),
|
|
2142
|
+
total: typeof total === "number" ? total : void 0,
|
|
2143
|
+
progressToken: typeof progressToken === "string" || typeof progressToken === "number" ? progressToken : void 0,
|
|
2144
|
+
timestamp
|
|
2145
|
+
});
|
|
2146
|
+
};
|
|
1980
2147
|
async listResources() {
|
|
1981
2148
|
return this.request("resources/list", {});
|
|
1982
2149
|
}
|
|
@@ -1998,6 +2165,11 @@ var McpTestClient = class {
|
|
|
1998
2165
|
// ═══════════════════════════════════════════════════════════════════
|
|
1999
2166
|
// PRIVATE: TRANSPORT & REQUEST HELPERS
|
|
2000
2167
|
// ═══════════════════════════════════════════════════════════════════
|
|
2168
|
+
/** The MCP path this client is currently pointed at, for log and error text. */
|
|
2169
|
+
mcpPathLabel() {
|
|
2170
|
+
const entry = (this.config.entryPath ?? "").replace(/^\/+|\/+$/g, "");
|
|
2171
|
+
return entry ? `/${entry}` : "/";
|
|
2172
|
+
}
|
|
2001
2173
|
createTransport() {
|
|
2002
2174
|
let baseUrl = this.config.baseUrl;
|
|
2003
2175
|
if (this.config.queryParams && Object.keys(this.config.queryParams).length > 0) {
|
|
@@ -2011,13 +2183,15 @@ var McpTestClient = class {
|
|
|
2011
2183
|
case "streamable-http":
|
|
2012
2184
|
return new StreamableHttpTransport({
|
|
2013
2185
|
baseUrl,
|
|
2186
|
+
entryPath: this.config.entryPath,
|
|
2014
2187
|
timeout: this.config.timeout,
|
|
2015
2188
|
auth: this.config.auth,
|
|
2016
2189
|
publicMode: this.config.publicMode,
|
|
2017
2190
|
debug: this.config.debug,
|
|
2018
2191
|
interceptors: this._interceptors,
|
|
2019
2192
|
clientInfo: this.config.clientInfo,
|
|
2020
|
-
elicitationHandler: this._elicitationHandler
|
|
2193
|
+
elicitationHandler: this._elicitationHandler,
|
|
2194
|
+
notificationHandler: this.recordNotification
|
|
2021
2195
|
});
|
|
2022
2196
|
case "sse":
|
|
2023
2197
|
throw new Error("SSE transport not yet implemented");
|
|
@@ -3002,13 +3176,17 @@ var currentConfig = {};
|
|
|
3002
3176
|
var serverInstance = null;
|
|
3003
3177
|
var tokenFactory = null;
|
|
3004
3178
|
var serverStartedByUs = false;
|
|
3179
|
+
var skipScopes = [{ skipped: false }];
|
|
3180
|
+
function currentSkipScope() {
|
|
3181
|
+
return skipScopes[skipScopes.length - 1];
|
|
3182
|
+
}
|
|
3005
3183
|
async function initializeSharedResources() {
|
|
3006
3184
|
if (!tokenFactory) {
|
|
3007
3185
|
const sharedSecret = currentConfig.env?.["JWT_SECRET"];
|
|
3008
3186
|
tokenFactory = new TestTokenFactory(sharedSecret ? { hmacSecret: sharedSecret } : {});
|
|
3009
3187
|
}
|
|
3010
3188
|
if (!serverInstance) {
|
|
3011
|
-
if (currentConfig.baseUrl) {
|
|
3189
|
+
if (currentConfig.baseUrl && !currentConfig.server) {
|
|
3012
3190
|
serverInstance = TestServer.connect(currentConfig.baseUrl);
|
|
3013
3191
|
serverStartedByUs = false;
|
|
3014
3192
|
} else if (currentConfig.server) {
|
|
@@ -3058,7 +3236,8 @@ async function createTestFixtures() {
|
|
|
3058
3236
|
throw new Error("Token factory not initialized");
|
|
3059
3237
|
}
|
|
3060
3238
|
const clientInstance = McpTestClient.create({
|
|
3061
|
-
baseUrl: serverInstance
|
|
3239
|
+
baseUrl: resolveClientBaseUrl(serverInstance),
|
|
3240
|
+
entryPath: currentConfig.entryPath,
|
|
3062
3241
|
transport: currentConfig.transport ?? "streamable-http",
|
|
3063
3242
|
publicMode: currentConfig.publicMode
|
|
3064
3243
|
}).build();
|
|
@@ -3107,6 +3286,9 @@ async function cleanupSharedResources() {
|
|
|
3107
3286
|
tokenFactory = null;
|
|
3108
3287
|
serverStartedByUs = false;
|
|
3109
3288
|
}
|
|
3289
|
+
function resolveClientBaseUrl(server) {
|
|
3290
|
+
return currentConfig.baseUrl ?? server.info.baseUrl;
|
|
3291
|
+
}
|
|
3110
3292
|
function createAuthFixture(factory) {
|
|
3111
3293
|
const users = {
|
|
3112
3294
|
admin: {
|
|
@@ -3156,7 +3338,8 @@ function createServerFixture(server) {
|
|
|
3156
3338
|
info: server.info,
|
|
3157
3339
|
createClient: async (opts) => {
|
|
3158
3340
|
return McpTestClient.create({
|
|
3159
|
-
baseUrl: server
|
|
3341
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3342
|
+
entryPath: opts?.entryPath ?? currentConfig.entryPath,
|
|
3160
3343
|
transport: opts?.transport ?? "streamable-http",
|
|
3161
3344
|
auth: opts?.token ? { token: opts.token } : void 0,
|
|
3162
3345
|
clientInfo: opts?.clientInfo,
|
|
@@ -3165,7 +3348,8 @@ function createServerFixture(server) {
|
|
|
3165
3348
|
},
|
|
3166
3349
|
createClientBuilder: () => {
|
|
3167
3350
|
const builder = new McpTestClientBuilder({
|
|
3168
|
-
baseUrl: server
|
|
3351
|
+
baseUrl: resolveClientBaseUrl(server),
|
|
3352
|
+
entryPath: currentConfig.entryPath,
|
|
3169
3353
|
publicMode: currentConfig.publicMode
|
|
3170
3354
|
});
|
|
3171
3355
|
return builder;
|
|
@@ -3181,8 +3365,8 @@ function resolveServerCommand(server) {
|
|
|
3181
3365
|
}
|
|
3182
3366
|
return `npx tsx ${server}`;
|
|
3183
3367
|
}
|
|
3184
|
-
function
|
|
3185
|
-
|
|
3368
|
+
function runWithFixtures(fn) {
|
|
3369
|
+
return async () => {
|
|
3186
3370
|
const fixtures = await createTestFixtures();
|
|
3187
3371
|
let testFailed = false;
|
|
3188
3372
|
try {
|
|
@@ -3193,7 +3377,15 @@ function testWithFixtures(name, fn) {
|
|
|
3193
3377
|
} finally {
|
|
3194
3378
|
await cleanupTestFixtures(fixtures, testFailed);
|
|
3195
3379
|
}
|
|
3196
|
-
}
|
|
3380
|
+
};
|
|
3381
|
+
}
|
|
3382
|
+
function testWithFixtures(name, fn) {
|
|
3383
|
+
const scope = currentSkipScope();
|
|
3384
|
+
if (scope.skipped) {
|
|
3385
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
it(name, runWithFixtures(fn));
|
|
3197
3389
|
}
|
|
3198
3390
|
function use(config) {
|
|
3199
3391
|
currentConfig = { ...currentConfig, ...config };
|
|
@@ -3201,40 +3393,60 @@ function use(config) {
|
|
|
3201
3393
|
await cleanupSharedResources();
|
|
3202
3394
|
});
|
|
3203
3395
|
}
|
|
3204
|
-
function skip(
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
} catch (error) {
|
|
3211
|
-
testFailed = true;
|
|
3212
|
-
throw error;
|
|
3213
|
-
} finally {
|
|
3214
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3396
|
+
function skip(nameOrCondition, fnOrReason) {
|
|
3397
|
+
if (typeof nameOrCondition === "boolean") {
|
|
3398
|
+
if (nameOrCondition) {
|
|
3399
|
+
const scope = currentSkipScope();
|
|
3400
|
+
scope.skipped = true;
|
|
3401
|
+
scope.reason = typeof fnOrReason === "string" ? fnOrReason : void 0;
|
|
3215
3402
|
}
|
|
3216
|
-
|
|
3403
|
+
return;
|
|
3404
|
+
}
|
|
3405
|
+
if (typeof fnOrReason !== "function") {
|
|
3406
|
+
throw new TypeError(
|
|
3407
|
+
`test.skip expects either (name: string, fn) or (condition: boolean, reason?: string); received (${typeof nameOrCondition}, ${typeof fnOrReason}).`
|
|
3408
|
+
);
|
|
3409
|
+
}
|
|
3410
|
+
it.skip(nameOrCondition, runWithFixtures(fnOrReason));
|
|
3217
3411
|
}
|
|
3218
3412
|
function only(name, fn) {
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
testFailed = true;
|
|
3226
|
-
throw error;
|
|
3227
|
-
} finally {
|
|
3228
|
-
await cleanupTestFixtures(fixtures, testFailed);
|
|
3229
|
-
}
|
|
3230
|
-
});
|
|
3413
|
+
const scope = currentSkipScope();
|
|
3414
|
+
if (scope.skipped) {
|
|
3415
|
+
it.skip(scope.reason ? `${name} (skipped: ${scope.reason})` : name, runWithFixtures(fn));
|
|
3416
|
+
return;
|
|
3417
|
+
}
|
|
3418
|
+
it.only(name, runWithFixtures(fn));
|
|
3231
3419
|
}
|
|
3232
3420
|
function todo(name) {
|
|
3233
3421
|
it.todo(name);
|
|
3234
3422
|
}
|
|
3423
|
+
function withSkipScope(register) {
|
|
3424
|
+
return (name, fn) => {
|
|
3425
|
+
register(name, () => {
|
|
3426
|
+
const parent = currentSkipScope();
|
|
3427
|
+
skipScopes.push({ skipped: parent.skipped, reason: parent.reason });
|
|
3428
|
+
try {
|
|
3429
|
+
fn();
|
|
3430
|
+
} finally {
|
|
3431
|
+
skipScopes.pop();
|
|
3432
|
+
}
|
|
3433
|
+
});
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
function eachWithSkipScope(each) {
|
|
3437
|
+
return ((...eachArgs) => {
|
|
3438
|
+
const register = each(...eachArgs);
|
|
3439
|
+
return withSkipScope(register);
|
|
3440
|
+
});
|
|
3441
|
+
}
|
|
3442
|
+
var describeWithSkipScope = Object.assign(withSkipScope(describe), describe, {
|
|
3443
|
+
only: Object.assign(withSkipScope(describe.only), { each: eachWithSkipScope(describe.only.each) }),
|
|
3444
|
+
skip: Object.assign(withSkipScope(describe.skip), { each: eachWithSkipScope(describe.skip.each) }),
|
|
3445
|
+
each: eachWithSkipScope(describe.each)
|
|
3446
|
+
});
|
|
3235
3447
|
var test = testWithFixtures;
|
|
3236
3448
|
test.use = use;
|
|
3237
|
-
test.describe =
|
|
3449
|
+
test.describe = describeWithSkipScope;
|
|
3238
3450
|
test.beforeAll = beforeAll;
|
|
3239
3451
|
test.beforeEach = beforeEach;
|
|
3240
3452
|
test.afterEach = afterEach;
|