Files
repomix-mirror/tests/cli/actions/defaultAction.test.ts
Kazuki Yamada cb00a7cbc0 test(core): Add test coverage for progressCallback in runDefaultAction
Add three test cases for handleProgress:
- Callback is invoked with progress messages from pack()
- Async callback rejection is isolated (pack completes, error logged)
- Spinner still updates when callback throws synchronously

Also restore the outer try-catch for sync errors — Promise.resolve()
only catches async rejections, not synchronous throws from the
callback invocation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 21:56:15 +09:00

429 lines
13 KiB
TypeScript

import process from 'node:process';
import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest';
import { buildCliConfig, runDefaultAction } from '../../../src/cli/actions/defaultAction.js';
import type { CliOptions } from '../../../src/cli/types.js';
import * as configLoader from '../../../src/config/configLoad.js';
import * as fileStdin from '../../../src/core/file/fileStdin.js';
import * as packageJsonParser from '../../../src/core/file/packageJsonParse.js';
import * as packager from '../../../src/core/packager.js';
import * as loggerModule from '../../../src/shared/logger.js';
import { createMockConfig } from '../../testing/testUtils.js';
vi.mock('../../../src/core/packager');
vi.mock('../../../src/config/configLoad');
vi.mock('../../../src/core/file/packageJsonParse');
vi.mock('../../../src/core/file/fileStdin');
vi.mock('../../../src/shared/logger');
const mockSpinner = {
start: vi.fn() as MockedFunction<() => void>,
update: vi.fn() as MockedFunction<(message: string) => void>,
succeed: vi.fn() as MockedFunction<(message: string) => void>,
fail: vi.fn() as MockedFunction<(message: string) => void>,
};
vi.mock('../../../src/cli/cliSpinner', () => {
const MockSpinner = class {
start = mockSpinner.start;
update = mockSpinner.update;
succeed = mockSpinner.succeed;
fail = mockSpinner.fail;
};
return { Spinner: MockSpinner };
});
vi.mock('../../../src/cli/cliReport');
describe('defaultAction', () => {
beforeEach(() => {
vi.resetAllMocks();
// Reset mockSpinner functions
vi.clearAllMocks();
vi.mocked(packageJsonParser.getVersion).mockResolvedValue('1.0.0');
vi.mocked(configLoader.loadFileConfig).mockResolvedValue({});
vi.mocked(configLoader.mergeConfigs).mockReturnValue(
createMockConfig({
cwd: process.cwd(),
input: {
maxFileSize: 50 * 1024 * 1024,
},
output: {
filePath: 'output.txt',
style: 'plain',
parsableStyle: false,
fileSummary: true,
directoryStructure: true,
topFilesLength: 5,
showLineNumbers: false,
removeComments: false,
removeEmptyLines: false,
compress: false,
copyToClipboard: false,
stdout: false,
git: {
sortByChanges: true,
sortByChangesMaxCommits: 100,
includeDiffs: false,
},
files: true,
},
ignore: {
useGitignore: true,
useDefaultPatterns: true,
customPatterns: [],
},
include: [],
security: {
enableSecurityCheck: true,
},
tokenCount: {
encoding: 'o200k_base',
},
}),
);
vi.mocked(fileStdin.readFilePathsFromStdin).mockResolvedValue({
filePaths: ['test1.txt', 'test2.txt'],
emptyDirPaths: [],
});
vi.mocked(packager.pack).mockResolvedValue({
totalFiles: 10,
totalCharacters: 1000,
totalTokens: 200,
fileCharCounts: {},
fileTokenCounts: {},
suspiciousFilesResults: [],
suspiciousGitDiffResults: [],
suspiciousGitLogResults: [],
processedFiles: [],
safeFilePaths: [],
gitDiffTokenCount: 0,
gitLogTokenCount: 0,
skippedFiles: [],
});
});
afterEach(() => {
vi.resetAllMocks();
});
it('should run the default command successfully', async () => {
const options: CliOptions = {
output: 'custom-output.txt',
verbose: true,
};
await runDefaultAction(['.'], process.cwd(), options);
expect(packager.pack).toHaveBeenCalled();
expect(mockSpinner.start).toHaveBeenCalled();
expect(mockSpinner.succeed).toHaveBeenCalled();
});
it('should handle custom include patterns', async () => {
const options: CliOptions = {
include: '*.js,*.ts',
};
await runDefaultAction(['.'], process.cwd(), options);
expect(packager.pack).toHaveBeenCalled();
});
it('should handle stdin mode', async () => {
const options: CliOptions = {
stdin: true,
};
await runDefaultAction(['.'], process.cwd(), options);
expect(fileStdin.readFilePathsFromStdin).toHaveBeenCalledWith(process.cwd());
expect(packager.pack).toHaveBeenCalledWith(
[process.cwd()],
expect.any(Object),
expect.any(Function),
{},
['test1.txt', 'test2.txt'],
expect.any(Object),
);
});
it('should handle errors gracefully', async () => {
vi.mocked(packager.pack).mockRejectedValue(new Error('Test error'));
const options: CliOptions = {};
await expect(runDefaultAction(['.'], process.cwd(), options)).rejects.toThrow('Test error');
expect(mockSpinner.fail).toHaveBeenCalledWith('Error during packing');
});
describe('progressCallback', () => {
it('should forward progress messages to the provided callback', async () => {
// Configure pack mock to invoke its 3rd argument (progressCallback)
vi.mocked(packager.pack).mockImplementation(async (_paths, _config, progressCallback = () => {}) => {
progressCallback('Searching for files...');
progressCallback('Processing files...');
return {
totalFiles: 10,
totalCharacters: 1000,
totalTokens: 200,
fileCharCounts: {},
fileTokenCounts: {},
suspiciousFilesResults: [],
suspiciousGitDiffResults: [],
suspiciousGitLogResults: [],
processedFiles: [],
safeFilePaths: [],
gitDiffTokenCount: 0,
gitLogTokenCount: 0,
skippedFiles: [],
};
});
const callback = vi.fn();
await runDefaultAction(['.'], process.cwd(), {}, callback);
expect(callback).toHaveBeenCalledWith('Searching for files...');
expect(callback).toHaveBeenCalledWith('Processing files...');
});
it('should isolate async callback errors without affecting pack flow', async () => {
vi.mocked(packager.pack).mockImplementation(async (_paths, _config, progressCallback = () => {}) => {
progressCallback('test message');
// Allow microtask to process the rejected promise
await new Promise((resolve) => setTimeout(resolve, 10));
return {
totalFiles: 10,
totalCharacters: 1000,
totalTokens: 200,
fileCharCounts: {},
fileTokenCounts: {},
suspiciousFilesResults: [],
suspiciousGitDiffResults: [],
suspiciousGitLogResults: [],
processedFiles: [],
safeFilePaths: [],
gitDiffTokenCount: 0,
gitLogTokenCount: 0,
skippedFiles: [],
};
});
const rejectingCallback = vi.fn().mockRejectedValue(new Error('callback error'));
const result = await runDefaultAction(['.'], process.cwd(), {}, rejectingCallback);
expect(result.packResult.totalFiles).toBe(10);
expect(loggerModule.logger.trace).toHaveBeenCalledWith('progressCallback error:', expect.any(Error));
});
it('should still update spinner even when callback throws synchronously', async () => {
vi.mocked(packager.pack).mockImplementation(async (_paths, _config, progressCallback = () => {}) => {
progressCallback('test message');
return {
totalFiles: 10,
totalCharacters: 1000,
totalTokens: 200,
fileCharCounts: {},
fileTokenCounts: {},
suspiciousFilesResults: [],
suspiciousGitDiffResults: [],
suspiciousGitLogResults: [],
processedFiles: [],
safeFilePaths: [],
gitDiffTokenCount: 0,
gitLogTokenCount: 0,
skippedFiles: [],
};
});
const throwingCallback = vi.fn().mockImplementation(() => {
throw new Error('sync error');
});
await runDefaultAction(['.'], process.cwd(), {}, throwingCallback);
// Spinner should still be updated despite callback failure
expect(mockSpinner.update).toHaveBeenCalledWith('test message');
});
});
describe('buildCliConfig', () => {
it('should handle custom include patterns', () => {
const options = {
include: '*.js,*.ts',
};
const config = buildCliConfig(options);
expect(config.include).toEqual(['*.js', '*.ts']);
});
it('should handle custom ignore patterns', () => {
const options = {
ignore: 'node_modules,*.log',
};
const config = buildCliConfig(options);
expect(config.ignore?.customPatterns).toEqual(['node_modules', '*.log']);
});
it('should handle custom output style', () => {
const options: CliOptions = {
style: 'xml' as const,
};
const config = buildCliConfig(options);
expect(config.output?.style).toBe('xml');
});
it('should properly trim whitespace from comma-separated patterns', () => {
const options = {
include: 'src/**/*, tests/**/*, examples/**/*',
ignore: 'node_modules/**, dist/**, coverage/**',
};
const config = buildCliConfig(options);
expect(config.include).toEqual(['src/**/*', 'tests/**/*', 'examples/**/*']);
expect(config.ignore?.customPatterns).toEqual(['node_modules/**', 'dist/**', 'coverage/**']);
});
it('should handle --no-security-check flag', () => {
const options = {
securityCheck: false,
};
const config = buildCliConfig(options);
expect(config.security?.enableSecurityCheck).toBe(false);
});
it('should handle --no-file-summary flag', () => {
const options = {
fileSummary: false,
};
const config = buildCliConfig(options);
expect(config.output?.fileSummary).toBe(false);
});
it('should handle --remove-comments flag', () => {
const options = {
removeComments: true,
};
const config = buildCliConfig(options);
expect(config.output?.removeComments).toBe(true);
});
it('should handle --no-gitignore flag', () => {
const options = {
gitignore: false,
};
const config = buildCliConfig(options);
expect(config.ignore?.useGitignore).toBe(false);
});
it('should handle --no-dot-ignore flag', () => {
const options = {
dotIgnore: false,
};
const config = buildCliConfig(options);
expect(config.ignore?.useDotIgnore).toBe(false);
});
it('should handle --no-default-patterns flag', () => {
const options = {
defaultPatterns: false,
};
const config = buildCliConfig(options);
expect(config.ignore?.useDefaultPatterns).toBe(false);
});
it('should handle --skill-generate with string name', () => {
const options: CliOptions = {
skillGenerate: 'my-skill',
};
const config = buildCliConfig(options);
expect(config.skillGenerate).toBe('my-skill');
});
it('should handle --skill-generate without name (boolean true)', () => {
const options: CliOptions = {
skillGenerate: true,
};
const config = buildCliConfig(options);
expect(config.skillGenerate).toBe(true);
});
});
describe('skill-generate validation', () => {
it('should throw error when --skill-generate is used with --stdout', async () => {
vi.mocked(configLoader.mergeConfigs).mockReturnValue(
createMockConfig({
cwd: process.cwd(),
skillGenerate: 'my-skill',
output: {
stdout: true,
filePath: 'output.txt',
style: 'plain',
parsableStyle: false,
fileSummary: true,
directoryStructure: true,
topFilesLength: 5,
showLineNumbers: false,
removeComments: false,
removeEmptyLines: false,
compress: false,
copyToClipboard: false,
files: true,
},
}),
);
const options: CliOptions = {
skillGenerate: 'my-skill',
stdout: true,
};
await expect(runDefaultAction(['.'], process.cwd(), options)).rejects.toThrow(
'--skill-generate cannot be used with --stdout',
);
});
it('should throw error when --skill-generate is used with --copy', async () => {
vi.mocked(configLoader.mergeConfigs).mockReturnValue(
createMockConfig({
cwd: process.cwd(),
skillGenerate: 'my-skill',
output: {
copyToClipboard: true,
stdout: false,
filePath: 'output.txt',
style: 'plain',
parsableStyle: false,
fileSummary: true,
directoryStructure: true,
topFilesLength: 5,
showLineNumbers: false,
removeComments: false,
removeEmptyLines: false,
compress: false,
files: true,
},
}),
);
const options: CliOptions = {
skillGenerate: 'my-skill',
copy: true,
};
await expect(runDefaultAction(['.'], process.cwd(), options)).rejects.toThrow(
'--skill-generate cannot be used with --copy',
);
});
});
});