import fs from 'node:fs/promises'; import process from 'node:process'; import { DOMParser } from '@xmldom/xmldom'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { ProcessedFile } from '../../../src/core/file/fileTypes.js'; import { buildOutputGeneratorContext, generateOutput } from '../../../src/core/output/outputGenerate.js'; import { RepomixError } from '../../../src/shared/errorHandle.js'; import { createMockConfig } from '../../testing/testUtils.js'; const createStrictXmlParser = () => { return new DOMParser({ onError: (_level, msg) => { throw new Error(msg); }, }); }; describe('outputGenerate', () => { const mockDeps = { buildOutputGeneratorContext: vi.fn(), generateHandlebarOutput: vi.fn(), generateParsableXmlOutput: vi.fn(), generateParsableJsonOutput: vi.fn(), sortOutputFiles: vi.fn(), }; test('generateOutput should use sortOutputFiles before generating content', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'plain', git: { sortByChanges: true }, }, }); const mockProcessedFiles: ProcessedFile[] = [ { path: 'file1.txt', content: 'content1' }, { path: 'file2.txt', content: 'content2' }, ]; const sortedFiles = [ { path: 'file2.txt', content: 'content2' }, { path: 'file1.txt', content: 'content1' }, ]; mockDeps.sortOutputFiles.mockResolvedValue(sortedFiles); mockDeps.buildOutputGeneratorContext.mockResolvedValue({ processedFiles: sortedFiles, config: mockConfig, treeString: '', generationDate: new Date().toISOString(), instruction: '', filesEnabled: true, }); mockDeps.generateHandlebarOutput.mockResolvedValue('mock output'); const output = await generateOutput( [process.cwd()], mockConfig, mockProcessedFiles, [], undefined, undefined, undefined, undefined, mockDeps, ); expect(mockDeps.sortOutputFiles).toHaveBeenCalledWith(mockProcessedFiles, mockConfig); expect(mockDeps.buildOutputGeneratorContext).toHaveBeenCalledWith( [process.cwd()], mockConfig, [], sortedFiles, undefined, undefined, undefined, undefined, ); expect(output).toBe('mock output'); }); test('generateOutput should write correct content to file (plain style)', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'plain', topFilesLength: 2, showLineNumbers: false, removeComments: false, removeEmptyLines: false, }, }); const mockProcessedFiles: ProcessedFile[] = [ { path: 'file1.txt', content: 'content1' }, { path: 'dir/file2.txt', content: 'content2' }, ]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); expect(output).toContain('File Summary'); expect(output).toContain('File: file1.txt'); expect(output).toContain('content1'); expect(output).toContain('File: dir/file2.txt'); expect(output).toContain('content2'); }); test('generateOutput should write correct content to file (parsable xml style)', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'xml', parsableStyle: true, topFilesLength: 2, showLineNumbers: false, removeComments: false, removeEmptyLines: false, }, }); const mockProcessedFiles: ProcessedFile[] = [ { path: 'file1.txt', content: '
foo
' }, { path: 'dir/file2.txt', content: 'if (a && b)' }, ]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); const doc = createStrictXmlParser().parseFromString(output, 'text/xml'); const repomix = doc.documentElement; if (!repomix) throw new Error('documentElement is null'); expect(repomix.getElementsByTagName('file_summary').length).toBe(1); const fileElements = repomix.getElementsByTagName('file'); expect(fileElements.length).toBe(2); expect(fileElements[0].getAttribute('path')).toBe(mockProcessedFiles[0].path); expect(fileElements[0].textContent).toBe(mockProcessedFiles[0].content); expect(fileElements[1].getAttribute('path')).toBe(mockProcessedFiles[1].path); expect(fileElements[1].textContent).toBe(mockProcessedFiles[1].content); }); test('generateOutput should write correct content to file (parsable markdown style)', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'markdown', parsableStyle: true, topFilesLength: 2, showLineNumbers: false, removeComments: false, removeEmptyLines: false, }, }); const mockProcessedFiles: ProcessedFile[] = [ { path: 'file1.txt', content: 'content1' }, { path: 'dir/file2.txt', content: '```\ncontent2\n```' }, ]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); expect(output).toContain('# File Summary'); expect(output).toContain('## File: file1.txt'); expect(output).toContain('````\ncontent1\n````'); expect(output).toContain('## File: dir/file2.txt'); expect(output).toContain('````\n```\ncontent2\n```\n````'); }); test('generateOutput (txt) should omit generationHeader when fileSummaryEnabled is false, but always include headerText if provided', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'plain', fileSummary: false, headerText: 'ALWAYS SHOW THIS HEADER', }, }); const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); expect(output).not.toContain('This file is a merged representation'); // generationHeader expect(output).toContain('ALWAYS SHOW THIS HEADER'); }); test('generateOutput (xml) omits generationHeader when fileSummaryEnabled is false, but always includes headerText', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.xml', style: 'xml', fileSummary: false, headerText: 'XML HEADER', parsableStyle: true, }, }); const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: '
foo
' }]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); const doc = createStrictXmlParser().parseFromString(output, 'text/xml'); const repomix = doc.documentElement; if (!repomix) throw new Error('documentElement is null'); expect(repomix.getElementsByTagName('file_summary').length).toBe(0); const header = repomix.getElementsByTagName('user_provided_header')[0]; expect(header.textContent).toBe('XML HEADER'); }); test('generateOutput (markdown) omits generationHeader when fileSummaryEnabled is false, but always includes headerText', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.md', style: 'markdown', fileSummary: false, headerText: 'MARKDOWN HEADER', parsableStyle: false, }, }); const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); expect(output).not.toContain('This file is a merged representation'); expect(output).toContain('MARKDOWN HEADER'); }); test('generateOutput should include git diffs when enabled', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'plain', git: { includeDiffs: true }, }, }); const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }]; const mockGitDiffResult = { workTreeDiffContent: 'diff --git a/file.txt', stagedDiffContent: 'diff --git b/staged.txt', }; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, [], mockGitDiffResult); expect(output).toContain('Git Diffs'); expect(output).toContain('diff --git a/file.txt'); expect(output).toContain('diff --git b/staged.txt'); }); test('generateOutput should include git logs when enabled', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'plain', git: { includeLogs: true }, }, }); const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }]; const mockGitLogResult = { logContent: 'commit abc123\nAuthor: test', commits: [ { date: '2024-01-01', message: 'Initial commit', files: ['file1.txt'], }, ], }; const output = await generateOutput( [process.cwd()], mockConfig, mockProcessedFiles, [], undefined, mockGitLogResult, ); expect(output).toContain('Git Logs'); expect(output).toContain('Initial commit'); expect(output).toContain('2024-01-01'); }); test('generateOutput should write correct content to file (json style)', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.json', style: 'json', }, }); const mockProcessedFiles: ProcessedFile[] = [ { path: 'file1.txt', content: 'content1' }, { path: 'file2.txt', content: 'content2' }, ]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); const parsed = JSON.parse(output); expect(parsed).toHaveProperty('files'); expect(parsed.files).toHaveProperty('file1.txt'); expect(parsed.files).toHaveProperty('file2.txt'); expect(parsed.files['file1.txt']).toBe('content1'); expect(parsed.files['file2.txt']).toBe('content2'); }); test('generateOutput should exclude files section when files output is disabled', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'plain', files: false, }, }); const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, []); expect(output).not.toContain('File: file1.txt'); expect(output).not.toContain('content1'); }); test('generateOutput should exclude directory structure when disabled', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'plain', directoryStructure: false, }, }); const mockProcessedFiles: ProcessedFile[] = [{ path: 'file1.txt', content: 'content1' }]; const output = await generateOutput([process.cwd()], mockConfig, mockProcessedFiles, ['file1.txt']); expect(output).not.toContain('Directory Structure'); }); test('generateOutput throws RepomixError for unsupported style', async () => { const mockConfig = createMockConfig({ output: { filePath: 'output.txt', style: 'unsupported' as 'plain' }, }); await expect(generateOutput([process.cwd()], mockConfig, [], [])).rejects.toBeInstanceOf(RepomixError); }); }); describe('buildOutputGeneratorContext', () => { afterEach(() => { vi.restoreAllMocks(); }); const baseConfig = (overrides = {}) => createMockConfig({ cwd: '/repo', output: { filePath: 'output.txt', style: 'plain', directoryStructure: false, }, ...overrides, }); test('reads the instruction file when configured', async () => { vi.spyOn(fs, 'readFile').mockResolvedValue('be helpful'); const config = baseConfig({ output: { filePath: 'output.txt', style: 'plain', directoryStructure: false, instructionFilePath: 'INSTRUCTIONS.md', }, }); const ctx = await buildOutputGeneratorContext(['/repo'], config, [], []); expect(ctx.instruction).toBe('be helpful'); }); test('throws RepomixError when instruction file is missing', async () => { vi.spyOn(fs, 'readFile').mockRejectedValue(new Error('ENOENT')); const config = baseConfig({ output: { filePath: 'output.txt', style: 'plain', directoryStructure: false, instructionFilePath: 'missing.md', }, }); const promise = buildOutputGeneratorContext(['/repo'], config, [], []); await expect(promise).rejects.toBeInstanceOf(RepomixError); await expect(promise).rejects.toThrow(/Instruction file not found/); }); test('uses pre-computed emptyDirPaths when includeEmptyDirectories is on', async () => { const config = baseConfig({ output: { filePath: 'output.txt', style: 'plain', directoryStructure: true, includeEmptyDirectories: true, }, }); const searchFiles = vi.fn(); const ctx = await buildOutputGeneratorContext( ['/repo'], config, [], [], undefined, undefined, undefined, ['empty-dir'], { listDirectories: vi.fn(), listFiles: vi.fn(), searchFiles, }, ); // Pre-computed paths win — searchFiles should not be called. expect(searchFiles).not.toHaveBeenCalled(); expect(ctx.treeString).toContain('empty-dir'); }); test('falls back to searchFiles when emptyDirPaths is not provided', async () => { const config = baseConfig({ output: { filePath: 'output.txt', style: 'plain', directoryStructure: true, includeEmptyDirectories: true, }, }); const searchFiles = vi.fn().mockResolvedValue({ filePaths: [], emptyDirPaths: ['scanned-dir'] }); const ctx = await buildOutputGeneratorContext( ['/repo'], config, [], [], undefined, undefined, undefined, undefined, { listDirectories: vi.fn(), listFiles: vi.fn(), searchFiles, }, ); expect(searchFiles).toHaveBeenCalledWith('/repo', config); expect(ctx.treeString).toContain('scanned-dir'); }); test('wraps searchFiles failure in RepomixError', async () => { const config = baseConfig({ output: { filePath: 'output.txt', style: 'plain', directoryStructure: true, includeEmptyDirectories: true, }, }); const promise = buildOutputGeneratorContext(['/repo'], config, [], [], undefined, undefined, undefined, undefined, { listDirectories: vi.fn(), listFiles: vi.fn(), searchFiles: vi.fn().mockRejectedValue(new Error('boom')), }); await expect(promise).rejects.toBeInstanceOf(RepomixError); await expect(promise).rejects.toThrow(/Failed to search for empty directories.*boom/); }); test('includes the full directory tree when includeFullDirectoryStructure is on', async () => { const config = baseConfig({ include: ['src/**/*.ts'], output: { filePath: 'output.txt', style: 'plain', directoryStructure: true, includeFullDirectoryStructure: true, }, }); const listDirectories = vi.fn().mockResolvedValue(['src', 'src/feature']); const listFiles = vi.fn().mockResolvedValue(['src/index.ts', 'src/feature/extra.ts']); const ctx = await buildOutputGeneratorContext( ['/repo'], config, ['src/index.ts'], [], undefined, undefined, undefined, undefined, { listDirectories, listFiles, searchFiles: vi.fn(), }, ); expect(listDirectories).toHaveBeenCalledWith('/repo', config); expect(listFiles).toHaveBeenCalledWith('/repo', config); // The extra file from listFiles (not in allFilePaths) should be merged into the tree. expect(ctx.treeString).toContain('extra.ts'); }); test('wraps full-tree listing failure in RepomixError', async () => { const config = baseConfig({ include: ['src/**/*.ts'], output: { filePath: 'output.txt', style: 'plain', directoryStructure: true, includeFullDirectoryStructure: true, }, }); const promise = buildOutputGeneratorContext( ['/repo'], config, ['src/index.ts'], [], undefined, undefined, undefined, undefined, { listDirectories: vi.fn().mockRejectedValue(new Error('list failed')), listFiles: vi.fn().mockResolvedValue([]), searchFiles: vi.fn(), }, ); await expect(promise).rejects.toBeInstanceOf(RepomixError); await expect(promise).rejects.toThrow(/Failed to build full directory structure.*list failed/); }); });