Skip to content

Commit c6be6cf

Browse files
committed
test(llms-txt): add unit tests for utility functions
Cover discoverAgentFiles, fetchAgentFiles, and generateLlmsTxt including root files, directory scanning, graceful failures, scoped packages, and full/minimal output generation.
1 parent 25341d7 commit c6be6cf

File tree

1 file changed

+301
-0
lines changed

1 file changed

+301
-0
lines changed
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import type { JsDelivrFileNode, LlmsTxtResult } from '../../../../shared/types'
3+
import {
4+
discoverAgentFiles,
5+
fetchAgentFiles,
6+
generateLlmsTxt,
7+
} from '../../../../server/utils/llms-txt'
8+
9+
describe('discoverAgentFiles', () => {
10+
it('discovers root-level agent files', () => {
11+
const files: JsDelivrFileNode[] = [
12+
{ type: 'file', name: 'CLAUDE.md', size: 100 },
13+
{ type: 'file', name: 'AGENTS.md', size: 200 },
14+
{ type: 'file', name: 'AGENT.md', size: 50 },
15+
{ type: 'file', name: '.cursorrules', size: 80 },
16+
{ type: 'file', name: '.windsurfrules', size: 60 },
17+
{ type: 'file', name: '.clinerules', size: 40 },
18+
{ type: 'file', name: 'package.json', size: 500 },
19+
{ type: 'file', name: 'README.md', size: 3000 },
20+
]
21+
22+
const result = discoverAgentFiles(files)
23+
24+
expect(result).toContain('CLAUDE.md')
25+
expect(result).toContain('AGENTS.md')
26+
expect(result).toContain('AGENT.md')
27+
expect(result).toContain('.cursorrules')
28+
expect(result).toContain('.windsurfrules')
29+
expect(result).toContain('.clinerules')
30+
expect(result).not.toContain('package.json')
31+
expect(result).not.toContain('README.md')
32+
expect(result).toHaveLength(6)
33+
})
34+
35+
it('discovers .github/copilot-instructions.md', () => {
36+
const files: JsDelivrFileNode[] = [
37+
{
38+
type: 'directory',
39+
name: '.github',
40+
files: [
41+
{ type: 'file', name: 'copilot-instructions.md', size: 150 },
42+
{ type: 'file', name: 'FUNDING.yml', size: 30 },
43+
],
44+
},
45+
]
46+
47+
const result = discoverAgentFiles(files)
48+
49+
expect(result).toEqual(['.github/copilot-instructions.md'])
50+
})
51+
52+
it('discovers .cursor/rules/*.md files', () => {
53+
const files: JsDelivrFileNode[] = [
54+
{
55+
type: 'directory',
56+
name: '.cursor',
57+
files: [
58+
{
59+
type: 'directory',
60+
name: 'rules',
61+
files: [
62+
{ type: 'file', name: 'coding-style.md', size: 100 },
63+
{ type: 'file', name: 'testing.md', size: 80 },
64+
{ type: 'file', name: 'config.json', size: 50 },
65+
],
66+
},
67+
],
68+
},
69+
]
70+
71+
const result = discoverAgentFiles(files)
72+
73+
expect(result).toContain('.cursor/rules/coding-style.md')
74+
expect(result).toContain('.cursor/rules/testing.md')
75+
expect(result).not.toContain('.cursor/rules/config.json')
76+
expect(result).toHaveLength(2)
77+
})
78+
79+
it('discovers .windsurf/rules/*.md files', () => {
80+
const files: JsDelivrFileNode[] = [
81+
{
82+
type: 'directory',
83+
name: '.windsurf',
84+
files: [
85+
{
86+
type: 'directory',
87+
name: 'rules',
88+
files: [{ type: 'file', name: 'project.md', size: 200 }],
89+
},
90+
],
91+
},
92+
]
93+
94+
const result = discoverAgentFiles(files)
95+
96+
expect(result).toEqual(['.windsurf/rules/project.md'])
97+
})
98+
99+
it('returns empty array for empty file tree', () => {
100+
expect(discoverAgentFiles([])).toEqual([])
101+
})
102+
103+
it('returns empty array when no agent files exist', () => {
104+
const files: JsDelivrFileNode[] = [
105+
{ type: 'file', name: 'package.json', size: 500 },
106+
{ type: 'file', name: 'index.js', size: 1000 },
107+
{
108+
type: 'directory',
109+
name: 'src',
110+
files: [{ type: 'file', name: 'main.ts', size: 200 }],
111+
},
112+
]
113+
114+
expect(discoverAgentFiles(files)).toEqual([])
115+
})
116+
})
117+
118+
describe('fetchAgentFiles', () => {
119+
it('fetches files in parallel and returns results', async () => {
120+
const fetchMock = vi.fn().mockImplementation((url: string) => {
121+
if (url.includes('CLAUDE.md')) {
122+
return Promise.resolve({ ok: true, text: () => Promise.resolve('# Claude instructions') })
123+
}
124+
if (url.includes('AGENTS.md')) {
125+
return Promise.resolve({ ok: true, text: () => Promise.resolve('# Agent config') })
126+
}
127+
return Promise.resolve({ ok: false })
128+
})
129+
vi.stubGlobal('fetch', fetchMock)
130+
131+
try {
132+
const result = await fetchAgentFiles('test-pkg', '1.0.0', ['CLAUDE.md', 'AGENTS.md'])
133+
134+
expect(result).toHaveLength(2)
135+
expect(result[0]).toMatchObject({
136+
path: 'CLAUDE.md',
137+
content: '# Claude instructions',
138+
displayName: 'Claude Code',
139+
})
140+
expect(result[1]).toMatchObject({
141+
path: 'AGENTS.md',
142+
content: '# Agent config',
143+
displayName: 'Agent Instructions',
144+
})
145+
expect(fetchMock).toHaveBeenCalledTimes(2)
146+
} finally {
147+
vi.unstubAllGlobals()
148+
}
149+
})
150+
151+
it('gracefully skips failed fetches', async () => {
152+
const fetchMock = vi.fn().mockImplementation((url: string) => {
153+
if (url.includes('CLAUDE.md')) {
154+
return Promise.resolve({ ok: true, text: () => Promise.resolve('# Claude') })
155+
}
156+
return Promise.resolve({ ok: false })
157+
})
158+
vi.stubGlobal('fetch', fetchMock)
159+
160+
try {
161+
const result = await fetchAgentFiles('test-pkg', '1.0.0', ['CLAUDE.md', 'missing.md'])
162+
163+
expect(result).toHaveLength(1)
164+
expect(result[0]?.path).toBe('CLAUDE.md')
165+
} finally {
166+
vi.unstubAllGlobals()
167+
}
168+
})
169+
170+
it('gracefully handles network errors', async () => {
171+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')))
172+
173+
try {
174+
const result = await fetchAgentFiles('test-pkg', '1.0.0', ['CLAUDE.md'])
175+
expect(result).toEqual([])
176+
} finally {
177+
vi.unstubAllGlobals()
178+
}
179+
})
180+
181+
it('returns empty array for empty file paths', async () => {
182+
const result = await fetchAgentFiles('test-pkg', '1.0.0', [])
183+
expect(result).toEqual([])
184+
})
185+
186+
it('constructs correct CDN URLs for scoped packages', async () => {
187+
const fetchMock = vi.fn().mockResolvedValue({
188+
ok: true,
189+
text: () => Promise.resolve('content'),
190+
})
191+
vi.stubGlobal('fetch', fetchMock)
192+
193+
try {
194+
await fetchAgentFiles('@nuxt/kit', '1.0.0', ['CLAUDE.md'])
195+
expect(fetchMock).toHaveBeenCalledWith(
196+
'https://cdn.jsdelivr.net/npm/@nuxt/kit@1.0.0/CLAUDE.md',
197+
)
198+
} finally {
199+
vi.unstubAllGlobals()
200+
}
201+
})
202+
})
203+
204+
describe('generateLlmsTxt', () => {
205+
it('generates full output with all fields', () => {
206+
const result: LlmsTxtResult = {
207+
packageName: 'nuxt',
208+
version: '3.12.0',
209+
description: 'The Intuitive Vue Framework',
210+
homepage: 'https://nuxt.com',
211+
repositoryUrl: 'https://github.com/nuxt/nuxt',
212+
readme: '# Nuxt\n\nThe Intuitive Vue Framework.',
213+
agentFiles: [
214+
{
215+
path: 'CLAUDE.md',
216+
content: '# Claude\n\nUse Nuxt conventions.',
217+
displayName: 'Claude Code',
218+
},
219+
{ path: '.cursorrules', content: 'Use composition API.', displayName: 'Cursor Rules' },
220+
],
221+
}
222+
223+
const output = generateLlmsTxt(result)
224+
225+
expect(output).toContain('# nuxt@3.12.0')
226+
expect(output).toContain('> The Intuitive Vue Framework')
227+
expect(output).toContain('- Homepage: https://nuxt.com')
228+
expect(output).toContain('- Repository: https://github.com/nuxt/nuxt')
229+
expect(output).toContain('- npm: https://www.npmjs.com/package/nuxt/v/3.12.0')
230+
expect(output).toContain('## README')
231+
expect(output).toContain('# Nuxt')
232+
expect(output).toContain('## Agent Instructions')
233+
expect(output).toContain('### Claude Code (`CLAUDE.md`)')
234+
expect(output).toContain('Use Nuxt conventions.')
235+
expect(output).toContain('### Cursor Rules (`.cursorrules`)')
236+
expect(output).toContain('Use composition API.')
237+
expect(output.endsWith('\n')).toBe(true)
238+
})
239+
240+
it('generates minimal output with no optional fields', () => {
241+
const result: LlmsTxtResult = {
242+
packageName: 'tiny-pkg',
243+
version: '0.1.0',
244+
agentFiles: [],
245+
}
246+
247+
const output = generateLlmsTxt(result)
248+
249+
expect(output).toContain('# tiny-pkg@0.1.0')
250+
expect(output).toContain('- npm: https://www.npmjs.com/package/tiny-pkg/v/0.1.0')
251+
expect(output).not.toContain('>')
252+
expect(output).not.toContain('Homepage')
253+
expect(output).not.toContain('Repository')
254+
expect(output).not.toContain('## README')
255+
expect(output).not.toContain('## Agent Instructions')
256+
})
257+
258+
it('omits Agent Instructions section when no agent files exist', () => {
259+
const result: LlmsTxtResult = {
260+
packageName: 'test-pkg',
261+
version: '1.0.0',
262+
description: 'A test package',
263+
readme: '# Test\n\nHello world.',
264+
agentFiles: [],
265+
}
266+
267+
const output = generateLlmsTxt(result)
268+
269+
expect(output).toContain('## README')
270+
expect(output).not.toContain('## Agent Instructions')
271+
})
272+
273+
it('omits README section when no readme provided', () => {
274+
const result: LlmsTxtResult = {
275+
packageName: 'no-readme',
276+
version: '1.0.0',
277+
agentFiles: [
278+
{ path: 'AGENTS.md', content: 'Agent rules here.', displayName: 'Agent Instructions' },
279+
],
280+
}
281+
282+
const output = generateLlmsTxt(result)
283+
284+
expect(output).not.toContain('## README')
285+
expect(output).toContain('## Agent Instructions')
286+
expect(output).toContain('### Agent Instructions (`AGENTS.md`)')
287+
})
288+
289+
it('handles scoped package names in npm URL', () => {
290+
const result: LlmsTxtResult = {
291+
packageName: '@nuxt/kit',
292+
version: '1.0.0',
293+
agentFiles: [],
294+
}
295+
296+
const output = generateLlmsTxt(result)
297+
298+
expect(output).toContain('# @nuxt/kit@1.0.0')
299+
expect(output).toContain('- npm: https://www.npmjs.com/package/@nuxt/kit/v/1.0.0')
300+
})
301+
})

0 commit comments

Comments
 (0)