@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.
- package/README.md +32 -4
- package/dist/index.js +167 -6
- package/dist/index.js.map +1 -1
- package/dist/utils.d.ts +43 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +198 -1
- package/dist/utils.js.map +1 -1
- package/dist/utils.test.js +1076 -2
- package/dist/utils.test.js.map +1 -1
- package/package.json +1 -1
package/dist/utils.test.js
CHANGED
|
@@ -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
|