UNPKG

doctool

Version:

AI-powered documentation validation and management system

447 lines (433 loc) 18.7 kB
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { LinkValidator } from './linkValidator'; // Mock fetch for HTTP testing const mockFetch = vi.fn(); global.fetch = mockFetch; describe('LinkValidator', () => { let tempDir; let validator; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'link-validator-test-')); validator = new LinkValidator(tempDir); mockFetch.mockClear(); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); vi.resetAllMocks(); }); describe('extractLinks', () => { it('should extract markdown links', () => { const content = ` # Test Document See [documentation](docs/readme.md) for more info. Visit [our website](https://example.com) for updates. Contact us at [email](mailto:test@example.com). `.trim(); const links = validator.extractLinks(content, 'test.md'); expect(links).toHaveLength(3); expect(links[0].url).toBe('docs/readme.md'); expect(links[0].type).toBe('internal'); expect(links[1].url).toBe('https://example.com'); expect(links[1].type).toBe('https'); expect(links[2].url).toBe('mailto:test@example.com'); expect(links[2].type).toBe('mailto'); }); it('should extract autolinks', () => { const content = ` Visit <https://example.com> for more information. Also check <http://test.org> for updates. `.trim(); const links = validator.extractLinks(content, 'test.md'); expect(links).toHaveLength(2); expect(links[0].url).toBe('https://example.com'); expect(links[0].type).toBe('https'); expect(links[1].url).toBe('http://test.org'); expect(links[1].type).toBe('http'); }); it('should extract plain URLs', () => { const content = ` Visit https://example.com for more info. Also see http://test.org for updates. `.trim(); const links = validator.extractLinks(content, 'test.md'); expect(links).toHaveLength(2); expect(links[0].url).toBe('https://example.com'); expect(links[1].url).toBe('http://test.org'); }); it('should extract anchor links', () => { const content = ` See [installation section](#installation) below. Check [config in other file](config.md#settings). `.trim(); const links = validator.extractLinks(content, 'test.md'); expect(links).toHaveLength(2); expect(links[0].url).toBe('#installation'); expect(links[0].type).toBe('anchor'); expect(links[0].anchor).toBe('installation'); expect(links[1].url).toBe('config.md#settings'); expect(links[1].type).toBe('anchor'); expect(links[1].anchor).toBe('settings'); }); it('should deduplicate links from same line', () => { const content = ` Visit [example](https://example.com) and also [example again](https://example.com). `.trim(); const links = validator.extractLinks(content, 'test.md'); expect(links).toHaveLength(1); }); it('should handle link titles', () => { const content = ` See [documentation](docs/readme.md "Read the docs") for more info. `.trim(); const links = validator.extractLinks(content, 'test.md'); expect(links).toHaveLength(1); expect(links[0].url).toBe('docs/readme.md'); }); }); describe('validateHttpUrl', () => { it('should validate successful HTTP responses', async () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK' }); const link = { url: 'https://example.com', type: 'https', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const result = await validator.validateLink(link, 'test.md'); expect(result.valid).toBe(true); expect(result.issues).toHaveLength(0); expect(mockFetch).toHaveBeenCalledWith('https://example.com', { method: 'HEAD', signal: expect.any(AbortSignal), headers: { 'User-Agent': 'DocTool Link Validator' } }); }); it('should handle 404 errors', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' }); const link = { url: 'https://example.com/missing', type: 'https', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const result = await validator.validateLink(link, 'test.md'); expect(result.valid).toBe(false); expect(result.issues).toHaveLength(1); expect(result.issues[0].severity).toBe('error'); // 404 is permanent expect(result.issues[0].message).toContain('404'); }); it('should handle network errors', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')); const link = { url: 'https://unreachable.example', type: 'https', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const result = await validator.validateLink(link, 'test.md'); expect(result.valid).toBe(false); expect(result.issues).toHaveLength(1); expect(result.issues[0].severity).toBe('warning'); // Network errors are temporary }); it('should handle timeouts', async () => { const validator = new LinkValidator(tempDir, 100); // Short timeout // Mock fetch to throw AbortError after delay mockFetch.mockImplementationOnce(() => { return new Promise((resolve, reject) => { setTimeout(() => { const error = new Error('Request timeout'); error.name = 'AbortError'; reject(error); }, 150); }); }); const link = { url: 'https://slow.example', type: 'https', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const result = await validator.validateLink(link, 'test.md'); expect(result.valid).toBe(false); expect(result.issues[0].message).toContain('timeout'); }); }); describe('validateInternalLink', () => { beforeEach(() => { // Create test files fs.writeFileSync(path.join(tempDir, 'existing.md'), '# Existing File'); fs.mkdirSync(path.join(tempDir, 'docs')); fs.writeFileSync(path.join(tempDir, 'docs', 'readme.md'), '# Documentation'); }); it('should validate existing internal links', async () => { const link = { url: 'existing.md', type: 'internal', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(true); expect(result.issues).toHaveLength(0); }); it('should detect missing internal links', async () => { const link = { url: 'missing.md', type: 'internal', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(false); expect(result.issues).toHaveLength(1); expect(result.issues[0].type).toBe('missing_file'); }); it('should validate relative paths', async () => { const link = { url: 'docs/readme.md', type: 'internal', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(true); expect(result.issues).toHaveLength(0); }); it('should suggest similar files for missing links', async () => { // Create a similar file fs.writeFileSync(path.join(tempDir, 'configuration.md'), '# Config'); const link = { url: 'config.md', // Missing, but configuration.md exists type: 'internal', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(false); expect(result.issues[0].suggestion).toContain('configuration.md'); }); }); describe('validateAnchorLink', () => { beforeEach(() => { // Create test file with headings const content = ` # Main Title ## Installation ### Prerequisites ## Configuration ### Settings `.trim(); fs.writeFileSync(path.join(tempDir, 'test.md'), content); }); it('should validate existing anchor links in same file', async () => { const link = { url: '#installation', type: 'anchor', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown', anchor: 'installation' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(true); expect(result.issues).toHaveLength(0); }); it('should validate anchor links with different casing', async () => { const link = { url: '#INSTALLATION', type: 'anchor', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown', anchor: 'INSTALLATION' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(true); }); it('should detect missing anchor links', async () => { const link = { url: '#missing-section', type: 'anchor', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown', anchor: 'missing-section' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(false); expect(result.issues).toHaveLength(1); expect(result.issues[0].severity).toBe('warning'); }); it('should suggest similar headings', async () => { const link = { url: '#config', type: 'anchor', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown', anchor: 'config' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(false); expect(result.issues[0].suggestion).toContain('Configuration'); }); it('should validate anchor links to other files', async () => { // Create another file with headings fs.writeFileSync(path.join(tempDir, 'other.md'), '# Other\n\n## Features'); const link = { url: 'other.md#features', type: 'anchor', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown', anchor: 'features' }; const docPath = path.join(tempDir, 'test.md'); const result = await validator.validateLink(link, docPath); expect(result.valid).toBe(true); }); }); describe('validateEmailAddress', () => { it('should validate correct email addresses', async () => { const link = { url: 'mailto:test@example.com', type: 'mailto', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const result = await validator.validateLink(link, 'test.md'); expect(result.valid).toBe(true); expect(result.issues).toHaveLength(0); }); it('should detect invalid email addresses', async () => { const link = { url: 'mailto:invalid-email', type: 'mailto', mentioned_in: { file: 'test.md', line: 1, context: 'test' }, status: 'unknown' }; const result = await validator.validateLink(link, 'test.md'); expect(result.valid).toBe(false); expect(result.issues).toHaveLength(1); expect(result.issues[0].message).toContain('Invalid email address'); }); }); describe('validateDocumentationFile', () => { it('should validate a complete documentation file', async () => { // Create test files fs.writeFileSync(path.join(tempDir, 'config.md'), '# Configuration'); // Mock successful HTTP response mockFetch.mockResolvedValueOnce({ ok: true, status: 200, statusText: 'OK' }); const docContent = ` # Test Project ## Links Check [configuration](config.md) for settings. Visit [our website](https://example.com) for updates. See [installation](#installation) section below. ## Installation Installation instructions here. `.trim(); const docPath = path.join(tempDir, 'test.md'); fs.writeFileSync(docPath, docContent); const issues = await validator.validateDocumentationFile(docPath); // Should have no issues - config.md exists, URL works, anchor exists expect(issues).toHaveLength(0); }); it('should detect multiple types of broken links', async () => { // Mock failed HTTP response mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found' }); const docContent = ` # Test Project Check [missing file](missing.md) for info. Visit [broken website](https://broken.example) for updates. See [missing section](#missing) below. `.trim(); const docPath = path.join(tempDir, 'test.md'); fs.writeFileSync(docPath, docContent); const issues = await validator.validateDocumentationFile(docPath); expect(issues).toHaveLength(3); // Check issue types const issueTypes = issues.map(i => i.message); expect(issueTypes.some(msg => msg.includes('missing.md'))).toBe(true); expect(issueTypes.some(msg => msg.includes('broken.example'))).toBe(true); expect(issueTypes.some(msg => msg.includes('missing'))).toBe(true); }); }); describe('helper methods', () => { it('should normalize anchors correctly', () => { const validator = new LinkValidator(); // Access private method through type assertion for testing const normalize = validator.normalizeAnchor.bind(validator); expect(normalize('Installation Guide')).toBe('installation-guide'); expect(normalize('API & SDK')).toBe('api-sdk'); expect(normalize('Getting Started!')).toBe('getting-started'); expect(normalize(' Multiple Spaces ')).toBe('multiple-spaces'); }); it('should extract headings correctly', () => { const content = ` # Main Title Some content here. ## Secondary Heading ### Tertiary Heading ### #### Another Heading `.trim(); const validator = new LinkValidator(); const headings = validator.extractHeadings(content); expect(headings).toEqual([ 'Main Title', 'Secondary Heading', 'Tertiary Heading', 'Another Heading' ]); }); }); describe('edge cases', () => { it('should handle empty documentation files', async () => { const docPath = path.join(tempDir, 'empty.md'); fs.writeFileSync(docPath, ''); const issues = await validator.validateDocumentationFile(docPath); expect(issues).toHaveLength(0); }); it('should handle files with no links', async () => { const docContent = ` # Pure Text Document This document contains no links. Just some regular text and explanations. `.trim(); const docPath = path.join(tempDir, 'no-links.md'); fs.writeFileSync(docPath, docContent); const issues = await validator.validateDocumentationFile(docPath); expect(issues).toHaveLength(0); }); it('should handle file reading errors gracefully', async () => { const nonExistentDoc = path.join(tempDir, 'missing-doc.md'); const issues = await validator.validateDocumentationFile(nonExistentDoc); expect(issues).toHaveLength(1); expect(issues[0].type).toBe('invalid_path'); expect(issues[0].severity).toBe('error'); }); }); }); //# sourceMappingURL=linkValidator.test.js.map