mirror of
https://github.com/yamadashy/repomix.git
synced 2026-05-30 11:18:53 +02:00
6c3ea34ca4
Add assertions that mockDeps.cancel is called with the expected message in all three cancellation test cases for promptSkillLocation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
245 lines
9.1 KiB
TypeScript
245 lines
9.1 KiB
TypeScript
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, expect, test, vi } from 'vitest';
|
|
import {
|
|
getSkillBaseDir,
|
|
getSkillLocation,
|
|
prepareSkillDir,
|
|
promptSkillLocation,
|
|
resolveAndPrepareSkillDir,
|
|
} from '../../../src/cli/prompts/skillPrompts.js';
|
|
import { OperationCancelledError, RepomixError } from '../../../src/shared/errorHandle.js';
|
|
|
|
// Helper to create mock deps with proper typing
|
|
const createMockDeps = (overrides: {
|
|
selectValue: unknown;
|
|
confirmValue?: unknown;
|
|
isCancelFn: (value: unknown) => boolean;
|
|
accessRejects: boolean;
|
|
}) => ({
|
|
select: vi.fn().mockResolvedValue(overrides.selectValue),
|
|
confirm: vi.fn().mockResolvedValue(overrides.confirmValue),
|
|
isCancel: overrides.isCancelFn as (value: unknown) => value is symbol,
|
|
cancel: vi.fn(),
|
|
access: overrides.accessRejects
|
|
? vi.fn().mockRejectedValue(new Error('ENOENT'))
|
|
: vi.fn().mockResolvedValue(undefined),
|
|
rm: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
describe('skillPrompts', () => {
|
|
describe('getSkillBaseDir', () => {
|
|
test('should return personal skills directory for personal location', () => {
|
|
const result = getSkillBaseDir('/test/project', 'personal');
|
|
expect(result).toBe(path.join(os.homedir(), '.claude', 'skills'));
|
|
});
|
|
|
|
test('should return project skills directory for project location', () => {
|
|
const result = getSkillBaseDir('/test/project', 'project');
|
|
expect(result).toBe(path.join('/test/project', '.claude', 'skills'));
|
|
});
|
|
});
|
|
|
|
describe('promptSkillLocation', () => {
|
|
test('should return personal location when selected', async () => {
|
|
const mockDeps = createMockDeps({
|
|
selectValue: 'personal',
|
|
isCancelFn: () => false,
|
|
accessRejects: true,
|
|
});
|
|
|
|
const result = await promptSkillLocation('test-skill', '/test/project', mockDeps);
|
|
|
|
expect(result.location).toBe('personal');
|
|
expect(result.skillDir).toBe(path.join(os.homedir(), '.claude', 'skills', 'test-skill'));
|
|
expect(mockDeps.select).toHaveBeenCalledOnce();
|
|
expect(mockDeps.confirm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should return project location when selected', async () => {
|
|
const mockDeps = createMockDeps({
|
|
selectValue: 'project',
|
|
isCancelFn: () => false,
|
|
accessRejects: true,
|
|
});
|
|
|
|
const result = await promptSkillLocation('test-skill', '/test/project', mockDeps);
|
|
|
|
expect(result.location).toBe('project');
|
|
expect(result.skillDir).toBe(path.join('/test/project', '.claude', 'skills', 'test-skill'));
|
|
});
|
|
|
|
test('should prompt for overwrite when directory exists', async () => {
|
|
const mockDeps = createMockDeps({
|
|
selectValue: 'personal',
|
|
confirmValue: true,
|
|
isCancelFn: () => false,
|
|
accessRejects: false, // Directory exists
|
|
});
|
|
|
|
const result = await promptSkillLocation('test-skill', '/test/project', mockDeps);
|
|
|
|
expect(mockDeps.confirm).toHaveBeenCalledOnce();
|
|
expect(mockDeps.rm).toHaveBeenCalledOnce();
|
|
expect(result.location).toBe('personal');
|
|
});
|
|
|
|
test('should throw OperationCancelledError when select is cancelled', async () => {
|
|
const mockDeps = createMockDeps({
|
|
selectValue: Symbol('cancel'),
|
|
isCancelFn: () => true,
|
|
accessRejects: true,
|
|
});
|
|
|
|
await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow(
|
|
OperationCancelledError,
|
|
);
|
|
expect(mockDeps.cancel).toHaveBeenCalledWith('Skill generation cancelled.');
|
|
});
|
|
|
|
test('should throw OperationCancelledError when overwrite is declined', async () => {
|
|
const mockDeps = createMockDeps({
|
|
selectValue: 'personal',
|
|
confirmValue: false,
|
|
isCancelFn: () => false,
|
|
accessRejects: false, // Directory exists
|
|
});
|
|
|
|
await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow(
|
|
OperationCancelledError,
|
|
);
|
|
expect(mockDeps.cancel).toHaveBeenCalledWith('Skill generation cancelled.');
|
|
});
|
|
|
|
test('should throw OperationCancelledError when confirm is cancelled', async () => {
|
|
let callCount = 0;
|
|
const mockDeps = createMockDeps({
|
|
selectValue: 'personal',
|
|
confirmValue: Symbol('cancel'),
|
|
isCancelFn: () => {
|
|
callCount++;
|
|
// First call for select returns false, second call for confirm returns true (cancelled)
|
|
return callCount > 1;
|
|
},
|
|
accessRejects: false, // Directory exists
|
|
});
|
|
|
|
await expect(promptSkillLocation('test-skill', '/test/project', mockDeps)).rejects.toThrow(
|
|
OperationCancelledError,
|
|
);
|
|
expect(mockDeps.cancel).toHaveBeenCalledWith('Skill generation cancelled.');
|
|
});
|
|
});
|
|
|
|
describe('prepareSkillDir', () => {
|
|
const createMockStats = (isDir: boolean) => ({
|
|
isDirectory: () => isDir,
|
|
isFile: () => !isDir,
|
|
});
|
|
|
|
test('should succeed when directory does not exist', async () => {
|
|
const enoentError = new Error('ENOENT') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
const mockDeps = {
|
|
access: vi.fn().mockRejectedValue(enoentError),
|
|
rm: vi.fn(),
|
|
stat: vi.fn(),
|
|
};
|
|
|
|
await expect(prepareSkillDir('/test/skill-dir', false, mockDeps)).resolves.toBeUndefined();
|
|
expect(mockDeps.rm).not.toHaveBeenCalled();
|
|
expect(mockDeps.stat).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should throw RepomixError when directory exists and force is false', async () => {
|
|
const mockDeps = {
|
|
access: vi.fn().mockResolvedValue(undefined),
|
|
rm: vi.fn(),
|
|
stat: vi.fn().mockResolvedValue(createMockStats(true)),
|
|
};
|
|
|
|
await expect(prepareSkillDir('/test/skill-dir', false, mockDeps)).rejects.toThrow(RepomixError);
|
|
await expect(prepareSkillDir('/test/skill-dir', false, mockDeps)).rejects.toThrow(
|
|
'Skill directory already exists: /test/skill-dir. Use --force to overwrite.',
|
|
);
|
|
expect(mockDeps.rm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should remove directory when force is true and directory exists', async () => {
|
|
const mockDeps = {
|
|
access: vi.fn().mockResolvedValue(undefined),
|
|
rm: vi.fn().mockResolvedValue(undefined),
|
|
stat: vi.fn().mockResolvedValue(createMockStats(true)),
|
|
};
|
|
|
|
await expect(prepareSkillDir('/test/skill-dir', true, mockDeps)).resolves.toBeUndefined();
|
|
expect(mockDeps.rm).toHaveBeenCalledWith('/test/skill-dir', { recursive: true, force: true });
|
|
});
|
|
|
|
test('should throw RepomixError when path exists but is a file', async () => {
|
|
const mockDeps = {
|
|
access: vi.fn().mockResolvedValue(undefined),
|
|
rm: vi.fn(),
|
|
stat: vi.fn().mockResolvedValue(createMockStats(false)),
|
|
};
|
|
|
|
await expect(prepareSkillDir('/test/config.json', false, mockDeps)).rejects.toThrow(RepomixError);
|
|
await expect(prepareSkillDir('/test/config.json', false, mockDeps)).rejects.toThrow(
|
|
'Skill output path exists but is not a directory: /test/config.json',
|
|
);
|
|
expect(mockDeps.rm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should throw RepomixError when path is a file even with force flag', async () => {
|
|
const mockDeps = {
|
|
access: vi.fn().mockResolvedValue(undefined),
|
|
rm: vi.fn(),
|
|
stat: vi.fn().mockResolvedValue(createMockStats(false)),
|
|
};
|
|
|
|
await expect(prepareSkillDir('/test/config.json', true, mockDeps)).rejects.toThrow(RepomixError);
|
|
await expect(prepareSkillDir('/test/config.json', true, mockDeps)).rejects.toThrow(
|
|
'Skill output path exists but is not a directory: /test/config.json',
|
|
);
|
|
expect(mockDeps.rm).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('should re-throw non-ENOENT errors', async () => {
|
|
const permissionError = new Error('EACCES') as NodeJS.ErrnoException;
|
|
permissionError.code = 'EACCES';
|
|
const mockDeps = {
|
|
access: vi.fn().mockRejectedValue(permissionError),
|
|
rm: vi.fn(),
|
|
stat: vi.fn(),
|
|
};
|
|
|
|
await expect(prepareSkillDir('/test/skill-dir', false, mockDeps)).rejects.toThrow(permissionError);
|
|
expect(mockDeps.rm).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('resolveAndPrepareSkillDir', () => {
|
|
test('should resolve relative path to absolute path', async () => {
|
|
const result = await resolveAndPrepareSkillDir('my-skill', '/test/project', false);
|
|
expect(result).toBe(path.resolve('/test/project', 'my-skill'));
|
|
});
|
|
|
|
test('should keep absolute path as is', async () => {
|
|
const result = await resolveAndPrepareSkillDir('/absolute/path/skill', '/test/project', false);
|
|
expect(result).toBe('/absolute/path/skill');
|
|
});
|
|
});
|
|
|
|
describe('getSkillLocation', () => {
|
|
test('should return personal for paths under ~/.claude/skills', () => {
|
|
const personalPath = path.join(os.homedir(), '.claude', 'skills', 'my-skill');
|
|
expect(getSkillLocation(personalPath)).toBe('personal');
|
|
});
|
|
|
|
test('should return project for paths not under ~/.claude/skills', () => {
|
|
expect(getSkillLocation('/project/.claude/skills/my-skill')).toBe('project');
|
|
expect(getSkillLocation('/some/other/path')).toBe('project');
|
|
});
|
|
});
|
|
});
|