@splunk/splunk-ui-mcp 0.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.
@@ -0,0 +1,149 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const NO_RESULTS_TEXT = 'No icons found. Try different keywords like "search", "notification", "user", "chart", etc.';
3
+ const ICON_CATALOG_FIXTURE = vi.hoisted(() => [
4
+ {
5
+ name: 'SearchIcon',
6
+ key: './SearchIcon',
7
+ category: 'Interface',
8
+ description: 'Use for search interactions.',
9
+ keywords: 'search, magnify, find',
10
+ variants: ['default', 'filled'],
11
+ },
12
+ {
13
+ name: 'AlertIcon',
14
+ key: './AlertIcon',
15
+ description: 'Communicates alert or warning states.',
16
+ keywords: 'alert, warning',
17
+ },
18
+ {
19
+ name: 'UserIcon',
20
+ key: './UserIcon',
21
+ keywords: 'profile, account',
22
+ },
23
+ { name: 'PrimaryIcon', key: './PrimaryIcon' },
24
+ { name: 'AltOne', key: './AltOne' },
25
+ { name: 'AltTwo', key: './AltTwo' },
26
+ { name: 'AltThree', key: './AltThree' },
27
+ { name: 'AltFour', key: './AltFour' },
28
+ { name: 'AltFive', key: './AltFive' },
29
+ { name: 'SoloIcon', key: './SoloIcon' },
30
+ ]);
31
+ const INDEXED_DATA = { indexed: true };
32
+ const addSearchDataMock = vi.fn();
33
+ const searchMock = vi.fn();
34
+ vi.mock('@splunk/react-icons/icon-catalog.json', () => ({
35
+ default: ICON_CATALOG_FIXTURE,
36
+ }));
37
+ vi.mock('@splunk/ui-utils/search', () => ({
38
+ addSearchData: addSearchDataMock,
39
+ search: searchMock,
40
+ }));
41
+ describe('find_icons tool', () => {
42
+ beforeEach(() => {
43
+ vi.resetModules();
44
+ addSearchDataMock.mockReset();
45
+ searchMock.mockReset();
46
+ searchMock.mockReturnValue([]);
47
+ addSearchDataMock.mockReturnValue(INDEXED_DATA);
48
+ });
49
+ it('exposes metadata and prepares search index', async () => {
50
+ const { default: tool } = await import("../find_icons.js");
51
+ expect(tool.name).toBe('find_icons');
52
+ expect('config' in tool).toBe(true);
53
+ expect(tool.config?.title).toBeDefined();
54
+ expect(tool.config?.description).toBeDefined();
55
+ expect(tool.config.inputSchema?.query).toBeDefined();
56
+ });
57
+ it('prepares search index', async () => {
58
+ addSearchDataMock.mockReturnValue({
59
+ mock: true,
60
+ });
61
+ await import("../find_icons.js");
62
+ expect(addSearchDataMock).toHaveBeenCalledWith(ICON_CATALOG_FIXTURE);
63
+ expect(addSearchDataMock).toHaveReturnedWith({ mock: true });
64
+ });
65
+ describe('handler', () => {
66
+ it('returns guidance when query is empty', async () => {
67
+ const { default: tool } = await import("../find_icons.js");
68
+ const result = tool.handler({ query: '' });
69
+ expect(searchMock).not.toHaveBeenCalled();
70
+ expect(result.content[0]?.type).toBe('text');
71
+ expect(result.content[0]?.text).toBe(NO_RESULTS_TEXT);
72
+ });
73
+ it('returns guidance when no icons match search', async () => {
74
+ const { default: tool } = await import("../find_icons.js");
75
+ const result = tool.handler({ query: 'alert' });
76
+ expect(searchMock).toHaveBeenCalledWith('alert', INDEXED_DATA);
77
+ expect(result.content[0]?.text).toBe(NO_RESULTS_TEXT);
78
+ });
79
+ it('returns icon details and alternative resource links when matches are found', async () => {
80
+ searchMock.mockReturnValue([
81
+ {
82
+ name: 'SearchIcon',
83
+ description: 'Use for search interactions.',
84
+ searchScore: { nameScore: 100, keywordsScore: 20, descriptionScore: 0 },
85
+ },
86
+ { name: 'AlertIcon', description: 'Communicates alert or warning states.' },
87
+ { name: 'UserIcon', keywords: 'profile, account' },
88
+ ]);
89
+ const { default: tool } = await import("../find_icons.js");
90
+ const result = tool.handler({ query: 'search' });
91
+ expect(searchMock).toHaveBeenCalledWith('search', INDEXED_DATA);
92
+ const [summary, ...rest] = result.content;
93
+ const resourceLinks = rest.filter((item) => item?.type === 'resource_link');
94
+ expect(summary).toMatchObject({ type: 'text' });
95
+ const summaryText = summary?.text ?? '';
96
+ expect(summaryText).toContain('# Icons for "search"');
97
+ expect(summaryText).toContain('## Recommended Icon');
98
+ expect(summaryText).toContain('Recommended to use <SearchIcon /> for "search"');
99
+ expect(summaryText).toContain('- Import: `@splunk/react-icons/SearchIcon`');
100
+ expect(summaryText).toContain('- Category: Interface');
101
+ expect(summaryText).toContain('- Keywords: search, magnify, find');
102
+ expect(summaryText).toContain('```tsx');
103
+ expect(summaryText).toContain('Follow the resource links below for full metadata and usage snippets.');
104
+ expect(summaryText).toContain('## Alternative Icons');
105
+ expect(summaryText).toContain('- AlertIcon');
106
+ expect(summaryText).toContain('- UserIcon');
107
+ expect(resourceLinks).toHaveLength(3);
108
+ expect(resourceLinks[0]).toMatchObject({
109
+ name: 'SearchIcon',
110
+ uri: 'mcp://splunk-ui/icons/SearchIcon',
111
+ });
112
+ expect(resourceLinks[1]).toMatchObject({
113
+ name: 'AlertIcon',
114
+ uri: 'mcp://splunk-ui/icons/AlertIcon',
115
+ });
116
+ expect(resourceLinks[2]).toMatchObject({
117
+ name: 'UserIcon',
118
+ uri: 'mcp://splunk-ui/icons/UserIcon',
119
+ });
120
+ });
121
+ it('limits alternative icons to four entries', async () => {
122
+ searchMock.mockReturnValue([
123
+ { name: 'PrimaryIcon' },
124
+ { name: 'AltOne' },
125
+ { name: 'AltTwo' },
126
+ { name: 'AltThree' },
127
+ { name: 'AltFour' },
128
+ { name: 'AltFive' },
129
+ ]);
130
+ const { default: tool } = await import("../find_icons.js");
131
+ const result = tool.handler({ query: 'primary' });
132
+ const summaryText = result.content[0]?.type === 'text' ? (result.content[0]?.text ?? '') : '';
133
+ expect(summaryText).toContain('- AltOne');
134
+ expect(summaryText).toContain('- AltTwo');
135
+ expect(summaryText).toContain('- AltThree');
136
+ expect(summaryText).toContain('- AltFour');
137
+ expect(summaryText).not.toContain('- AltFive');
138
+ });
139
+ it('omits alternative section when only one icon matches', async () => {
140
+ const { default: tool } = await import("../find_icons.js");
141
+ const { handler } = tool;
142
+ const args = { query: 'solo' };
143
+ searchMock.mockReturnValue([{ name: 'SoloIcon' }]);
144
+ const result = await handler(args);
145
+ const summaryText = result.content[0]?.type === 'text' ? (result.content[0]?.text ?? '') : '';
146
+ expect(summaryText).not.toContain('## Alternative Icons');
147
+ });
148
+ });
149
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ const MOCK_COMPONENT_LIST = [
3
+ {
4
+ name: 'Button',
5
+ filename: 'Button.md',
6
+ category: 'Base',
7
+ },
8
+ {
9
+ name: 'Card Layout',
10
+ filename: 'Card Layout.md',
11
+ category: 'Base',
12
+ },
13
+ {
14
+ name: 'Text',
15
+ filename: 'Text.md',
16
+ category: 'Data Entry',
17
+ },
18
+ ];
19
+ const MOCK_BUTTON_DOCS = `# Button
20
+
21
+ ## Overview
22
+
23
+ A button component for triggering actions.
24
+
25
+ ## Examples
26
+
27
+ \`\`\`typescript
28
+ import Button from '@splunk/react-ui/Button';
29
+
30
+ <Button label="Click me" />
31
+ \`\`\`
32
+
33
+ ## API
34
+
35
+ ### Props
36
+
37
+ - label: string
38
+ - onClick: () => void
39
+ `;
40
+ // Mock the component catalog
41
+ vi.mock('../../utils/component-catalog.ts', () => ({
42
+ getComponentList: vi.fn(() => MOCK_COMPONENT_LIST),
43
+ componentExists: vi.fn((name) => MOCK_COMPONENT_LIST.some((c) => c.name === name)),
44
+ getComponentDocs: vi.fn((name) => {
45
+ if (name === 'Button') {
46
+ return MOCK_BUTTON_DOCS;
47
+ }
48
+ throw new Error(`Component "${name}" not found in catalog`);
49
+ }),
50
+ }));
51
+ // Mock the components resource
52
+ vi.mock('../../resources/components.ts', () => ({
53
+ createComponentResourceLink: vi.fn((name) => ({
54
+ type: 'resource_link',
55
+ uri: `mcp://splunk-ui/components/${encodeURIComponent(name)}`,
56
+ name,
57
+ })),
58
+ }));
59
+ describe('get_component_docs tool', () => {
60
+ beforeEach(() => {
61
+ vi.clearAllMocks();
62
+ });
63
+ it('returns documentation for an existing component', async () => {
64
+ const tool = (await import("../get_component_docs.js")).default;
65
+ const result = tool.handler({ componentName: 'Button' });
66
+ expect(result.content).toHaveLength(2);
67
+ expect(result.content[0]).toMatchObject({
68
+ type: 'text',
69
+ text: MOCK_BUTTON_DOCS,
70
+ });
71
+ expect(result.content[1]).toMatchObject({
72
+ type: 'resource_link',
73
+ name: 'Button',
74
+ });
75
+ });
76
+ it('returns error message for non-existent component', async () => {
77
+ const tool = (await import("../get_component_docs.js")).default;
78
+ const result = tool.handler({ componentName: 'NonExistent' });
79
+ expect(result.content).toHaveLength(1);
80
+ expect(result.content[0]).toMatchObject({
81
+ type: 'text',
82
+ });
83
+ expect(result.content[0].text).toContain('Component "NonExistent" not found');
84
+ expect(result.content[0].text).toContain('Available components include');
85
+ });
86
+ it('handles empty component name', async () => {
87
+ const tool = (await import("../get_component_docs.js")).default;
88
+ const result = tool.handler({ componentName: '' });
89
+ expect(result.content).toHaveLength(1);
90
+ expect(result.content[0]).toMatchObject({
91
+ type: 'text',
92
+ });
93
+ expect(result.content[0].text).toContain('Component name is required');
94
+ });
95
+ it('handles whitespace-only component name', async () => {
96
+ const tool = (await import("../get_component_docs.js")).default;
97
+ const result = tool.handler({ componentName: ' ' });
98
+ expect(result.content).toHaveLength(1);
99
+ expect(result.content[0]).toMatchObject({
100
+ type: 'text',
101
+ });
102
+ expect(result.content[0].text).toContain('Component name is required');
103
+ });
104
+ it('handles errors when reading documentation', async () => {
105
+ const { getComponentDocs } = await import("../../utils/component-catalog.js");
106
+ // Mock an error for a specific component
107
+ getComponentDocs.mockImplementationOnce(() => {
108
+ throw new Error('File system error');
109
+ });
110
+ const tool = (await import("../get_component_docs.js")).default;
111
+ const result = tool.handler({ componentName: 'Button' });
112
+ expect(result.isError).toBe(true);
113
+ expect(result.content).toHaveLength(1);
114
+ expect(result.content[0].text).toContain('Failed to retrieve documentation');
115
+ expect(result.content[0].text).toContain('File system error');
116
+ });
117
+ it('includes resource link in successful response', async () => {
118
+ const { createComponentResourceLink } = await import("../../resources/components.js");
119
+ const tool = (await import("../get_component_docs.js")).default;
120
+ tool.handler({ componentName: 'Button' });
121
+ expect(createComponentResourceLink).toHaveBeenCalledWith('Button');
122
+ });
123
+ it('works with components that have spaces in name', async () => {
124
+ const tool = (await import("../get_component_docs.js")).default;
125
+ // Card Layout exists in mock data
126
+ const result = tool.handler({ componentName: 'Card Layout' });
127
+ // Should try to get docs (will fail in mock, but proves it checked existence)
128
+ expect(result.isError).toBe(true);
129
+ expect(result.content[0].text).toContain('Failed to retrieve documentation');
130
+ });
131
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it, test } from 'vitest';
2
+ import requirements from "../requirements.js";
3
+ import VERSIONS from "../../constants/versions.js";
4
+ describe('requirements tool', () => {
5
+ describe('configuration', () => {
6
+ it('should have correct tool name', () => {
7
+ expect(requirements.name).toBe('list_splunk_ui_requirements');
8
+ });
9
+ it('should have proper config structure', () => {
10
+ expect(requirements.config).toBeDefined();
11
+ expect(requirements.config.title).toBeDefined();
12
+ expect(requirements.config.description).toBeDefined();
13
+ expect(requirements.config.inputSchema).toEqual({});
14
+ });
15
+ it('should have a handler function', () => {
16
+ expect(typeof requirements.handler).toBe('function');
17
+ });
18
+ });
19
+ describe('handler', () => {
20
+ it('returns the correct JSON structure', () => {
21
+ const result = requirements.handler();
22
+ const text = result.content[0].text;
23
+ const parsed = JSON.parse(text);
24
+ expect(parsed).toBeDefined();
25
+ expect(typeof parsed).toBe('object');
26
+ });
27
+ test.each(Object.entries(VERSIONS))('should include %s and version in JSON response', (pkg, version) => {
28
+ const result = requirements.handler();
29
+ const text = result.content[0].text;
30
+ const parsed = JSON.parse(text);
31
+ expect(parsed[pkg]).toBe(version);
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,26 @@
1
+ export type ComponentInfo = {
2
+ name: string;
3
+ filename: string;
4
+ category?: string;
5
+ };
6
+ /**
7
+ * Override the docs-llm path (primarily for testing)
8
+ * @internal
9
+ */
10
+ export declare function setDocsLlmPath(path: string | null): void;
11
+ /**
12
+ * Returns the cached list of all components
13
+ */
14
+ export declare function getComponentList(): ComponentInfo[];
15
+ /**
16
+ * Checks if a component exists in the catalog
17
+ */
18
+ export declare function componentExists(componentName: string): boolean;
19
+ /**
20
+ * Gets component info by name
21
+ */
22
+ export declare function getComponentInfo(componentName: string): ComponentInfo | undefined;
23
+ /**
24
+ * Reads the markdown documentation for a component
25
+ */
26
+ export declare function getComponentDocs(componentName: string): string;
@@ -0,0 +1,148 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join, dirname, resolve, relative, isAbsolute } from 'path';
3
+ import { createRequire } from 'module';
4
+ let componentCache = null;
5
+ let docsLlmPath = null;
6
+ /**
7
+ * Override the docs-llm path (primarily for testing)
8
+ * @internal
9
+ */
10
+ export function setDocsLlmPath(path) {
11
+ docsLlmPath = path;
12
+ componentCache = null; // Clear cache when path changes
13
+ }
14
+ /**
15
+ * Sanitizes component names; Rejects names containing path separators or parent directory references
16
+ */
17
+ function sanitizeComponentName(name) {
18
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) {
19
+ throw new Error(`Invalid component name: "${name}".`);
20
+ }
21
+ return name;
22
+ }
23
+ /**
24
+ * Resolves the path to the @splunk/react-ui/docs-llm directory
25
+ *
26
+ * Resolution order:
27
+ * 1. User's project node_modules (if @splunk/react-ui is installed locally)
28
+ * 2. MCP's bundled dependencies (fallback for standalone usage)
29
+ */
30
+ function resolveDocsLlmPath() {
31
+ if (docsLlmPath) {
32
+ return docsLlmPath;
33
+ }
34
+ // Try resolving from the user's project first
35
+ try {
36
+ const require = createRequire(join(process.cwd(), 'package.json'));
37
+ const reactUiPackagePath = require.resolve('@splunk/react-ui/package.json');
38
+ const reactUiPath = dirname(reactUiPackagePath);
39
+ docsLlmPath = join(reactUiPath, 'docs-llm');
40
+ return docsLlmPath;
41
+ }
42
+ catch {
43
+ // Project doesn't have @splunk/react-ui installed, fall back to MCP's bundled version
44
+ }
45
+ // Fall back to MCP's bundled version (for standalone usage via setup tool)
46
+ try {
47
+ const require = createRequire(import.meta.url);
48
+ const reactUiPackagePath = require.resolve('@splunk/react-ui/package.json');
49
+ const reactUiPath = dirname(reactUiPackagePath);
50
+ docsLlmPath = join(reactUiPath, 'docs-llm');
51
+ return docsLlmPath;
52
+ }
53
+ catch (error) {
54
+ throw new Error('@splunk/react-ui package not found. ' +
55
+ 'Either install it in your project (npm install @splunk/react-ui) ' +
56
+ 'or ensure the MCP was installed with peer dependencies.\n' +
57
+ `Error: ${error.message}`);
58
+ }
59
+ }
60
+ /**
61
+ * Parses the llms.txt file to extract component names and categories
62
+ */
63
+ function parseComponentCatalog() {
64
+ const llmsTxtPath = join(resolveDocsLlmPath(), 'llms.txt');
65
+ try {
66
+ const content = readFileSync(llmsTxtPath, 'utf-8');
67
+ const components = [];
68
+ let currentCategory;
69
+ const lines = content.split('\n');
70
+ lines.forEach((line) => {
71
+ const categoryMatch = line.match(/^## (.+)$/);
72
+ if (categoryMatch) {
73
+ [, currentCategory] = categoryMatch;
74
+ return;
75
+ }
76
+ const componentMatch = line.match(/^- \*\*(.+?)\*\*$/);
77
+ if (componentMatch) {
78
+ const [, name] = componentMatch;
79
+ components.push({
80
+ name,
81
+ filename: `${name}.md`,
82
+ category: currentCategory,
83
+ });
84
+ }
85
+ });
86
+ return components;
87
+ }
88
+ catch (parseError) {
89
+ throw new Error(`Failed to parse llms.txt: ${parseError.message}`);
90
+ }
91
+ }
92
+ /**
93
+ * Returns the cached list of all components
94
+ */
95
+ export function getComponentList() {
96
+ if (!componentCache) {
97
+ componentCache = parseComponentCatalog();
98
+ }
99
+ return componentCache;
100
+ }
101
+ /**
102
+ * Checks if a component exists in the catalog
103
+ */
104
+ export function componentExists(componentName) {
105
+ const components = getComponentList();
106
+ return components.some((c) => c.name === componentName);
107
+ }
108
+ /**
109
+ * Gets component info by name
110
+ */
111
+ export function getComponentInfo(componentName) {
112
+ const components = getComponentList();
113
+ return components.find((c) => c.name === componentName);
114
+ }
115
+ /**
116
+ * Reads the markdown documentation for a component
117
+ */
118
+ export function getComponentDocs(componentName) {
119
+ const sanitizedComponentName = sanitizeComponentName(componentName);
120
+ const componentInfo = getComponentInfo(sanitizedComponentName);
121
+ if (!componentInfo) {
122
+ throw new Error(`Component "${sanitizedComponentName}" not found in catalog`);
123
+ }
124
+ const docsLlmBase = resolveDocsLlmPath();
125
+ const docPath = join(docsLlmBase, componentInfo.filename);
126
+ // Verify the resolved path stays within docs-llm directory
127
+ const resolvedPath = resolve(docPath);
128
+ const resolvedBase = resolve(docsLlmBase);
129
+ // Use relative path check to prevent path traversal attacks
130
+ // This prevents bypasses via sibling directories that share a prefix
131
+ // (e.g., base=/path/docs-llm vs malicious=/path/docs-llm2/evil.md)
132
+ // If relativePath starts with '..', the target is outside the base directory
133
+ // If relativePath is absolute, resolve() returned a different root entirely
134
+ const relativePath = relative(resolvedBase, resolvedPath);
135
+ if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
136
+ throw new Error(`Attempted to access path outside docs-llm directory.`);
137
+ }
138
+ try {
139
+ return readFileSync(docPath, 'utf-8');
140
+ }
141
+ catch (error) {
142
+ throw new Error(`Failed to read documentation for "${sanitizedComponentName}": ${error.message}`);
143
+ }
144
+ }
145
+ // TODO: Future enhancement - add section extraction helpers
146
+ // These would parse markdown sections like "## Examples", "## API", etc.
147
+ // Example:
148
+ // export function getComponentSection(componentName: string, sectionName: string): string
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,144 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const MOCK_LLMS_TXT = `# React UI (@splunk/react-ui)
3
+
4
+ A library of UI components, all independent of the Splunk Enterprise environment.
5
+
6
+ ## Base
7
+
8
+ - **Anchor**
9
+ - **Avatar**
10
+ - **Button**
11
+ - **Card Layout**
12
+
13
+ ## Content
14
+
15
+ - **Heading**
16
+ - **Paragraph**
17
+
18
+ ## Deprecated
19
+
20
+ - **Accordion**
21
+ `;
22
+ const MOCK_BUTTON_DOCS = `# Button
23
+
24
+ ## Overview
25
+
26
+ A button component for triggering actions.
27
+
28
+ ## Examples
29
+
30
+ \`\`\`typescript
31
+ import Button from '@splunk/react-ui/Button';
32
+ \`\`\`
33
+ `;
34
+ // Mock file system operations
35
+ vi.mock('fs', () => ({
36
+ readFileSync: vi.fn((path) => {
37
+ if (path.includes('llms.txt')) {
38
+ return MOCK_LLMS_TXT;
39
+ }
40
+ if (path.includes('Button.md')) {
41
+ return MOCK_BUTTON_DOCS;
42
+ }
43
+ throw new Error(`File not found: ${path}`);
44
+ }),
45
+ }));
46
+ describe('component-catalog', () => {
47
+ beforeEach(async () => {
48
+ vi.resetModules();
49
+ const { setDocsLlmPath } = await import("../component-catalog.js");
50
+ setDocsLlmPath('/mocked/docs-llm');
51
+ });
52
+ describe('getComponentList', () => {
53
+ it('parses components from llms.txt', async () => {
54
+ const { getComponentList } = await import("../component-catalog.js");
55
+ const components = getComponentList();
56
+ expect(components).toHaveLength(7);
57
+ expect(components[0]).toEqual({
58
+ name: 'Anchor',
59
+ filename: 'Anchor.md',
60
+ category: 'Base',
61
+ });
62
+ expect(components[2]).toEqual({
63
+ name: 'Button',
64
+ filename: 'Button.md',
65
+ category: 'Base',
66
+ });
67
+ });
68
+ it('preserves category information', async () => {
69
+ const { getComponentList } = await import("../component-catalog.js");
70
+ const components = getComponentList();
71
+ const heading = components.find((c) => c.name === 'Heading');
72
+ expect(heading?.category).toBe('Content');
73
+ const accordion = components.find((c) => c.name === 'Accordion');
74
+ expect(accordion?.category).toBe('Deprecated');
75
+ });
76
+ it('caches the result on subsequent calls', async () => {
77
+ // Need to clear mocks and reload in this test to accurately count calls
78
+ vi.clearAllMocks();
79
+ vi.resetModules();
80
+ const { readFileSync } = await import('fs');
81
+ const { setDocsLlmPath, getComponentList } = await import("../component-catalog.js");
82
+ setDocsLlmPath('/mocked/docs-llm');
83
+ getComponentList();
84
+ getComponentList();
85
+ getComponentList();
86
+ // Should only read the llms.txt file once (cached after first read)
87
+ const llmsTxtCalls = readFileSync.mock.calls.filter((call) => call[0].includes('llms.txt'));
88
+ expect(llmsTxtCalls).toHaveLength(1);
89
+ });
90
+ });
91
+ describe('componentExists', () => {
92
+ it('returns true for components in the catalog', async () => {
93
+ const { componentExists } = await import("../component-catalog.js");
94
+ expect(componentExists('Button')).toBe(true);
95
+ expect(componentExists('Heading')).toBe(true);
96
+ });
97
+ it('returns false for components not in the catalog', async () => {
98
+ const { componentExists } = await import("../component-catalog.js");
99
+ expect(componentExists('NonExistent')).toBe(false);
100
+ expect(componentExists('Unknown')).toBe(false);
101
+ });
102
+ });
103
+ describe('getComponentInfo', () => {
104
+ it('returns component info for existing components', async () => {
105
+ const { getComponentInfo } = await import("../component-catalog.js");
106
+ const buttonInfo = getComponentInfo('Button');
107
+ expect(buttonInfo).toEqual({
108
+ name: 'Button',
109
+ filename: 'Button.md',
110
+ category: 'Base',
111
+ });
112
+ });
113
+ it('returns undefined for non-existent components', async () => {
114
+ const { getComponentInfo } = await import("../component-catalog.js");
115
+ expect(getComponentInfo('NonExistent')).toBeUndefined();
116
+ });
117
+ });
118
+ describe('getComponentDocs', () => {
119
+ it('reads and returns markdown documentation', async () => {
120
+ const { getComponentDocs } = await import("../component-catalog.js");
121
+ const docs = getComponentDocs('Button');
122
+ expect(docs).toBe(MOCK_BUTTON_DOCS);
123
+ });
124
+ it('throws error for non-existent component', async () => {
125
+ const { getComponentDocs } = await import("../component-catalog.js");
126
+ expect(() => getComponentDocs('NonExistent')).toThrow('Component "NonExistent" not found in catalog');
127
+ });
128
+ it('rejects component names with path separators', async () => {
129
+ const { getComponentDocs } = await import("../component-catalog.js");
130
+ expect(() => getComponentDocs('some/path/Button')).toThrow(/Invalid component name/);
131
+ expect(() => getComponentDocs('some\\path\\Button')).toThrow(/Invalid component name/);
132
+ });
133
+ it('rejects component names with parent directory references', async () => {
134
+ const { getComponentDocs } = await import("../component-catalog.js");
135
+ expect(() => getComponentDocs('../../etc/passwd')).toThrow(/Invalid component name/);
136
+ expect(() => getComponentDocs('Button/../../../etc/passwd')).toThrow(/Invalid component name/);
137
+ });
138
+ it('rejects absolute paths', async () => {
139
+ const { getComponentDocs } = await import("../component-catalog.js");
140
+ expect(() => getComponentDocs('/etc/passwd')).toThrow(/Invalid component name/);
141
+ expect(() => getComponentDocs('C:\\Windows\\System32\\config\\sam')).toThrow(/Invalid component name/);
142
+ });
143
+ });
144
+ });