@frontmcp/testing 0.5.0 → 0.6.0
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/package.json +4 -4
- package/src/auth/mock-api-server.d.ts +99 -0
- package/src/auth/mock-api-server.js +200 -0
- package/src/auth/mock-api-server.js.map +1 -0
- package/src/auth/mock-oauth-server.d.ts +85 -0
- package/src/auth/mock-oauth-server.js +253 -0
- package/src/auth/mock-oauth-server.js.map +1 -0
- package/src/client/mcp-test-client.builder.d.ts +43 -1
- package/src/client/mcp-test-client.builder.js +52 -0
- package/src/client/mcp-test-client.builder.js.map +1 -1
- package/src/client/mcp-test-client.js +22 -14
- package/src/client/mcp-test-client.js.map +1 -1
- package/src/client/mcp-test-client.types.d.ts +67 -6
- package/src/client/mcp-test-client.types.js +9 -0
- package/src/client/mcp-test-client.types.js.map +1 -1
- package/src/example-tools/index.d.ts +19 -0
- package/src/example-tools/index.js +40 -0
- package/src/example-tools/index.js.map +1 -0
- package/src/example-tools/tool-configs.d.ts +170 -0
- package/src/example-tools/tool-configs.js +222 -0
- package/src/example-tools/tool-configs.js.map +1 -0
- package/src/expect.d.ts +6 -5
- package/src/expect.js.map +1 -1
- package/src/fixtures/fixture-types.d.ts +19 -0
- package/src/fixtures/fixture-types.js.map +1 -1
- package/src/fixtures/test-fixture.d.ts +3 -1
- package/src/fixtures/test-fixture.js +35 -4
- package/src/fixtures/test-fixture.js.map +1 -1
- package/src/index.d.ts +7 -0
- package/src/index.js +40 -1
- package/src/index.js.map +1 -1
- package/src/matchers/matcher-types.js.map +1 -1
- package/src/matchers/mcp-matchers.d.ts +7 -0
- package/src/matchers/mcp-matchers.js +8 -4
- package/src/matchers/mcp-matchers.js.map +1 -1
- package/src/platform/index.d.ts +28 -0
- package/src/platform/index.js +47 -0
- package/src/platform/index.js.map +1 -0
- package/src/platform/platform-client-info.d.ts +97 -0
- package/src/platform/platform-client-info.js +155 -0
- package/src/platform/platform-client-info.js.map +1 -0
- package/src/platform/platform-types.d.ts +72 -0
- package/src/platform/platform-types.js +110 -0
- package/src/platform/platform-types.js.map +1 -0
- package/src/server/test-server.d.ts +4 -0
- package/src/server/test-server.js +58 -3
- package/src/server/test-server.js.map +1 -1
- package/src/transport/streamable-http.transport.js +6 -0
- package/src/transport/streamable-http.transport.js.map +1 -1
- package/src/transport/transport.interface.d.ts +3 -0
- package/src/transport/transport.interface.js.map +1 -1
- package/src/ui/ui-assertions.d.ts +59 -0
- package/src/ui/ui-assertions.js +152 -0
- package/src/ui/ui-assertions.js.map +1 -1
- package/src/ui/ui-matchers.d.ts +8 -0
- package/src/ui/ui-matchers.js +218 -0
- package/src/ui/ui-matchers.js.map +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file platform-client-info.ts
|
|
3
|
+
* @description Client info, User-Agent mappings, and capabilities for each platform.
|
|
4
|
+
*
|
|
5
|
+
* These mappings enable platform detection in MCP servers via:
|
|
6
|
+
* - User-Agent header (most platforms)
|
|
7
|
+
* - Client capabilities (ext-apps via io.modelcontextprotocol/ui extension)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { getPlatformClientInfo, getPlatformCapabilities } from '@frontmcp/testing';
|
|
12
|
+
*
|
|
13
|
+
* const client = McpTestClient.create({ baseUrl })
|
|
14
|
+
* .withPlatform('ext-apps') // Auto-sets clientInfo AND capabilities
|
|
15
|
+
* .buildAndConnect();
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import type { TestPlatformType } from './platform-types';
|
|
19
|
+
/**
|
|
20
|
+
* MCP Apps extension key used for ext-apps platform detection.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MCP_APPS_EXTENSION_KEY: "io.modelcontextprotocol/ui";
|
|
23
|
+
/**
|
|
24
|
+
* Client info for MCP initialization.
|
|
25
|
+
* Matches the Implementation type from @modelcontextprotocol/sdk.
|
|
26
|
+
*/
|
|
27
|
+
export interface TestClientInfo {
|
|
28
|
+
/** Client name (used for User-Agent header) */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Client version */
|
|
31
|
+
version: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get client info for a specific platform.
|
|
35
|
+
*
|
|
36
|
+
* These values are used to:
|
|
37
|
+
* 1. Set the clientInfo during MCP initialize
|
|
38
|
+
* 2. Generate the User-Agent header for platform detection
|
|
39
|
+
*/
|
|
40
|
+
export declare function getPlatformClientInfo(platform: TestPlatformType): TestClientInfo;
|
|
41
|
+
/**
|
|
42
|
+
* Build User-Agent header string from client info.
|
|
43
|
+
*
|
|
44
|
+
* Format: "{name}/{version}"
|
|
45
|
+
*
|
|
46
|
+
* Examples:
|
|
47
|
+
* - "ChatGPT/1.0" (OpenAI)
|
|
48
|
+
* - "mcp-ext-apps/1.0" (ext-apps)
|
|
49
|
+
* - "claude-desktop/1.0" (Claude)
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildUserAgent(clientInfo: TestClientInfo): string;
|
|
52
|
+
/**
|
|
53
|
+
* Get the User-Agent string for a platform.
|
|
54
|
+
*/
|
|
55
|
+
export declare function getPlatformUserAgent(platform: TestPlatformType): string;
|
|
56
|
+
/**
|
|
57
|
+
* Platform detection patterns for parsing User-Agent headers.
|
|
58
|
+
* These are the patterns that MCP servers use to detect platforms.
|
|
59
|
+
*
|
|
60
|
+
* Note: ext-apps is detected via capabilities, not User-Agent.
|
|
61
|
+
*/
|
|
62
|
+
export declare const PLATFORM_DETECTION_PATTERNS: Record<TestPlatformType, RegExp>;
|
|
63
|
+
/**
|
|
64
|
+
* MCP Apps extension capability for ext-apps platform detection.
|
|
65
|
+
*/
|
|
66
|
+
export interface McpAppsExtension {
|
|
67
|
+
/** Supported MIME types */
|
|
68
|
+
mimeTypes?: string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Experimental capabilities for MCP clients.
|
|
72
|
+
*/
|
|
73
|
+
export interface ExperimentalCapabilities {
|
|
74
|
+
[MCP_APPS_EXTENSION_KEY]?: McpAppsExtension;
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Client capabilities sent during MCP initialization.
|
|
79
|
+
*/
|
|
80
|
+
export interface TestClientCapabilities {
|
|
81
|
+
sampling?: Record<string, unknown>;
|
|
82
|
+
experimental?: ExperimentalCapabilities;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get client capabilities for a specific platform.
|
|
86
|
+
*
|
|
87
|
+
* Currently only ext-apps requires special capabilities:
|
|
88
|
+
* - ext-apps: Sets io.modelcontextprotocol/ui extension for platform detection
|
|
89
|
+
*
|
|
90
|
+
* @param platform - The platform type
|
|
91
|
+
* @returns Client capabilities to send during initialization
|
|
92
|
+
*/
|
|
93
|
+
export declare function getPlatformCapabilities(platform: TestPlatformType): TestClientCapabilities;
|
|
94
|
+
/**
|
|
95
|
+
* Check if a platform requires capability-based detection (vs User-Agent).
|
|
96
|
+
*/
|
|
97
|
+
export declare function requiresCapabilityDetection(platform: TestPlatformType): boolean;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @file platform-client-info.ts
|
|
4
|
+
* @description Client info, User-Agent mappings, and capabilities for each platform.
|
|
5
|
+
*
|
|
6
|
+
* These mappings enable platform detection in MCP servers via:
|
|
7
|
+
* - User-Agent header (most platforms)
|
|
8
|
+
* - Client capabilities (ext-apps via io.modelcontextprotocol/ui extension)
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { getPlatformClientInfo, getPlatformCapabilities } from '@frontmcp/testing';
|
|
13
|
+
*
|
|
14
|
+
* const client = McpTestClient.create({ baseUrl })
|
|
15
|
+
* .withPlatform('ext-apps') // Auto-sets clientInfo AND capabilities
|
|
16
|
+
* .buildAndConnect();
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.PLATFORM_DETECTION_PATTERNS = exports.MCP_APPS_EXTENSION_KEY = void 0;
|
|
21
|
+
exports.getPlatformClientInfo = getPlatformClientInfo;
|
|
22
|
+
exports.buildUserAgent = buildUserAgent;
|
|
23
|
+
exports.getPlatformUserAgent = getPlatformUserAgent;
|
|
24
|
+
exports.getPlatformCapabilities = getPlatformCapabilities;
|
|
25
|
+
exports.requiresCapabilityDetection = requiresCapabilityDetection;
|
|
26
|
+
/**
|
|
27
|
+
* MCP Apps extension key used for ext-apps platform detection.
|
|
28
|
+
*/
|
|
29
|
+
exports.MCP_APPS_EXTENSION_KEY = 'io.modelcontextprotocol/ui';
|
|
30
|
+
/**
|
|
31
|
+
* Get client info for a specific platform.
|
|
32
|
+
*
|
|
33
|
+
* These values are used to:
|
|
34
|
+
* 1. Set the clientInfo during MCP initialize
|
|
35
|
+
* 2. Generate the User-Agent header for platform detection
|
|
36
|
+
*/
|
|
37
|
+
function getPlatformClientInfo(platform) {
|
|
38
|
+
switch (platform) {
|
|
39
|
+
case 'openai':
|
|
40
|
+
return {
|
|
41
|
+
name: 'ChatGPT',
|
|
42
|
+
version: '1.0',
|
|
43
|
+
};
|
|
44
|
+
case 'ext-apps':
|
|
45
|
+
return {
|
|
46
|
+
name: 'mcp-ext-apps',
|
|
47
|
+
version: '1.0',
|
|
48
|
+
};
|
|
49
|
+
case 'claude':
|
|
50
|
+
return {
|
|
51
|
+
name: 'claude-desktop',
|
|
52
|
+
version: '1.0',
|
|
53
|
+
};
|
|
54
|
+
case 'cursor':
|
|
55
|
+
return {
|
|
56
|
+
name: 'cursor',
|
|
57
|
+
version: '1.0',
|
|
58
|
+
};
|
|
59
|
+
case 'continue':
|
|
60
|
+
return {
|
|
61
|
+
name: 'continue',
|
|
62
|
+
version: '1.0',
|
|
63
|
+
};
|
|
64
|
+
case 'cody':
|
|
65
|
+
return {
|
|
66
|
+
name: 'cody',
|
|
67
|
+
version: '1.0',
|
|
68
|
+
};
|
|
69
|
+
case 'gemini':
|
|
70
|
+
return {
|
|
71
|
+
name: 'gemini',
|
|
72
|
+
version: '1.0',
|
|
73
|
+
};
|
|
74
|
+
case 'generic-mcp':
|
|
75
|
+
return {
|
|
76
|
+
name: 'generic-mcp-client',
|
|
77
|
+
version: '1.0',
|
|
78
|
+
};
|
|
79
|
+
case 'unknown':
|
|
80
|
+
default:
|
|
81
|
+
return {
|
|
82
|
+
name: 'mcp-test-client',
|
|
83
|
+
version: '1.0',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Build User-Agent header string from client info.
|
|
89
|
+
*
|
|
90
|
+
* Format: "{name}/{version}"
|
|
91
|
+
*
|
|
92
|
+
* Examples:
|
|
93
|
+
* - "ChatGPT/1.0" (OpenAI)
|
|
94
|
+
* - "mcp-ext-apps/1.0" (ext-apps)
|
|
95
|
+
* - "claude-desktop/1.0" (Claude)
|
|
96
|
+
*/
|
|
97
|
+
function buildUserAgent(clientInfo) {
|
|
98
|
+
return `${clientInfo.name}/${clientInfo.version}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Get the User-Agent string for a platform.
|
|
102
|
+
*/
|
|
103
|
+
function getPlatformUserAgent(platform) {
|
|
104
|
+
return buildUserAgent(getPlatformClientInfo(platform));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Platform detection patterns for parsing User-Agent headers.
|
|
108
|
+
* These are the patterns that MCP servers use to detect platforms.
|
|
109
|
+
*
|
|
110
|
+
* Note: ext-apps is detected via capabilities, not User-Agent.
|
|
111
|
+
*/
|
|
112
|
+
exports.PLATFORM_DETECTION_PATTERNS = {
|
|
113
|
+
openai: /chatgpt/i,
|
|
114
|
+
'ext-apps': /mcp-ext-apps/i, // Note: Actual detection uses capabilities
|
|
115
|
+
claude: /claude|claude-desktop/i,
|
|
116
|
+
cursor: /cursor/i,
|
|
117
|
+
continue: /continue/i,
|
|
118
|
+
cody: /cody/i,
|
|
119
|
+
gemini: /gemini/i,
|
|
120
|
+
'generic-mcp': /generic-mcp/i,
|
|
121
|
+
unknown: /.*/, // Matches anything (fallback)
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Get client capabilities for a specific platform.
|
|
125
|
+
*
|
|
126
|
+
* Currently only ext-apps requires special capabilities:
|
|
127
|
+
* - ext-apps: Sets io.modelcontextprotocol/ui extension for platform detection
|
|
128
|
+
*
|
|
129
|
+
* @param platform - The platform type
|
|
130
|
+
* @returns Client capabilities to send during initialization
|
|
131
|
+
*/
|
|
132
|
+
function getPlatformCapabilities(platform) {
|
|
133
|
+
const baseCapabilities = {
|
|
134
|
+
sampling: {},
|
|
135
|
+
};
|
|
136
|
+
// ext-apps requires the io.modelcontextprotocol/ui extension for detection
|
|
137
|
+
if (platform === 'ext-apps') {
|
|
138
|
+
return {
|
|
139
|
+
...baseCapabilities,
|
|
140
|
+
experimental: {
|
|
141
|
+
[exports.MCP_APPS_EXTENSION_KEY]: {
|
|
142
|
+
mimeTypes: ['text/html+mcp'],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return baseCapabilities;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check if a platform requires capability-based detection (vs User-Agent).
|
|
151
|
+
*/
|
|
152
|
+
function requiresCapabilityDetection(platform) {
|
|
153
|
+
return platform === 'ext-apps';
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=platform-client-info.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform-client-info.js","sourceRoot":"","sources":["../../../src/platform/platform-client-info.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;GAgBG;;;AA2BH,sDAiDC;AAYD,wCAEC;AAKD,oDAEC;AAyDD,0DAkBC;AAKD,kEAEC;AA/KD;;GAEG;AACU,QAAA,sBAAsB,GAAG,4BAAqC,CAAC;AAa5E;;;;;;GAMG;AACH,SAAgB,qBAAqB,CAAC,QAA0B;IAC9D,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO;gBACL,IAAI,EAAE,SAAS;gBACf,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,UAAU;YACb,OAAO;gBACL,IAAI,EAAE,cAAc;gBACpB,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,QAAQ;YACX,OAAO;gBACL,IAAI,EAAE,gBAAgB;gBACtB,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,QAAQ;YACX,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,UAAU;YACb,OAAO;gBACL,IAAI,EAAE,UAAU;gBAChB,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,MAAM;gBACZ,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,QAAQ;YACX,OAAO;gBACL,IAAI,EAAE,QAAQ;gBACd,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,aAAa;YAChB,OAAO;gBACL,IAAI,EAAE,oBAAoB;gBAC1B,OAAO,EAAE,KAAK;aACf,CAAC;QACJ,KAAK,SAAS,CAAC;QACf;YACE,OAAO;gBACL,IAAI,EAAE,iBAAiB;gBACvB,OAAO,EAAE,KAAK;aACf,CAAC;IACN,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,SAAgB,cAAc,CAAC,UAA0B;IACvD,OAAO,GAAG,UAAU,CAAC,IAAI,IAAI,UAAU,CAAC,OAAO,EAAE,CAAC;AACpD,CAAC;AAED;;GAEG;AACH,SAAgB,oBAAoB,CAAC,QAA0B;IAC7D,OAAO,cAAc,CAAC,qBAAqB,CAAC,QAAQ,CAAC,CAAC,CAAC;AACzD,CAAC;AAED;;;;;GAKG;AACU,QAAA,2BAA2B,GAAqC;IAC3E,MAAM,EAAE,UAAU;IAClB,UAAU,EAAE,eAAe,EAAE,2CAA2C;IACxE,MAAM,EAAE,wBAAwB;IAChC,MAAM,EAAE,SAAS;IACjB,QAAQ,EAAE,WAAW;IACrB,IAAI,EAAE,OAAO;IACb,MAAM,EAAE,SAAS;IACjB,aAAa,EAAE,cAAc;IAC7B,OAAO,EAAE,IAAI,EAAE,8BAA8B;CAC9C,CAAC;AA8BF;;;;;;;;GAQG;AACH,SAAgB,uBAAuB,CAAC,QAA0B;IAChE,MAAM,gBAAgB,GAA2B;QAC/C,QAAQ,EAAE,EAAE;KACb,CAAC;IAEF,2EAA2E;IAC3E,IAAI,QAAQ,KAAK,UAAU,EAAE,CAAC;QAC5B,OAAO;YACL,GAAG,gBAAgB;YACnB,YAAY,EAAE;gBACZ,CAAC,8BAAsB,CAAC,EAAE;oBACxB,SAAS,EAAE,CAAC,eAAe,CAAC;iBAC7B;aACF;SACF,CAAC;IACJ,CAAC;IAED,OAAO,gBAAgB,CAAC;AAC1B,CAAC;AAED;;GAEG;AACH,SAAgB,2BAA2B,CAAC,QAA0B;IACpE,OAAO,QAAQ,KAAK,UAAU,CAAC;AACjC,CAAC","sourcesContent":["/**\n * @file platform-client-info.ts\n * @description Client info, User-Agent mappings, and capabilities for each platform.\n *\n * These mappings enable platform detection in MCP servers via:\n * - User-Agent header (most platforms)\n * - Client capabilities (ext-apps via io.modelcontextprotocol/ui extension)\n *\n * @example\n * ```typescript\n * import { getPlatformClientInfo, getPlatformCapabilities } from '@frontmcp/testing';\n *\n * const client = McpTestClient.create({ baseUrl })\n * .withPlatform('ext-apps') // Auto-sets clientInfo AND capabilities\n * .buildAndConnect();\n * ```\n */\n\nimport type { TestPlatformType } from './platform-types';\n\n/**\n * MCP Apps extension key used for ext-apps platform detection.\n */\nexport const MCP_APPS_EXTENSION_KEY = 'io.modelcontextprotocol/ui' as const;\n\n/**\n * Client info for MCP initialization.\n * Matches the Implementation type from @modelcontextprotocol/sdk.\n */\nexport interface TestClientInfo {\n /** Client name (used for User-Agent header) */\n name: string;\n /** Client version */\n version: string;\n}\n\n/**\n * Get client info for a specific platform.\n *\n * These values are used to:\n * 1. Set the clientInfo during MCP initialize\n * 2. Generate the User-Agent header for platform detection\n */\nexport function getPlatformClientInfo(platform: TestPlatformType): TestClientInfo {\n switch (platform) {\n case 'openai':\n return {\n name: 'ChatGPT',\n version: '1.0',\n };\n case 'ext-apps':\n return {\n name: 'mcp-ext-apps',\n version: '1.0',\n };\n case 'claude':\n return {\n name: 'claude-desktop',\n version: '1.0',\n };\n case 'cursor':\n return {\n name: 'cursor',\n version: '1.0',\n };\n case 'continue':\n return {\n name: 'continue',\n version: '1.0',\n };\n case 'cody':\n return {\n name: 'cody',\n version: '1.0',\n };\n case 'gemini':\n return {\n name: 'gemini',\n version: '1.0',\n };\n case 'generic-mcp':\n return {\n name: 'generic-mcp-client',\n version: '1.0',\n };\n case 'unknown':\n default:\n return {\n name: 'mcp-test-client',\n version: '1.0',\n };\n }\n}\n\n/**\n * Build User-Agent header string from client info.\n *\n * Format: \"{name}/{version}\"\n *\n * Examples:\n * - \"ChatGPT/1.0\" (OpenAI)\n * - \"mcp-ext-apps/1.0\" (ext-apps)\n * - \"claude-desktop/1.0\" (Claude)\n */\nexport function buildUserAgent(clientInfo: TestClientInfo): string {\n return `${clientInfo.name}/${clientInfo.version}`;\n}\n\n/**\n * Get the User-Agent string for a platform.\n */\nexport function getPlatformUserAgent(platform: TestPlatformType): string {\n return buildUserAgent(getPlatformClientInfo(platform));\n}\n\n/**\n * Platform detection patterns for parsing User-Agent headers.\n * These are the patterns that MCP servers use to detect platforms.\n *\n * Note: ext-apps is detected via capabilities, not User-Agent.\n */\nexport const PLATFORM_DETECTION_PATTERNS: Record<TestPlatformType, RegExp> = {\n openai: /chatgpt/i,\n 'ext-apps': /mcp-ext-apps/i, // Note: Actual detection uses capabilities\n claude: /claude|claude-desktop/i,\n cursor: /cursor/i,\n continue: /continue/i,\n cody: /cody/i,\n gemini: /gemini/i,\n 'generic-mcp': /generic-mcp/i,\n unknown: /.*/, // Matches anything (fallback)\n};\n\n// ═══════════════════════════════════════════════════════════════════\n// PLATFORM CAPABILITIES\n// ═══════════════════════════════════════════════════════════════════\n\n/**\n * MCP Apps extension capability for ext-apps platform detection.\n */\nexport interface McpAppsExtension {\n /** Supported MIME types */\n mimeTypes?: string[];\n}\n\n/**\n * Experimental capabilities for MCP clients.\n */\nexport interface ExperimentalCapabilities {\n [MCP_APPS_EXTENSION_KEY]?: McpAppsExtension;\n [key: string]: unknown;\n}\n\n/**\n * Client capabilities sent during MCP initialization.\n */\nexport interface TestClientCapabilities {\n sampling?: Record<string, unknown>;\n experimental?: ExperimentalCapabilities;\n}\n\n/**\n * Get client capabilities for a specific platform.\n *\n * Currently only ext-apps requires special capabilities:\n * - ext-apps: Sets io.modelcontextprotocol/ui extension for platform detection\n *\n * @param platform - The platform type\n * @returns Client capabilities to send during initialization\n */\nexport function getPlatformCapabilities(platform: TestPlatformType): TestClientCapabilities {\n const baseCapabilities: TestClientCapabilities = {\n sampling: {},\n };\n\n // ext-apps requires the io.modelcontextprotocol/ui extension for detection\n if (platform === 'ext-apps') {\n return {\n ...baseCapabilities,\n experimental: {\n [MCP_APPS_EXTENSION_KEY]: {\n mimeTypes: ['text/html+mcp'],\n },\n },\n };\n }\n\n return baseCapabilities;\n}\n\n/**\n * Check if a platform requires capability-based detection (vs User-Agent).\n */\nexport function requiresCapabilityDetection(platform: TestPlatformType): boolean {\n return platform === 'ext-apps';\n}\n"]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file platform-types.ts
|
|
3
|
+
* @description Platform type definitions for E2E testing.
|
|
4
|
+
*
|
|
5
|
+
* These types mirror the AIPlatformType from @frontmcp/ui/adapters
|
|
6
|
+
* for use in testing without creating a hard dependency.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { TestPlatformType } from '@frontmcp/testing';
|
|
11
|
+
*
|
|
12
|
+
* const platform: TestPlatformType = 'openai';
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Supported AI platform types for testing.
|
|
17
|
+
*
|
|
18
|
+
* - `openai`: OpenAI ChatGPT (uses openai/* meta keys)
|
|
19
|
+
* - `ext-apps`: MCP Apps per SEP-1865 (uses ui/* meta keys)
|
|
20
|
+
* - `claude`: Claude Desktop (uses frontmcp/* + ui/* keys)
|
|
21
|
+
* - `cursor`: Cursor IDE (uses frontmcp/* + ui/* keys)
|
|
22
|
+
* - `continue`: Continue Dev (uses frontmcp/* + ui/* keys)
|
|
23
|
+
* - `cody`: Sourcegraph Cody (uses frontmcp/* + ui/* keys)
|
|
24
|
+
* - `gemini`: Google Gemini (uses frontmcp/* + ui/* keys)
|
|
25
|
+
* - `generic-mcp`: Generic MCP client (uses frontmcp/* + ui/* keys)
|
|
26
|
+
* - `unknown`: Unknown platform (uses frontmcp/* + ui/* keys)
|
|
27
|
+
*/
|
|
28
|
+
export type TestPlatformType = 'openai' | 'ext-apps' | 'claude' | 'cursor' | 'continue' | 'cody' | 'gemini' | 'generic-mcp' | 'unknown';
|
|
29
|
+
/**
|
|
30
|
+
* Platform meta namespace used for tool responses.
|
|
31
|
+
*
|
|
32
|
+
* - `openai`: Uses `openai/*` keys only
|
|
33
|
+
* - `ui`: Uses `ui/*` keys only (ext-apps per SEP-1865)
|
|
34
|
+
* - `frontmcp`: Uses `frontmcp/*` + `ui/*` keys for compatibility
|
|
35
|
+
*/
|
|
36
|
+
export type PlatformMetaNamespace = 'openai' | 'ui' | 'frontmcp';
|
|
37
|
+
/**
|
|
38
|
+
* Get the meta namespace for a platform type.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getPlatformMetaNamespace(platform: TestPlatformType): PlatformMetaNamespace;
|
|
41
|
+
/**
|
|
42
|
+
* Get the expected MIME type for a platform.
|
|
43
|
+
*
|
|
44
|
+
* - OpenAI uses `text/html+skybridge`
|
|
45
|
+
* - All other platforms use `text/html+mcp`
|
|
46
|
+
*/
|
|
47
|
+
export declare function getPlatformMimeType(platform: TestPlatformType): string;
|
|
48
|
+
/**
|
|
49
|
+
* Check if a platform uses OpenAI-specific meta keys.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isOpenAIPlatform(platform: TestPlatformType): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Check if a platform is ext-apps (SEP-1865 MCP Apps).
|
|
54
|
+
*/
|
|
55
|
+
export declare function isExtAppsPlatform(platform: TestPlatformType): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Check if a platform uses FrontMCP meta keys (non-OpenAI, non-ext-apps).
|
|
58
|
+
*/
|
|
59
|
+
export declare function isFrontmcpPlatform(platform: TestPlatformType): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Get all expected meta key prefixes for a platform's tools/list response.
|
|
62
|
+
*/
|
|
63
|
+
export declare function getToolsListMetaPrefixes(platform: TestPlatformType): string[];
|
|
64
|
+
/**
|
|
65
|
+
* Get all expected meta key prefixes for a platform's tool/call response.
|
|
66
|
+
*/
|
|
67
|
+
export declare function getToolCallMetaPrefixes(platform: TestPlatformType): string[];
|
|
68
|
+
/**
|
|
69
|
+
* Get forbidden meta key prefixes for a platform.
|
|
70
|
+
* These prefixes should NOT appear in responses for the given platform.
|
|
71
|
+
*/
|
|
72
|
+
export declare function getForbiddenMetaPrefixes(platform: TestPlatformType): string[];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @file platform-types.ts
|
|
4
|
+
* @description Platform type definitions for E2E testing.
|
|
5
|
+
*
|
|
6
|
+
* These types mirror the AIPlatformType from @frontmcp/ui/adapters
|
|
7
|
+
* for use in testing without creating a hard dependency.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { TestPlatformType } from '@frontmcp/testing';
|
|
12
|
+
*
|
|
13
|
+
* const platform: TestPlatformType = 'openai';
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.getPlatformMetaNamespace = getPlatformMetaNamespace;
|
|
18
|
+
exports.getPlatformMimeType = getPlatformMimeType;
|
|
19
|
+
exports.isOpenAIPlatform = isOpenAIPlatform;
|
|
20
|
+
exports.isExtAppsPlatform = isExtAppsPlatform;
|
|
21
|
+
exports.isFrontmcpPlatform = isFrontmcpPlatform;
|
|
22
|
+
exports.getToolsListMetaPrefixes = getToolsListMetaPrefixes;
|
|
23
|
+
exports.getToolCallMetaPrefixes = getToolCallMetaPrefixes;
|
|
24
|
+
exports.getForbiddenMetaPrefixes = getForbiddenMetaPrefixes;
|
|
25
|
+
/**
|
|
26
|
+
* Get the meta namespace for a platform type.
|
|
27
|
+
*/
|
|
28
|
+
function getPlatformMetaNamespace(platform) {
|
|
29
|
+
switch (platform) {
|
|
30
|
+
case 'openai':
|
|
31
|
+
return 'openai';
|
|
32
|
+
case 'ext-apps':
|
|
33
|
+
return 'ui';
|
|
34
|
+
default:
|
|
35
|
+
return 'frontmcp';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Get the expected MIME type for a platform.
|
|
40
|
+
*
|
|
41
|
+
* - OpenAI uses `text/html+skybridge`
|
|
42
|
+
* - All other platforms use `text/html+mcp`
|
|
43
|
+
*/
|
|
44
|
+
function getPlatformMimeType(platform) {
|
|
45
|
+
return platform === 'openai' ? 'text/html+skybridge' : 'text/html+mcp';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check if a platform uses OpenAI-specific meta keys.
|
|
49
|
+
*/
|
|
50
|
+
function isOpenAIPlatform(platform) {
|
|
51
|
+
return platform === 'openai';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if a platform is ext-apps (SEP-1865 MCP Apps).
|
|
55
|
+
*/
|
|
56
|
+
function isExtAppsPlatform(platform) {
|
|
57
|
+
return platform === 'ext-apps';
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if a platform uses FrontMCP meta keys (non-OpenAI, non-ext-apps).
|
|
61
|
+
*/
|
|
62
|
+
function isFrontmcpPlatform(platform) {
|
|
63
|
+
return platform !== 'openai' && platform !== 'ext-apps';
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Get all expected meta key prefixes for a platform's tools/list response.
|
|
67
|
+
*/
|
|
68
|
+
function getToolsListMetaPrefixes(platform) {
|
|
69
|
+
switch (platform) {
|
|
70
|
+
case 'openai':
|
|
71
|
+
return ['openai/'];
|
|
72
|
+
case 'ext-apps':
|
|
73
|
+
return ['ui/'];
|
|
74
|
+
default:
|
|
75
|
+
// Other platforms use frontmcp/* + ui/* for compatibility
|
|
76
|
+
return ['frontmcp/', 'ui/'];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get all expected meta key prefixes for a platform's tool/call response.
|
|
81
|
+
*/
|
|
82
|
+
function getToolCallMetaPrefixes(platform) {
|
|
83
|
+
switch (platform) {
|
|
84
|
+
case 'openai':
|
|
85
|
+
return ['openai/'];
|
|
86
|
+
case 'ext-apps':
|
|
87
|
+
return ['ui/'];
|
|
88
|
+
default:
|
|
89
|
+
// Other platforms use frontmcp/* + ui/* for compatibility
|
|
90
|
+
return ['frontmcp/', 'ui/'];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get forbidden meta key prefixes for a platform.
|
|
95
|
+
* These prefixes should NOT appear in responses for the given platform.
|
|
96
|
+
*/
|
|
97
|
+
function getForbiddenMetaPrefixes(platform) {
|
|
98
|
+
switch (platform) {
|
|
99
|
+
case 'openai':
|
|
100
|
+
// OpenAI should NOT have ui/* or frontmcp/* keys
|
|
101
|
+
return ['ui/', 'frontmcp/'];
|
|
102
|
+
case 'ext-apps':
|
|
103
|
+
// ext-apps should NOT have openai/* or frontmcp/* keys
|
|
104
|
+
return ['openai/', 'frontmcp/'];
|
|
105
|
+
default:
|
|
106
|
+
// Other platforms should NOT have openai/* keys
|
|
107
|
+
return ['openai/'];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=platform-types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"platform-types.js","sourceRoot":"","sources":["../../../src/platform/platform-types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;GAaG;;AAsCH,4DASC;AAQD,kDAEC;AAKD,4CAEC;AAKD,8CAEC;AAKD,gDAEC;AAKD,4DAUC;AAKD,0DAUC;AAMD,4DAYC;AA3FD;;GAEG;AACH,SAAgB,wBAAwB,CAAC,QAA0B;IACjE,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO,QAAQ,CAAC;QAClB,KAAK,UAAU;YACb,OAAO,IAAI,CAAC;QACd;YACE,OAAO,UAAU,CAAC;IACtB,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,SAAgB,mBAAmB,CAAC,QAA0B;IAC5D,OAAO,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,eAAe,CAAC;AACzE,CAAC;AAED;;GAEG;AACH,SAAgB,gBAAgB,CAAC,QAA0B;IACzD,OAAO,QAAQ,KAAK,QAAQ,CAAC;AAC/B,CAAC;AAED;;GAEG;AACH,SAAgB,iBAAiB,CAAC,QAA0B;IAC1D,OAAO,QAAQ,KAAK,UAAU,CAAC;AACjC,CAAC;AAED;;GAEG;AACH,SAAgB,kBAAkB,CAAC,QAA0B;IAC3D,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,KAAK,UAAU,CAAC;AAC1D,CAAC;AAED;;GAEG;AACH,SAAgB,wBAAwB,CAAC,QAA0B;IACjE,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO,CAAC,SAAS,CAAC,CAAC;QACrB,KAAK,UAAU;YACb,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB;YACE,0DAA0D;YAC1D,OAAO,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;IAChC,CAAC;AACH,CAAC;AAED;;GAEG;AACH,SAAgB,uBAAuB,CAAC,QAA0B;IAChE,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO,CAAC,SAAS,CAAC,CAAC;QACrB,KAAK,UAAU;YACb,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB;YACE,0DAA0D;YAC1D,OAAO,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;IAChC,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,SAAgB,wBAAwB,CAAC,QAA0B;IACjE,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,QAAQ;YACX,iDAAiD;YACjD,OAAO,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;QAC9B,KAAK,UAAU;YACb,uDAAuD;YACvD,OAAO,CAAC,SAAS,EAAE,WAAW,CAAC,CAAC;QAClC;YACE,gDAAgD;YAChD,OAAO,CAAC,SAAS,CAAC,CAAC;IACvB,CAAC;AACH,CAAC","sourcesContent":["/**\n * @file platform-types.ts\n * @description Platform type definitions for E2E testing.\n *\n * These types mirror the AIPlatformType from @frontmcp/ui/adapters\n * for use in testing without creating a hard dependency.\n *\n * @example\n * ```typescript\n * import { TestPlatformType } from '@frontmcp/testing';\n *\n * const platform: TestPlatformType = 'openai';\n * ```\n */\n\n/**\n * Supported AI platform types for testing.\n *\n * - `openai`: OpenAI ChatGPT (uses openai/* meta keys)\n * - `ext-apps`: MCP Apps per SEP-1865 (uses ui/* meta keys)\n * - `claude`: Claude Desktop (uses frontmcp/* + ui/* keys)\n * - `cursor`: Cursor IDE (uses frontmcp/* + ui/* keys)\n * - `continue`: Continue Dev (uses frontmcp/* + ui/* keys)\n * - `cody`: Sourcegraph Cody (uses frontmcp/* + ui/* keys)\n * - `gemini`: Google Gemini (uses frontmcp/* + ui/* keys)\n * - `generic-mcp`: Generic MCP client (uses frontmcp/* + ui/* keys)\n * - `unknown`: Unknown platform (uses frontmcp/* + ui/* keys)\n */\nexport type TestPlatformType =\n | 'openai'\n | 'ext-apps'\n | 'claude'\n | 'cursor'\n | 'continue'\n | 'cody'\n | 'gemini'\n | 'generic-mcp'\n | 'unknown';\n\n/**\n * Platform meta namespace used for tool responses.\n *\n * - `openai`: Uses `openai/*` keys only\n * - `ui`: Uses `ui/*` keys only (ext-apps per SEP-1865)\n * - `frontmcp`: Uses `frontmcp/*` + `ui/*` keys for compatibility\n */\nexport type PlatformMetaNamespace = 'openai' | 'ui' | 'frontmcp';\n\n/**\n * Get the meta namespace for a platform type.\n */\nexport function getPlatformMetaNamespace(platform: TestPlatformType): PlatformMetaNamespace {\n switch (platform) {\n case 'openai':\n return 'openai';\n case 'ext-apps':\n return 'ui';\n default:\n return 'frontmcp';\n }\n}\n\n/**\n * Get the expected MIME type for a platform.\n *\n * - OpenAI uses `text/html+skybridge`\n * - All other platforms use `text/html+mcp`\n */\nexport function getPlatformMimeType(platform: TestPlatformType): string {\n return platform === 'openai' ? 'text/html+skybridge' : 'text/html+mcp';\n}\n\n/**\n * Check if a platform uses OpenAI-specific meta keys.\n */\nexport function isOpenAIPlatform(platform: TestPlatformType): boolean {\n return platform === 'openai';\n}\n\n/**\n * Check if a platform is ext-apps (SEP-1865 MCP Apps).\n */\nexport function isExtAppsPlatform(platform: TestPlatformType): boolean {\n return platform === 'ext-apps';\n}\n\n/**\n * Check if a platform uses FrontMCP meta keys (non-OpenAI, non-ext-apps).\n */\nexport function isFrontmcpPlatform(platform: TestPlatformType): boolean {\n return platform !== 'openai' && platform !== 'ext-apps';\n}\n\n/**\n * Get all expected meta key prefixes for a platform's tools/list response.\n */\nexport function getToolsListMetaPrefixes(platform: TestPlatformType): string[] {\n switch (platform) {\n case 'openai':\n return ['openai/'];\n case 'ext-apps':\n return ['ui/'];\n default:\n // Other platforms use frontmcp/* + ui/* for compatibility\n return ['frontmcp/', 'ui/'];\n }\n}\n\n/**\n * Get all expected meta key prefixes for a platform's tool/call response.\n */\nexport function getToolCallMetaPrefixes(platform: TestPlatformType): string[] {\n switch (platform) {\n case 'openai':\n return ['openai/'];\n case 'ext-apps':\n return ['ui/'];\n default:\n // Other platforms use frontmcp/* + ui/* for compatibility\n return ['frontmcp/', 'ui/'];\n }\n}\n\n/**\n * Get forbidden meta key prefixes for a platform.\n * These prefixes should NOT appear in responses for the given platform.\n */\nexport function getForbiddenMetaPrefixes(platform: TestPlatformType): string[] {\n switch (platform) {\n case 'openai':\n // OpenAI should NOT have ui/* or frontmcp/* keys\n return ['ui/', 'frontmcp/'];\n case 'ext-apps':\n // ext-apps should NOT have openai/* or frontmcp/* keys\n return ['openai/', 'frontmcp/'];\n default:\n // Other platforms should NOT have openai/* keys\n return ['openai/'];\n }\n}\n"]}
|
|
@@ -216,6 +216,10 @@ class TestServer {
|
|
|
216
216
|
if (this.process.pid !== undefined) {
|
|
217
217
|
this._info.pid = this.process.pid;
|
|
218
218
|
}
|
|
219
|
+
// Track process exit for early failure detection
|
|
220
|
+
let processExited = false;
|
|
221
|
+
let exitCode = null;
|
|
222
|
+
let exitError = null;
|
|
219
223
|
// Capture stdout
|
|
220
224
|
this.process.stdout?.on('data', (data) => {
|
|
221
225
|
const text = data.toString();
|
|
@@ -235,16 +239,67 @@ class TestServer {
|
|
|
235
239
|
// Handle spawn errors to prevent unhandled error events
|
|
236
240
|
this.process.on('error', (err) => {
|
|
237
241
|
this.logs.push(`[SPAWN ERROR] ${err.message}`);
|
|
242
|
+
exitError = err;
|
|
238
243
|
if (this.options.debug) {
|
|
239
244
|
console.error('[SERVER SPAWN ERROR]', err);
|
|
240
245
|
}
|
|
241
246
|
});
|
|
242
|
-
// Handle process exit -
|
|
247
|
+
// Handle process exit - track early failures
|
|
243
248
|
this.process.once('exit', (code) => {
|
|
249
|
+
processExited = true;
|
|
250
|
+
exitCode = code;
|
|
244
251
|
this.log(`Server process exited with code ${code}`);
|
|
245
252
|
});
|
|
246
|
-
// Wait for server to be ready
|
|
247
|
-
await this.
|
|
253
|
+
// Wait for server to be ready, but detect early process exit
|
|
254
|
+
await this.waitForReadyWithExitDetection(() => {
|
|
255
|
+
if (exitError) {
|
|
256
|
+
return { exited: true, error: exitError };
|
|
257
|
+
}
|
|
258
|
+
if (processExited) {
|
|
259
|
+
const recentLogs = this.logs.slice(-10).join('\n');
|
|
260
|
+
return {
|
|
261
|
+
exited: true,
|
|
262
|
+
error: new Error(`Server process exited unexpectedly with code ${exitCode}.\n\nRecent logs:\n${recentLogs}`),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return { exited: false };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Wait for server to be ready, but also detect early process exit
|
|
270
|
+
*/
|
|
271
|
+
async waitForReadyWithExitDetection(checkExit) {
|
|
272
|
+
const timeoutMs = this.options.startupTimeout;
|
|
273
|
+
const deadline = Date.now() + timeoutMs;
|
|
274
|
+
const checkInterval = 100;
|
|
275
|
+
while (Date.now() < deadline) {
|
|
276
|
+
// Check if process has exited before continuing to poll
|
|
277
|
+
const exitStatus = checkExit();
|
|
278
|
+
if (exitStatus.exited) {
|
|
279
|
+
throw exitStatus.error ?? new Error('Server process exited unexpectedly');
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {
|
|
283
|
+
method: 'GET',
|
|
284
|
+
signal: AbortSignal.timeout(1000),
|
|
285
|
+
});
|
|
286
|
+
if (response.ok || response.status === 404) {
|
|
287
|
+
// 404 is okay - it means the server is running but might not have a health endpoint
|
|
288
|
+
this.log('Server is ready');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Server not ready yet
|
|
294
|
+
}
|
|
295
|
+
await sleep(checkInterval);
|
|
296
|
+
}
|
|
297
|
+
// Final check before throwing timeout error
|
|
298
|
+
const finalExitStatus = checkExit();
|
|
299
|
+
if (finalExitStatus.exited) {
|
|
300
|
+
throw finalExitStatus.error ?? new Error('Server process exited unexpectedly');
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`Server did not become ready within ${timeoutMs}ms`);
|
|
248
303
|
}
|
|
249
304
|
log(message) {
|
|
250
305
|
if (this.options.debug) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-server.js","sourceRoot":"","sources":["../../../src/server/test-server.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @file test-server.ts\n * @description Test server management for E2E testing\n */\n\nimport { spawn, ChildProcess } from 'child_process';\n\n// ═══════════════════════════════════════════════════════════════════\n// TYPES\n// ═══════════════════════════════════════════════════════════════════\n\nexport interface TestServerOptions {\n /** Port to run the server on (default: random available port) */\n port?: number;\n /** Command to start the server */\n command?: string;\n /** Working directory */\n cwd?: string;\n /** Environment variables */\n env?: Record<string, string>;\n /** Timeout for server startup in milliseconds (default: 30000) */\n startupTimeout?: number;\n /** Path to check for server readiness (default: /health) */\n healthCheckPath?: string;\n /** Enable debug logging */\n debug?: boolean;\n}\n\nexport interface TestServerInfo {\n /** Base URL of the server */\n baseUrl: string;\n /** Port the server is running on */\n port: number;\n /** Process ID (if available) */\n pid?: number;\n}\n\n// ═══════════════════════════════════════════════════════════════════\n// TEST SERVER CLASS\n// ═══════════════════════════════════════════════════════════════════\n\n/**\n * Manages test server lifecycle for E2E testing\n *\n * @example\n * ```typescript\n * // Start a server with custom command\n * const server = await TestServer.start({\n * command: 'node dist/main.js',\n * port: 3003,\n * cwd: './apps/my-server',\n * });\n *\n * // Or start an Nx project\n * const server = await TestServer.startNx('demo-public', { port: 3003 });\n *\n * // Use the server\n * console.log(server.info.baseUrl); // http://localhost:3003\n *\n * // Stop when done\n * await server.stop();\n * ```\n */\nexport class TestServer {\n private process: ChildProcess | null = null;\n private readonly options: Required<TestServerOptions>;\n private _info: TestServerInfo;\n private logs: string[] = [];\n\n private constructor(options: TestServerOptions, port: number) {\n this.options = {\n port,\n command: options.command ?? '',\n cwd: options.cwd ?? process.cwd(),\n env: options.env ?? {},\n startupTimeout: options.startupTimeout ?? 30000,\n healthCheckPath: options.healthCheckPath ?? '/health',\n debug: options.debug ?? false,\n };\n\n this._info = {\n baseUrl: `http://localhost:${port}`,\n port,\n };\n }\n\n /**\n * Start a test server with custom command\n */\n static async start(options: TestServerOptions): Promise<TestServer> {\n const port = options.port ?? (await findAvailablePort());\n const server = new TestServer(options, port);\n try {\n await server.startProcess();\n } catch (error) {\n await server.stop(); // Clean up spawned process to prevent leaks\n throw error;\n }\n return server;\n }\n\n /**\n * Start an Nx project as test server\n */\n static async startNx(project: string, options: Partial<TestServerOptions> = {}): Promise<TestServer> {\n // Validate project name contains only safe characters to prevent shell injection\n if (!/^[\\w-]+$/.test(project)) {\n throw new Error(\n `Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`,\n );\n }\n\n const port = options.port ?? (await findAvailablePort());\n\n const serverOptions: TestServerOptions = {\n ...options,\n port,\n command: `npx nx serve ${project} --port ${port}`,\n cwd: options.cwd ?? process.cwd(),\n };\n\n const server = new TestServer(serverOptions, port);\n try {\n await server.startProcess();\n } catch (error) {\n await server.stop(); // Clean up spawned process to prevent leaks\n throw error;\n }\n return server;\n }\n\n /**\n * Create a test server connected to an already running server\n */\n static connect(baseUrl: string): TestServer {\n const url = new URL(baseUrl);\n const port = parseInt(url.port, 10) || (url.protocol === 'https:' ? 443 : 80);\n\n const server = new TestServer(\n {\n command: '',\n port,\n },\n port,\n );\n\n server._info = {\n baseUrl: baseUrl.replace(/\\/$/, ''),\n port,\n };\n\n return server;\n }\n\n /**\n * Get server information\n */\n get info(): TestServerInfo {\n return { ...this._info };\n }\n\n /**\n * Stop the test server\n */\n async stop(): Promise<void> {\n if (this.process) {\n this.log('Stopping server...');\n\n // Try graceful shutdown first\n this.process.kill('SIGTERM');\n\n // Wait for process to exit\n const exitPromise = new Promise<void>((resolve) => {\n if (this.process) {\n this.process.once('exit', () => resolve());\n } else {\n resolve();\n }\n });\n\n // Force kill after timeout (but still wait for actual exit)\n const killTimeout = setTimeout(() => {\n if (this.process) {\n this.log('Force killing server after timeout...');\n this.process.kill('SIGKILL');\n }\n }, 5000);\n\n await exitPromise;\n clearTimeout(killTimeout);\n this.process = null;\n this.log('Server stopped');\n }\n }\n\n /**\n * Wait for server to be ready\n */\n async waitForReady(timeout?: number): Promise<void> {\n const timeoutMs = timeout ?? this.options.startupTimeout;\n const deadline = Date.now() + timeoutMs;\n const checkInterval = 100;\n\n while (Date.now() < deadline) {\n try {\n const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {\n method: 'GET',\n signal: AbortSignal.timeout(1000),\n });\n\n if (response.ok || response.status === 404) {\n // 404 is okay - it means the server is running but might not have a health endpoint\n this.log('Server is ready');\n return;\n }\n } catch {\n // Server not ready yet\n }\n\n await sleep(checkInterval);\n }\n\n throw new Error(`Server did not become ready within ${timeoutMs}ms`);\n }\n\n /**\n * Restart the server\n */\n async restart(): Promise<void> {\n await this.stop();\n await this.startProcess();\n }\n\n /**\n * Get captured server logs\n */\n getLogs(): string[] {\n return [...this.logs];\n }\n\n /**\n * Clear captured logs\n */\n clearLogs(): void {\n this.logs = [];\n }\n\n // ═══════════════════════════════════════════════════════════════════\n // PRIVATE METHODS\n // ═══════════════════════════════════════════════════════════════════\n\n private async startProcess(): Promise<void> {\n if (!this.options.command) {\n // No command means we're connecting to an existing server\n await this.waitForReady();\n return;\n }\n\n this.log(`Starting server: ${this.options.command}`);\n\n const env = {\n ...process.env,\n ...this.options.env,\n PORT: String(this.options.port),\n };\n\n // Use shell: true to handle complex commands with quoted arguments\n // This avoids fragile command parsing with split(' ')\n this.process = spawn(this.options.command, [], {\n cwd: this.options.cwd,\n env,\n shell: true,\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n\n // pid can be undefined if spawn fails\n if (this.process.pid !== undefined) {\n this._info.pid = this.process.pid;\n }\n\n // Capture stdout\n this.process.stdout?.on('data', (data: Buffer) => {\n const text = data.toString();\n this.logs.push(text);\n if (this.options.debug) {\n console.log('[SERVER]', text);\n }\n });\n\n // Capture stderr\n this.process.stderr?.on('data', (data: Buffer) => {\n const text = data.toString();\n this.logs.push(`[ERROR] ${text}`);\n if (this.options.debug) {\n console.error('[SERVER ERROR]', text);\n }\n });\n\n // Handle spawn errors to prevent unhandled error events\n this.process.on('error', (err) => {\n this.logs.push(`[SPAWN ERROR] ${err.message}`);\n if (this.options.debug) {\n console.error('[SERVER SPAWN ERROR]', err);\n }\n });\n\n // Handle process exit - use once() to avoid memory leak from listener not being cleaned up\n this.process.once('exit', (code) => {\n this.log(`Server process exited with code ${code}`);\n });\n\n // Wait for server to be ready\n await this.waitForReady();\n }\n\n private log(message: string): void {\n if (this.options.debug) {\n console.log(`[TestServer] ${message}`);\n }\n }\n}\n\n// ═══════════════════════════════════════════════════════════════════\n// UTILITIES\n// ═══════════════════════════════════════════════════════════════════\n\n/**\n * Find an available port\n */\nexport async function findAvailablePort(): Promise<number> {\n // Use a simple approach: try to create a server on port 0 to get an available port\n const { createServer } = await import('net');\n\n return new Promise((resolve, reject) => {\n const server = createServer();\n\n server.listen(0, () => {\n const address = server.address();\n if (address && typeof address !== 'string') {\n const port = address.port;\n server.close(() => resolve(port));\n } else {\n reject(new Error('Could not get port'));\n }\n });\n\n server.on('error', reject);\n });\n}\n\n/**\n * Sleep for specified milliseconds\n */\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"]}
|
|
1
|
+
{"version":3,"file":"test-server.js","sourceRoot":"","sources":["../../../src/server/test-server.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @file test-server.ts\n * @description Test server management for E2E testing\n */\n\nimport { spawn, ChildProcess } from 'child_process';\n\n// ═══════════════════════════════════════════════════════════════════\n// TYPES\n// ═══════════════════════════════════════════════════════════════════\n\nexport interface TestServerOptions {\n /** Port to run the server on (default: random available port) */\n port?: number;\n /** Command to start the server */\n command?: string;\n /** Working directory */\n cwd?: string;\n /** Environment variables */\n env?: Record<string, string>;\n /** Timeout for server startup in milliseconds (default: 30000) */\n startupTimeout?: number;\n /** Path to check for server readiness (default: /health) */\n healthCheckPath?: string;\n /** Enable debug logging */\n debug?: boolean;\n}\n\nexport interface TestServerInfo {\n /** Base URL of the server */\n baseUrl: string;\n /** Port the server is running on */\n port: number;\n /** Process ID (if available) */\n pid?: number;\n}\n\n// ═══════════════════════════════════════════════════════════════════\n// TEST SERVER CLASS\n// ═══════════════════════════════════════════════════════════════════\n\n/**\n * Manages test server lifecycle for E2E testing\n *\n * @example\n * ```typescript\n * // Start a server with custom command\n * const server = await TestServer.start({\n * command: 'node dist/main.js',\n * port: 3003,\n * cwd: './apps/my-server',\n * });\n *\n * // Or start an Nx project\n * const server = await TestServer.startNx('demo-public', { port: 3003 });\n *\n * // Use the server\n * console.log(server.info.baseUrl); // http://localhost:3003\n *\n * // Stop when done\n * await server.stop();\n * ```\n */\nexport class TestServer {\n private process: ChildProcess | null = null;\n private readonly options: Required<TestServerOptions>;\n private _info: TestServerInfo;\n private logs: string[] = [];\n\n private constructor(options: TestServerOptions, port: number) {\n this.options = {\n port,\n command: options.command ?? '',\n cwd: options.cwd ?? process.cwd(),\n env: options.env ?? {},\n startupTimeout: options.startupTimeout ?? 30000,\n healthCheckPath: options.healthCheckPath ?? '/health',\n debug: options.debug ?? false,\n };\n\n this._info = {\n baseUrl: `http://localhost:${port}`,\n port,\n };\n }\n\n /**\n * Start a test server with custom command\n */\n static async start(options: TestServerOptions): Promise<TestServer> {\n const port = options.port ?? (await findAvailablePort());\n const server = new TestServer(options, port);\n try {\n await server.startProcess();\n } catch (error) {\n await server.stop(); // Clean up spawned process to prevent leaks\n throw error;\n }\n return server;\n }\n\n /**\n * Start an Nx project as test server\n */\n static async startNx(project: string, options: Partial<TestServerOptions> = {}): Promise<TestServer> {\n // Validate project name contains only safe characters to prevent shell injection\n if (!/^[\\w-]+$/.test(project)) {\n throw new Error(\n `Invalid project name: ${project}. Must contain only alphanumeric, underscore, and hyphen characters.`,\n );\n }\n\n const port = options.port ?? (await findAvailablePort());\n\n const serverOptions: TestServerOptions = {\n ...options,\n port,\n command: `npx nx serve ${project} --port ${port}`,\n cwd: options.cwd ?? process.cwd(),\n };\n\n const server = new TestServer(serverOptions, port);\n try {\n await server.startProcess();\n } catch (error) {\n await server.stop(); // Clean up spawned process to prevent leaks\n throw error;\n }\n return server;\n }\n\n /**\n * Create a test server connected to an already running server\n */\n static connect(baseUrl: string): TestServer {\n const url = new URL(baseUrl);\n const port = parseInt(url.port, 10) || (url.protocol === 'https:' ? 443 : 80);\n\n const server = new TestServer(\n {\n command: '',\n port,\n },\n port,\n );\n\n server._info = {\n baseUrl: baseUrl.replace(/\\/$/, ''),\n port,\n };\n\n return server;\n }\n\n /**\n * Get server information\n */\n get info(): TestServerInfo {\n return { ...this._info };\n }\n\n /**\n * Stop the test server\n */\n async stop(): Promise<void> {\n if (this.process) {\n this.log('Stopping server...');\n\n // Try graceful shutdown first\n this.process.kill('SIGTERM');\n\n // Wait for process to exit\n const exitPromise = new Promise<void>((resolve) => {\n if (this.process) {\n this.process.once('exit', () => resolve());\n } else {\n resolve();\n }\n });\n\n // Force kill after timeout (but still wait for actual exit)\n const killTimeout = setTimeout(() => {\n if (this.process) {\n this.log('Force killing server after timeout...');\n this.process.kill('SIGKILL');\n }\n }, 5000);\n\n await exitPromise;\n clearTimeout(killTimeout);\n this.process = null;\n this.log('Server stopped');\n }\n }\n\n /**\n * Wait for server to be ready\n */\n async waitForReady(timeout?: number): Promise<void> {\n const timeoutMs = timeout ?? this.options.startupTimeout;\n const deadline = Date.now() + timeoutMs;\n const checkInterval = 100;\n\n while (Date.now() < deadline) {\n try {\n const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {\n method: 'GET',\n signal: AbortSignal.timeout(1000),\n });\n\n if (response.ok || response.status === 404) {\n // 404 is okay - it means the server is running but might not have a health endpoint\n this.log('Server is ready');\n return;\n }\n } catch {\n // Server not ready yet\n }\n\n await sleep(checkInterval);\n }\n\n throw new Error(`Server did not become ready within ${timeoutMs}ms`);\n }\n\n /**\n * Restart the server\n */\n async restart(): Promise<void> {\n await this.stop();\n await this.startProcess();\n }\n\n /**\n * Get captured server logs\n */\n getLogs(): string[] {\n return [...this.logs];\n }\n\n /**\n * Clear captured logs\n */\n clearLogs(): void {\n this.logs = [];\n }\n\n // ═══════════════════════════════════════════════════════════════════\n // PRIVATE METHODS\n // ═══════════════════════════════════════════════════════════════════\n\n private async startProcess(): Promise<void> {\n if (!this.options.command) {\n // No command means we're connecting to an existing server\n await this.waitForReady();\n return;\n }\n\n this.log(`Starting server: ${this.options.command}`);\n\n const env = {\n ...process.env,\n ...this.options.env,\n PORT: String(this.options.port),\n };\n\n // Use shell: true to handle complex commands with quoted arguments\n // This avoids fragile command parsing with split(' ')\n this.process = spawn(this.options.command, [], {\n cwd: this.options.cwd,\n env,\n shell: true,\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n\n // pid can be undefined if spawn fails\n if (this.process.pid !== undefined) {\n this._info.pid = this.process.pid;\n }\n\n // Track process exit for early failure detection\n let processExited = false;\n let exitCode: number | null = null;\n let exitError: Error | null = null;\n\n // Capture stdout\n this.process.stdout?.on('data', (data: Buffer) => {\n const text = data.toString();\n this.logs.push(text);\n if (this.options.debug) {\n console.log('[SERVER]', text);\n }\n });\n\n // Capture stderr\n this.process.stderr?.on('data', (data: Buffer) => {\n const text = data.toString();\n this.logs.push(`[ERROR] ${text}`);\n if (this.options.debug) {\n console.error('[SERVER ERROR]', text);\n }\n });\n\n // Handle spawn errors to prevent unhandled error events\n this.process.on('error', (err) => {\n this.logs.push(`[SPAWN ERROR] ${err.message}`);\n exitError = err;\n if (this.options.debug) {\n console.error('[SERVER SPAWN ERROR]', err);\n }\n });\n\n // Handle process exit - track early failures\n this.process.once('exit', (code) => {\n processExited = true;\n exitCode = code;\n this.log(`Server process exited with code ${code}`);\n });\n\n // Wait for server to be ready, but detect early process exit\n await this.waitForReadyWithExitDetection(() => {\n if (exitError) {\n return { exited: true, error: exitError };\n }\n if (processExited) {\n const recentLogs = this.logs.slice(-10).join('\\n');\n return {\n exited: true,\n error: new Error(`Server process exited unexpectedly with code ${exitCode}.\\n\\nRecent logs:\\n${recentLogs}`),\n };\n }\n return { exited: false };\n });\n }\n\n /**\n * Wait for server to be ready, but also detect early process exit\n */\n private async waitForReadyWithExitDetection(checkExit: () => { exited: boolean; error?: Error }): Promise<void> {\n const timeoutMs = this.options.startupTimeout;\n const deadline = Date.now() + timeoutMs;\n const checkInterval = 100;\n\n while (Date.now() < deadline) {\n // Check if process has exited before continuing to poll\n const exitStatus = checkExit();\n if (exitStatus.exited) {\n throw exitStatus.error ?? new Error('Server process exited unexpectedly');\n }\n\n try {\n const response = await fetch(`${this._info.baseUrl}${this.options.healthCheckPath}`, {\n method: 'GET',\n signal: AbortSignal.timeout(1000),\n });\n\n if (response.ok || response.status === 404) {\n // 404 is okay - it means the server is running but might not have a health endpoint\n this.log('Server is ready');\n return;\n }\n } catch {\n // Server not ready yet\n }\n\n await sleep(checkInterval);\n }\n\n // Final check before throwing timeout error\n const finalExitStatus = checkExit();\n if (finalExitStatus.exited) {\n throw finalExitStatus.error ?? new Error('Server process exited unexpectedly');\n }\n\n throw new Error(`Server did not become ready within ${timeoutMs}ms`);\n }\n\n private log(message: string): void {\n if (this.options.debug) {\n console.log(`[TestServer] ${message}`);\n }\n }\n}\n\n// ═══════════════════════════════════════════════════════════════════\n// UTILITIES\n// ═══════════════════════════════════════════════════════════════════\n\n/**\n * Find an available port\n */\nexport async function findAvailablePort(): Promise<number> {\n // Use a simple approach: try to create a server on port 0 to get an available port\n const { createServer } = await import('net');\n\n return new Promise((resolve, reject) => {\n const server = createServer();\n\n server.listen(0, () => {\n const address = server.address();\n if (address && typeof address !== 'string') {\n const port = address.port;\n server.close(() => resolve(port));\n } else {\n reject(new Error('Could not get port'));\n }\n });\n\n server.on('error', reject);\n });\n}\n\n/**\n * Sleep for specified milliseconds\n */\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n"]}
|