This is page 4 of 4. Use http://codebase.md/circleci-public/mcp-server-circleci?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .circleci
│ └── config.yml
├── .dockerignore
├── .github
│ ├── CODEOWNERS
│ ├── ISSUE_TEMPLATE
│ │ ├── BUG.yml
│ │ └── FEATURE_REQUEST.yml
│ └── PULL_REQUEST_TEMPLATE
│ └── PULL_REQUEST.md
├── .gitignore
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── renovate.json
├── scripts
│ └── create-tool.js
├── smithery.yaml
├── src
│ ├── circleci-tools.ts
│ ├── clients
│ │ ├── circleci
│ │ │ ├── configValidate.ts
│ │ │ ├── deploys.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── insights.ts
│ │ │ ├── jobs.ts
│ │ │ ├── jobsV1.ts
│ │ │ ├── pipelines.ts
│ │ │ ├── projects.ts
│ │ │ ├── tests.ts
│ │ │ ├── usage.ts
│ │ │ └── workflows.ts
│ │ ├── circleci-private
│ │ │ ├── index.ts
│ │ │ ├── jobsPrivate.ts
│ │ │ └── me.ts
│ │ ├── circlet
│ │ │ ├── circlet.ts
│ │ │ └── index.ts
│ │ ├── client.ts
│ │ └── schemas.ts
│ ├── index.ts
│ ├── lib
│ │ ├── flaky-tests
│ │ │ └── getFlakyTests.ts
│ │ ├── getWorkflowIdFromURL.test.ts
│ │ ├── getWorkflowIdFromURL.ts
│ │ ├── latest-pipeline
│ │ │ ├── formatLatestPipelineStatus.ts
│ │ │ └── getLatestPipelineWorkflows.ts
│ │ ├── mcpErrorOutput.test.ts
│ │ ├── mcpErrorOutput.ts
│ │ ├── mcpResponse.test.ts
│ │ ├── mcpResponse.ts
│ │ ├── outputTextTruncated.test.ts
│ │ ├── outputTextTruncated.ts
│ │ ├── pipeline-job-logs
│ │ │ ├── getJobLogs.ts
│ │ │ └── getPipelineJobLogs.ts
│ │ ├── pipeline-job-tests
│ │ │ ├── formatJobTests.ts
│ │ │ └── getJobTests.ts
│ │ ├── project-detection
│ │ │ ├── index.test.ts
│ │ │ ├── index.ts
│ │ │ └── vcsTool.ts
│ │ ├── rateLimitedRequests
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ └── usage-api
│ │ ├── findUnderusedResourceClasses.test.ts
│ │ ├── findUnderusedResourceClasses.ts
│ │ ├── getUsageApiData.test.ts
│ │ ├── getUsageApiData.ts
│ │ └── parseDateTimeString.ts
│ ├── tools
│ │ ├── analyzeDiff
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── configHelper
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── createPromptTemplate
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── downloadUsageApiData
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── findUnderusedResourceClasses
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getBuildFailureLogs
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getFlakyTests
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getJobTestResults
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getLatestPipelineStatus
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── listComponentVersions
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── listFollowedProjects
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── recommendPromptTemplateTests
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── rerunWorkflow
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── runEvaluationTests
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── runPipeline
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── runRollbackPipeline
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ └── shared
│ │ └── constants.ts
│ └── transports
│ ├── stdio.ts
│ └── unified.ts
├── tsconfig.json
├── tsconfig.test.json
└── vitest.config.js
```
# Files
--------------------------------------------------------------------------------
/src/tools/runEvaluationTests/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { runEvaluationTests } from './handler.js';
3 | import * as projectDetection from '../../lib/project-detection/index.js';
4 | import * as clientModule from '../../clients/client.js';
5 |
6 | vi.mock('../../lib/project-detection/index.js');
7 | vi.mock('../../clients/client.js');
8 |
9 | describe('runEvaluationTests handler', () => {
10 | const mockCircleCIClient = {
11 | projects: {
12 | getProject: vi.fn(),
13 | },
14 | pipelines: {
15 | getPipelineDefinitions: vi.fn(),
16 | runPipeline: vi.fn(),
17 | },
18 | };
19 |
20 | beforeEach(() => {
21 | vi.resetAllMocks();
22 | vi.spyOn(clientModule, 'getCircleCIClient').mockReturnValue(
23 | mockCircleCIClient as any,
24 | );
25 | });
26 |
27 | it('should return a valid MCP error response when no inputs are provided', async () => {
28 | const args = {
29 | params: {},
30 | } as any;
31 |
32 | const controller = new AbortController();
33 | const response = await runEvaluationTests(args, {
34 | signal: controller.signal,
35 | });
36 |
37 | expect(response).toHaveProperty('content');
38 | expect(response).toHaveProperty('isError', true);
39 | expect(Array.isArray(response.content)).toBe(true);
40 | expect(response.content[0]).toHaveProperty('type', 'text');
41 | expect(typeof response.content[0].text).toBe('string');
42 | });
43 |
44 | it('should return a valid MCP error response when project is not found', async () => {
45 | vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
46 | undefined,
47 | );
48 |
49 | const args = {
50 | params: {
51 | workspaceRoot: '/workspace',
52 | gitRemoteURL: 'https://github.com/org/repo.git',
53 | branch: 'main',
54 | promptFiles: [
55 | {
56 | fileName: 'test.prompt.yml',
57 | fileContent: 'test: content',
58 | },
59 | ],
60 | },
61 | } as any;
62 |
63 | const controller = new AbortController();
64 | const response = await runEvaluationTests(args, {
65 | signal: controller.signal,
66 | });
67 |
68 | expect(response).toHaveProperty('content');
69 | expect(response).toHaveProperty('isError', true);
70 | expect(Array.isArray(response.content)).toBe(true);
71 | expect(response.content[0]).toHaveProperty('type', 'text');
72 | expect(typeof response.content[0].text).toBe('string');
73 | });
74 |
75 | it('should return a valid MCP error response when no branch is provided', async () => {
76 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
77 | 'gh/org/repo',
78 | );
79 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue(undefined);
80 |
81 | const args = {
82 | params: {
83 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
84 | promptFiles: [
85 | {
86 | fileName: 'test.prompt.yml',
87 | fileContent: 'test: content',
88 | },
89 | ],
90 | },
91 | } as any;
92 |
93 | const controller = new AbortController();
94 | const response = await runEvaluationTests(args, {
95 | signal: controller.signal,
96 | });
97 |
98 | expect(response).toHaveProperty('content');
99 | expect(response).toHaveProperty('isError', true);
100 | expect(Array.isArray(response.content)).toBe(true);
101 | expect(response.content[0]).toHaveProperty('type', 'text');
102 | expect(typeof response.content[0].text).toBe('string');
103 | expect(response.content[0].text).toContain('No branch provided');
104 | });
105 |
106 | it('should return a valid MCP error response when projectSlug is provided without branch', async () => {
107 | const args = {
108 | params: {
109 | projectSlug: 'gh/org/repo',
110 | promptFiles: [
111 | {
112 | fileName: 'test.prompt.yml',
113 | fileContent: 'test: content',
114 | },
115 | ],
116 | // No branch provided
117 | },
118 | } as any;
119 |
120 | const controller = new AbortController();
121 | const response = await runEvaluationTests(args, {
122 | signal: controller.signal,
123 | });
124 |
125 | expect(response).toHaveProperty('content');
126 | expect(response).toHaveProperty('isError', true);
127 | expect(Array.isArray(response.content)).toBe(true);
128 | expect(response.content[0]).toHaveProperty('type', 'text');
129 | expect(response.content[0].text).toContain('Branch not provided');
130 |
131 | // Verify that CircleCI API was not called
132 | expect(mockCircleCIClient.projects.getProject).not.toHaveBeenCalled();
133 | expect(
134 | mockCircleCIClient.pipelines.getPipelineDefinitions,
135 | ).not.toHaveBeenCalled();
136 | expect(mockCircleCIClient.pipelines.runPipeline).not.toHaveBeenCalled();
137 | });
138 |
139 | it('should return a valid MCP error response when no prompt files are provided', async () => {
140 | const args = {
141 | params: {
142 | projectSlug: 'gh/org/repo',
143 | branch: 'main',
144 | promptFiles: [], // Empty array
145 | },
146 | } as any;
147 |
148 | const controller = new AbortController();
149 | const response = await runEvaluationTests(args, {
150 | signal: controller.signal,
151 | });
152 |
153 | expect(response).toHaveProperty('content');
154 | expect(response).toHaveProperty('isError', true);
155 | expect(Array.isArray(response.content)).toBe(true);
156 | expect(response.content[0]).toHaveProperty('type', 'text');
157 | expect(response.content[0].text).toContain(
158 | 'No prompt template files provided',
159 | );
160 | expect(response.content[0].text).toContain('./prompts directory');
161 |
162 | // Verify that CircleCI API was not called
163 | expect(mockCircleCIClient.projects.getProject).not.toHaveBeenCalled();
164 | expect(
165 | mockCircleCIClient.pipelines.getPipelineDefinitions,
166 | ).not.toHaveBeenCalled();
167 | expect(mockCircleCIClient.pipelines.runPipeline).not.toHaveBeenCalled();
168 | });
169 |
170 | it('should return a valid MCP error response when promptFiles is undefined', async () => {
171 | const args = {
172 | params: {
173 | projectSlug: 'gh/org/repo',
174 | branch: 'main',
175 | // promptFiles is undefined
176 | },
177 | } as any;
178 |
179 | const controller = new AbortController();
180 | const response = await runEvaluationTests(args, {
181 | signal: controller.signal,
182 | });
183 |
184 | expect(response).toHaveProperty('content');
185 | expect(response).toHaveProperty('isError', true);
186 | expect(Array.isArray(response.content)).toBe(true);
187 | expect(response.content[0]).toHaveProperty('type', 'text');
188 | expect(response.content[0].text).toContain(
189 | 'No prompt template files provided',
190 | );
191 | expect(response.content[0].text).toContain('./prompts directory');
192 |
193 | // Verify that CircleCI API was not called
194 | expect(mockCircleCIClient.projects.getProject).not.toHaveBeenCalled();
195 | expect(
196 | mockCircleCIClient.pipelines.getPipelineDefinitions,
197 | ).not.toHaveBeenCalled();
198 | expect(mockCircleCIClient.pipelines.runPipeline).not.toHaveBeenCalled();
199 | });
200 |
201 | it('should return a valid MCP error response when no pipeline definitions are found', async () => {
202 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
203 | 'gh/org/repo',
204 | );
205 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
206 |
207 | mockCircleCIClient.projects.getProject.mockResolvedValue({
208 | id: 'project-id',
209 | });
210 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([]);
211 |
212 | const args = {
213 | params: {
214 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
215 | promptFiles: [
216 | {
217 | fileName: 'test.prompt.yml',
218 | fileContent: 'test: content',
219 | },
220 | ],
221 | },
222 | } as any;
223 |
224 | const controller = new AbortController();
225 | const response = await runEvaluationTests(args, {
226 | signal: controller.signal,
227 | });
228 |
229 | expect(response).toHaveProperty('content');
230 | expect(response).toHaveProperty('isError', true);
231 | expect(Array.isArray(response.content)).toBe(true);
232 | expect(response.content[0]).toHaveProperty('type', 'text');
233 | expect(typeof response.content[0].text).toBe('string');
234 | expect(response.content[0].text).toContain('No pipeline definitions found');
235 | });
236 |
237 | it('should return a list of pipeline choices when multiple pipeline definitions are found and no choice is provided', async () => {
238 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
239 | 'gh/org/repo',
240 | );
241 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
242 |
243 | mockCircleCIClient.projects.getProject.mockResolvedValue({
244 | id: 'project-id',
245 | });
246 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
247 | { id: 'def1', name: 'Pipeline 1' },
248 | { id: 'def2', name: 'Pipeline 2' },
249 | ]);
250 |
251 | const args = {
252 | params: {
253 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
254 | promptFiles: [
255 | {
256 | fileName: 'test.prompt.yml',
257 | fileContent: 'test: content',
258 | },
259 | ],
260 | },
261 | } as any;
262 |
263 | const controller = new AbortController();
264 | const response = await runEvaluationTests(args, {
265 | signal: controller.signal,
266 | });
267 |
268 | expect(response).toHaveProperty('content');
269 | expect(Array.isArray(response.content)).toBe(true);
270 | expect(response.content[0]).toHaveProperty('type', 'text');
271 | expect(typeof response.content[0].text).toBe('string');
272 | expect(response.content[0].text).toContain(
273 | 'Multiple pipeline definitions found',
274 | );
275 | expect(response.content[0].text).toContain('Pipeline 1');
276 | expect(response.content[0].text).toContain('Pipeline 2');
277 | });
278 |
279 | it('should return an error when an invalid pipeline choice is provided', async () => {
280 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
281 | 'gh/org/repo',
282 | );
283 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
284 |
285 | mockCircleCIClient.projects.getProject.mockResolvedValue({
286 | id: 'project-id',
287 | });
288 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
289 | { id: 'def1', name: 'Pipeline 1' },
290 | { id: 'def2', name: 'Pipeline 2' },
291 | ]);
292 |
293 | const args = {
294 | params: {
295 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
296 | pipelineChoiceName: 'Non-existent Pipeline',
297 | promptFiles: [
298 | {
299 | fileName: 'test.prompt.yml',
300 | fileContent: 'test: content',
301 | },
302 | ],
303 | },
304 | } as any;
305 |
306 | const controller = new AbortController();
307 | const response = await runEvaluationTests(args, {
308 | signal: controller.signal,
309 | });
310 |
311 | expect(response).toHaveProperty('content');
312 | expect(response).toHaveProperty('isError', true);
313 | expect(Array.isArray(response.content)).toBe(true);
314 | expect(response.content[0]).toHaveProperty('type', 'text');
315 | expect(typeof response.content[0].text).toBe('string');
316 | expect(response.content[0].text).toContain(
317 | 'Pipeline definition with name Non-existent Pipeline not found',
318 | );
319 | });
320 |
321 | it('should run evaluation tests with multiple prompt files and correct parallelism', async () => {
322 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
323 | 'gh/org/repo',
324 | );
325 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
326 |
327 | mockCircleCIClient.projects.getProject.mockResolvedValue({
328 | id: 'project-id',
329 | });
330 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
331 | { id: 'def1', name: 'Pipeline 1' },
332 | ]);
333 | mockCircleCIClient.pipelines.runPipeline.mockResolvedValue({
334 | number: 123,
335 | state: 'pending',
336 | id: 'pipeline-id',
337 | });
338 |
339 | const args = {
340 | params: {
341 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
342 | promptFiles: [
343 | {
344 | fileName: 'test1.prompt.json',
345 | fileContent: '{"template": "test content 1"}',
346 | },
347 | {
348 | fileName: 'test2.prompt.yml',
349 | fileContent: 'template: test content 2',
350 | },
351 | ],
352 | },
353 | } as any;
354 |
355 | const controller = new AbortController();
356 | const response = await runEvaluationTests(args, {
357 | signal: controller.signal,
358 | });
359 |
360 | expect(response).toHaveProperty('content');
361 | expect(Array.isArray(response.content)).toBe(true);
362 | expect(response.content[0]).toHaveProperty('type', 'text');
363 | expect(typeof response.content[0].text).toBe('string');
364 | expect(response.content[0].text).toContain('Pipeline run successfully');
365 |
366 | // Verify that the pipeline was called with correct configuration
367 | expect(mockCircleCIClient.pipelines.runPipeline).toHaveBeenCalledWith({
368 | projectSlug: 'gh/org/repo',
369 | branch: 'main',
370 | definitionId: 'def1',
371 | configContent: expect.stringContaining('parallelism: 2'), // Should match number of files
372 | });
373 |
374 | // Verify the config contains conditional file creation logic
375 | const configContent =
376 | mockCircleCIClient.pipelines.runPipeline.mock.calls[0][0].configContent;
377 | expect(configContent).toContain('CIRCLE_NODE_INDEX');
378 | expect(configContent).toContain('test1.prompt.json');
379 | expect(configContent).toContain('test2.prompt.yml');
380 | expect(configContent).toContain('python eval.py');
381 | });
382 |
383 | it('should process JSON files with proper formatting', async () => {
384 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
385 | 'gh/org/repo',
386 | );
387 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
388 |
389 | mockCircleCIClient.projects.getProject.mockResolvedValue({
390 | id: 'project-id',
391 | });
392 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
393 | { id: 'def1', name: 'Pipeline 1' },
394 | ]);
395 | mockCircleCIClient.pipelines.runPipeline.mockResolvedValue({
396 | number: 123,
397 | state: 'pending',
398 | id: 'pipeline-id',
399 | });
400 |
401 | const args = {
402 | params: {
403 | projectSlug: 'gh/org/repo',
404 | branch: 'main',
405 | promptFiles: [
406 | {
407 | fileName: 'test.prompt.json',
408 | fileContent: '{"template":"test","vars":["a","b"]}',
409 | },
410 | ],
411 | },
412 | } as any;
413 |
414 | const controller = new AbortController();
415 | await runEvaluationTests(args, {
416 | signal: controller.signal,
417 | });
418 |
419 | // Verify that the pipeline was called
420 | expect(mockCircleCIClient.pipelines.runPipeline).toHaveBeenCalled();
421 |
422 | const configContent =
423 | mockCircleCIClient.pipelines.runPipeline.mock.calls[0][0].configContent;
424 | expect(configContent).toContain('parallelism: 1');
425 | expect(configContent).toContain('test.prompt.json');
426 | });
427 |
428 | it('should detect project from git remote and run evaluation tests', async () => {
429 | vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
430 | 'gh/org/repo',
431 | );
432 |
433 | mockCircleCIClient.projects.getProject.mockResolvedValue({
434 | id: 'project-id',
435 | });
436 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
437 | { id: 'def1', name: 'Pipeline 1' },
438 | ]);
439 | mockCircleCIClient.pipelines.runPipeline.mockResolvedValue({
440 | number: 123,
441 | state: 'pending',
442 | id: 'pipeline-id',
443 | });
444 |
445 | const args = {
446 | params: {
447 | workspaceRoot: '/workspace',
448 | gitRemoteURL: 'https://github.com/org/repo.git',
449 | branch: 'feature-branch',
450 | promptFiles: [
451 | {
452 | fileName: 'test.prompt.yml',
453 | fileContent: 'template: test content',
454 | },
455 | ],
456 | },
457 | } as any;
458 |
459 | const controller = new AbortController();
460 | const response = await runEvaluationTests(args, {
461 | signal: controller.signal,
462 | });
463 |
464 | expect(response).toHaveProperty('content');
465 | expect(Array.isArray(response.content)).toBe(true);
466 | expect(response.content[0]).toHaveProperty('type', 'text');
467 | expect(typeof response.content[0].text).toBe('string');
468 | expect(response.content[0].text).toContain('Pipeline run successfully');
469 | expect(mockCircleCIClient.pipelines.runPipeline).toHaveBeenCalledWith({
470 | projectSlug: 'gh/org/repo',
471 | branch: 'feature-branch',
472 | definitionId: 'def1',
473 | configContent: expect.any(String),
474 | });
475 | });
476 | });
477 |
```
--------------------------------------------------------------------------------
/src/tools/listComponentVersions/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { listComponentVersions } from './handler.js';
3 | import * as clientModule from '../../clients/client.js';
4 |
5 | vi.mock('../../clients/client.js');
6 |
7 | describe('listComponentVersions handler', () => {
8 | const mockCircleCIClient = {
9 | deploys: {
10 | fetchComponentVersions: vi.fn(),
11 | fetchEnvironments: vi.fn(),
12 | fetchProjectComponents: vi.fn(),
13 | },
14 | projects: {
15 | getProject: vi.fn(),
16 | getProjectByID: vi.fn(),
17 | },
18 | };
19 |
20 | const mockExtra = {
21 | signal: new AbortController().signal,
22 | requestId: 'test-id',
23 | sendNotification: vi.fn(),
24 | sendRequest: vi.fn(),
25 | };
26 |
27 | beforeEach(() => {
28 | vi.resetAllMocks();
29 | vi.spyOn(clientModule, 'getCircleCIClient').mockReturnValue(
30 | mockCircleCIClient as any,
31 | );
32 | });
33 |
34 | it('should return the formatted component versions when found', async () => {
35 | const mockComponentVersions = {
36 | items: [
37 | {
38 | id: 'version-1',
39 | component_id: 'test-component-id',
40 | environment_id: 'test-environment-id',
41 | version: '1.0.0',
42 | sha: 'abc123',
43 | created_at: '2023-01-01T00:00:00Z',
44 | updated_at: '2023-01-02T00:00:00Z',
45 | is_live: true,
46 | },
47 | {
48 | id: 'version-2',
49 | component_id: 'test-component-id',
50 | environment_id: 'test-environment-id',
51 | version: '1.1.0',
52 | sha: 'def456',
53 | created_at: '2023-01-03T00:00:00Z',
54 | updated_at: '2023-01-04T00:00:00Z',
55 | is_live: false,
56 | },
57 | ],
58 | next_page_token: null,
59 | };
60 |
61 | // Mock project resolution
62 | mockCircleCIClient.projects.getProject.mockResolvedValue({
63 | id: 'test-project-id',
64 | organization_id: 'test-org-id',
65 | });
66 |
67 | mockCircleCIClient.deploys.fetchComponentVersions.mockResolvedValue(mockComponentVersions);
68 |
69 | const args = {
70 | params: {
71 | projectSlug: 'gh/test-org/test-repo',
72 | componentID: 'test-component-id',
73 | environmentID: 'test-environment-id',
74 | },
75 | } as any;
76 |
77 | const response = await listComponentVersions(args, mockExtra);
78 |
79 | expect(response).toHaveProperty('content');
80 | expect(Array.isArray(response.content)).toBe(true);
81 | expect(response.content[0]).toHaveProperty('type', 'text');
82 | expect(typeof response.content[0].text).toBe('string');
83 | expect(response.content[0].text).toContain('Versions for the component:');
84 | expect(response.content[0].text).toContain(JSON.stringify(mockComponentVersions));
85 |
86 | expect(mockCircleCIClient.projects.getProject).toHaveBeenCalledTimes(1);
87 | expect(mockCircleCIClient.projects.getProject).toHaveBeenCalledWith({
88 | projectSlug: 'gh/test-org/test-repo',
89 | });
90 | expect(mockCircleCIClient.deploys.fetchComponentVersions).toHaveBeenCalledTimes(1);
91 | expect(mockCircleCIClient.deploys.fetchComponentVersions).toHaveBeenCalledWith({
92 | componentID: 'test-component-id',
93 | environmentID: 'test-environment-id',
94 | });
95 | });
96 |
97 | it('should return "No component versions found" when component versions list is empty', async () => {
98 | // Mock project resolution
99 | mockCircleCIClient.projects.getProject.mockResolvedValue({
100 | id: 'test-project-id',
101 | organization_id: 'test-org-id',
102 | });
103 |
104 | mockCircleCIClient.deploys.fetchComponentVersions.mockResolvedValue({
105 | items: [],
106 | next_page_token: null,
107 | });
108 |
109 | const args = {
110 | params: {
111 | projectSlug: 'gh/test-org/test-repo',
112 | componentID: 'test-component-id',
113 | environmentID: 'test-environment-id',
114 | },
115 | } as any;
116 |
117 | const response = await listComponentVersions(args, mockExtra);
118 |
119 | expect(response).toHaveProperty('content');
120 | expect(Array.isArray(response.content)).toBe(true);
121 | expect(response.content[0]).toHaveProperty('type', 'text');
122 | expect(typeof response.content[0].text).toBe('string');
123 | expect(response.content[0].text).toBe('No component versions found');
124 | });
125 |
126 | it('should handle API errors gracefully', async () => {
127 | const errorMessage = 'Component versions API request failed';
128 |
129 | // Mock project resolution
130 | mockCircleCIClient.projects.getProject.mockResolvedValue({
131 | id: 'test-project-id',
132 | organization_id: 'test-org-id',
133 | });
134 |
135 | mockCircleCIClient.deploys.fetchComponentVersions.mockRejectedValue(
136 | new Error(errorMessage),
137 | );
138 |
139 | const args = {
140 | params: {
141 | projectSlug: 'gh/test-org/test-repo',
142 | componentID: 'test-component-id',
143 | environmentID: 'test-environment-id',
144 | },
145 | } as any;
146 |
147 | const response = await listComponentVersions(args, mockExtra);
148 |
149 | expect(response).toHaveProperty('content');
150 | expect(response).toHaveProperty('isError', true);
151 | expect(Array.isArray(response.content)).toBe(true);
152 | expect(response.content[0]).toHaveProperty('type', 'text');
153 | expect(typeof response.content[0].text).toBe('string');
154 | expect(response.content[0].text).toContain('Failed to list component versions:');
155 | expect(response.content[0].text).toContain(errorMessage);
156 | });
157 |
158 | it('should handle non-Error exceptions gracefully', async () => {
159 | // Mock project resolution
160 | mockCircleCIClient.projects.getProject.mockResolvedValue({
161 | id: 'test-project-id',
162 | organization_id: 'test-org-id',
163 | });
164 |
165 | mockCircleCIClient.deploys.fetchComponentVersions.mockRejectedValue(
166 | 'Unexpected error',
167 | );
168 |
169 | const args = {
170 | params: {
171 | projectSlug: 'gh/test-org/test-repo',
172 | componentID: 'test-component-id',
173 | environmentID: 'test-environment-id',
174 | },
175 | } as any;
176 |
177 | const response = await listComponentVersions(args, mockExtra);
178 |
179 | expect(response).toHaveProperty('content');
180 | expect(response).toHaveProperty('isError', true);
181 | expect(Array.isArray(response.content)).toBe(true);
182 | expect(response.content[0]).toHaveProperty('type', 'text');
183 | expect(typeof response.content[0].text).toBe('string');
184 | expect(response.content[0].text).toContain('Failed to list component versions:');
185 | expect(response.content[0].text).toContain('Unknown error');
186 | });
187 |
188 | describe('Project resolution scenarios', () => {
189 | it('should use projectID and orgID directly when both are provided', async () => {
190 | const mockComponentVersions = {
191 | items: [
192 | {
193 | id: 'version-1',
194 | component_id: 'test-component-id',
195 | environment_id: 'test-environment-id',
196 | version: '1.0.0',
197 | sha: 'abc123',
198 | created_at: '2023-01-01T00:00:00Z',
199 | updated_at: '2023-01-02T00:00:00Z',
200 | is_live: true,
201 | },
202 | ],
203 | next_page_token: null,
204 | };
205 |
206 | mockCircleCIClient.deploys.fetchComponentVersions.mockResolvedValue(mockComponentVersions);
207 |
208 | const args = {
209 | params: {
210 | projectID: 'test-project-id',
211 | orgID: 'test-org-id',
212 | componentID: 'test-component-id',
213 | environmentID: 'test-environment-id',
214 | },
215 | } as any;
216 |
217 | const response = await listComponentVersions(args, mockExtra);
218 |
219 | expect(response).toHaveProperty('content');
220 | expect(response.content[0].text).toContain('Versions for the component:');
221 |
222 | // Should not call project resolution methods when both IDs are provided
223 | expect(mockCircleCIClient.projects.getProject).not.toHaveBeenCalled();
224 | expect(mockCircleCIClient.projects.getProjectByID).not.toHaveBeenCalled();
225 | expect(mockCircleCIClient.deploys.fetchComponentVersions).toHaveBeenCalledWith({
226 | componentID: 'test-component-id',
227 | environmentID: 'test-environment-id',
228 | });
229 | });
230 |
231 | it('should resolve orgID from projectID when only projectID is provided', async () => {
232 | const mockComponentVersions = {
233 | items: [
234 | {
235 | id: 'version-1',
236 | component_id: 'test-component-id',
237 | environment_id: 'test-environment-id',
238 | version: '1.0.0',
239 | sha: 'abc123',
240 | created_at: '2023-01-01T00:00:00Z',
241 | updated_at: '2023-01-02T00:00:00Z',
242 | is_live: true,
243 | },
244 | ],
245 | next_page_token: null,
246 | };
247 |
248 | mockCircleCIClient.projects.getProjectByID.mockResolvedValue({
249 | id: 'test-project-id',
250 | organization_id: 'resolved-org-id',
251 | });
252 |
253 | mockCircleCIClient.deploys.fetchComponentVersions.mockResolvedValue(mockComponentVersions);
254 |
255 | const args = {
256 | params: {
257 | projectID: 'test-project-id',
258 | componentID: 'test-component-id',
259 | environmentID: 'test-environment-id',
260 | },
261 | } as any;
262 |
263 | const response = await listComponentVersions(args, mockExtra);
264 |
265 | expect(response).toHaveProperty('content');
266 | expect(response.content[0].text).toContain('Versions for the component:');
267 |
268 | expect(mockCircleCIClient.projects.getProjectByID).toHaveBeenCalledWith({
269 | projectID: 'test-project-id',
270 | });
271 | expect(mockCircleCIClient.projects.getProject).not.toHaveBeenCalled();
272 | });
273 |
274 | it('should handle project resolution errors for projectSlug', async () => {
275 | const errorMessage = 'Project not found';
276 |
277 | mockCircleCIClient.projects.getProject.mockRejectedValue(
278 | new Error(errorMessage),
279 | );
280 |
281 | const args = {
282 | params: {
283 | projectSlug: 'gh/invalid/repo',
284 | componentID: 'test-component-id',
285 | environmentID: 'test-environment-id',
286 | },
287 | } as any;
288 |
289 | const response = await listComponentVersions(args, mockExtra);
290 |
291 | expect(response).toHaveProperty('content');
292 | expect(response).toHaveProperty('isError', true);
293 | expect(response.content[0].text).toContain('Failed to resolve project information for gh/invalid/repo');
294 | expect(response.content[0].text).toContain(errorMessage);
295 | });
296 |
297 | it('should handle project resolution errors for projectID', async () => {
298 | const errorMessage = 'Project ID not found';
299 |
300 | mockCircleCIClient.projects.getProjectByID.mockRejectedValue(
301 | new Error(errorMessage),
302 | );
303 |
304 | const args = {
305 | params: {
306 | projectID: 'invalid-project-id',
307 | componentID: 'test-component-id',
308 | environmentID: 'test-environment-id',
309 | },
310 | } as any;
311 |
312 | const response = await listComponentVersions(args, mockExtra);
313 |
314 | expect(response).toHaveProperty('content');
315 | expect(response).toHaveProperty('isError', true);
316 | expect(response.content[0].text).toContain('Failed to resolve project information for project ID invalid-project-id');
317 | expect(response.content[0].text).toContain(errorMessage);
318 | });
319 |
320 | it('should return error when neither projectSlug nor projectID is provided', async () => {
321 | const args = {
322 | params: {
323 | componentID: 'test-component-id',
324 | environmentID: 'test-environment-id',
325 | },
326 | } as any;
327 |
328 | const response = await listComponentVersions(args, mockExtra);
329 |
330 | expect(response).toHaveProperty('content');
331 | expect(response).toHaveProperty('isError', true);
332 | expect(response.content[0].text).toContain('Invalid request. Please specify either a project slug or a project ID.');
333 | });
334 | });
335 |
336 | describe('Missing environmentID scenarios', () => {
337 | it('should list environments when environmentID is not provided', async () => {
338 | const mockEnvironments = {
339 | items: [
340 | { id: 'env-1', name: 'production' },
341 | { id: 'env-2', name: 'staging' },
342 | ],
343 | next_page_token: null,
344 | };
345 |
346 | mockCircleCIClient.projects.getProject.mockResolvedValue({
347 | id: 'test-project-id',
348 | organization_id: 'test-org-id',
349 | });
350 |
351 | mockCircleCIClient.deploys.fetchEnvironments.mockResolvedValue(mockEnvironments);
352 |
353 | const args = {
354 | params: {
355 | projectSlug: 'gh/test-org/test-repo',
356 | componentID: 'test-component-id',
357 | },
358 | } as any;
359 |
360 | const response = await listComponentVersions(args, mockExtra);
361 |
362 | expect(response).toHaveProperty('content');
363 | expect(response.content[0].text).toContain('Please provide an environmentID. Available environments:');
364 | expect(response.content[0].text).toContain('1. production (ID: env-1)');
365 | expect(response.content[0].text).toContain('2. staging (ID: env-2)');
366 |
367 | expect(mockCircleCIClient.deploys.fetchEnvironments).toHaveBeenCalledWith({
368 | orgID: 'test-org-id',
369 | });
370 | });
371 |
372 | it('should handle empty environments list', async () => {
373 | mockCircleCIClient.projects.getProject.mockResolvedValue({
374 | id: 'test-project-id',
375 | organization_id: 'test-org-id',
376 | });
377 |
378 | mockCircleCIClient.deploys.fetchEnvironments.mockResolvedValue({
379 | items: [],
380 | next_page_token: null,
381 | });
382 |
383 | const args = {
384 | params: {
385 | projectSlug: 'gh/test-org/test-repo',
386 | componentID: 'test-component-id',
387 | },
388 | } as any;
389 |
390 | const response = await listComponentVersions(args, mockExtra);
391 |
392 | expect(response).toHaveProperty('content');
393 | expect(response.content[0].text).toBe('No environments found');
394 | });
395 |
396 | it('should handle fetchEnvironments API errors', async () => {
397 | const errorMessage = 'Failed to fetch environments';
398 |
399 | mockCircleCIClient.projects.getProject.mockResolvedValue({
400 | id: 'test-project-id',
401 | organization_id: 'test-org-id',
402 | });
403 |
404 | mockCircleCIClient.deploys.fetchEnvironments.mockRejectedValue(
405 | new Error(errorMessage),
406 | );
407 |
408 | const args = {
409 | params: {
410 | projectSlug: 'gh/test-org/test-repo',
411 | componentID: 'test-component-id',
412 | },
413 | } as any;
414 |
415 | const response = await listComponentVersions(args, mockExtra);
416 |
417 | expect(response).toHaveProperty('content');
418 | expect(response).toHaveProperty('isError', true);
419 | expect(response.content[0].text).toContain('Failed to list component versions:');
420 | expect(response.content[0].text).toContain(errorMessage);
421 | });
422 | });
423 |
424 | describe('Missing componentID scenarios', () => {
425 | it('should list components when componentID is not provided', async () => {
426 | const mockComponents = {
427 | items: [
428 | { id: 'comp-1', name: 'frontend' },
429 | { id: 'comp-2', name: 'backend' },
430 | ],
431 | next_page_token: null,
432 | };
433 |
434 | mockCircleCIClient.projects.getProject.mockResolvedValue({
435 | id: 'test-project-id',
436 | organization_id: 'test-org-id',
437 | });
438 |
439 | mockCircleCIClient.deploys.fetchProjectComponents.mockResolvedValue(mockComponents);
440 |
441 | const args = {
442 | params: {
443 | projectSlug: 'gh/test-org/test-repo',
444 | environmentID: 'test-environment-id',
445 | },
446 | } as any;
447 |
448 | const response = await listComponentVersions(args, mockExtra);
449 |
450 | expect(response).toHaveProperty('content');
451 | expect(response.content[0].text).toContain('Please provide a componentID. Available components:');
452 | expect(response.content[0].text).toContain('1. frontend (ID: comp-1)');
453 | expect(response.content[0].text).toContain('2. backend (ID: comp-2)');
454 |
455 | expect(mockCircleCIClient.deploys.fetchProjectComponents).toHaveBeenCalledWith({
456 | projectID: 'test-project-id',
457 | orgID: 'test-org-id',
458 | });
459 | });
460 |
461 | it('should handle empty components list', async () => {
462 | mockCircleCIClient.projects.getProject.mockResolvedValue({
463 | id: 'test-project-id',
464 | organization_id: 'test-org-id',
465 | });
466 |
467 | mockCircleCIClient.deploys.fetchProjectComponents.mockResolvedValue({
468 | items: [],
469 | next_page_token: null,
470 | });
471 |
472 | const args = {
473 | params: {
474 | projectSlug: 'gh/test-org/test-repo',
475 | environmentID: 'test-environment-id',
476 | },
477 | } as any;
478 |
479 | const response = await listComponentVersions(args, mockExtra);
480 |
481 | expect(response).toHaveProperty('content');
482 | expect(response.content[0].text).toBe('No components found');
483 | });
484 |
485 | it('should handle fetchProjectComponents API errors', async () => {
486 | const errorMessage = 'Failed to fetch components';
487 |
488 | mockCircleCIClient.projects.getProject.mockResolvedValue({
489 | id: 'test-project-id',
490 | organization_id: 'test-org-id',
491 | });
492 |
493 | mockCircleCIClient.deploys.fetchProjectComponents.mockRejectedValue(
494 | new Error(errorMessage),
495 | );
496 |
497 | const args = {
498 | params: {
499 | projectSlug: 'gh/test-org/test-repo',
500 | environmentID: 'test-environment-id',
501 | },
502 | } as any;
503 |
504 | const response = await listComponentVersions(args, mockExtra);
505 |
506 | expect(response).toHaveProperty('content');
507 | expect(response).toHaveProperty('isError', true);
508 | expect(response.content[0].text).toContain('Failed to list component versions:');
509 | expect(response.content[0].text).toContain(errorMessage);
510 | });
511 | });
512 | });
513 |
```