feat: Enhanced project initialization with Git worktree detection (#743)
* Fix Cursor deeplink installation with copy-paste instructions (#723) * detect git worktree * add changeset * add aliases and git flags * add changeset * rename and update test * add store tasks in git functionality * update changeset * fix newline * remove unused import * update command wording * update command option text
This commit is contained in:
538
tests/unit/initialize-project.test.js
Normal file
538
tests/unit/initialize-project.test.js
Normal file
@@ -0,0 +1,538 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Reduce noise in test output
|
||||
process.env.TASKMASTER_LOG_LEVEL = 'error';
|
||||
|
||||
// === Mock everything early ===
|
||||
jest.mock('child_process', () => ({ execSync: jest.fn() }));
|
||||
jest.mock('fs', () => ({
|
||||
...jest.requireActual('fs'),
|
||||
mkdirSync: jest.fn(),
|
||||
writeFileSync: jest.fn(),
|
||||
readFileSync: jest.fn(),
|
||||
appendFileSync: jest.fn(),
|
||||
existsSync: jest.fn(),
|
||||
mkdtempSync: jest.requireActual('fs').mkdtempSync,
|
||||
rmSync: jest.requireActual('fs').rmSync
|
||||
}));
|
||||
|
||||
// Mock console methods to suppress output
|
||||
const consoleMethods = ['log', 'info', 'warn', 'error', 'clear'];
|
||||
consoleMethods.forEach((method) => {
|
||||
global.console[method] = jest.fn();
|
||||
});
|
||||
|
||||
// Mock ES modules using unstable_mockModule
|
||||
jest.unstable_mockModule('../../scripts/modules/utils.js', () => ({
|
||||
isSilentMode: jest.fn(() => true),
|
||||
enableSilentMode: jest.fn(),
|
||||
log: jest.fn(),
|
||||
findProjectRoot: jest.fn(() => process.cwd())
|
||||
}));
|
||||
|
||||
// Mock git-utils module
|
||||
jest.unstable_mockModule('../../scripts/modules/utils/git-utils.js', () => ({
|
||||
insideGitWorkTree: jest.fn(() => false)
|
||||
}));
|
||||
|
||||
// Mock rule transformer
|
||||
jest.unstable_mockModule('../../src/utils/rule-transformer.js', () => ({
|
||||
convertAllRulesToProfileRules: jest.fn(),
|
||||
getRulesProfile: jest.fn(() => ({
|
||||
conversionConfig: {},
|
||||
globalReplacements: []
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock any other modules that might output or do real operations
|
||||
jest.unstable_mockModule('../../scripts/modules/config-manager.js', () => ({
|
||||
createDefaultConfig: jest.fn(() => ({ models: {}, project: {} })),
|
||||
saveConfig: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock display libraries
|
||||
jest.mock('figlet', () => ({ textSync: jest.fn(() => 'MOCKED BANNER') }));
|
||||
jest.mock('boxen', () => jest.fn(() => 'MOCKED BOX'));
|
||||
jest.mock('gradient-string', () => jest.fn(() => jest.fn((text) => text)));
|
||||
jest.mock('chalk', () => ({
|
||||
blue: jest.fn((text) => text),
|
||||
green: jest.fn((text) => text),
|
||||
red: jest.fn((text) => text),
|
||||
yellow: jest.fn((text) => text),
|
||||
cyan: jest.fn((text) => text),
|
||||
white: jest.fn((text) => text),
|
||||
dim: jest.fn((text) => text),
|
||||
bold: jest.fn((text) => text),
|
||||
underline: jest.fn((text) => text)
|
||||
}));
|
||||
|
||||
const { execSync } = jest.requireMock('child_process');
|
||||
const mockFs = jest.requireMock('fs');
|
||||
|
||||
// Import the mocked modules
|
||||
const mockUtils = await import('../../scripts/modules/utils.js');
|
||||
const mockGitUtils = await import('../../scripts/modules/utils/git-utils.js');
|
||||
const mockRuleTransformer = await import('../../src/utils/rule-transformer.js');
|
||||
|
||||
// Import after mocks
|
||||
const { initializeProject } = await import('../../scripts/init.js');
|
||||
|
||||
describe('initializeProject – Git / Alias flag logic', () => {
|
||||
let tmpDir;
|
||||
const origCwd = process.cwd();
|
||||
|
||||
// Standard non-interactive options for all tests
|
||||
const baseOptions = {
|
||||
yes: true,
|
||||
skipInstall: true,
|
||||
name: 'test-project',
|
||||
description: 'Test project description',
|
||||
version: '1.0.0',
|
||||
author: 'Test Author'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Set up basic fs mocks
|
||||
mockFs.mkdirSync.mockImplementation(() => {});
|
||||
mockFs.writeFileSync.mockImplementation(() => {});
|
||||
mockFs.readFileSync.mockImplementation((filePath) => {
|
||||
if (filePath.includes('assets') || filePath.includes('.cursor/rules')) {
|
||||
return 'mock template content';
|
||||
}
|
||||
if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) {
|
||||
return '# existing config';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
mockFs.appendFileSync.mockImplementation(() => {});
|
||||
mockFs.existsSync.mockImplementation((filePath) => {
|
||||
// Template source files exist
|
||||
if (filePath.includes('assets') || filePath.includes('.cursor/rules')) {
|
||||
return true;
|
||||
}
|
||||
// Shell config files exist by default
|
||||
if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Reset utils mocks
|
||||
mockUtils.isSilentMode.mockReturnValue(true);
|
||||
mockGitUtils.insideGitWorkTree.mockReturnValue(false);
|
||||
|
||||
// Default execSync mock
|
||||
execSync.mockImplementation(() => '');
|
||||
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-init-'));
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(origCwd);
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Git Flag Behavior', () => {
|
||||
it('completes successfully with git:false in dry run', async () => {
|
||||
const result = await initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: true
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
it('completes successfully with git:true when not inside repo', async () => {
|
||||
mockGitUtils.insideGitWorkTree.mockReturnValue(false);
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: true,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('completes successfully when already inside repo', async () => {
|
||||
mockGitUtils.insideGitWorkTree.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: true,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('uses default git behavior without errors', async () => {
|
||||
mockGitUtils.insideGitWorkTree.mockReturnValue(false);
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles git command failures gracefully', async () => {
|
||||
mockGitUtils.insideGitWorkTree.mockReturnValue(false);
|
||||
execSync.mockImplementation((cmd) => {
|
||||
if (cmd.includes('git init')) {
|
||||
throw new Error('git not found');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: true,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alias Flag Behavior', () => {
|
||||
it('completes successfully when aliases:true and environment is set up', async () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
process.env.SHELL = '/bin/zsh';
|
||||
process.env.HOME = '/mock/home';
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: true,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
process.env.SHELL = originalShell;
|
||||
process.env.HOME = originalHome;
|
||||
});
|
||||
|
||||
it('completes successfully when aliases:false', async () => {
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles missing shell gracefully', async () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
delete process.env.SHELL; // Remove shell env var
|
||||
process.env.HOME = '/mock/home';
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: true,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
process.env.SHELL = originalShell;
|
||||
process.env.HOME = originalHome;
|
||||
});
|
||||
|
||||
it('handles missing shell config file gracefully', async () => {
|
||||
const originalShell = process.env.SHELL;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
process.env.SHELL = '/bin/zsh';
|
||||
process.env.HOME = '/mock/home';
|
||||
|
||||
// Shell config doesn't exist
|
||||
mockFs.existsSync.mockImplementation((filePath) => {
|
||||
if (filePath.includes('.zshrc') || filePath.includes('.bashrc')) {
|
||||
return false;
|
||||
}
|
||||
if (filePath.includes('assets') || filePath.includes('.cursor/rules')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: true,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
process.env.SHELL = originalShell;
|
||||
process.env.HOME = originalHome;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flag Combinations', () => {
|
||||
it.each`
|
||||
git | aliases | description
|
||||
${true} | ${true} | ${'git & aliases enabled'}
|
||||
${true} | ${false} | ${'git enabled, aliases disabled'}
|
||||
${false} | ${true} | ${'git disabled, aliases enabled'}
|
||||
${false} | ${false} | ${'git & aliases disabled'}
|
||||
`('handles $description without errors', async ({ git, aliases }) => {
|
||||
const originalShell = process.env.SHELL;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
if (aliases) {
|
||||
process.env.SHELL = '/bin/zsh';
|
||||
process.env.HOME = '/mock/home';
|
||||
}
|
||||
|
||||
if (git) {
|
||||
mockGitUtils.insideGitWorkTree.mockReturnValue(false);
|
||||
}
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git,
|
||||
aliases,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
process.env.SHELL = originalShell;
|
||||
process.env.HOME = originalHome;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dry Run Mode', () => {
|
||||
it('returns dry run result and performs no operations', async () => {
|
||||
const result = await initializeProject({
|
||||
...baseOptions,
|
||||
git: true,
|
||||
aliases: true,
|
||||
dryRun: true
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
it.each`
|
||||
git | aliases | description
|
||||
${true} | ${false} | ${'git-specific behavior'}
|
||||
${false} | ${false} | ${'no-git behavior'}
|
||||
${false} | ${true} | ${'alias behavior'}
|
||||
`('shows $description in dry run', async ({ git, aliases }) => {
|
||||
const result = await initializeProject({
|
||||
...baseOptions,
|
||||
git,
|
||||
aliases,
|
||||
dryRun: true
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('handles npm install failures gracefully', async () => {
|
||||
execSync.mockImplementation((cmd) => {
|
||||
if (cmd.includes('npm install')) {
|
||||
throw new Error('npm failed');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: false,
|
||||
skipInstall: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles git failures gracefully', async () => {
|
||||
mockGitUtils.insideGitWorkTree.mockReturnValue(false);
|
||||
execSync.mockImplementation((cmd) => {
|
||||
if (cmd.includes('git init')) {
|
||||
throw new Error('git failed');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: true,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles file system errors gracefully', async () => {
|
||||
mockFs.mkdirSync.mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
// Should handle file system errors gracefully
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-Interactive Mode', () => {
|
||||
it('bypasses prompts with yes:true', async () => {
|
||||
const result = await initializeProject({
|
||||
...baseOptions,
|
||||
git: true,
|
||||
aliases: true,
|
||||
dryRun: true
|
||||
});
|
||||
|
||||
expect(result).toEqual({ dryRun: true });
|
||||
});
|
||||
|
||||
it('completes without hanging', async () => {
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles all flag combinations without hanging', async () => {
|
||||
const flagCombinations = [
|
||||
{ git: true, aliases: true },
|
||||
{ git: true, aliases: false },
|
||||
{ git: false, aliases: true },
|
||||
{ git: false, aliases: false },
|
||||
{} // No flags (uses defaults)
|
||||
];
|
||||
|
||||
for (const flags of flagCombinations) {
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
...flags,
|
||||
dryRun: true // Use dry run for speed
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts complete project details', async () => {
|
||||
await expect(
|
||||
initializeProject({
|
||||
name: 'test-project',
|
||||
description: 'test description',
|
||||
version: '2.0.0',
|
||||
author: 'Test User',
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: true
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('works with skipInstall option', async () => {
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
skipInstall: true,
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Function Integration', () => {
|
||||
it('calls utility functions without errors', async () => {
|
||||
await initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
});
|
||||
|
||||
// Verify that utility functions were called
|
||||
expect(mockUtils.isSilentMode).toHaveBeenCalled();
|
||||
expect(
|
||||
mockRuleTransformer.convertAllRulesToProfileRules
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles template operations gracefully', async () => {
|
||||
// Make file operations throw errors
|
||||
mockFs.writeFileSync.mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
// Should complete despite file operation failures
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false,
|
||||
aliases: false,
|
||||
dryRun: false
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('validates boolean flag conversion', async () => {
|
||||
// Test the boolean flag handling specifically
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: true, // Should convert to initGit: true
|
||||
aliases: false, // Should convert to addAliases: false
|
||||
dryRun: true
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
initializeProject({
|
||||
...baseOptions,
|
||||
git: false, // Should convert to initGit: false
|
||||
aliases: true, // Should convert to addAliases: true
|
||||
dryRun: true
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
439
tests/unit/manage-gitignore.test.js
Normal file
439
tests/unit/manage-gitignore.test.js
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Unit tests for manage-gitignore.js module
|
||||
* Tests the logic with Jest spies instead of mocked modules
|
||||
*/
|
||||
|
||||
import { jest } from '@jest/globals';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Import the module under test and its exports
|
||||
import manageGitignoreFile, {
|
||||
normalizeLine,
|
||||
isTaskLine,
|
||||
buildTaskFilesSection,
|
||||
TASK_FILES_COMMENT,
|
||||
TASK_JSON_PATTERN,
|
||||
TASK_DIR_PATTERN
|
||||
} from '../../src/utils/manage-gitignore.js';
|
||||
|
||||
describe('manage-gitignore.js Unit Tests', () => {
|
||||
let tempDir;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create a temporary directory for testing
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'manage-gitignore-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up the temporary directory
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('Constants', () => {
|
||||
test('should have correct constant values', () => {
|
||||
expect(TASK_FILES_COMMENT).toBe('# Task files');
|
||||
expect(TASK_JSON_PATTERN).toBe('tasks.json');
|
||||
expect(TASK_DIR_PATTERN).toBe('tasks/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeLine function', () => {
|
||||
test('should remove leading/trailing whitespace', () => {
|
||||
expect(normalizeLine(' test ')).toBe('test');
|
||||
});
|
||||
|
||||
test('should remove comment hash and trim', () => {
|
||||
expect(normalizeLine('# tasks.json')).toBe('tasks.json');
|
||||
expect(normalizeLine('#tasks/')).toBe('tasks/');
|
||||
});
|
||||
|
||||
test('should handle empty strings', () => {
|
||||
expect(normalizeLine('')).toBe('');
|
||||
expect(normalizeLine(' ')).toBe('');
|
||||
});
|
||||
|
||||
test('should handle lines without comments', () => {
|
||||
expect(normalizeLine('tasks.json')).toBe('tasks.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTaskLine function', () => {
|
||||
test('should identify task.json patterns', () => {
|
||||
expect(isTaskLine('tasks.json')).toBe(true);
|
||||
expect(isTaskLine('# tasks.json')).toBe(true);
|
||||
expect(isTaskLine(' # tasks.json ')).toBe(true);
|
||||
});
|
||||
|
||||
test('should identify tasks/ patterns', () => {
|
||||
expect(isTaskLine('tasks/')).toBe(true);
|
||||
expect(isTaskLine('# tasks/')).toBe(true);
|
||||
expect(isTaskLine(' # tasks/ ')).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject non-task patterns', () => {
|
||||
expect(isTaskLine('node_modules/')).toBe(false);
|
||||
expect(isTaskLine('# Some comment')).toBe(false);
|
||||
expect(isTaskLine('')).toBe(false);
|
||||
expect(isTaskLine('tasks.txt')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTaskFilesSection function', () => {
|
||||
test('should build commented section when storeTasksInGit is true (tasks stored in git)', () => {
|
||||
const result = buildTaskFilesSection(true);
|
||||
expect(result).toEqual(['# Task files', '# tasks.json', '# tasks/ ']);
|
||||
});
|
||||
|
||||
test('should build uncommented section when storeTasksInGit is false (tasks ignored)', () => {
|
||||
const result = buildTaskFilesSection(false);
|
||||
expect(result).toEqual(['# Task files', 'tasks.json', 'tasks/ ']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manageGitignoreFile function - Input Validation', () => {
|
||||
test('should throw error for invalid targetPath', () => {
|
||||
expect(() => {
|
||||
manageGitignoreFile('', 'content', false);
|
||||
}).toThrow('targetPath must be a non-empty string');
|
||||
|
||||
expect(() => {
|
||||
manageGitignoreFile(null, 'content', false);
|
||||
}).toThrow('targetPath must be a non-empty string');
|
||||
|
||||
expect(() => {
|
||||
manageGitignoreFile('invalid.txt', 'content', false);
|
||||
}).toThrow('targetPath must end with .gitignore');
|
||||
});
|
||||
|
||||
test('should throw error for invalid content', () => {
|
||||
expect(() => {
|
||||
manageGitignoreFile('.gitignore', '', false);
|
||||
}).toThrow('content must be a non-empty string');
|
||||
|
||||
expect(() => {
|
||||
manageGitignoreFile('.gitignore', null, false);
|
||||
}).toThrow('content must be a non-empty string');
|
||||
});
|
||||
|
||||
test('should throw error for invalid storeTasksInGit', () => {
|
||||
expect(() => {
|
||||
manageGitignoreFile('.gitignore', 'content', 'not-boolean');
|
||||
}).toThrow('storeTasksInGit must be a boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manageGitignoreFile function - File Operations with Spies', () => {
|
||||
let writeFileSyncSpy;
|
||||
let readFileSyncSpy;
|
||||
let existsSyncSpy;
|
||||
let mockLog;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up spies
|
||||
writeFileSyncSpy = jest
|
||||
.spyOn(fs, 'writeFileSync')
|
||||
.mockImplementation(() => {});
|
||||
readFileSyncSpy = jest
|
||||
.spyOn(fs, 'readFileSync')
|
||||
.mockImplementation(() => '');
|
||||
existsSyncSpy = jest
|
||||
.spyOn(fs, 'existsSync')
|
||||
.mockImplementation(() => false);
|
||||
mockLog = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original implementations
|
||||
writeFileSyncSpy.mockRestore();
|
||||
readFileSyncSpy.mockRestore();
|
||||
existsSyncSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('New File Creation', () => {
|
||||
const templateContent = `# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/ `;
|
||||
|
||||
test('should create new file with commented task lines when storeTasksInGit is true', () => {
|
||||
existsSyncSpy.mockReturnValue(false); // File doesn't exist
|
||||
|
||||
manageGitignoreFile('.gitignore', templateContent, true, mockLog);
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
||||
'.gitignore',
|
||||
`# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Task files
|
||||
# tasks.json
|
||||
# tasks/ `
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'success',
|
||||
'Created .gitignore with full template'
|
||||
);
|
||||
});
|
||||
|
||||
test('should create new file with uncommented task lines when storeTasksInGit is false', () => {
|
||||
existsSyncSpy.mockReturnValue(false); // File doesn't exist
|
||||
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
||||
'.gitignore',
|
||||
`# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/ `
|
||||
);
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'success',
|
||||
'Created .gitignore with full template'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle write errors gracefully', () => {
|
||||
existsSyncSpy.mockReturnValue(false);
|
||||
const writeError = new Error('Permission denied');
|
||||
writeFileSyncSpy.mockImplementation(() => {
|
||||
throw writeError;
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
}).toThrow('Permission denied');
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to create .gitignore: Permission denied'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Merging', () => {
|
||||
const templateContent = `# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/ `;
|
||||
|
||||
test('should merge with existing file and add new content', () => {
|
||||
const existingContent = `# Old content
|
||||
old-file.txt
|
||||
|
||||
# Task files
|
||||
# tasks.json
|
||||
# tasks/`;
|
||||
|
||||
existsSyncSpy.mockReturnValue(true); // File exists
|
||||
readFileSyncSpy.mockReturnValue(existingContent);
|
||||
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
||||
'.gitignore',
|
||||
expect.stringContaining('# Old content')
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
||||
'.gitignore',
|
||||
expect.stringContaining('# Logs')
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
||||
'.gitignore',
|
||||
expect.stringContaining('# Dependencies')
|
||||
);
|
||||
expect(writeFileSyncSpy).toHaveBeenCalledWith(
|
||||
'.gitignore',
|
||||
expect.stringContaining('# Task files')
|
||||
);
|
||||
});
|
||||
|
||||
test('should remove existing task section and replace with new preferences', () => {
|
||||
const existingContent = `# Existing
|
||||
existing.txt
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/
|
||||
|
||||
# More content
|
||||
more.txt`;
|
||||
|
||||
existsSyncSpy.mockReturnValue(true);
|
||||
readFileSyncSpy.mockReturnValue(existingContent);
|
||||
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
|
||||
const writtenContent = writeFileSyncSpy.mock.calls[0][1];
|
||||
|
||||
// Should contain existing non-task content
|
||||
expect(writtenContent).toContain('# Existing');
|
||||
expect(writtenContent).toContain('existing.txt');
|
||||
expect(writtenContent).toContain('# More content');
|
||||
expect(writtenContent).toContain('more.txt');
|
||||
|
||||
// Should contain new template content
|
||||
expect(writtenContent).toContain('# Logs');
|
||||
expect(writtenContent).toContain('# Dependencies');
|
||||
|
||||
// Should have uncommented task lines (storeTasksInGit = false means ignore tasks)
|
||||
expect(writtenContent).toMatch(
|
||||
/# Task files\s*[\r\n]+tasks\.json\s*[\r\n]+tasks\/ /
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle different task preferences correctly', () => {
|
||||
const existingContent = `# Existing
|
||||
existing.txt
|
||||
|
||||
# Task files
|
||||
# tasks.json
|
||||
# tasks/`;
|
||||
|
||||
existsSyncSpy.mockReturnValue(true);
|
||||
readFileSyncSpy.mockReturnValue(existingContent);
|
||||
|
||||
// Test with storeTasksInGit = true (commented)
|
||||
manageGitignoreFile('.gitignore', templateContent, true, mockLog);
|
||||
|
||||
const writtenContent = writeFileSyncSpy.mock.calls[0][1];
|
||||
expect(writtenContent).toMatch(
|
||||
/# Task files\s*[\r\n]+# tasks\.json\s*[\r\n]+# tasks\/ /
|
||||
);
|
||||
});
|
||||
|
||||
test('should not duplicate existing template content', () => {
|
||||
const existingContent = `# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Task files
|
||||
# tasks.json
|
||||
# tasks/`;
|
||||
|
||||
existsSyncSpy.mockReturnValue(true);
|
||||
readFileSyncSpy.mockReturnValue(existingContent);
|
||||
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
|
||||
const writtenContent = writeFileSyncSpy.mock.calls[0][1];
|
||||
|
||||
// Should not duplicate the logs section
|
||||
const logsCount = (writtenContent.match(/# Logs/g) || []).length;
|
||||
expect(logsCount).toBe(1);
|
||||
|
||||
// Should not duplicate dependencies
|
||||
const depsCount = (writtenContent.match(/# Dependencies/g) || [])
|
||||
.length;
|
||||
expect(depsCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should handle read errors gracefully', () => {
|
||||
existsSyncSpy.mockReturnValue(true);
|
||||
const readError = new Error('File not readable');
|
||||
readFileSyncSpy.mockImplementation(() => {
|
||||
throw readError;
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
}).toThrow('File not readable');
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to merge content with .gitignore: File not readable'
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle write errors during merge gracefully', () => {
|
||||
existsSyncSpy.mockReturnValue(true);
|
||||
readFileSyncSpy.mockReturnValue('existing content');
|
||||
|
||||
const writeError = new Error('Disk full');
|
||||
writeFileSyncSpy.mockImplementation(() => {
|
||||
throw writeError;
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
}).toThrow('Disk full');
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'Failed to merge content with .gitignore: Disk full'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
test('should work without log function', () => {
|
||||
existsSyncSpy.mockReturnValue(false);
|
||||
const templateContent = `# Test
|
||||
test.txt
|
||||
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/`;
|
||||
|
||||
expect(() => {
|
||||
manageGitignoreFile('.gitignore', templateContent, false);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle empty existing file', () => {
|
||||
existsSyncSpy.mockReturnValue(true);
|
||||
readFileSyncSpy.mockReturnValue('');
|
||||
|
||||
const templateContent = `# Task files
|
||||
tasks.json
|
||||
tasks/`;
|
||||
|
||||
manageGitignoreFile('.gitignore', templateContent, false, mockLog);
|
||||
|
||||
expect(writeFileSyncSpy).toHaveBeenCalled();
|
||||
const writtenContent = writeFileSyncSpy.mock.calls[0][1];
|
||||
expect(writtenContent).toContain('# Task files');
|
||||
});
|
||||
|
||||
test('should handle template with only task files', () => {
|
||||
existsSyncSpy.mockReturnValue(false);
|
||||
const templateContent = `# Task files
|
||||
tasks.json
|
||||
tasks/ `;
|
||||
|
||||
manageGitignoreFile('.gitignore', templateContent, true, mockLog);
|
||||
|
||||
const writtenContent = writeFileSyncSpy.mock.calls[0][1];
|
||||
expect(writtenContent).toBe(`# Task files
|
||||
# tasks.json
|
||||
# tasks/ `);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user