diff --git a/.eslintrc b/.eslintrc index 8fdc7b0..aad2cac 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,8 +1,11 @@ { "extends": ["@ribeirogab/eslint-config/node"], + "globals": { + "vi": true + }, "overrides": [ { - "files": ["tsup.config.ts"], + "files": ["tsup.config.ts", "vitest.config.ts"], "rules": { "import/no-default-export": "off" } diff --git a/.gitignore b/.gitignore index 9ed99b7..53cd788 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ node_modules executables package-lock.json -lib \ No newline at end of file +lib +tmp/* +!tmp/.gitkeep +test-output +.DS_Store \ No newline at end of file diff --git a/.npmignore b/.npmignore index bf8358b..d9796a3 100644 --- a/.npmignore +++ b/.npmignore @@ -7,9 +7,13 @@ test.* .eslintignore .eslintrc* .gitignore +.DS_Store + prettier.config.js tsup.config.ts tsconfig.json -.DS_Store +vitest.config.ts + src -executables \ No newline at end of file +tmp +test-output \ No newline at end of file diff --git a/package.json b/package.json index 3b3eeed..b53cd1a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,15 @@ ], "scripts": { "build": "tsup", - "publish:npm": "npm run build && npm publish" + "publish:npm": "npm run build && npm publish", + "cfy": "npm run build -- --silent && node bin/cli.js", + "vitest": "vitest --globals --config vitest.config.ts", + "test": "npm run vitest -- run", + "test:unit": "npm run vitest -- --project unit --run", + "test:integration": "npm run vitest -- --project integration --run", + "test:watch": "npm run vitest", + "test:coverage": "npm run vitest -- run --coverage", + "test:ci": "npm run vitest -- run --silent --coverage --reporter=junit --outputFile.junit=./test-output/junit.xml" }, "bin": { "commitfy": "bin/cli.js", @@ -36,9 +44,12 @@ "devDependencies": { "@ribeirogab/eslint-config": "^2.0.1", "@types/node": "^22.1.0", + "@vitest/coverage-v8": "^2.0.5", "eslint": "^8.57.0", "tsup": "^8.2.4", "tsx": "^4.16.5", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^2.0.5" } } diff --git a/src/commands/generate-commit.spec.ts b/src/commands/generate-commit.spec.ts new file mode 100644 index 0000000..110d465 --- /dev/null +++ b/src/commands/generate-commit.spec.ts @@ -0,0 +1,151 @@ +import { GenerateCommit } from './generate-commit'; +import { ProviderEnum } from '@/interfaces'; +import { makeProvidersFake } from '@/tests/fakes/providers'; +import { + makeAppUtilsFake, + makeEnvUtilsFake, + makeInputUtilsFake, + makeProcessUtilsFake, +} from '@/tests/fakes/utils'; + +export const makeSut = () => { + const processUtils = makeProcessUtilsFake(); + const inputUtils = makeInputUtilsFake(); + const providers = makeProvidersFake(); + const envUtils = makeEnvUtilsFake(); + const appUtils = makeAppUtilsFake(); + const sut = new GenerateCommit( + providers, + envUtils, + processUtils, + inputUtils, + appUtils, + ); + + return { + processUtils, + inputUtils, + providers, + envUtils, + appUtils, + sut, + }; +}; + +describe('GenerateCommit', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create an instance of GenerateCommit', () => { + const { sut } = makeSut(); + + expect(sut).toBeInstanceOf(GenerateCommit); + }); + + it('should log an error and exit if provider is not set', async () => { + const { sut, appUtils } = makeSut(); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((number) => { + throw new Error('process.exit: ' + number); + }); + const loggerMessageSpy = vi.spyOn(appUtils.logger, 'message'); + const loggerErrorSpy = vi.spyOn(appUtils.logger, 'error'); + + await expect(sut.execute()).rejects.toThrow(); + + expect(loggerErrorSpy).toHaveBeenCalledWith('AI provider not set.'); + + expect(loggerMessageSpy).toHaveBeenCalledWith( + "Run 'commitfy setup' to set up the provider.", + ); + + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('should log an error and exit if there are no changes to commit', async () => { + const { sut, appUtils, envUtils, processUtils } = makeSut(); + + const loggerMessageSpy = vi.spyOn(appUtils.logger, 'error'); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((number) => { + throw new Error('process.exit: ' + number); + }); + + vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + PROVIDER: ProviderEnum.OpenAI, + }); + + vi.spyOn(processUtils, 'exec').mockResolvedValueOnce(''); + + await expect(sut.execute()).rejects.toThrow(); + expect(loggerMessageSpy).toHaveBeenCalledWith('No changes to commit.'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('should execute successfully if provider is set and there are changes to commit', async () => { + const { sut, envUtils, processUtils, inputUtils, providers } = makeSut(); + + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((number) => number as never); + + const variablesSpy = vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + PROVIDER: ProviderEnum.OpenAI, + }); + + const generateCommitMessagesSpy = vi + .spyOn(providers[ProviderEnum.OpenAI], 'generateCommitMessages') + .mockResolvedValueOnce(['commit message']); + + const execSpy = vi + .spyOn(processUtils, 'exec') + .mockResolvedValueOnce('some changes'); + + const promptSpy = vi + .spyOn(inputUtils, 'prompt') + .mockResolvedValueOnce('commit message'); + + await expect(sut.execute()).resolves.not.toThrow(); + + expect(variablesSpy).toHaveBeenCalled(); + + expect(generateCommitMessagesSpy).toHaveBeenCalledWith({ + diff: 'some changes', + }); + + expect(execSpy).toHaveBeenCalledWith(`git commit -m "commit message"`, { + showStdout: true, + }); + + expect(promptSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('should call execute again if user chooses to regenerate', async () => { + const { sut, envUtils, processUtils, inputUtils, providers } = makeSut(); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + vi.spyOn(envUtils, 'variables').mockReturnValue({ + PROVIDER: ProviderEnum.OpenAI, + }); + + vi.spyOn( + providers[ProviderEnum.OpenAI], + 'generateCommitMessages', + ).mockResolvedValueOnce(['commit 1', 'commit 2']); + + vi.spyOn(processUtils, 'exec').mockResolvedValueOnce('some changes'); + + vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce('↻ regenerate'); + + const executeSpy = vi.spyOn(sut, 'execute'); + + await expect(sut.execute()).rejects.toThrow('process.exit called'); + + expect(executeSpy).toHaveBeenCalledTimes(2); + expect(exitSpy).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/commands/generate-commit.ts b/src/commands/generate-commit.ts index 12e2e57..35c80c8 100644 --- a/src/commands/generate-commit.ts +++ b/src/commands/generate-commit.ts @@ -6,10 +6,11 @@ import { type ProcessUtils, type Provider, type Providers, -} from '../interfaces'; +} from '@/interfaces'; export class GenerateCommit { - private readonly provider: Provider; + private readonly regeneratorText = '↻ regenerate'; + private provider: Provider; constructor( private readonly providers: Providers, @@ -17,14 +18,17 @@ export class GenerateCommit { private readonly processUtils: ProcessUtils, private readonly inputUtils: InputUtils, private readonly appUtils: AppUtils, - ) { - this.provider = this.providers[this.envUtils.get().PROVIDER]; - } + ) {} public async execute(): Promise { + this.provider = this.providers[this.envUtils.variables().PROVIDER]; + if (!this.provider) { this.appUtils.logger.error('AI provider not set.'); - console.log("Run 'commitfy setup' to set up the provider."); + + this.appUtils.logger.message( + "Run 'commitfy setup' to set up the provider.", + ); process.exit(0); } @@ -34,19 +38,18 @@ export class GenerateCommit { }); if (!diff) { - console.error(`${this.appUtils.name}: no changes to commit.`); + this.appUtils.logger.error('No changes to commit.'); process.exit(0); } const commits = await this.provider.generateCommitMessages({ diff }); const oneLineCommits = commits.map((commit) => commit.split('\n')[0]); - const regenerateText = '↻ regenerate'; const choices = [ ...oneLineCommits, InputUtilsCustomChoiceEnum.Separator, - regenerateText, + this.regeneratorText, ]; const response = await this.inputUtils.prompt({ @@ -55,7 +58,7 @@ export class GenerateCommit { choices, }); - if (response === regenerateText) { + if (response === this.regeneratorText) { return this.execute(); } diff --git a/src/commands/get-version.spec.ts b/src/commands/get-version.spec.ts new file mode 100644 index 0000000..7cf2ac6 --- /dev/null +++ b/src/commands/get-version.spec.ts @@ -0,0 +1,32 @@ +import { GetVersion } from './get-version'; +import { makeAppUtilsFake } from '@/tests/fakes/utils'; + +const makeSut = () => { + const appUtils = makeAppUtilsFake(); + const sut = new GetVersion(appUtils); + + return { + appUtils, + sut, + }; +}; + +describe('GetVersion', () => { + it('should log the application name and version', () => { + const { sut, appUtils } = makeSut(); + + sut.execute(); + + expect(appUtils.logger.message).toHaveBeenCalledWith( + `${appUtils.name} version v${appUtils.version}`, + ); + }); + + it('should not log an error', () => { + const { sut, appUtils } = makeSut(); + + sut.execute(); + + expect(appUtils.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/get-version.ts b/src/commands/get-version.ts index e271481..fddd2ee 100644 --- a/src/commands/get-version.ts +++ b/src/commands/get-version.ts @@ -1,9 +1,11 @@ -import type { AppUtils } from '../interfaces'; +import type { AppUtils } from '@/interfaces'; export class GetVersion { constructor(private appUtils: AppUtils) {} public execute(): void { - console.log(`${this.appUtils.name} version v${this.appUtils.version}`); + this.appUtils.logger.message( + `${this.appUtils.name} version v${this.appUtils.version}`, + ); } } diff --git a/src/commands/help.spec.ts b/src/commands/help.spec.ts new file mode 100644 index 0000000..86fa35e --- /dev/null +++ b/src/commands/help.spec.ts @@ -0,0 +1,33 @@ +import { Help } from './help'; + +const makeSut = () => { + const sut = new Help(); + + return { sut }; +}; + +describe('Help', () => { + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + it('should execute without errors', () => { + const { sut } = makeSut(); + + expect(() => sut.execute()).not.toThrow(); + }); + + it('should log messages when executed', () => { + const { sut } = makeSut(); + + sut.execute(); + + expect(consoleLogSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/commands/setup.spec.ts b/src/commands/setup.spec.ts new file mode 100644 index 0000000..d64df41 --- /dev/null +++ b/src/commands/setup.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Setup } from './setup'; +import { InputUtils, ProviderEnum } from '@/interfaces'; +import { makeProvidersFake } from '@/tests/fakes/providers'; + +const makeInputUtilsFake = (): InputUtils => ({ + prompt: vi.fn(), +}); + +const makeSut = () => { + const providers = makeProvidersFake(); + const inputUtils = makeInputUtilsFake(); + const sut = new Setup(providers, inputUtils); + + return { + sut, + providers, + inputUtils, + }; +}; + +describe('Setup', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + it('should call setup on the chosen provider', async () => { + const { sut, providers, inputUtils } = makeSut(); + + vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce(ProviderEnum.OpenAI); + + await expect(sut.execute()).rejects.toThrow('process.exit called'); + + expect(providers[ProviderEnum.OpenAI].setup).toHaveBeenCalled(); + + expect(inputUtils.prompt).toHaveBeenCalledWith({ + choices: Object.values(ProviderEnum), + message: 'Choose your AI provider', + type: 'list', + }); + }); + + it('should exit the process after setup is complete', async () => { + const { sut, inputUtils } = makeSut(); + + vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce(ProviderEnum.OpenAI); + + await expect(sut.execute()).rejects.toThrow('process.exit called'); + + expect(exitSpy).toHaveBeenCalledWith(0); + }); +}); diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 458596e..96bafb4 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,4 +1,4 @@ -import type { InputUtils, Provider, ProviderEnum } from '../interfaces'; +import type { InputUtils, Provider, ProviderEnum } from '@/interfaces'; export class Setup { constructor( diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..7573411 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,8 @@ +import os from 'node:os'; +import path from 'node:path'; + +export const PACKAGE_JSON_PATH = path.resolve(__dirname, '..', 'package.json'); + +export const USER_HOME_DIRECTORY = os.homedir(); + +export const TEMP_DIRECTORY = path.resolve(__dirname, '..', 'tmp'); diff --git a/src/container.ts b/src/container.ts index c46f074..ab7c99f 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,7 +1,7 @@ -import { GenerateCommit, GetVersion, Help, Setup } from './commands'; -import type { Providers } from './interfaces'; -import { OpenAIProvider } from './providers'; -import { AppUtils, EnvUtils, InputUtils, ProcessUtils } from './utils'; +import { GenerateCommit, GetVersion, Help, Setup } from '@/commands'; +import type { Providers } from '@/interfaces'; +import { OpenAIProvider } from '@/providers'; +import { AppUtils, EnvUtils, InputUtils, ProcessUtils } from '@/utils'; const inputUtils = new InputUtils(); const appUtils = new AppUtils(); diff --git a/src/index.ts b/src/index.ts index bb4d5a2..f6fc720 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { generateCommit, getVersion, help, setup } from './container'; +import { generateCommit, getVersion, help, setup } from '@/container'; enum CommandEnum { GenerateCommit, diff --git a/src/interfaces/utils/app.utils.ts b/src/interfaces/utils/app.utils.ts index 3d06251..ac0a437 100644 --- a/src/interfaces/utils/app.utils.ts +++ b/src/interfaces/utils/app.utils.ts @@ -11,11 +11,12 @@ type LoggerFunction = ( export interface AppUtils { projectConfigDirectory: string; - homeDirectory: string; envFilePath: string; version: string; name: string; logger: { + /** Log without a prefix */ + message: LoggerFunction; error: LoggerFunction; warn: LoggerFunction; log: LoggerFunction; diff --git a/src/interfaces/utils/env.utils.ts b/src/interfaces/utils/env.utils.ts index bc2807e..b29c748 100644 --- a/src/interfaces/utils/env.utils.ts +++ b/src/interfaces/utils/env.utils.ts @@ -3,10 +3,10 @@ import type { ProviderEnum } from '../provider'; export type Env = { PROVIDER?: ProviderEnum; OPENAI_API_KEY?: string; - OPENAI_N_COMMITS?: number; + OPENAI_N_COMMITS?: number | string; }; export interface EnvUtils { + variables(): Env; update(updates: Partial): void; - get(): Env; } diff --git a/src/providers/openai.provider.spec.ts b/src/providers/openai.provider.spec.ts new file mode 100644 index 0000000..f98ac1a --- /dev/null +++ b/src/providers/openai.provider.spec.ts @@ -0,0 +1,181 @@ +import { OpenAI } from 'openai'; +import type { ChatCompletion } from 'openai/resources'; + +import { OpenAIProvider } from './openai.provider'; +import { ProviderEnum } from '@/interfaces'; +import { + makeAppUtilsFake, + makeEnvUtilsFake, + makeInputUtilsFake, +} from '@/tests/fakes/utils'; + +vi.mock('openai'); + +const makeSut = () => { + const envUtils = makeEnvUtilsFake(); + const inputUtils = makeInputUtilsFake(); + const appUtils = makeAppUtilsFake(); + const openAIClient = { + chat: { + completions: { + create: vi.fn(), + }, + }, + }; + + vi.mocked(OpenAI).mockImplementation(() => openAIClient as unknown as OpenAI); + + const sut = new OpenAIProvider(envUtils, inputUtils, appUtils); + + return { + openAIClient, + inputUtils, + appUtils, + envUtils, + sut, + }; +}; + +describe('OpenAIProvider', () => { + let exitSpy: ReturnType; + + beforeEach(() => { + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + describe('generateCommitMessages', () => { + it('should throw an error if OPENAI_API_KEY is not set', async () => { + const { sut, envUtils, appUtils } = makeSut(); + + vi.spyOn(envUtils, 'variables').mockReturnValueOnce({}); + const loggerErrorSpy = vi.spyOn(appUtils.logger, 'error'); + + await expect( + sut.generateCommitMessages({ diff: 'some changes' }), + ).rejects.toThrow(); + + expect(loggerErrorSpy).toHaveBeenCalledWith('OPENAI_API_KEY is required'); + }); + + it('should generate commit messages successfully', async () => { + const { sut, envUtils, openAIClient } = makeSut(); + + vi.spyOn(envUtils, 'variables').mockReturnValue({ + OPENAI_API_KEY: 'valid-api-key', + OPENAI_N_COMMITS: 2, + }); + + const chatCompletionMock = { + choices: [ + { message: { content: 'feat: implement new feature' } }, + { message: { content: 'fix: resolve bug' } }, + ], + } as ChatCompletion; + + vi.spyOn(openAIClient.chat.completions, 'create').mockResolvedValueOnce( + chatCompletionMock, + ); + + const commitMessages = await sut.generateCommitMessages({ + diff: 'some changes', + }); + + expect(commitMessages).toEqual([ + 'feat: implement new feature', + 'fix: resolve bug', + ]); + }); + + it('should log an error and exit if commit message generation fails', async () => { + const { sut, envUtils, appUtils, openAIClient } = makeSut(); + + vi.spyOn(envUtils, 'variables').mockReturnValue({ + OPENAI_API_KEY: 'valid-api-key', + OPENAI_N_COMMITS: 2, + }); + + const loggerErrorSpy = vi.spyOn(appUtils.logger, 'error'); + + vi.spyOn(openAIClient.chat.completions, 'create').mockRejectedValueOnce( + new Error('OpenAI API error'), + ); + + await expect( + sut.generateCommitMessages({ diff: 'some changes' }), + ).rejects.toThrow('process.exit called'); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Failed to generate commit message.', + 'OpenAI API error', + ); + }); + }); + + describe('setup', () => { + it('should successfully setup OpenAI provider', async () => { + const { sut, envUtils, inputUtils, appUtils } = makeSut(); + + vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + OPENAI_API_KEY: '', + }); + + vi.spyOn(inputUtils, 'prompt') + .mockResolvedValueOnce('new-api-key') + .mockResolvedValueOnce('3'); + + const loggerMessageSpy = vi.spyOn(appUtils.logger, 'message'); + + const testSpy = vi + .spyOn(sut as OpenAIProvider & { test: () => Promise }, 'test') + .mockReturnValueOnce(Promise.resolve()); + + await sut.setup(); + + expect(envUtils.update).toHaveBeenCalledWith({ + OPENAI_API_KEY: 'new-api-key', + OPENAI_N_COMMITS: 3, + PROVIDER: ProviderEnum.OpenAI, + }); + + expect(loggerMessageSpy).toHaveBeenCalledWith( + '\x1b[32m✓\x1b[0m', + 'OpenAI setup successfully completed.', + ); + + expect(testSpy).toHaveBeenCalled(); + }); + + it('should log an error and exit if setup fails', async () => { + const { sut, envUtils, inputUtils, appUtils } = makeSut(); + + vi.spyOn(envUtils, 'variables').mockReturnValueOnce({ + OPENAI_API_KEY: '', + }); + + vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce('new-api-key'); + vi.spyOn(inputUtils, 'prompt').mockResolvedValueOnce('3'); + + vi.spyOn( + sut as OpenAIProvider & { test: () => Promise }, + 'test', + ).mockRejectedValueOnce(new Error('Test failed')); + + const loggerErrorSpy = vi.spyOn(appUtils.logger, 'error'); + + await expect(sut.setup()).rejects.toThrow('process.exit called'); + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Failed to set up OpenAI.', + 'Test failed', + ); + + expect(envUtils.update).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/providers/openai.provider.ts b/src/providers/openai.provider.ts index 7d705db..a4a1e79 100644 --- a/src/providers/openai.provider.ts +++ b/src/providers/openai.provider.ts @@ -23,7 +23,7 @@ export class OpenAIProvider implements Provider { return this.openai; } - const env = this.envUtils.get(); + const env = this.envUtils.variables(); if (!env.OPENAI_API_KEY) { throw new Error('OPENAI_API_KEY is required'); @@ -35,7 +35,7 @@ export class OpenAIProvider implements Provider { } public async setup(): Promise { - const env = this.envUtils.get(); + const env = this.envUtils.variables(); try { const apiKey = await this.inputUtils.prompt({ @@ -50,11 +50,6 @@ export class OpenAIProvider implements Provider { type: InputTypeEnum.Input, }); - this.envUtils.update({ - ...env, - OPENAI_API_KEY: apiKey, - }); - this.envUtils.update({ ...env, OPENAI_N_COMMITS: Number(numberOfCommits), @@ -65,11 +60,20 @@ export class OpenAIProvider implements Provider { // Ensure the OpenAI integration is working await this.test(); - console.log('\x1b[32m✓\x1b[0m', 'OpenAI setup successfully completed.'); + this.appUtils.logger.message( + '\x1b[32m✓\x1b[0m', + 'OpenAI setup successfully completed.', + ); } catch (error) { this.envUtils.update(env); - this.appUtils.logger.error('Failed to set up OpenAI.', error); + this.appUtils.logger.error( + 'Failed to set up OpenAI.', + ...(error?.error + ? [`\nOpenAI error:`, error.error] + : [error.message || '']), + ); + process.exit(1); } } @@ -83,7 +87,7 @@ export class OpenAIProvider implements Provider { this.checkRequiredEnvVars(); const prompt = `Generate a concise and clear commit message using the commitizen format (e.g., feat, chore, refactor, etc.) for the following code changes. The message should be at most 72 characters long:`; - const n = Number(this.envUtils.get().OPENAI_N_COMMITS) || 2; + const n = Number(this.envUtils.variables().OPENAI_N_COMMITS) || 2; const chatCompletion = await this.client.chat.completions.create({ messages: [ @@ -107,8 +111,9 @@ export class OpenAIProvider implements Provider { } catch (error) { this.appUtils.logger.error( 'Failed to generate commit message.', - error?.error ? `\nOpenAI error:` : '', - error?.error ? error?.error : '', + ...(error?.error + ? [`\nOpenAI error:`, error.error] + : [error.message || '']), ); process.exit(1); @@ -116,11 +121,9 @@ export class OpenAIProvider implements Provider { } private checkRequiredEnvVars(): void { - const env = this.envUtils.get(); - - if (!env.OPENAI_API_KEY) { + if (!this.envUtils.variables().OPENAI_API_KEY) { this.appUtils.logger.error('OPENAI_API_KEY is required'); - this.appUtils.logger.log("Run 'commitfy setup' to set up."); + this.appUtils.logger.message("Run 'commitfy setup' to set up."); process.exit(0); } } diff --git a/src/utils/app.utils.spec.ts b/src/utils/app.utils.spec.ts new file mode 100644 index 0000000..7778050 --- /dev/null +++ b/src/utils/app.utils.spec.ts @@ -0,0 +1,60 @@ +import { AppUtils } from './app.utils'; + +const makeSut = () => { + const appUtils = new AppUtils(); + + return { sut: appUtils }; +}; + +describe('AppUtils', () => { + it('should create an instance of AppUtils', () => { + const { sut } = makeSut(); + expect(sut).toBeInstanceOf(AppUtils); + }); + + describe('projectConfigDirectory', () => { + it('should have a projectConfigDirectory property', () => { + const { sut } = makeSut(); + expect(sut.projectConfigDirectory).toBeDefined(); + }); + }); + + describe('envFilePath', () => { + it('should have an envFilePath property', () => { + const { sut } = makeSut(); + expect(sut.envFilePath).toBeDefined(); + }); + }); + + describe('logger', () => { + it('should have a logger property', () => { + const { sut } = makeSut(); + expect(sut.logger).toBeDefined(); + }); + + it('should have all the required properties', () => { + const { sut } = makeSut(); + + expect(Object.keys(sut.logger)).toEqual([ + 'error', + 'warn', + 'log', + 'message', + ]); + }); + }); + + describe('name', () => { + it('should have a name property', () => { + const { sut } = makeSut(); + expect(sut.name).toBeDefined(); + }); + }); + + describe('version', () => { + it('should have a version property', () => { + const { sut } = makeSut(); + expect(sut.version).toBeDefined(); + }); + }); +}); diff --git a/src/utils/app.utils.ts b/src/utils/app.utils.ts index d5a6954..884b283 100644 --- a/src/utils/app.utils.ts +++ b/src/utils/app.utils.ts @@ -1,13 +1,12 @@ import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; -import { AppUtils as AppUtilsInterface } from '../interfaces'; +import { PACKAGE_JSON_PATH, USER_HOME_DIRECTORY } from '@/constants'; +import { AppUtils as AppUtilsInterface } from '@/interfaces'; export class AppUtils implements AppUtilsInterface { - public readonly homeDirectory = os.homedir(); public readonly projectConfigDirectory = path.resolve( - this.homeDirectory, + USER_HOME_DIRECTORY, `.${this.name}`, ); @@ -18,11 +17,12 @@ export class AppUtils implements AppUtilsInterface { public readonly logger: AppUtilsInterface['logger'] = { error: (message, ...params) => - console.error(`${this.name}:`, ...[message, ...params]), + console.error(`${this.name}:`, message, ...params), warn: (message, ...params) => - console.warn(`${this.name}:`, ...[message, ...params]), + console.warn(`${this.name}:`, message, ...params), log: (message, ...params) => - console.log(`${this.name}:`, ...[message, ...params]), + console.log(`${this.name}:`, message, ...params), + message: (message, ...params) => console.log(message, ...params), }; constructor() { @@ -40,8 +40,6 @@ export class AppUtils implements AppUtilsInterface { } private get packageJson(): { version: string; name: string } { - return JSON.parse( - fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'), - ); + return JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf-8')); } } diff --git a/src/utils/env.utils.spec.ts b/src/utils/env.utils.spec.ts new file mode 100644 index 0000000..7fe056b --- /dev/null +++ b/src/utils/env.utils.spec.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import path from 'path'; + +import { makeAppUtilsFake } from '../../tests/fakes/utils'; +import { TEMP_DIRECTORY } from '../constants'; +import { EnvUtils as EnvUtilsInterface, ProviderEnum } from '../interfaces'; +import { EnvUtils } from './env.utils'; + +const ENV_FILE_PATH = path.join(TEMP_DIRECTORY, '.env'); + +export const makeFakeEnvUtils = () => + ({ update: vi.fn(), variables: vi.fn() } as EnvUtilsInterface); + +const makeSut = () => { + const appUtils = makeAppUtilsFake(); + + const envUtils = new EnvUtils(appUtils); + + return { sut: envUtils }; +}; + +describe('EnvUtils', () => { + beforeEach(() => { + const fileContent = Object.entries({ + PROVIDER: ProviderEnum.OpenAI, + OPENAI_API_KEY: '123456789', + OPENAI_N_COMMITS: '2', + }) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + + fs.writeFileSync(ENV_FILE_PATH, fileContent, 'utf8'); + }); + + describe('variables', () => { + it('should return an object with environment variables', () => { + const { sut } = makeSut(); + + const variables = sut.variables(); + + expect(variables).toBeDefined(); + expect(typeof variables).toBe('object'); + }); + }); + + describe('update', () => { + it('should update the environment variables', () => { + const { sut } = makeSut(); + + const updates = { + PROVIDER: ProviderEnum.OpenAI, + OPENAI_API_KEY: '123456789', + OPENAI_N_COMMITS: '5', + }; + + sut.update(updates); + + const variables = sut.variables(); + + expect(Object.keys(variables)).toEqual(Object.keys(updates)); + expect(Object.values(variables)).toEqual(Object.values(updates)); + }); + + it('should merge the updates with existing environment variables', () => { + const { sut } = makeSut(); + const initialVariables = sut.variables(); + + const updates = { + OPENAI_N_COMMITS: '5', + }; + + sut.update(updates); + + expect(sut.variables()).toEqual({ ...initialVariables, ...updates }); + }); + }); +}); diff --git a/src/utils/env.utils.ts b/src/utils/env.utils.ts index 227a28a..0bf07cc 100644 --- a/src/utils/env.utils.ts +++ b/src/utils/env.utils.ts @@ -1,12 +1,15 @@ import * as fs from 'node:fs'; -import type { Env, EnvUtils as EnvUtilsInterface } from '../interfaces'; -import type { AppUtils } from './app.utils'; +import type { + AppUtils, + Env, + EnvUtils as EnvUtilsInterface, +} from '@/interfaces'; export class EnvUtils implements EnvUtilsInterface { constructor(private readonly appUtils: AppUtils) {} - public get(): Env { + public variables(): Env { if (!fs.existsSync(this.appUtils.envFilePath)) { return {}; } @@ -24,9 +27,7 @@ export class EnvUtils implements EnvUtilsInterface { } public update(updates: Partial) { - const currentConfig = this.get(); - - const fileContent = Object.entries({ ...currentConfig, ...updates }) + const fileContent = Object.entries({ ...this.variables(), ...updates }) .map(([key, value]) => `${key}=${value}`) .join('\n'); diff --git a/src/utils/input.utils.spec.ts b/src/utils/input.utils.spec.ts new file mode 100644 index 0000000..014599d --- /dev/null +++ b/src/utils/input.utils.spec.ts @@ -0,0 +1,125 @@ +import inquirer from 'inquirer'; +import type { Mock } from 'vitest'; + +import { InputUtils } from './input.utils'; +import { + InputPromptDto, + InputTypeEnum, + InputUtilsCustomChoiceEnum, +} from '@/interfaces'; + +vi.mock('inquirer'); + +const makeSut = () => { + const inputUtils = new InputUtils(); + + return { sut: inputUtils }; +}; + +class SeparatorMock { + constructor() { + return ''; + } +} + +describe('InputUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + + (inquirer.Separator as unknown as Mock).mockReturnValueOnce(SeparatorMock); + }); + + describe('prompt', () => { + it('should prompt for user input', async () => { + const { sut } = makeSut(); + + const input: InputPromptDto = { + type: InputTypeEnum.Input, + message: 'Enter your name:', + }; + + const expectedData = 'John Doe'; + + (inquirer.prompt as unknown as Mock).mockResolvedValueOnce({ + data: expectedData, + }); + + const result = await sut.prompt(input); + + expect(result).toBe(expectedData); + + expect(inquirer.prompt).toHaveBeenCalledWith([ + { name: 'data', ...input }, + ]); + }); + + it('should handle list input type with custom choices', async () => { + const { sut } = makeSut(); + + const input: InputPromptDto = { + type: InputTypeEnum.List, + message: 'Select your favorite color:', + choices: [ + 'Red', + 'Green', + 'Blue', + InputUtilsCustomChoiceEnum.Separator, + 'Yellow', + ], + }; + + const expectedData = 'Green'; + + (inquirer.prompt as unknown as Mock).mockResolvedValueOnce({ + data: expectedData, + }); + + const result = await sut.prompt(input); + + expect(result).toBe(expectedData); + + expect(inquirer.prompt).toHaveBeenCalledWith([ + { + name: 'data', + type: 'list', + message: 'Select your favorite color:', + choices: ['Red', 'Green', 'Blue', SeparatorMock, 'Yellow'], + }, + ]); + }); + + it('should exit the process if user force closes the prompt', async () => { + const { sut } = makeSut(); + + const input: InputPromptDto = { + type: InputTypeEnum.Input, + message: 'Enter your name:', + }; + + (inquirer.prompt as unknown as Mock).mockRejectedValueOnce( + new Error('User force closed the prompt with 0 null'), + ); + + const exitSpy = vi.spyOn(process, 'exit'); + + await expect(sut.prompt(input)).rejects.toThrowError(); + + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('should rethrow any other errors', async () => { + const { sut } = makeSut(); + + const input: InputPromptDto = { + type: InputTypeEnum.Input, + message: 'Enter your name:', + }; + + const expectedError = new Error('Something went wrong'); + + (inquirer.prompt as unknown as Mock).mockRejectedValueOnce(expectedError); + + await expect(sut.prompt(input)).rejects.toThrowError(expectedError); + }); + }); +}); diff --git a/src/utils/input.utils.ts b/src/utils/input.utils.ts index 3333e33..318405c 100644 --- a/src/utils/input.utils.ts +++ b/src/utils/input.utils.ts @@ -5,7 +5,7 @@ import { InputTypeEnum, InputUtilsCustomChoiceEnum, type InputUtils as InputUtilsInterface, -} from '../interfaces'; +} from '@/interfaces'; export class InputUtils implements InputUtilsInterface { public async prompt(input: InputPromptDto) { diff --git a/src/utils/process.utils.spec.ts b/src/utils/process.utils.spec.ts new file mode 100644 index 0000000..2ed2992 --- /dev/null +++ b/src/utils/process.utils.spec.ts @@ -0,0 +1,53 @@ +import { AppUtils } from './app.utils'; +import { ProcessUtils } from './process.utils'; + +const makeSut = () => { + const appUtils = new AppUtils(); + const sut = new ProcessUtils(appUtils); + + return { sut, appUtils }; +}; + +describe('ProcessUtils', () => { + describe('exec', () => { + it('should execute the command and resolve with the stdout', async () => { + const { sut } = makeSut(); + const command = 'echo "Hello, World!"'; + const expectedStdout = 'Hello, World!\n'; + + const result = await sut.exec(command); + + expect(result).toBe(expectedStdout); + }); + + it('should execute the command and reject with an error if the command fails', async () => { + const { sut } = makeSut(); + const command = 'invalid-command'; + + await expect(sut.exec(command)).rejects.toMatch('Command failed'); + }); + + it('should execute the command and write the stdout to process.stdout if showStdout is true', async () => { + const { sut } = makeSut(); + const command = 'echo "Hello, World!"'; + const expectedStdout = 'Hello, World!\n'; + + vi.spyOn(process.stdout, 'write'); + + await sut.exec(command, { showStdout: true }); + + expect(process.stdout.write).toHaveBeenCalledWith(expectedStdout); + }); + + it('should execute the command and write the stderr to process.stderr', async () => { + const { sut } = makeSut(); + const command = 'invalid-command'; + + vi.spyOn(process.stderr, 'write'); + + await expect(sut.exec(command)).rejects.toThrow(); + + expect(process.stderr.write).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/process.utils.ts b/src/utils/process.utils.ts index dfe8e08..db41a0d 100644 --- a/src/utils/process.utils.ts +++ b/src/utils/process.utils.ts @@ -4,7 +4,7 @@ import type { AppUtils, ProcessUtilsExecOptions, ProcessUtils as ProcessUtilsInterface, -} from '../interfaces'; +} from '@/interfaces'; export class ProcessUtils implements ProcessUtilsInterface { constructor(private readonly appUtils: AppUtils) {} diff --git a/tests/fakes/providers/index.ts b/tests/fakes/providers/index.ts new file mode 100644 index 0000000..c2c4eeb --- /dev/null +++ b/tests/fakes/providers/index.ts @@ -0,0 +1,13 @@ +import { ProviderEnum, type Providers } from '@/interfaces'; + +export const makeProvidersFake = () => + Object.values(ProviderEnum).reduce( + (providers, provider) => ({ + ...providers, + [provider]: { + generateCommitMessages: vi.fn(), + setup: vi.fn(), + }, + }), + {}, + ) as Providers; diff --git a/tests/fakes/utils/app.utils.fake.ts b/tests/fakes/utils/app.utils.fake.ts new file mode 100644 index 0000000..61b4185 --- /dev/null +++ b/tests/fakes/utils/app.utils.fake.ts @@ -0,0 +1,20 @@ +import path from 'node:path'; + +import { TEMP_DIRECTORY } from '@/constants'; +import { AppUtils as AppUtilsInterface } from '@/interfaces'; + +const ENV_FILE_PATH = path.join(TEMP_DIRECTORY, '.env'); + +export const makeAppUtilsFake = () => + ({ + projectConfigDirectory: TEMP_DIRECTORY, + envFilePath: ENV_FILE_PATH, + version: '1.0.0', + name: 'commitfy', + logger: { + message: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + }, + } as AppUtilsInterface); diff --git a/tests/fakes/utils/env.utils.fake.ts b/tests/fakes/utils/env.utils.fake.ts new file mode 100644 index 0000000..8bae906 --- /dev/null +++ b/tests/fakes/utils/env.utils.fake.ts @@ -0,0 +1,13 @@ +import { EnvUtils as EnvUtilsInterface } from '@/interfaces'; + +export const makeEnvUtilsFake = () => { + class EnvUtilsFake { + public update = vi.fn(); + + public variables() { + return {}; + } + } + + return new EnvUtilsFake() as EnvUtilsInterface; +}; diff --git a/tests/fakes/utils/index.ts b/tests/fakes/utils/index.ts new file mode 100644 index 0000000..9af364e --- /dev/null +++ b/tests/fakes/utils/index.ts @@ -0,0 +1,4 @@ +export * from './process.utils.fake'; +export * from './input.utils.fake'; +export * from './app.utils.fake'; +export * from './env.utils.fake'; diff --git a/tests/fakes/utils/input.utils.fake.ts b/tests/fakes/utils/input.utils.fake.ts new file mode 100644 index 0000000..469fc48 --- /dev/null +++ b/tests/fakes/utils/input.utils.fake.ts @@ -0,0 +1,9 @@ +import type { InputUtils } from '@/interfaces'; + +export const makeInputUtilsFake = () => { + class InputUtilsFake { + public prompt = vi.fn(); + } + + return new InputUtilsFake() as InputUtils; +}; diff --git a/tests/fakes/utils/process.utils.fake.ts b/tests/fakes/utils/process.utils.fake.ts new file mode 100644 index 0000000..826d530 --- /dev/null +++ b/tests/fakes/utils/process.utils.fake.ts @@ -0,0 +1,9 @@ +import type { ProcessUtils } from '@/interfaces'; + +export const makeProcessUtilsFake = () => { + class ProcessUtilsFake { + public exec = vi.fn(); + } + + return new ProcessUtilsFake() as ProcessUtils; +}; diff --git a/tmp/.gitkeep b/tmp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json index e75b1d8..37ff57e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,12 @@ "target": "ES2021", "esModuleInterop": true, "allowJs": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["vitest/globals"], + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"], + "@/tests/*": ["./tests/*"] + } } } diff --git a/tsup.config.ts b/tsup.config.ts index 8c88123..9c4dda3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src', '!src/**/*.spec.*', '!src/api-dev.ts'], + entry: ['src', '!src/**/*.spec.*', '!src/**/*.test.*'], outDir: 'lib', }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0e22c1e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,35 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { configDefaults, defineConfig } from 'vitest/config'; + +const EXCLUDE_PATHS: string[] = [ + '**/bin/**', + '**/lib/**', + '**/node_modules/**', +]; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + globals: true, + forceRerunTriggers: [ + ...configDefaults.forceRerunTriggers, + '**/src/**', + '**/tests/**', + ], + exclude: [...configDefaults.exclude, ...EXCLUDE_PATHS], + coverage: { + exclude: [ + ...EXCLUDE_PATHS, + '**/src/*/index.ts', + '**/*.test.ts', + '**/*.spec.ts', + '**/src/*.ts', + '*.ts', + ], + reportsDirectory: './test-output/coverage', + reporter: ['cobertura', 'lcov'], + provider: 'v8', + }, + env: {}, + }, +});