Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Fixed Gemini CLI integration https://github.com/codeaholicguy/ai-devkit/issues/3
- Added test for TemplateManager.ts
- Fixed Github Copilot integration https://github.com/codeaholicguy/ai-devkit/issues/4

## [0.4.0] - 2025-10-31

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ Supported Tools & Agents:
| Agent | Support | Notes |
|-----------------------------------------------------------|---------|---------------------------------------------------|
| [Claude Code](https://www.anthropic.com/claude-code) | ✅ | |
| [GitHub Copilot](https://code.visualstudio.com/) | 🚧 | Testing |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | 🚧 | Testing |
| [GitHub Copilot](https://code.visualstudio.com/) | | VSCode only |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | | |
| [Cursor](https://cursor.sh/) | ✅ | |
| [opencode](https://opencode.ai/) | 🚧 | Testing |
| [Windsurf](https://windsurf.com/) | 🚧 | Testing |
Expand Down
240 changes: 239 additions & 1 deletion src/__tests__/lib/TemplateManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { TemplateManager } from '../../lib/TemplateManager';
import { EnvironmentDefinition } from '../../types';
import { EnvironmentDefinition, Phase, EnvironmentCode } from '../../types';

jest.mock('fs-extra');
jest.mock('../../util/env');

describe('TemplateManager', () => {
let templateManager: TemplateManager;
let mockFs: jest.Mocked<typeof fs>;
let mockGetEnvironment: jest.MockedFunction<any>;

beforeEach(() => {
mockFs = fs as jest.Mocked<typeof fs>;
mockGetEnvironment = require('../../util/env').getEnvironment as jest.MockedFunction<any>;
templateManager = new TemplateManager('/test/target');

jest.clearAllMocks();
Expand Down Expand Up @@ -218,4 +221,239 @@ describe('TemplateManager', () => {
consoleErrorSpy.mockRestore();
});
});

describe('copyPhaseTemplate', () => {
it('should copy phase template and return target file path', async () => {
const phase: Phase = 'requirements';

(mockFs.ensureDir as any).mockResolvedValue(undefined);
(mockFs.copy as any).mockResolvedValue(undefined);

const result = await templateManager.copyPhaseTemplate(phase);

expect(mockFs.ensureDir).toHaveBeenCalledWith(
path.join(templateManager['targetDir'], 'docs', 'ai', phase)
);
expect(mockFs.copy).toHaveBeenCalledWith(
path.join(templateManager['templatesDir'], 'phases', `${phase}.md`),
path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md')
);
expect(result).toBe(path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md'));
});
});

describe('fileExists', () => {
it('should return true when phase file exists', async () => {
const phase: Phase = 'design';

(mockFs.pathExists as any).mockResolvedValue(true);

const result = await templateManager.fileExists(phase);

expect(mockFs.pathExists).toHaveBeenCalledWith(
path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md')
);
expect(result).toBe(true);
});

it('should return false when phase file does not exist', async () => {
const phase: Phase = 'planning';

(mockFs.pathExists as any).mockResolvedValue(false);

const result = await templateManager.fileExists(phase);

expect(mockFs.pathExists).toHaveBeenCalledWith(
path.join(templateManager['targetDir'], 'docs', 'ai', phase, 'README.md')
);
expect(result).toBe(false);
});
});

describe('setupMultipleEnvironments', () => {
it('should setup multiple environments successfully', async () => {
const envIds: EnvironmentCode[] = ['cursor', 'gemini'];
const cursorEnv = {
code: 'cursor',
name: 'Cursor',
contextFileName: 'AGENTS.md',
commandPath: '.cursor/commands',
};
const geminiEnv = {
code: 'gemini',
name: 'Gemini',
contextFileName: 'AGENTS.md',
commandPath: '.gemini/commands',
isCustomCommandPath: true,
};

mockGetEnvironment
.mockReturnValueOnce(cursorEnv)
.mockReturnValueOnce(geminiEnv);

// Mock setupSingleEnvironment
const mockSetupSingleEnvironment = jest.fn();
mockSetupSingleEnvironment
.mockResolvedValueOnce(['/path/to/cursor/file1', '/path/to/cursor/file2'])
.mockResolvedValueOnce(['/path/to/gemini/file1']);

(templateManager as any).setupSingleEnvironment = mockSetupSingleEnvironment;

const result = await templateManager.setupMultipleEnvironments(envIds);

expect(mockGetEnvironment).toHaveBeenCalledWith('cursor');
expect(mockGetEnvironment).toHaveBeenCalledWith('gemini');
expect(mockSetupSingleEnvironment).toHaveBeenCalledWith(cursorEnv);
expect(mockSetupSingleEnvironment).toHaveBeenCalledWith(geminiEnv);
expect(result).toEqual([
'/path/to/cursor/file1',
'/path/to/cursor/file2',
'/path/to/gemini/file1'
]);
});

it('should skip invalid environments and continue with valid ones', async () => {
const envIds: EnvironmentCode[] = ['cursor', 'invalid' as any, 'gemini'];
const cursorEnv = {
code: 'cursor',
name: 'Cursor',
contextFileName: 'AGENTS.md',
commandPath: '.cursor/commands',
};
const geminiEnv = {
code: 'gemini',
name: 'Gemini',
contextFileName: 'AGENTS.md',
commandPath: '.gemini/commands',
isCustomCommandPath: true,
};

const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();

mockGetEnvironment
.mockReturnValueOnce(cursorEnv)
.mockReturnValueOnce(undefined) // invalid environment
.mockReturnValueOnce(geminiEnv);

// Mock setupSingleEnvironment
const mockSetupSingleEnvironment = jest.fn();
mockSetupSingleEnvironment
.mockResolvedValueOnce(['/path/to/cursor/file1'])
.mockResolvedValueOnce(['/path/to/gemini/file1']);

(templateManager as any).setupSingleEnvironment = mockSetupSingleEnvironment;

const result = await templateManager.setupMultipleEnvironments(envIds);

expect(consoleWarnSpy).toHaveBeenCalledWith("Warning: Environment 'invalid' not found, skipping");
expect(result).toEqual([
'/path/to/cursor/file1',
'/path/to/gemini/file1'
]);

consoleWarnSpy.mockRestore();
});

it('should throw error when setupSingleEnvironment fails', async () => {
const envIds: EnvironmentCode[] = ['cursor'];
const cursorEnv = {
code: 'cursor',
name: 'Cursor',
contextFileName: 'AGENTS.md',
commandPath: '.cursor/commands',
};

mockGetEnvironment.mockReturnValue(cursorEnv);

const mockSetupSingleEnvironment = jest.fn().mockRejectedValue(new Error('Setup failed'));
(templateManager as any).setupSingleEnvironment = mockSetupSingleEnvironment;

const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();

await expect(templateManager.setupMultipleEnvironments(envIds)).rejects.toThrow('Setup failed');

expect(consoleErrorSpy).toHaveBeenCalledWith("Error setting up environment 'Cursor':", expect.any(Error));

consoleErrorSpy.mockRestore();
});
});

describe('checkEnvironmentExists', () => {
it('should return false when environment does not exist', async () => {
const envId: EnvironmentCode = 'cursor';

mockGetEnvironment.mockReturnValue(undefined);

const result = await templateManager.checkEnvironmentExists(envId);

expect(mockGetEnvironment).toHaveBeenCalledWith(envId);
expect(result).toBe(false);
});

it('should return true when context file exists', async () => {
const envId: EnvironmentCode = 'cursor';
const env = {
code: 'cursor',
name: 'Cursor',
contextFileName: 'AGENTS.md',
commandPath: '.cursor/commands',
};

mockGetEnvironment.mockReturnValue(env);

(mockFs.pathExists as any)
.mockResolvedValueOnce(true) // context file exists
.mockResolvedValueOnce(false); // command dir doesn't exist

const result = await templateManager.checkEnvironmentExists(envId);

expect(mockFs.pathExists).toHaveBeenCalledWith(
path.join(templateManager['targetDir'], env.contextFileName)
);
expect(mockFs.pathExists).toHaveBeenCalledWith(
path.join(templateManager['targetDir'], env.commandPath)
);
expect(result).toBe(true);
});

it('should return true when command directory exists', async () => {
const envId: EnvironmentCode = 'cursor';
const env = {
code: 'cursor',
name: 'Cursor',
contextFileName: 'AGENTS.md',
commandPath: '.cursor/commands',
};

mockGetEnvironment.mockReturnValue(env);

(mockFs.pathExists as any)
.mockResolvedValueOnce(false) // context file doesn't exist
.mockResolvedValueOnce(true); // command dir exists

const result = await templateManager.checkEnvironmentExists(envId);

expect(result).toBe(true);
});

it('should return false when neither context file nor command directory exists', async () => {
const envId: EnvironmentCode = 'cursor';
const env = {
code: 'cursor',
name: 'Cursor',
contextFileName: 'AGENTS.md',
commandPath: '.cursor/commands',
};

mockGetEnvironment.mockReturnValue(env);

(mockFs.pathExists as any)
.mockResolvedValueOnce(false) // context file doesn't exist
.mockResolvedValueOnce(false); // command dir doesn't exist

const result = await templateManager.checkEnvironmentExists(envId);

expect(result).toBe(false);
});
});
});
6 changes: 4 additions & 2 deletions src/lib/TemplateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class TemplateManager {
copiedFiles: string[]
): Promise<void> {
const commandsSourceDir = path.join(this.templatesDir, "commands");
const commandExtension = env.customCommandExtension || ".md";
const commandsTargetDir = path.join(this.targetDir, env.commandPath);

if (await fs.pathExists(commandsSourceDir)) {
Expand All @@ -127,11 +128,12 @@ export class TemplateManager {
commandFiles
.filter((file: string) => file.endsWith(".md"))
.map(async (file: string) => {
const targetFile = file.replace('.md', commandExtension);
await fs.copy(
path.join(commandsSourceDir, file),
path.join(commandsTargetDir, file)
path.join(commandsTargetDir, targetFile)
);
copiedFiles.push(path.join(commandsTargetDir, file));
copiedFiles.push(path.join(commandsTargetDir, targetFile));
})
);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface EnvironmentDefinition {
commandPath: string;
description?: string;
isCustomCommandPath?: boolean;
customCommandExtension?: string;
}

export type EnvironmentCode = 'cursor' | 'claude' | 'github' | 'gemini' | 'codex' | 'windsurf' | 'kilocode' | 'amp' | 'opencode' | 'roo';
Expand Down
3 changes: 2 additions & 1 deletion src/util/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export const ENVIRONMENT_DEFINITIONS: Record<EnvironmentCode, EnvironmentDefinit
code: 'github',
name: 'GitHub Copilot',
contextFileName: 'AGENTS.md',
commandPath: '.github/commands',
commandPath: '.github/prompts',
customCommandExtension: '.prompt.md',
},
gemini: {
code: 'gemini',
Expand Down