@metrostar/comet-mcp 1.0.0-beta.0 → 1.1.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.
@@ -1,10 +1,1084 @@
1
- import { describe, test, expect } from 'vitest';
2
- import { getFriendlyDirectoryName } from './utils';
1
+ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { getFriendlyDirectoryName, getComponentsFromPackage, getComponentDetails, extractJSDocDescription, extractProps, extractTypes, findComponentFile, findComponentDirectory, } from './utils';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ // Mock fs and path modules
6
+ vi.mock('fs');
7
+ vi.mock('path');
8
+ const mockedFs = vi.mocked(fs);
9
+ const mockedPath = vi.mocked(path);
3
10
  describe('MCP Utils', () => {
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ // Mock path.join to return predictable paths
14
+ mockedPath.join.mockImplementation((...args) => args.join('/'));
15
+ // Mock process.cwd() to return a consistent value for testing
16
+ vi.spyOn(process, 'cwd').mockReturnValue('/mock/project/root');
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
4
21
  test('getFriendlyDirectoryName should convert PascalCase to kebab-case', () => {
5
22
  expect(getFriendlyDirectoryName('MyComponent')).toBe('my-component');
6
23
  expect(getFriendlyDirectoryName('SimpleButton')).toBe('simple-button');
7
24
  expect(getFriendlyDirectoryName('DataTable')).toBe('data-table');
8
25
  });
26
+ describe('getComponentsFromPackage', () => {
27
+ test('should extract components from comet-uswds package', () => {
28
+ // Mock file system
29
+ mockedFs.existsSync.mockReturnValue(true);
30
+ mockedFs.readFileSync.mockReturnValue(`
31
+ export { default as Accordion, AccordionItem } from './accordion';
32
+ export type { AccordionItemProps } from './accordion';
33
+ export { default as Alert } from './alert';
34
+ export { default as Button } from './button';
35
+ export { default as Card, CardFooter, CardBody } from './card';
36
+ `);
37
+ const components = getComponentsFromPackage('@metrostar/comet-uswds');
38
+ expect(components).toEqual([
39
+ 'Accordion',
40
+ 'AccordionItem',
41
+ 'Alert',
42
+ 'Button',
43
+ 'Card',
44
+ 'CardBody',
45
+ 'CardFooter',
46
+ ]);
47
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/mock/project/root/node_modules/@metrostar/comet-uswds');
48
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/mock/project/root/node_modules/@metrostar/comet-uswds/dist/index.d.ts');
49
+ });
50
+ test('should extract components from comet-extras package', () => {
51
+ mockedFs.existsSync.mockReturnValue(true);
52
+ mockedFs.readFileSync.mockReturnValue(`
53
+ export { default as DataTable } from './data-table';
54
+ export { default as Spinner } from './spinner';
55
+ export { default as Tabs, TabPanel } from './tabs';
56
+ `);
57
+ const components = getComponentsFromPackage('@metrostar/comet-extras');
58
+ expect(components).toEqual(['DataTable', 'Spinner', 'TabPanel', 'Tabs']);
59
+ });
60
+ test('should extract components from comet-data-viz package', () => {
61
+ mockedFs.existsSync.mockReturnValue(true);
62
+ mockedFs.readFileSync.mockReturnValue(`
63
+ export { default as BarGraph } from './bar-graph';
64
+ export { default as LineGraph } from './line-graph';
65
+ export { default as PieChart } from './pie-chart';
66
+ `);
67
+ const components = getComponentsFromPackage('@metrostar/comet-data-viz');
68
+ expect(components).toEqual(['BarGraph', 'LineGraph', 'PieChart']);
69
+ });
70
+ test('should return empty array when package directory does not exist', () => {
71
+ mockedFs.existsSync.mockReturnValueOnce(false);
72
+ const components = getComponentsFromPackage('@metrostar/comet-nonexistent');
73
+ expect(components).toEqual([]);
74
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/mock/project/root/node_modules/@metrostar/comet-nonexistent');
75
+ });
76
+ test('should return empty array when components index file does not exist', () => {
77
+ mockedFs.existsSync.mockReturnValueOnce(true).mockReturnValueOnce(false);
78
+ const components = getComponentsFromPackage('@metrostar/comet-uswds');
79
+ expect(components).toEqual([]);
80
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/mock/project/root/node_modules/@metrostar/comet-uswds');
81
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/mock/project/root/node_modules/@metrostar/comet-uswds/dist/index.d.ts');
82
+ });
83
+ test('should handle readFileSync errors gracefully', () => {
84
+ mockedFs.existsSync.mockReturnValue(true);
85
+ mockedFs.readFileSync.mockImplementation(() => {
86
+ throw new Error('File read error');
87
+ });
88
+ const components = getComponentsFromPackage('@metrostar/comet-uswds');
89
+ expect(components).toEqual([]);
90
+ });
91
+ test('should handle complex export patterns', () => {
92
+ mockedFs.existsSync.mockReturnValue(true);
93
+ mockedFs.readFileSync.mockReturnValue(`
94
+ export { default as Button } from './button';
95
+ export { default as ButtonGroup } from './button-group';
96
+ export { default as Card, CardFooter, CardBody, CardHeader, CardMedia } from './card';
97
+ export type { CardProps } from './card';
98
+ export { default as Checkbox, CheckboxGroup } from './checkbox';
99
+ export type { CheckboxData } from './checkbox';
100
+ `);
101
+ const components = getComponentsFromPackage('@metrostar/comet-uswds');
102
+ expect(components).toEqual([
103
+ 'Button',
104
+ 'ButtonGroup',
105
+ 'Card',
106
+ 'CardBody',
107
+ 'CardFooter',
108
+ 'CardHeader',
109
+ 'CardMedia',
110
+ 'Checkbox',
111
+ 'CheckboxGroup',
112
+ ]);
113
+ });
114
+ test('should remove duplicates and sort components', () => {
115
+ mockedFs.existsSync.mockReturnValue(true);
116
+ mockedFs.readFileSync.mockReturnValue(`
117
+ export { default as Button } from './button';
118
+ export { default as Alert } from './alert';
119
+ export { default as Button } from './button-duplicate';
120
+ export { default as Card } from './card';
121
+ `);
122
+ const components = getComponentsFromPackage('@metrostar/comet-uswds');
123
+ expect(components).toEqual(['Alert', 'Button', 'Card']);
124
+ });
125
+ test('should use PROJECT_ROOT environment variable when available', () => {
126
+ const originalEnv = process.env.PROJECT_ROOT;
127
+ const customRoot = '/custom/root';
128
+ process.env.PROJECT_ROOT = customRoot;
129
+ mockedFs.existsSync.mockReturnValue(true);
130
+ mockedFs.readFileSync.mockReturnValue(`
131
+ export { default as Button } from './button';
132
+ `);
133
+ getComponentsFromPackage('@metrostar/comet-uswds');
134
+ expect(mockedPath.join).toHaveBeenCalledWith(`${customRoot}/node_modules/@metrostar/comet-uswds`, 'dist', 'index.d.ts');
135
+ process.env.PROJECT_ROOT = originalEnv;
136
+ });
137
+ });
138
+ describe('extractJSDocDescription', () => {
139
+ test('should extract description from single-line JSDoc comment', () => {
140
+ const content = `
141
+ /**
142
+ * This is a button component that renders a clickable element
143
+ */
144
+ const Button = () => {
145
+ return <button>Click me</button>;
146
+ };
147
+ `;
148
+ const result = extractJSDocDescription(content);
149
+ expect(result).toBe('This is a button component that renders a clickable element');
150
+ });
151
+ test('should extract description from multi-line JSDoc comment with only first line', () => {
152
+ const content = `
153
+ /**
154
+ * A versatile card component for displaying content
155
+ * @param title The title of the card
156
+ * @param children The content to display in the card
157
+ */
158
+ const Card = ({ title, children }) => {
159
+ return <div className="card">{children}</div>;
160
+ };
161
+ `;
162
+ const result = extractJSDocDescription(content);
163
+ expect(result).toBe('A versatile card component for displaying content');
164
+ });
165
+ test('should handle JSDoc comment with extra whitespace', () => {
166
+ const content = `
167
+ /**
168
+ * This is a component with extra whitespace
169
+ */
170
+ const Component = () => {
171
+ return <div>Content</div>;
172
+ };
173
+ `;
174
+ const result = extractJSDocDescription(content);
175
+ expect(result).toBe('This is a component with extra whitespace');
176
+ });
177
+ test('should return undefined when no JSDoc comment is found', () => {
178
+ const content = `
179
+ // This is a regular comment
180
+ const Button = () => {
181
+ return <button>Click me</button>;
182
+ };
183
+ `;
184
+ const result = extractJSDocDescription(content);
185
+ expect(result).toBeUndefined();
186
+ });
187
+ test('should return undefined when JSDoc comment is malformed', () => {
188
+ const content = `
189
+ /*
190
+ * This is not a proper JSDoc comment
191
+ */
192
+ const Button = () => {
193
+ return <button>Click me</button>;
194
+ };
195
+ `;
196
+ const result = extractJSDocDescription(content);
197
+ expect(result).toBeUndefined();
198
+ });
199
+ test('should handle JSDoc comment with no description line', () => {
200
+ const content = `
201
+ /**
202
+ * @param props The component props
203
+ */
204
+ const Button = (props) => {
205
+ return <button>Click me</button>;
206
+ };
207
+ `;
208
+ const result = extractJSDocDescription(content);
209
+ expect(result).toBeUndefined();
210
+ });
211
+ test('should extract description from JSDoc comment with different asterisk formatting', () => {
212
+ const content = `
213
+ /**
214
+ * Alert component for displaying important messages
215
+ */
216
+ const Alert = () => {
217
+ return <div className="alert">Alert message</div>;
218
+ };
219
+ `;
220
+ const result = extractJSDocDescription(content);
221
+ expect(result).toBe('Alert component for displaying important messages');
222
+ });
223
+ test('should handle multiple JSDoc comments and extract from the first one', () => {
224
+ const content = `
225
+ /**
226
+ * First component description
227
+ */
228
+ const FirstComponent = () => {
229
+ return <div>First</div>;
230
+ };
231
+
232
+ /**
233
+ * Second component description
234
+ */
235
+ const SecondComponent = () => {
236
+ return <div>Second</div>;
237
+ };
238
+ `;
239
+ const result = extractJSDocDescription(content);
240
+ expect(result).toBe('First component description');
241
+ });
242
+ test('should handle empty content', () => {
243
+ const result = extractJSDocDescription('');
244
+ expect(result).toBeUndefined();
245
+ });
246
+ test('should handle content with only whitespace', () => {
247
+ const result = extractJSDocDescription(' \n \t ');
248
+ expect(result).toBeUndefined();
249
+ });
250
+ });
251
+ describe('extractProps', () => {
252
+ test('should extract props from a simple interface', () => {
253
+ const content = `
254
+ interface ButtonProps {
255
+ label: string;
256
+ onClick: () => void;
257
+ disabled?: boolean;
258
+ }
259
+ `;
260
+ const result = extractProps(content);
261
+ expect(result).toEqual(['disabled', 'label', 'onClick']);
262
+ });
263
+ test('should extract props from multiple interfaces', () => {
264
+ const content = `
265
+ interface CardProps {
266
+ title: string;
267
+ children: React.ReactNode;
268
+ }
269
+
270
+ interface ButtonProps {
271
+ label: string;
272
+ variant?: 'primary' | 'secondary';
273
+ }
274
+ `;
275
+ const result = extractProps(content);
276
+ expect(result).toEqual(['children', 'label', 'title', 'variant']);
277
+ });
278
+ test('should handle props with different types', () => {
279
+ const content = `
280
+ interface ComponentProps {
281
+ id: string;
282
+ count: number;
283
+ isVisible: boolean;
284
+ items: string[];
285
+ }
286
+ `;
287
+ const result = extractProps(content);
288
+ expect(result).toEqual(['count', 'id', 'isVisible', 'items']);
289
+ });
290
+ test('should handle optional props with question mark', () => {
291
+ const content = `
292
+ interface AlertProps {
293
+ message: string;
294
+ type?: 'info' | 'warning' | 'error';
295
+ dismissible?: boolean;
296
+ onClose?: () => void;
297
+ }
298
+ `;
299
+ const result = extractProps(content);
300
+ expect(result).toEqual(['dismissible', 'message', 'onClose', 'type']);
301
+ });
302
+ test('should handle props with complex types and generics', () => {
303
+ const content = `
304
+ interface DataTableProps {
305
+ data: Array<Record<string, any>>;
306
+ columns: Column<T>[];
307
+ onRowClick?: (row: T) => void;
308
+ renderCell?: (value: any, row: T) => React.ReactNode;
309
+ }
310
+ `;
311
+ const result = extractProps(content);
312
+ expect(result).toEqual(['columns', 'data', 'onRowClick', 'renderCell']);
313
+ });
314
+ test('should ignore non-Props interfaces', () => {
315
+ const content = `
316
+ interface User {
317
+ name: string;
318
+ email: string;
319
+ }
320
+
321
+ interface ButtonProps {
322
+ label: string;
323
+ onClick: () => void;
324
+ }
325
+
326
+ interface ApiResponse {
327
+ data: any;
328
+ success: boolean;
329
+ }
330
+ `;
331
+ const result = extractProps(content);
332
+ expect(result).toEqual(['label', 'onClick']);
333
+ });
334
+ test('should handle interfaces with extends (current limitation)', () => {
335
+ const content = `
336
+ interface ButtonProps extends BaseProps {
337
+ label: string;
338
+ variant: 'primary' | 'secondary';
339
+ }
340
+ `;
341
+ // The current regex doesn't handle 'extends' syntax properly
342
+ const result = extractProps(content);
343
+ expect(result).toEqual([]);
344
+ });
345
+ test('should handle simple nested interfaces and object properties', () => {
346
+ const content = `
347
+ interface FormProps {
348
+ fields: Array<FieldConfig>;
349
+ onSubmit: FormSubmitHandler;
350
+ validation?: ValidationConfig;
351
+ }
352
+ `;
353
+ const result = extractProps(content);
354
+ expect(result).toEqual(['fields', 'onSubmit', 'validation']);
355
+ });
356
+ test('should handle props with comments', () => {
357
+ const content = `
358
+ interface CardProps {
359
+ // The title to display
360
+ title: string;
361
+ /** The content of the card */
362
+ children: React.ReactNode;
363
+ // Optional footer content
364
+ footer?: React.ReactNode;
365
+ }
366
+ `;
367
+ const result = extractProps(content);
368
+ expect(result).toEqual(['children', 'footer', 'title']);
369
+ });
370
+ test('should remove duplicates and sort props', () => {
371
+ const content = `
372
+ interface FirstProps {
373
+ title: string;
374
+ count: number;
375
+ }
376
+
377
+ interface SecondProps {
378
+ title: string;
379
+ isVisible: boolean;
380
+ count: number;
381
+ }
382
+ `;
383
+ const result = extractProps(content);
384
+ expect(result).toEqual(['count', 'isVisible', 'title']);
385
+ });
386
+ test('should handle multiline prop definitions', () => {
387
+ const content = `
388
+ interface ComponentProps {
389
+ longPropertyName:
390
+ | 'option1'
391
+ | 'option2'
392
+ | 'option3';
393
+ callback: CallbackFunction;
394
+ config: ConfigObject;
395
+ }
396
+ `;
397
+ const result = extractProps(content);
398
+ expect(result).toEqual(['callback', 'config', 'longPropertyName']);
399
+ });
400
+ test('should return empty array when no Props interfaces found', () => {
401
+ const content = `
402
+ interface User {
403
+ name: string;
404
+ email: string;
405
+ }
406
+
407
+ interface Config {
408
+ theme: string;
409
+ debug: boolean;
410
+ }
411
+ `;
412
+ const result = extractProps(content);
413
+ expect(result).toEqual([]);
414
+ });
415
+ test('should handle empty content', () => {
416
+ const result = extractProps('');
417
+ expect(result).toEqual([]);
418
+ });
419
+ test('should handle content with only whitespace', () => {
420
+ const result = extractProps(' \n \t ');
421
+ expect(result).toEqual([]);
422
+ });
423
+ test('should handle malformed interfaces gracefully', () => {
424
+ const content = `
425
+ interface BrokenProps {
426
+ validProp: string;
427
+ // Missing type annotation
428
+ invalidProp;
429
+ anotherValidProp: number;
430
+ }
431
+ `;
432
+ const result = extractProps(content);
433
+ expect(result).toEqual(['anotherValidProp', 'validProp']);
434
+ });
435
+ test('should handle interfaces with different naming patterns', () => {
436
+ const content = `
437
+ interface ComponentProps {
438
+ basic: string;
439
+ }
440
+
441
+ interface MyComponentProps {
442
+ advanced: number;
443
+ }
444
+
445
+ interface Props {
446
+ simple: boolean;
447
+ }
448
+ `;
449
+ const result = extractProps(content);
450
+ expect(result).toEqual(['advanced', 'basic', 'simple']);
451
+ });
452
+ test('should extract properties from complex object definitions', () => {
453
+ const content = `
454
+ interface FormProps {
455
+ fields: {
456
+ name: string;
457
+ type: 'text' | 'email';
458
+ required: boolean;
459
+ }[];
460
+ }
461
+ `;
462
+ // The current implementation extracts nested properties when the regex matches object definitions
463
+ const result = extractProps(content);
464
+ expect(result).toEqual(['fields', 'name', 'required', 'type']);
465
+ });
466
+ });
467
+ describe('extractTypes', () => {
468
+ test('should extract exported type aliases', () => {
469
+ const content = `
470
+ export type ButtonVariant = 'primary' | 'secondary' | 'tertiary';
471
+ export type Size = 'small' | 'medium' | 'large';
472
+ `;
473
+ const result = extractTypes(content);
474
+ expect(result).toEqual(['ButtonVariant', 'Size']);
475
+ });
476
+ test('should extract exported interfaces', () => {
477
+ const content = `
478
+ export interface User {
479
+ id: string;
480
+ name: string;
481
+ }
482
+
483
+ export interface Product {
484
+ id: number;
485
+ title: string;
486
+ }
487
+ `;
488
+ const result = extractTypes(content);
489
+ expect(result).toEqual(['Product', 'User']);
490
+ });
491
+ test('should extract both types and interfaces', () => {
492
+ const content = `
493
+ export type Theme = 'light' | 'dark';
494
+ export interface Config {
495
+ theme: Theme;
496
+ debug: boolean;
497
+ }
498
+ export type Status = 'loading' | 'success' | 'error';
499
+ `;
500
+ const result = extractTypes(content);
501
+ expect(result).toEqual(['Config', 'Status', 'Theme']);
502
+ });
503
+ test('should ignore non-exported types and interfaces', () => {
504
+ const content = `
505
+ type InternalType = string;
506
+ interface InternalInterface {
507
+ value: string;
508
+ }
509
+ export type PublicType = number;
510
+ export interface PublicInterface {
511
+ data: string;
512
+ }
513
+ `;
514
+ const result = extractTypes(content);
515
+ expect(result).toEqual(['PublicInterface', 'PublicType']);
516
+ });
517
+ test('should handle different export syntax variations', () => {
518
+ const content = `
519
+ export type SpacedType = string;
520
+ export interface TabbedInterface {
521
+ value: number;
522
+ }
523
+ export type NormalType = boolean;
524
+ `;
525
+ const result = extractTypes(content);
526
+ expect(result).toEqual(['NormalType', 'SpacedType', 'TabbedInterface']);
527
+ });
528
+ test('should handle generic types and interfaces', () => {
529
+ const content = `
530
+ export type ApiResponse<T> = {
531
+ data: T;
532
+ success: boolean;
533
+ };
534
+
535
+ export interface Repository<T, K> {
536
+ findById(id: K): T | null;
537
+ save(entity: T): void;
538
+ }
539
+ `;
540
+ const result = extractTypes(content);
541
+ expect(result).toEqual(['ApiResponse', 'Repository']);
542
+ });
543
+ test('should handle complex type definitions', () => {
544
+ const content = `
545
+ export type EventHandler = (event: Event) => void;
546
+ export type ComponentProps = {
547
+ id: string;
548
+ children?: React.ReactNode;
549
+ };
550
+ export interface FormFieldConfig {
551
+ name: string;
552
+ type: 'text' | 'email' | 'password';
553
+ validation?: ValidationRule[];
554
+ }
555
+ `;
556
+ const result = extractTypes(content);
557
+ expect(result).toEqual(['ComponentProps', 'EventHandler', 'FormFieldConfig']);
558
+ });
559
+ test('should handle types and interfaces with extends', () => {
560
+ const content = `
561
+ export interface BaseEntity {
562
+ id: string;
563
+ createdAt: Date;
564
+ }
565
+
566
+ export interface User extends BaseEntity {
567
+ name: string;
568
+ email: string;
569
+ }
570
+
571
+ export type ExtendedConfig = Config & {
572
+ version: string;
573
+ };
574
+ `;
575
+ const result = extractTypes(content);
576
+ expect(result).toEqual(['BaseEntity', 'ExtendedConfig', 'User']);
577
+ });
578
+ test('should handle multiline type definitions', () => {
579
+ const content = `
580
+ export type ComplexUnion =
581
+ | 'option1'
582
+ | 'option2'
583
+ | 'option3';
584
+
585
+ export interface MultilineInterface
586
+ extends BaseInterface {
587
+ property: string;
588
+ }
589
+ `;
590
+ const result = extractTypes(content);
591
+ expect(result).toEqual(['ComplexUnion', 'MultilineInterface']);
592
+ });
593
+ test('should handle types and interfaces with comments', () => {
594
+ const content = `
595
+ // This is a type comment
596
+ export type CommentedType = string;
597
+
598
+ /**
599
+ * This is an interface comment
600
+ */
601
+ export interface CommentedInterface {
602
+ value: number;
603
+ }
604
+
605
+ /* Another comment style */
606
+ export type AnotherType = boolean;
607
+ `;
608
+ const result = extractTypes(content);
609
+ expect(result).toEqual(['AnotherType', 'CommentedInterface', 'CommentedType']);
610
+ });
611
+ test('should handle mixed exports with other items', () => {
612
+ const content = `
613
+ export const CONSTANT = 'value';
614
+ export type MyType = string;
615
+ export function myFunction() {}
616
+ export interface MyInterface {
617
+ prop: string;
618
+ }
619
+ export class MyClass {}
620
+ export enum MyEnum {
621
+ A, B, C
622
+ }
623
+ `;
624
+ const result = extractTypes(content);
625
+ expect(result).toEqual(['MyInterface', 'MyType']);
626
+ });
627
+ test('should return empty array when no exported types found', () => {
628
+ const content = `
629
+ const localVar = 'value';
630
+ type InternalType = string;
631
+ interface InternalInterface {
632
+ prop: string;
633
+ }
634
+ function normalFunction() {}
635
+ `;
636
+ const result = extractTypes(content);
637
+ expect(result).toEqual([]);
638
+ });
639
+ test('should handle empty content', () => {
640
+ const result = extractTypes('');
641
+ expect(result).toEqual([]);
642
+ });
643
+ test('should handle content with only whitespace', () => {
644
+ const result = extractTypes(' \n \t ');
645
+ expect(result).toEqual([]);
646
+ });
647
+ test('should handle malformed export statements gracefully', () => {
648
+ const content = `
649
+ export type ValidType = string;
650
+ export type // malformed
651
+ export interface ValidInterface {
652
+ prop: string;
653
+ }
654
+ export type AnotherValidType = number;
655
+ `;
656
+ const result = extractTypes(content);
657
+ expect(result).toEqual(['AnotherValidType', 'ValidInterface', 'ValidType']);
658
+ });
659
+ test('should remove duplicates and sort results', () => {
660
+ const content = `
661
+ export type TypeB = string;
662
+ export interface InterfaceA {
663
+ prop: string;
664
+ }
665
+ export type TypeA = number;
666
+ export interface InterfaceB {
667
+ prop: number;
668
+ }
669
+ `;
670
+ const result = extractTypes(content);
671
+ expect(result).toEqual(['InterfaceA', 'InterfaceB', 'TypeA', 'TypeB']);
672
+ });
673
+ test('should handle re-exported types', () => {
674
+ const content = `
675
+ export { type ExistingType } from './other-module';
676
+ export type NewType = string;
677
+ export { type AnotherType, interface SomeInterface } from './another-module';
678
+ `;
679
+ const result = extractTypes(content);
680
+ // Current implementation only catches direct export type/interface declarations
681
+ expect(result).toEqual(['NewType']);
682
+ });
683
+ });
684
+ describe('findComponentFile', () => {
685
+ beforeEach(() => {
686
+ vi.clearAllMocks();
687
+ });
688
+ test('should find index.tsx file in local development', () => {
689
+ const componentDir = '/path/to/button';
690
+ const componentName = 'Button';
691
+ mockedFs.existsSync.mockImplementation((filePath) => {
692
+ return filePath === '/path/to/button/index.tsx';
693
+ });
694
+ const result = findComponentFile(componentDir, componentName, true);
695
+ expect(result).toBe('/path/to/button/index.tsx');
696
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/button/index.tsx');
697
+ });
698
+ test('should find component-named file when index.tsx does not exist', () => {
699
+ const componentDir = '/path/to/alert';
700
+ const componentName = 'Alert';
701
+ mockedFs.existsSync.mockImplementation((filePath) => {
702
+ return filePath === '/path/to/alert/Alert.tsx';
703
+ });
704
+ const result = findComponentFile(componentDir, componentName, true);
705
+ expect(result).toBe('/path/to/alert/Alert.tsx');
706
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/alert/index.tsx');
707
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/alert/Alert.tsx');
708
+ });
709
+ test('should find lowercase component file when others do not exist', () => {
710
+ const componentDir = '/path/to/card';
711
+ const componentName = 'Card';
712
+ mockedFs.existsSync.mockImplementation((filePath) => {
713
+ return filePath === '/path/to/card/card.tsx';
714
+ });
715
+ const result = findComponentFile(componentDir, componentName, true);
716
+ expect(result).toBe('/path/to/card/card.tsx');
717
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/card/index.tsx');
718
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/card/Card.tsx');
719
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/card/card.tsx');
720
+ });
721
+ test('should return null when no files exist in local development', () => {
722
+ const componentDir = '/path/to/nonexistent';
723
+ const componentName = 'NonExistent';
724
+ mockedFs.existsSync.mockReturnValue(false);
725
+ const result = findComponentFile(componentDir, componentName, true);
726
+ expect(result).toBeNull();
727
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/nonexistent/index.tsx');
728
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/nonexistent/NonExistent.tsx');
729
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/nonexistent/nonexistent.tsx');
730
+ });
731
+ test('should return first matching file when multiple exist', () => {
732
+ const componentDir = '/path/to/button';
733
+ const componentName = 'Button';
734
+ // Mock both index.tsx and Button.tsx to exist
735
+ mockedFs.existsSync.mockReturnValue(true);
736
+ const result = findComponentFile(componentDir, componentName, true);
737
+ // Should return index.tsx since it's checked first
738
+ expect(result).toBe('/path/to/button/index.tsx');
739
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/button/index.tsx');
740
+ });
741
+ test('should return null when not in local development mode', () => {
742
+ const componentDir = '/path/to/button';
743
+ const componentName = 'Button';
744
+ mockedFs.existsSync.mockReturnValue(true);
745
+ const result = findComponentFile(componentDir, componentName, false);
746
+ expect(result).toBeNull();
747
+ expect(mockedFs.existsSync).not.toHaveBeenCalled();
748
+ });
749
+ test('should handle complex component names correctly', () => {
750
+ const componentDir = '/path/to/data-table';
751
+ const componentName = 'DataTable';
752
+ mockedFs.existsSync.mockImplementation((filePath) => {
753
+ return filePath === '/path/to/data-table/datatable.tsx';
754
+ });
755
+ const result = findComponentFile(componentDir, componentName, true);
756
+ expect(result).toBe('/path/to/data-table/datatable.tsx');
757
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/data-table/index.tsx');
758
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/data-table/DataTable.tsx');
759
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/data-table/datatable.tsx');
760
+ });
761
+ test('should handle single character component names', () => {
762
+ const componentDir = '/path/to/x';
763
+ const componentName = 'X';
764
+ mockedFs.existsSync.mockImplementation((filePath) => {
765
+ return filePath === '/path/to/x/X.tsx';
766
+ });
767
+ const result = findComponentFile(componentDir, componentName, true);
768
+ expect(result).toBe('/path/to/x/X.tsx');
769
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/x/index.tsx');
770
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/x/X.tsx');
771
+ });
772
+ test('should handle empty component directory path', () => {
773
+ const componentDir = '';
774
+ const componentName = 'Button';
775
+ mockedFs.existsSync.mockReturnValue(false);
776
+ const result = findComponentFile(componentDir, componentName, true);
777
+ expect(result).toBeNull();
778
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/index.tsx');
779
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/Button.tsx');
780
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/button.tsx');
781
+ });
782
+ test('should handle empty component name', () => {
783
+ const componentDir = '/path/to/component';
784
+ const componentName = '';
785
+ mockedFs.existsSync.mockImplementation((filePath) => {
786
+ return filePath === '/path/to/component/index.tsx';
787
+ });
788
+ const result = findComponentFile(componentDir, componentName, true);
789
+ expect(result).toBe('/path/to/component/index.tsx');
790
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/component/index.tsx');
791
+ // When index.tsx exists, the function returns early and doesn't check other files
792
+ });
793
+ test('should handle component names with numbers', () => {
794
+ const componentDir = '/path/to/form2';
795
+ const componentName = 'Form2';
796
+ mockedFs.existsSync.mockImplementation((filePath) => {
797
+ return filePath === '/path/to/form2/form2.tsx';
798
+ });
799
+ const result = findComponentFile(componentDir, componentName, true);
800
+ expect(result).toBe('/path/to/form2/form2.tsx');
801
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/form2/index.tsx');
802
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/form2/Form2.tsx');
803
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/form2/form2.tsx');
804
+ });
805
+ test('should use path.join correctly for cross-platform compatibility', () => {
806
+ const componentDir = '/path/to/button';
807
+ const componentName = 'Button';
808
+ mockedFs.existsSync.mockReturnValue(false);
809
+ mockedPath.join.mockImplementation((...args) => args.join('/'));
810
+ findComponentFile(componentDir, componentName, true);
811
+ expect(mockedPath.join).toHaveBeenCalledWith('/path/to/button', 'index.tsx');
812
+ expect(mockedPath.join).toHaveBeenCalledWith('/path/to/button', 'Button.tsx');
813
+ expect(mockedPath.join).toHaveBeenCalledWith('/path/to/button', 'button.tsx');
814
+ });
815
+ });
816
+ describe('findComponentDirectory', () => {
817
+ beforeEach(() => {
818
+ vi.clearAllMocks();
819
+ });
820
+ test('should find component directory with exact match in local development', () => {
821
+ const packagePath = '/path/to/package';
822
+ const componentName = 'Button';
823
+ mockedFs.existsSync.mockImplementation((filePath) => {
824
+ return filePath === '/path/to/package/src/components';
825
+ });
826
+ mockedFs.readdirSync.mockReturnValue([
827
+ { name: 'button', isDirectory: () => true },
828
+ { name: 'alert', isDirectory: () => true },
829
+ { name: 'card', isDirectory: () => true },
830
+ ]);
831
+ const result = findComponentDirectory(packagePath, componentName, true);
832
+ expect(result).toBe('/path/to/package/src/components/button');
833
+ expect(mockedPath.join).toHaveBeenCalledWith(packagePath, 'src', 'components');
834
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/package/src/components');
835
+ expect(mockedFs.readdirSync).toHaveBeenCalledWith('/path/to/package/src/components', {
836
+ withFileTypes: true,
837
+ });
838
+ });
839
+ test('should find component directory with kebab-case match', () => {
840
+ const packagePath = '/path/to/package';
841
+ const componentName = 'DataTable';
842
+ mockedFs.existsSync.mockReturnValue(true);
843
+ mockedFs.readdirSync.mockReturnValue([
844
+ { name: 'data-table', isDirectory: () => true },
845
+ { name: 'button', isDirectory: () => true },
846
+ { name: 'card', isDirectory: () => true },
847
+ ]);
848
+ const result = findComponentDirectory(packagePath, componentName, true);
849
+ expect(result).toBe('/path/to/package/src/components/data-table');
850
+ });
851
+ test('should find component directory with lowercase match', () => {
852
+ const packagePath = '/path/to/package';
853
+ const componentName = 'Alert';
854
+ mockedFs.existsSync.mockReturnValue(true);
855
+ mockedFs.readdirSync.mockReturnValue([
856
+ { name: 'alert', isDirectory: () => true },
857
+ { name: 'button', isDirectory: () => true },
858
+ { name: 'card', isDirectory: () => true },
859
+ ]);
860
+ const result = findComponentDirectory(packagePath, componentName, true);
861
+ expect(result).toBe('/path/to/package/src/components/alert');
862
+ });
863
+ test('should find component directory with exact case match', () => {
864
+ const packagePath = '/path/to/package';
865
+ const componentName = 'Card';
866
+ mockedFs.existsSync.mockReturnValue(true);
867
+ mockedFs.readdirSync.mockReturnValue([
868
+ { name: 'Card', isDirectory: () => true },
869
+ { name: 'button', isDirectory: () => true },
870
+ { name: 'alert', isDirectory: () => true },
871
+ ]);
872
+ const result = findComponentDirectory(packagePath, componentName, true);
873
+ expect(result).toBe('/path/to/package/src/components/Card');
874
+ });
875
+ test('should prioritize kebab-case over other matches', () => {
876
+ const packagePath = '/path/to/package';
877
+ const componentName = 'DataTable';
878
+ mockedFs.existsSync.mockReturnValue(true);
879
+ mockedFs.readdirSync.mockReturnValue([
880
+ { name: 'data-table', isDirectory: () => true },
881
+ { name: 'datatable', isDirectory: () => true },
882
+ { name: 'DataTable', isDirectory: () => true },
883
+ ]);
884
+ const result = findComponentDirectory(packagePath, componentName, true);
885
+ // The find method returns the first match, which should be kebab-case
886
+ expect(result).toBe('/path/to/package/src/components/data-table');
887
+ });
888
+ test('should return null when components directory does not exist', () => {
889
+ const packagePath = '/path/to/package';
890
+ const componentName = 'Button';
891
+ mockedFs.existsSync.mockReturnValue(false);
892
+ const result = findComponentDirectory(packagePath, componentName, true);
893
+ expect(result).toBeNull();
894
+ expect(mockedFs.existsSync).toHaveBeenCalledWith('/path/to/package/src/components');
895
+ expect(mockedFs.readdirSync).not.toHaveBeenCalled();
896
+ });
897
+ test('should return null when no matching directory found', () => {
898
+ const packagePath = '/path/to/package';
899
+ const componentName = 'NonExistent';
900
+ mockedFs.existsSync.mockReturnValue(true);
901
+ mockedFs.readdirSync.mockReturnValue([
902
+ { name: 'button', isDirectory: () => true },
903
+ { name: 'alert', isDirectory: () => true },
904
+ { name: 'card', isDirectory: () => true },
905
+ ]);
906
+ const result = findComponentDirectory(packagePath, componentName, true);
907
+ expect(result).toBeNull();
908
+ });
909
+ test('should filter out non-directories', () => {
910
+ const packagePath = '/path/to/package';
911
+ const componentName = 'Button';
912
+ mockedFs.existsSync.mockReturnValue(true);
913
+ mockedFs.readdirSync.mockReturnValue([
914
+ { name: 'button', isDirectory: () => true },
915
+ { name: 'readme.md', isDirectory: () => false },
916
+ { name: 'index.ts', isDirectory: () => false },
917
+ { name: 'alert', isDirectory: () => true },
918
+ ]);
919
+ const result = findComponentDirectory(packagePath, componentName, true);
920
+ expect(result).toBe('/path/to/package/src/components/button');
921
+ });
922
+ test('should return package path when not in local development mode', () => {
923
+ const packagePath = '/path/to/node_modules/package';
924
+ const componentName = 'Button';
925
+ const result = findComponentDirectory(packagePath, componentName, false);
926
+ expect(result).toBe(packagePath);
927
+ expect(mockedFs.existsSync).not.toHaveBeenCalled();
928
+ expect(mockedFs.readdirSync).not.toHaveBeenCalled();
929
+ });
930
+ test('should handle complex component names with multiple capital letters', () => {
931
+ const packagePath = '/path/to/package';
932
+ const componentName = 'XMLHttpRequest';
933
+ mockedFs.existsSync.mockReturnValue(true);
934
+ mockedFs.readdirSync.mockReturnValue([
935
+ { name: 'xmlhttp-request', isDirectory: () => true },
936
+ { name: 'button', isDirectory: () => true },
937
+ ]);
938
+ const result = findComponentDirectory(packagePath, componentName, true);
939
+ expect(result).toBe('/path/to/package/src/components/xmlhttp-request');
940
+ });
941
+ test('should handle component names with numbers', () => {
942
+ const packagePath = '/path/to/package';
943
+ const componentName = 'Form2';
944
+ mockedFs.existsSync.mockReturnValue(true);
945
+ mockedFs.readdirSync.mockReturnValue([
946
+ { name: 'form2', isDirectory: () => true },
947
+ { name: 'form', isDirectory: () => true },
948
+ ]);
949
+ const result = findComponentDirectory(packagePath, componentName, true);
950
+ expect(result).toBe('/path/to/package/src/components/form2');
951
+ });
952
+ test('should handle single character component names', () => {
953
+ const packagePath = '/path/to/package';
954
+ const componentName = 'X';
955
+ mockedFs.existsSync.mockReturnValue(true);
956
+ mockedFs.readdirSync.mockReturnValue([
957
+ { name: 'x', isDirectory: () => true },
958
+ { name: 'button', isDirectory: () => true },
959
+ ]);
960
+ const result = findComponentDirectory(packagePath, componentName, true);
961
+ expect(result).toBe('/path/to/package/src/components/x');
962
+ });
963
+ test('should handle empty components directory', () => {
964
+ const packagePath = '/path/to/package';
965
+ const componentName = 'Button';
966
+ mockedFs.existsSync.mockReturnValue(true);
967
+ mockedFs.readdirSync.mockReturnValue([]);
968
+ const result = findComponentDirectory(packagePath, componentName, true);
969
+ expect(result).toBeNull();
970
+ });
971
+ test('should handle directory with only files (no subdirectories)', () => {
972
+ const packagePath = '/path/to/package';
973
+ const componentName = 'Button';
974
+ mockedFs.existsSync.mockReturnValue(true);
975
+ mockedFs.readdirSync.mockReturnValue([
976
+ { name: 'index.ts', isDirectory: () => false },
977
+ { name: 'types.ts', isDirectory: () => false },
978
+ { name: 'utils.ts', isDirectory: () => false },
979
+ ]);
980
+ const result = findComponentDirectory(packagePath, componentName, true);
981
+ expect(result).toBeNull();
982
+ });
983
+ test('should use path.join correctly for cross-platform compatibility', () => {
984
+ const packagePath = '/path/to/package';
985
+ const componentName = 'Button';
986
+ mockedFs.existsSync.mockReturnValue(true);
987
+ mockedFs.readdirSync.mockReturnValue([{ name: 'button', isDirectory: () => true }]);
988
+ mockedPath.join.mockImplementation((...args) => args.join('/'));
989
+ findComponentDirectory(packagePath, componentName, true);
990
+ expect(mockedPath.join).toHaveBeenCalledWith(packagePath, 'src', 'components');
991
+ expect(mockedPath.join).toHaveBeenCalledWith('/path/to/package/src/components', 'button');
992
+ });
993
+ test('should handle readdir error gracefully', () => {
994
+ const packagePath = '/path/to/package';
995
+ const componentName = 'Button';
996
+ mockedFs.existsSync.mockReturnValue(true);
997
+ mockedFs.readdirSync.mockImplementation(() => {
998
+ throw new Error('Permission denied');
999
+ });
1000
+ expect(() => {
1001
+ findComponentDirectory(packagePath, componentName, true);
1002
+ }).toThrow('Permission denied');
1003
+ });
1004
+ test('should handle special characters in component names', () => {
1005
+ const packagePath = '/path/to/package';
1006
+ const componentName = 'My$Component';
1007
+ mockedFs.existsSync.mockReturnValue(true);
1008
+ mockedFs.readdirSync.mockReturnValue([
1009
+ { name: 'my$component', isDirectory: () => true },
1010
+ { name: 'button', isDirectory: () => true },
1011
+ ]);
1012
+ const result = findComponentDirectory(packagePath, componentName, true);
1013
+ expect(result).toBe('/path/to/package/src/components/my$component');
1014
+ });
1015
+ });
1016
+ describe('getComponentDetails', () => {
1017
+ test('should return component details when component is found', () => {
1018
+ const componentName = 'Button';
1019
+ // Set PROJECT_ROOT environment variable
1020
+ const originalEnv = process.env.PROJECT_ROOT;
1021
+ process.env.PROJECT_ROOT = '/mock/project/root';
1022
+ // Mock that the package directory exists
1023
+ mockedFs.existsSync.mockReturnValueOnce(true); // package exists
1024
+ mockedFs.existsSync.mockReturnValueOnce(true); // index.d.ts exists
1025
+ // Mock the package index file content
1026
+ mockedFs.readFileSync.mockReturnValueOnce(`
1027
+ export { default as Button } from './button';
1028
+ export { default as Alert } from './alert';
1029
+ `);
1030
+ // Mock that component directory doesn't exist in node_modules (so it checks local dev)
1031
+ mockedFs.existsSync.mockReturnValueOnce(false); // node_modules path
1032
+ mockedFs.existsSync.mockReturnValueOnce(true); // local packages path
1033
+ mockedFs.existsSync.mockReturnValueOnce(true); // src/components directory
1034
+ // Mock reading the components directory
1035
+ mockedFs.readdirSync.mockReturnValueOnce([
1036
+ { name: 'button', isDirectory: () => true },
1037
+ { name: 'alert', isDirectory: () => true },
1038
+ ]);
1039
+ // Mock that component file exists and read its content
1040
+ mockedFs.existsSync.mockReturnValueOnce(true); // index.tsx exists
1041
+ mockedFs.readFileSync.mockReturnValueOnce(`
1042
+ /**
1043
+ * A reusable button component
1044
+ */
1045
+ export interface ButtonProps {
1046
+ variant?: 'primary' | 'secondary';
1047
+ size?: 'small' | 'medium' | 'large';
1048
+ disabled?: boolean;
1049
+ children: React.ReactNode;
1050
+ }
1051
+
1052
+ export type ButtonVariant = 'primary' | 'secondary';
1053
+
1054
+ const Button: React.FC<ButtonProps> = ({ children, ...props }) => {
1055
+ return <button {...props}>{children}</button>;
1056
+ };
1057
+
1058
+ export default Button;
1059
+ `);
1060
+ const result = getComponentDetails(componentName);
1061
+ expect(result).toEqual({
1062
+ name: 'Button',
1063
+ package: '@metrostar/comet-uswds',
1064
+ filePath: '/mock/project/root/packages/comet-uswds/src/components/button',
1065
+ description: 'A reusable button component',
1066
+ props: ['children', 'disabled', 'size', 'variant'],
1067
+ types: ['ButtonProps', 'ButtonVariant'],
1068
+ });
1069
+ // Restore environment
1070
+ process.env.PROJECT_ROOT = originalEnv;
1071
+ });
1072
+ test('should return null when component is not found', () => {
1073
+ // Mock that packages exist but don't contain the component
1074
+ mockedFs.existsSync.mockReturnValue(true);
1075
+ mockedFs.readFileSync.mockReturnValue(`
1076
+ export { default as Alert } from './alert';
1077
+ export { default as Card } from './card';
1078
+ `);
1079
+ const result = getComponentDetails('NonExistentComponent');
1080
+ expect(result).toBeNull();
1081
+ });
1082
+ });
9
1083
  });
10
1084
  //# sourceMappingURL=utils.test.js.map