From a55b101ec0c767aaeadf5e8990fbb7183813977d Mon Sep 17 00:00:00 2001 From: Yago Azevedo Borba <140000816+YagoBorba@users.noreply.github.com> Date: Mon, 4 Aug 2025 23:34:00 +0000 Subject: [PATCH 1/3] =?UTF-8?q?test(cli):=20=E2=9C=85=20add=20unit=20tests?= =?UTF-8?q?=20for=20release=20command=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 16 +++-- package-lock.json | 11 +-- packages/cli/dist/index.js | 2 +- packages/cli/package.json | 17 +++-- packages/cli/test/commands/commit.test.ts | 30 +++++++++ packages/cli/test/commands/release.test.ts | 78 ++++++++++++++++++++++ packages/i18n/dist/locales/pt.json | 2 +- 7 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 packages/cli/test/commands/commit.test.ts create mode 100644 packages/cli/test/commands/release.test.ts diff --git a/.gitignore b/.gitignore index 509f2ccc..07497cff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,14 @@ +cat < .gitignore # Dependencies -/node_modules -/.pnp +node_modules/ +.pnp .pnp.js .yarn/install-state.gz # Production -/build -/dist -/.out +build/ +dist/ +.out/ # Misc .DS_Store @@ -27,7 +28,7 @@ lerna-debug.log* .env.production.local # IDEs and editors -/.vscode/* +.vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json @@ -35,4 +36,5 @@ lerna-debug.log* *.sublime-workspace # TypeScript -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo +EOF \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index db4014f0..009acce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3916,6 +3916,8 @@ }, "node_modules/vitest": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { @@ -4127,7 +4129,7 @@ "packages/cli": { "name": "@stackcode/cli", "version": "1.0.3", - "license": "ISC", + "license": "MIT", "dependencies": { "@stackcode/core": "^1.0.3", "@stackcode/i18n": "^1.0.3", @@ -4149,7 +4151,8 @@ "@types/open": "^6.1.0", "@types/semver": "^7.7.0", "@types/yargs": "^17.0.32", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^3.2.4" } }, "packages/cli/node_modules/@types/node": { @@ -4168,7 +4171,7 @@ "packages/core": { "name": "@stackcode/core", "version": "1.0.3", - "license": "ISC", + "license": "MIT", "dependencies": { "@octokit/rest": "^22.0.0", "conventional-changelog-angular": "^8.0.0", @@ -4185,7 +4188,7 @@ "packages/i18n": { "name": "@stackcode/i18n", "version": "1.0.3", - "license": "ISC", + "license": "MIT", "dependencies": { "configstore": "^7.0.0", "write-file-atomic": "^3.0.3" diff --git a/packages/cli/dist/index.js b/packages/cli/dist/index.js index f5db2768..f4ce9a2a 100755 --- a/packages/cli/dist/index.js +++ b/packages/cli/dist/index.js @@ -15,7 +15,7 @@ async function main() { const locale = getLocale(); yargs(hideBin(process.argv)) .scriptName("stackcode") - .version('1.0.0') + .version('1.0.3') .alias('h', 'help') .alias('v', 'version') .strict() diff --git a/packages/cli/package.json b/packages/cli/package.json index 8701a304..908f06da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -11,9 +11,17 @@ "scripts": { "clean": "rm -rf dist tsconfig.tsbuildinfo", "build": "tsc && chmod +x dist/index.js", - "test": "echo \"✅ No tests for this package\"" + "test": "vitest run" }, - "keywords": ["cli", "scaffolding", "conventional-commits", "gitflow", "automation", "devops", "typescript"], + "keywords": [ + "cli", + "scaffolding", + "conventional-commits", + "gitflow", + "automation", + "devops", + "typescript" + ], "author": "Yago Borba", "license": "MIT", "homepage": "https://github.com/YagoBorba/StackCode#readme", @@ -42,6 +50,7 @@ "@types/open": "^6.1.0", "@types/semver": "^7.7.0", "@types/yargs": "^17.0.32", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^3.2.4" } -} \ No newline at end of file +} diff --git a/packages/cli/test/commands/commit.test.ts b/packages/cli/test/commands/commit.test.ts new file mode 100644 index 00000000..79b0930f --- /dev/null +++ b/packages/cli/test/commands/commit.test.ts @@ -0,0 +1,30 @@ +// packages/cli/test/commands/commit.test.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getCommitCommand } from '../../src/commands/commit'; +import * as core from '@stackcode/core'; +import inquirer from 'inquirer'; + +// Mock das dependências externas +vi.mock('@stackcode/core'); +vi.mock('inquirer'); + +describe('Commit Command Handler', () => { + // Assumindo que o comando exporta um handler, assim como o 'release' + const { handler } = getCommitCommand(); + + beforeEach(() => { + vi.clearAllMocks(); + // Silencia o console.log para manter a saída limpa + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should format a simple commit message correctly and call git commit', async () => { + // Nosso primeiro teste virá aqui + expect(true).toBe(true); // Placeholder + }); +}); \ No newline at end of file diff --git a/packages/cli/test/commands/release.test.ts b/packages/cli/test/commands/release.test.ts new file mode 100644 index 00000000..c440b110 --- /dev/null +++ b/packages/cli/test/commands/release.test.ts @@ -0,0 +1,78 @@ +// packages/cli/test/commands/release.test.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getReleaseCommand } from '../../src/commands/release'; +import * as core from '@stackcode/core'; +import inquirer from 'inquirer'; +import fs from 'fs/promises'; + +// Mock das dependências externas +vi.mock('@stackcode/core'); +vi.mock('inquirer'); +vi.mock('fs/promises'); + +describe('Release Command Handler', () => { + const { handler } = getReleaseCommand(); + + beforeEach(() => { + vi.clearAllMocks(); + // Silencia o console.log antes de cada teste + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'table').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restaura os mocks para o estado original depois de todos os testes + vi.restoreAllMocks(); + }); + + + it('should call the correct core functions for a "locked" release', async () => { + // Arrange + vi.mocked(core.detectVersioningStrategy).mockResolvedValue({ + strategy: 'locked', + rootDir: '/fake', + packages: [], + }); + vi.mocked(inquirer.prompt).mockResolvedValue({ confirm: true, createRelease: true }); + vi.mocked(core.getRecommendedBump).mockResolvedValue('patch'); + vi.mocked(core.getCommandOutput).mockResolvedValue('git@github.com:owner/repo.git'); + vi.mocked(fs.readFile).mockResolvedValue(''); + vi.mocked(fs.writeFile).mockResolvedValue(); + + // Act + // @ts-ignore + await handler({}); + + // Assert + expect(core.updateAllVersions).toHaveBeenCalledOnce(); + expect(fs.writeFile).toHaveBeenCalledOnce(); + expect(core.findChangedPackages).not.toHaveBeenCalled(); + }); + + it('should call the correct core functions for an "independent" release', async () => { + // Arrange + vi.mocked(core.detectVersioningStrategy).mockResolvedValue({ + strategy: 'independent', + rootDir: '/fake', + packages: [{ name: 'pkg1', path: '/fake/pkg1' }], + }); + vi.mocked(inquirer.prompt).mockResolvedValue({ confirmRelease: true, createRelease: true }); + vi.mocked(core.findChangedPackages).mockResolvedValue([{ name: 'pkg1', path: '/fake/pkg1' }]); + vi.mocked(core.determinePackageBumps).mockResolvedValue([{ + pkg: { name: 'pkg1', path: '/fake/pkg1' }, + bumpType: 'patch', + newVersion: '1.0.1' + }]); + vi.mocked(core.getCommandOutput).mockResolvedValue('git@github.com:owner/repo.git'); + + // Act + // @ts-ignore + await handler({}); + + // Assert + expect(core.findChangedPackages).toHaveBeenCalledOnce(); + expect(core.updateAllVersions).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/i18n/dist/locales/pt.json b/packages/i18n/dist/locales/pt.json index 23b9fb9f..d8b58954 100644 --- a/packages/i18n/dist/locales/pt.json +++ b/packages/i18n/dist/locales/pt.json @@ -7,7 +7,7 @@ "error_demand_command": "Você precisa especificar pelo menos um comando." }, "commit": { - "command_description": "Inicia um assistente interativo para criar uma mensagem de commit convencional.", + "command_description": "Inicia uistente interativo para criar uma mensagem de commit convencional.", "success": "✔ Commit criado com sucesso!", "error_no_changes_staged": "Nenhuma alteração adicionada ao stage. Por favor, use 'git add' antes de commitar.", "prompt": { From 45666f8314930f06a9cfe533f77296c338597037 Mon Sep 17 00:00:00 2001 From: Yago Azevedo Borba <140000816+YagoBorba@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:44:11 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat(cli):=20=E2=9C=A8=20add=20interactive?= =?UTF-8?q?=20and=20non-interactive=20modes=20to=20config=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/dist/commands/config.js | 148 +++++++++++++------ packages/cli/src/commands/config.ts | 172 +++++++++++++++------- packages/cli/test/commands/commit.test.ts | 63 +++++++- packages/cli/test/commands/config.test.ts | 114 ++++++++++++++ 4 files changed, 388 insertions(+), 109 deletions(-) create mode 100644 packages/cli/test/commands/config.test.ts diff --git a/packages/cli/dist/commands/config.js b/packages/cli/dist/commands/config.js index 25899521..bc04d8b5 100644 --- a/packages/cli/dist/commands/config.js +++ b/packages/cli/dist/commands/config.js @@ -1,9 +1,35 @@ +// packages/cli/src/commands/config.ts import chalk from 'chalk'; import Configstore from 'configstore'; import inquirer from 'inquirer'; import fs from 'fs/promises'; import path from 'path'; import { t } from '@stackcode/i18n'; +const globalConfig = new Configstore('@stackcode/cli'); +/** + * Handles the non-interactive command logic based on provided arguments. + * This function is easily testable in isolation. + * @param argv - The arguments object from yargs. + */ +export async function handleNonInteractiveMode(argv) { + switch (argv.action) { + case 'set': + if (!argv.key || !argv.value) { + console.error(chalk.red(t('config.error.missing_set_args'))); + return; + } + globalConfig.set(argv.key, argv.value); + console.log(chalk.green(t('config.success.set', { key: argv.key, value: argv.value }))); + break; + // Futuras implementações não-interativas (get, delete, etc.) podem ser adicionadas aqui. + default: + console.error(chalk.yellow(t('config.error.invalid_action', { action: argv.action || 'unknown' }))); + break; + } +} +/** + * Finds the project root by looking for a package.json file. + */ const findProjectRoot = async (startPath) => { let currentPath = startPath; while (currentPath !== path.parse(currentPath).root) { @@ -16,57 +42,87 @@ const findProjectRoot = async (startPath) => { } return null; }; -const globalConfig = new Configstore('@stackcode/cli'); -export const getConfigCommand = () => ({ - command: 'config', - describe: t('config.command_description'), - builder: {}, - handler: async () => { - const { choice } = await inquirer.prompt([ +/** + * Runs the fully interactive configuration session using inquirer. + */ +export async function runInteractiveMode() { + const { choice } = await inquirer.prompt([ + { + type: 'list', name: 'choice', message: t('config.prompt.main'), + choices: [ + { name: t('config.prompt.select_lang'), value: 'lang' }, + { name: t('config.prompt.toggle_validation'), value: 'commitValidation' }, + ], + } + ]); + if (choice === 'lang') { + const { lang } = await inquirer.prompt([ { - type: 'list', name: 'choice', message: t('config.prompt.main'), - choices: [ - { name: t('config.prompt.select_lang'), value: 'lang' }, - { name: t('config.prompt.toggle_validation'), value: 'commitValidation' }, - ], + type: 'list', name: 'lang', message: t('config.prompt.select_lang'), + choices: [{ name: 'English', value: 'en' }, { name: 'Português', value: 'pt' }], } ]); - if (choice === 'lang') { - const { lang } = await inquirer.prompt([ - { - type: 'list', name: 'lang', message: t('config.prompt.select_lang'), - choices: [{ name: 'English', value: 'en' }, { name: 'Português', value: 'pt' }], - } - ]); - globalConfig.set('lang', lang); - console.log(chalk.green(t('config.success.set', { key: 'lang', value: lang }))); + globalConfig.set('lang', lang); + console.log(chalk.green(t('config.success.set', { key: 'lang', value: lang }))); + } + else if (choice === 'commitValidation') { + const projectRoot = await findProjectRoot(process.cwd()); + if (!projectRoot) { + console.error(chalk.red(t('config.error.not_in_project'))); + return; } - else if (choice === 'commitValidation') { - const projectRoot = await findProjectRoot(process.cwd()); - if (!projectRoot) { - console.error(chalk.red(t('config.error.not_in_project'))); - return; - } - const localConfigPath = path.join(projectRoot, '.stackcoderc.json'); - try { - await fs.access(localConfigPath); - } - catch { - console.error(chalk.red(t('config.error.not_in_project'))); - return; + const localConfigPath = path.join(projectRoot, '.stackcoderc.json'); + try { + await fs.access(localConfigPath); + } + catch { + console.error(chalk.red(t('config.error.no_stackcoderc'))); + return; + } + const { enable } = await inquirer.prompt([ + { + type: 'confirm', name: 'enable', message: t('config.prompt.toggle_validation'), + default: true, } - const { enable } = await inquirer.prompt([ - { - type: 'confirm', name: 'enable', message: t('config.prompt.toggle_validation'), - default: true, - } - ]); - const localConfigContent = await fs.readFile(localConfigPath, 'utf-8'); - const localConfig = JSON.parse(localConfigContent); - localConfig.features.commitValidation = enable; - await fs.writeFile(localConfigPath, JSON.stringify(localConfig, null, 2)); - const status = enable ? t('config.status.enabled') : t('config.status.disabled'); - console.log(chalk.green(t('config.success.set_validation', { status }))); + ]); + const localConfigContent = await fs.readFile(localConfigPath, 'utf-8'); + const localConfig = JSON.parse(localConfigContent); + localConfig.features.commitValidation = enable; + await fs.writeFile(localConfigPath, JSON.stringify(localConfig, null, 2)); + const status = enable ? t('config.status.enabled') : t('config.status.disabled'); + console.log(chalk.green(t('config.success.set_validation', { status }))); + } +} +/** + * Defines the 'config' command, its arguments, and the handler logic. + */ +export const getConfigCommand = () => ({ + command: 'config [action] [key] [value]', + describe: t('config.command_description'), + builder: (yargs) => { + return yargs + .positional('action', { + describe: t('config.args.action_description'), + type: 'string', + choices: ['set'], // Apenas 'set' está implementado no modo não-interativo por enquanto + }) + .positional('key', { + describe: t('config.args.key_description'), + type: 'string', + }) + .positional('value', { + describe: t('config.args.value_description'), + type: 'string', + }); + }, + handler: async (argv) => { + // The handler is now an orchestrator. + const isInteractive = !argv.action; + if (isInteractive) { + await runInteractiveMode(); + } + else { + await handleNonInteractiveMode(argv); } }, }); diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index 3ee854bf..4994dab1 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -1,4 +1,6 @@ -import type { CommandModule } from 'yargs'; +// packages/cli/src/commands/config.ts + +import type { CommandModule, Argv, Arguments } from 'yargs'; import chalk from 'chalk'; import Configstore from 'configstore'; import inquirer from 'inquirer'; @@ -6,6 +8,34 @@ import fs from 'fs/promises'; import path from 'path'; import { t } from '@stackcode/i18n'; +const globalConfig = new Configstore('@stackcode/cli'); + +/** + * Handles the non-interactive command logic based on provided arguments. + * This function is easily testable in isolation. + * @param argv - The arguments object from yargs. + */ +export async function handleNonInteractiveMode(argv: Arguments<{ action?: string; key?: string; value?: string }>) { + switch (argv.action) { + case 'set': + if (!argv.key || !argv.value) { + console.error(chalk.red(t('config.error.missing_set_args'))); + return; + } + globalConfig.set(argv.key, argv.value); + console.log(chalk.green(t('config.success.set', { key: argv.key, value: argv.value }))); + break; + + // Futuras implementações não-interativas (get, delete, etc.) podem ser adicionadas aqui. + default: + console.error(chalk.yellow(t('config.error.invalid_action', { action: argv.action || 'unknown' }))); + break; + } +} + +/** + * Finds the project root by looking for a package.json file. + */ const findProjectRoot = async (startPath: string): Promise => { let currentPath = startPath; while (currentPath !== path.parse(currentPath).root) { @@ -18,64 +48,96 @@ const findProjectRoot = async (startPath: string): Promise => { return null; }; -const globalConfig = new Configstore('@stackcode/cli'); +/** + * Runs the fully interactive configuration session using inquirer. + */ +export async function runInteractiveMode() { + const { choice } = await inquirer.prompt([ + { + type: 'list', name: 'choice', message: t('config.prompt.main'), + choices: [ + { name: t('config.prompt.select_lang'), value: 'lang' }, + { name: t('config.prompt.toggle_validation'), value: 'commitValidation' }, + ], + } + ]); -export const getConfigCommand = (): CommandModule => ({ - command: 'config', - describe: t('config.command_description'), - builder: {}, - handler: async () => { - const { choice } = await inquirer.prompt([ - { - type: 'list', name: 'choice', message: t('config.prompt.main'), - choices: [ - { name: t('config.prompt.select_lang'), value: 'lang' }, - { name: t('config.prompt.toggle_validation'), value: 'commitValidation' }, - ], - } - ]); + if (choice === 'lang') { + const { lang } = await inquirer.prompt([ + { + type: 'list', name: 'lang', message: t('config.prompt.select_lang'), + choices: [ { name: 'English', value: 'en' }, { name: 'Português', value: 'pt' } ], + } + ]); + globalConfig.set('lang', lang); + console.log(chalk.green(t('config.success.set', { key: 'lang', value: lang }))); + } + + else if (choice === 'commitValidation') { + const projectRoot = await findProjectRoot(process.cwd()); + if (!projectRoot) { + console.error(chalk.red(t('config.error.not_in_project'))); + return; + } + + const localConfigPath = path.join(projectRoot, '.stackcoderc.json'); + + try { + await fs.access(localConfigPath); + } catch { + console.error(chalk.red(t('config.error.no_stackcoderc'))); + return; + } - if (choice === 'lang') { - const { lang } = await inquirer.prompt([ - { - type: 'list', name: 'lang', message: t('config.prompt.select_lang'), - choices: [ { name: 'English', value: 'en' }, { name: 'Português', value: 'pt' } ], - } - ]); - globalConfig.set('lang', lang); - console.log(chalk.green(t('config.success.set', { key: 'lang', value: lang }))); - } - - else if (choice === 'commitValidation') { - const projectRoot = await findProjectRoot(process.cwd()); - if (!projectRoot) { - console.error(chalk.red(t('config.error.not_in_project'))); - return; - } + const { enable } = await inquirer.prompt([ + { + type: 'confirm', name: 'enable', message: t('config.prompt.toggle_validation'), + default: true, + } + ]); - const localConfigPath = path.join(projectRoot, '.stackcoderc.json'); - - try { - await fs.access(localConfigPath); - } catch { - console.error(chalk.red(t('config.error.not_in_project'))); - return; - } + const localConfigContent = await fs.readFile(localConfigPath, 'utf-8'); + const localConfig = JSON.parse(localConfigContent); + localConfig.features.commitValidation = enable; + await fs.writeFile(localConfigPath, JSON.stringify(localConfig, null, 2)); - const { enable } = await inquirer.prompt([ - { - type: 'confirm', name: 'enable', message: t('config.prompt.toggle_validation'), - default: true, - } - ]); + const status = enable ? t('config.status.enabled') : t('config.status.disabled'); + console.log(chalk.green(t('config.success.set_validation', { status }))); + } +} - const localConfigContent = await fs.readFile(localConfigPath, 'utf-8'); - const localConfig = JSON.parse(localConfigContent); - localConfig.features.commitValidation = enable; - await fs.writeFile(localConfigPath, JSON.stringify(localConfig, null, 2)); +/** + * Defines the 'config' command, its arguments, and the handler logic. + */ +export const getConfigCommand = (): CommandModule => ({ + command: 'config [action] [key] [value]', + describe: t('config.command_description'), + + builder: (yargs: Argv) => { + return yargs + .positional('action', { + describe: t('config.args.action_description'), + type: 'string', + choices: ['set'], // Apenas 'set' está implementado no modo não-interativo por enquanto + }) + .positional('key', { + describe: t('config.args.key_description'), + type: 'string', + }) + .positional('value', { + describe: t('config.args.value_description'), + type: 'string', + }); + }, + + handler: async (argv) => { + // The handler is now an orchestrator. + const isInteractive = !argv.action; - const status = enable ? t('config.status.enabled') : t('config.status.disabled'); - console.log(chalk.green(t('config.success.set_validation', { status }))); - } - }, + if (isInteractive) { + await runInteractiveMode(); + } else { + await handleNonInteractiveMode(argv); + } + }, }); \ No newline at end of file diff --git a/packages/cli/test/commands/commit.test.ts b/packages/cli/test/commands/commit.test.ts index 79b0930f..cd21fce0 100644 --- a/packages/cli/test/commands/commit.test.ts +++ b/packages/cli/test/commands/commit.test.ts @@ -1,30 +1,77 @@ -// packages/cli/test/commands/commit.test.ts - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getCommitCommand } from '../../src/commands/commit'; import * as core from '@stackcode/core'; import inquirer from 'inquirer'; -// Mock das dependências externas vi.mock('@stackcode/core'); vi.mock('inquirer'); describe('Commit Command Handler', () => { - // Assumindo que o comando exporta um handler, assim como o 'release' const { handler } = getCommitCommand(); beforeEach(() => { vi.clearAllMocks(); - // Silencia o console.log para manter a saída limpa vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); - it('should format a simple commit message correctly and call git commit', async () => { - // Nosso primeiro teste virá aqui - expect(true).toBe(true); // Placeholder + it('should format a simple commit message and call git commit', async () => { + vi.mocked(core.getCommandOutput).mockResolvedValue('M packages/cli/src/commands/commit.ts'); + + vi.mocked(inquirer.prompt).mockResolvedValue({ + type: 'feat', + scope: 'api', + shortDescription: 'add new login endpoint', + longDescription: '', + breakingChanges: '', + affectedIssues: '', + }); + + const runCommandMock = vi.mocked(core.runCommand); + + await handler({ _: [], $0: 'stc' }); + expect(runCommandMock).toHaveBeenCalledOnce(); + expect(runCommandMock).toHaveBeenCalledWith( + 'git', + ['commit', '-m', 'feat(api): add new login endpoint'], + expect.anything() + ); + }); + + it('should format a complex commit message with body, breaking change, and footer', async () => { + vi.mocked(core.getCommandOutput).mockResolvedValue('M packages/cli/src/commands/commit.ts'); + + vi.mocked(inquirer.prompt).mockResolvedValue({ + type: 'refactor', + scope: 'auth', + shortDescription: 'use JWT service for authentication', + longDescription: 'Implement new JWT service for better security.|Separate concerns.', + breakingChanges: 'The token format has changed and now requires a new validation method.', + affectedIssues: 'closes #42', + }); + + const runCommandMock = vi.mocked(core.runCommand); + + await handler({ _: [], $0: 'stc' }); + + const expectedMessage = `refactor(auth): use JWT service for authentication + +Implement new JWT service for better security. +Separate concerns. + +BREAKING CHANGE: The token format has changed and now requires a new validation method. + +closes #42`; + + expect(runCommandMock).toHaveBeenCalledOnce(); + expect(runCommandMock).toHaveBeenCalledWith( + 'git', + ['commit', '-m', expectedMessage], + expect.anything() + ); }); }); \ No newline at end of file diff --git a/packages/cli/test/commands/config.test.ts b/packages/cli/test/commands/config.test.ts new file mode 100644 index 00000000..81e1709c --- /dev/null +++ b/packages/cli/test/commands/config.test.ts @@ -0,0 +1,114 @@ +// packages/cli/test/commands/config.test.ts + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import inquirer from 'inquirer'; +import { getConfigCommand, handleNonInteractiveMode, runInteractiveMode } from '../../src/commands/config'; +import fs from 'fs/promises'; +import path from 'path'; + +// --- Mocks --- +vi.mock('inquirer'); + +const mockSet = vi.fn(); +const mockGet = vi.fn(); +const mockDelete = vi.fn(); +const mockClear = vi.fn(); + +vi.mock('configstore', () => ({ + default: vi.fn(() => ({ + set: (...args) => mockSet(...args), + get: (...args) => mockGet(...args), + delete: (...args) => mockDelete(...args), + clear: (...args) => mockClear(...args), + })), +})); + +vi.mock('fs/promises'); +vi.mock('@stackcode/i18n', () => ({ t: (key: string) => key })); + + +// --- Type Assertions for Mocks --- +const mockedInquirer = vi.mocked(inquirer); +const mockedFs = vi.mocked(fs); + +describe('Config Command', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('Handler Orchestration', () => { + const { handler } = getConfigCommand(); + + it('should call runInteractiveMode when no action is provided', async () => { + // FIX: Provide a resolved value for the prompt to prevent the TypeError. + // An empty object is sufficient as we only want to test the call. + mockedInquirer.prompt.mockResolvedValue({}); + + await handler({ _: ['config'], $0: 'stc' }); + expect(mockedInquirer.prompt).toHaveBeenCalled(); + }); + + it('should call handleNonInteractiveMode when an action is provided', async () => { + const args = { _: ['config', 'set'], $0: 'stc', action: 'set', key: 'lang', value: 'en' }; + await handler(args); + expect(mockedInquirer.prompt).not.toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith('lang', 'en'); + }); + }); + + describe('Non-Interactive Mode', () => { + it('should set a configuration value', async () => { + const args = { action: 'set', key: 'lang', value: 'en', _: [], $0: 'stc' }; + await handleNonInteractiveMode(args); + expect(mockSet).toHaveBeenCalledOnce(); + expect(mockSet).toHaveBeenCalledWith('lang', 'en'); + expect(console.log).toHaveBeenCalledWith('config.success.set'); + }); + + it('should show an error if "set" is missing arguments', async () => { + const args = { action: 'set', key: 'lang', _: [], $0: 'stc' }; + await handleNonInteractiveMode(args); + expect(mockSet).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('config.error.missing_set_args'); + }); + }); + + describe('Interactive Mode', () => { + it('should set language preference when user selects that option', async () => { + mockedInquirer.prompt + .mockResolvedValueOnce({ choice: 'lang' }) + .mockResolvedValueOnce({ lang: 'pt' }); + + await runInteractiveMode(); + + expect(mockedInquirer.prompt).toHaveBeenCalledTimes(2); + expect(mockSet).toHaveBeenCalledWith('lang', 'pt'); + expect(console.log).toHaveBeenCalledWith('config.success.set'); + }); + + it('should enable commit validation when user confirms', async () => { + vi.spyOn(path, 'join').mockReturnValue('/fake/project/.stackcoderc.json'); + vi.spyOn(process, 'cwd').mockReturnValue('/fake/project/src'); + mockedFs.access.mockResolvedValue(undefined); + const fakeConfig = { features: { commitValidation: false } }; + mockedFs.readFile.mockResolvedValue(JSON.stringify(fakeConfig)); + + mockedInquirer.prompt + .mockResolvedValueOnce({ choice: 'commitValidation' }) + .mockResolvedValueOnce({ enable: true }); + + await runInteractiveMode(); + + expect(mockedFs.writeFile).toHaveBeenCalledOnce(); + const writtenContent = JSON.parse(vi.mocked(mockedFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent.features.commitValidation).toBe(true); + expect(console.log).toHaveBeenCalledWith('config.success.set_validation'); + }); + }); +}); \ No newline at end of file From e64777df5e3e32202455c04c298b399a94665380 Mon Sep 17 00:00:00 2001 From: Yago Azevedo Borba <140000816+YagoBorba@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:04:14 +0000 Subject: [PATCH 3/3] =?UTF-8?q?test(cli):=20=E2=9C=85=20add=20comprehensiv?= =?UTF-8?q?e=20tests=20for=20init=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #26 --- packages/cli/test/commands/config.test.ts | 8 +- packages/cli/test/commands/generate.test.ts | 132 ++++++++++++++++++++ packages/cli/test/commands/git.test.ts | 81 ++++++++++++ packages/cli/test/commands/init.test.ts | 105 ++++++++++++++++ packages/cli/test/commands/release.test.ts | 5 - packages/cli/test/commands/validate.test.ts | 54 ++++++++ 6 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 packages/cli/test/commands/generate.test.ts create mode 100644 packages/cli/test/commands/git.test.ts create mode 100644 packages/cli/test/commands/init.test.ts create mode 100644 packages/cli/test/commands/validate.test.ts diff --git a/packages/cli/test/commands/config.test.ts b/packages/cli/test/commands/config.test.ts index 81e1709c..8e49f324 100644 --- a/packages/cli/test/commands/config.test.ts +++ b/packages/cli/test/commands/config.test.ts @@ -1,12 +1,9 @@ -// packages/cli/test/commands/config.test.ts - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import inquirer from 'inquirer'; import { getConfigCommand, handleNonInteractiveMode, runInteractiveMode } from '../../src/commands/config'; import fs from 'fs/promises'; import path from 'path'; -// --- Mocks --- vi.mock('inquirer'); const mockSet = vi.fn(); @@ -26,8 +23,6 @@ vi.mock('configstore', () => ({ vi.mock('fs/promises'); vi.mock('@stackcode/i18n', () => ({ t: (key: string) => key })); - -// --- Type Assertions for Mocks --- const mockedInquirer = vi.mocked(inquirer); const mockedFs = vi.mocked(fs); @@ -46,8 +41,7 @@ describe('Config Command', () => { const { handler } = getConfigCommand(); it('should call runInteractiveMode when no action is provided', async () => { - // FIX: Provide a resolved value for the prompt to prevent the TypeError. - // An empty object is sufficient as we only want to test the call. + mockedInquirer.prompt.mockResolvedValue({}); await handler({ _: ['config'], $0: 'stc' }); diff --git a/packages/cli/test/commands/generate.test.ts b/packages/cli/test/commands/generate.test.ts new file mode 100644 index 00000000..c5e25c81 --- /dev/null +++ b/packages/cli/test/commands/generate.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import inquirer from 'inquirer'; +import fs from 'fs/promises'; +import { generateGitignoreContent, generateReadmeContent } from '@stackcode/core'; +import { getGenerateCommand } from '../../src/commands/generate'; + +vi.mock('@stackcode/core', () => ({ + generateReadmeContent: vi.fn(), + generateGitignoreContent: vi.fn(), +})); + +vi.mock('inquirer'); + +vi.mock('fs/promises'); + +vi.mock('@stackcode/i18n', () => ({ t: (key: string) => key })); + +const mockedInquirer = vi.mocked(inquirer); +const mockedFs = vi.mocked(fs); +const mockedCore = { + generateReadmeContent: vi.mocked(generateReadmeContent), + generateGitignoreContent: vi.mocked(generateGitignoreContent), +}; + +describe('Generate Command', () => { + const { handler } = getGenerateCommand(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + describe('Non-Interactive Mode', () => { + it('should generate a README.md when specified as an argument', async () => { + // Arrange + const mockContent = '# Mocked README'; + mockedCore.generateReadmeContent.mockResolvedValue(mockContent); + mockedFs.access.mockRejectedValue(new Error('File not found')); + const argv = { filetype: 'readme', _:[], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedCore.generateReadmeContent).toHaveBeenCalledOnce(); + expect(mockedFs.writeFile).toHaveBeenCalledOnce(); + expect(mockedFs.writeFile).toHaveBeenCalledWith(expect.stringContaining('README.md'), mockContent); + expect(console.log).toHaveBeenCalledWith('generate.success.readme'); + }); + + it('should generate a .gitignore when specified as an argument', async () => { + // Arrange + const mockContent = 'node_modules/'; + mockedCore.generateGitignoreContent.mockResolvedValue(mockContent); + mockedFs.access.mockRejectedValue(new Error('File not found')); + mockedFs.readFile.mockResolvedValue('{ "stack": "node-ts" }'); + const argv = { filetype: 'gitignore', _:[], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedCore.generateGitignoreContent).toHaveBeenCalledWith(['node-ts']); + expect(mockedFs.writeFile).toHaveBeenCalledWith(expect.stringContaining('.gitignore'), mockContent); + expect(console.log).toHaveBeenCalledWith('generate.success.gitignore'); + }); + }); + + describe('Interactive Mode', () => { + it('should generate selected files when no argument is provided', async () => { + // Arrange + mockedInquirer.prompt.mockResolvedValue({ filesToGenerate: ['readme', 'gitignore'] }); + mockedCore.generateReadmeContent.mockResolvedValue('# README'); + mockedCore.generateGitignoreContent.mockResolvedValue('node_modules'); + mockedFs.access.mockRejectedValue(new Error('File not found')); + const argv = { _: [], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedFs.writeFile).toHaveBeenCalledTimes(2); + expect(mockedFs.writeFile).toHaveBeenCalledWith(expect.stringContaining('README.md'), '# README'); + expect(mockedFs.writeFile).toHaveBeenCalledWith(expect.stringContaining('.gitignore'), 'node_modules'); + }); + + it('should cancel if user selects no files in interactive mode', async () => { + // Arrange + mockedInquirer.prompt.mockResolvedValue({ filesToGenerate: [] }); + const argv = { _: [], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedFs.writeFile).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('common.operation_cancelled'); + }); + }); + + describe('File Overwriting Logic', () => { + it('should prompt for overwrite if file exists and proceed if confirmed', async () => { + // Arrange + mockedFs.access.mockResolvedValue(undefined); + mockedInquirer.prompt.mockResolvedValue({ overwrite: true }); + mockedCore.generateReadmeContent.mockResolvedValue('# Overwritten'); + const argv = { filetype: 'readme', _:[], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedInquirer.prompt).toHaveBeenCalledOnce(); + expect(mockedFs.writeFile).toHaveBeenCalledOnce(); + }); + + it('should cancel if file exists and user denies overwrite', async () => { + // Arrange + mockedFs.access.mockResolvedValue(undefined); + mockedInquirer.prompt.mockResolvedValue({ overwrite: false }); + const argv = { filetype: 'readme', _:[], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedInquirer.prompt).toHaveBeenCalledOnce(); + expect(mockedFs.writeFile).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('common.operation_cancelled'); + }); + }); +}); \ No newline at end of file diff --git a/packages/cli/test/commands/git.test.ts b/packages/cli/test/commands/git.test.ts new file mode 100644 index 00000000..6a84044d --- /dev/null +++ b/packages/cli/test/commands/git.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import inquirer from 'inquirer'; +import { getGitCommand } from '../../src/commands/git'; + +import { createBranch } from '../../src/commands/git_sub/start.js'; +import { finishHandler } from '../../src/commands/git_sub/finish.js'; + +vi.mock('../../src/commands/git_sub/start.js', () => ({ + getStartCommand: vi.fn(() => ({ command: 'start', describe: 'starts a new branch', handler: vi.fn() })), + createBranch: vi.fn(), +})); + +vi.mock('../../src/commands/git_sub/finish.js', () => ({ + getFinishCommand: vi.fn(() => ({ command: 'finish', describe: 'finishes a branch', handler: vi.fn() })), + finishHandler: vi.fn(), +})); + +vi.mock('inquirer'); + +vi.mock('@stackcode/i18n', () => ({ t: (key: string) => key })); + +const mockedInquirer = vi.mocked(inquirer); +const mockedCreateBranch = vi.mocked(createBranch); +const mockedFinishHandler = vi.mocked(finishHandler); + + +describe('Git Command', () => { + const { handler } = getGitCommand(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Interactive Mode Handler', () => { + it('should call createBranch with correct arguments when user chooses "start"', async () => { + // Arrange + mockedInquirer.prompt + .mockResolvedValueOnce({ action: 'start' }) + .mockResolvedValueOnce({ branchName: 'new-feature' }) + .mockResolvedValueOnce({ branchType: 'feature' }); + + const argv = { _:['git'], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedInquirer.prompt).toHaveBeenCalledTimes(3); + expect(mockedCreateBranch).toHaveBeenCalledOnce(); + expect(mockedCreateBranch).toHaveBeenCalledWith('new-feature', 'feature'); + expect(mockedFinishHandler).not.toHaveBeenCalled(); + }); + + it('should call finishHandler when user chooses "finish"', async () => { + // Arrange + mockedInquirer.prompt.mockResolvedValueOnce({ action: 'finish' }); + const argv = { _:['git'], $0: 'stc' }; + + // Act + await handler(argv); + + // Assert + expect(mockedInquirer.prompt).toHaveBeenCalledOnce(); + expect(mockedFinishHandler).toHaveBeenCalledOnce(); + expect(mockedCreateBranch).not.toHaveBeenCalled(); + }); + }); + + describe('Non-Interactive Mode (Subcommand Dispatch)', () => { + it('should not prompt user if a subcommand is being run', async () => { + // Arrange + const argv = { _:['git', 'start'], $0: 'stc', subcommand: 'start' }; + + // Act + await handler(argv); + + // Assert + expect(mockedInquirer.prompt).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/packages/cli/test/commands/init.test.ts b/packages/cli/test/commands/init.test.ts new file mode 100644 index 00000000..7d6a35c1 --- /dev/null +++ b/packages/cli/test/commands/init.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import inquirer from 'inquirer'; +import fs from 'fs/promises'; +import { + scaffoldProject, + setupHusky, + generateReadmeContent, + generateGitignoreContent, + runCommand, +} from '@stackcode/core'; +import { getInitCommand } from '../../src/commands/init'; + +vi.mock('@stackcode/core', () => ({ + scaffoldProject: vi.fn(), + setupHusky: vi.fn(), + generateReadmeContent: vi.fn(), + generateGitignoreContent: vi.fn(), + runCommand: vi.fn(), +})); + +vi.mock('inquirer'); +vi.mock('fs/promises'); +vi.mock('@stackcode/i18n', () => ({ t: (key: string) => key })); + +const mockedInquirer = vi.mocked(inquirer); +const mockedFs = vi.mocked(fs); +const mockedCore = { + scaffoldProject: vi.mocked(scaffoldProject), + setupHusky: vi.mocked(setupHusky), + generateReadmeContent: vi.mocked(generateReadmeContent), + generateGitignoreContent: vi.mocked(generateGitignoreContent), + runCommand: vi.mocked(runCommand), +}; + +describe('Init Command', () => { + const { handler } = getInitCommand(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should run the full project initialization flow successfully', async () => { + const mockAnswers = { + projectName: 'test-project', + description: 'A test project.', + authorName: 'Test Author', + stack: 'node-ts', + features: ['docker', 'husky'], + commitValidation: true, + }; + mockedInquirer.prompt.mockResolvedValue(mockAnswers); + mockedFs.access.mockRejectedValue(new Error('not found')); + mockedCore.generateReadmeContent.mockResolvedValue('# Test Project'); + mockedCore.generateGitignoreContent.mockResolvedValue('node_modules'); + + // Act + await handler({ _:[], $0: 'stc' }); + // Assert + const projectPath = expect.stringContaining(mockAnswers.projectName); + + expect(mockedCore.scaffoldProject).toHaveBeenCalledWith({ + projectPath: projectPath, + stack: mockAnswers.stack, + features: mockAnswers.features, + replacements: { + projectName: mockAnswers.projectName, + description: mockAnswers.description, + authorName: mockAnswers.authorName, + }, + }); + + expect(mockedFs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('.stackcoderc.json'), + expect.stringContaining('"commitValidation": true') + ); + + expect(mockedFs.writeFile).toHaveBeenCalledWith(projectPath, '# Test Project'); + expect(mockedFs.writeFile).toHaveBeenCalledWith(projectPath, 'node_modules'); + + expect(mockedCore.setupHusky).toHaveBeenCalledWith(projectPath); + + expect(mockedCore.runCommand).toHaveBeenCalledWith('git', ['init'], { cwd: projectPath }); + expect(mockedCore.runCommand).toHaveBeenCalledWith('npm', ['install'], { cwd: projectPath }); + + expect(console.log).toHaveBeenCalledWith('init.success.ready'); + }); + + it('should cancel the operation if user denies overwrite', async () => { + // Arrange + const mockAnswers = { projectName: 'existing-project' }; + mockedInquirer.prompt + .mockResolvedValueOnce(mockAnswers) + .mockResolvedValueOnce({ overwrite: false }); + mockedFs.access.mockResolvedValue(undefined); + + // Act + await handler({ _:[], $0: 'stc' }); + + // Assert + expect(mockedInquirer.prompt).toHaveBeenCalledTimes(2); + expect(mockedCore.scaffoldProject).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith('common.operation_cancelled'); + }); +}); \ No newline at end of file diff --git a/packages/cli/test/commands/release.test.ts b/packages/cli/test/commands/release.test.ts index c440b110..c8419891 100644 --- a/packages/cli/test/commands/release.test.ts +++ b/packages/cli/test/commands/release.test.ts @@ -1,12 +1,9 @@ -// packages/cli/test/commands/release.test.ts - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getReleaseCommand } from '../../src/commands/release'; import * as core from '@stackcode/core'; import inquirer from 'inquirer'; import fs from 'fs/promises'; -// Mock das dependências externas vi.mock('@stackcode/core'); vi.mock('inquirer'); vi.mock('fs/promises'); @@ -16,14 +13,12 @@ describe('Release Command Handler', () => { beforeEach(() => { vi.clearAllMocks(); - // Silencia o console.log antes de cada teste vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'table').mockImplementation(() => {}); }); afterEach(() => { - // Restaura os mocks para o estado original depois de todos os testes vi.restoreAllMocks(); }); diff --git a/packages/cli/test/commands/validate.test.ts b/packages/cli/test/commands/validate.test.ts new file mode 100644 index 00000000..9adc9be4 --- /dev/null +++ b/packages/cli/test/commands/validate.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { validateCommitMessage } from '@stackcode/core'; +import { getValidateCommand } from '../../src/commands/validate'; + +vi.mock('@stackcode/core', () => ({ + validateCommitMessage: vi.fn(), +})); +vi.mock('@stackcode/i18n', () => ({ t: (key: string) => key })); + +const mockedCore = { + validateCommitMessage: vi.mocked(validateCommitMessage), +}; + +describe('Validate Command', () => { + const { handler } = getValidateCommand(); + const mockProcessExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should log a success message for a valid commit message', () => { + // Arrange + const argv = { message: 'feat: add new feature', _: [], $0: 'stc' }; + mockedCore.validateCommitMessage.mockReturnValue(true); + + // Act + handler(argv); + + // Assert + expect(mockedCore.validateCommitMessage).toHaveBeenCalledWith(argv.message); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('validate.success')); + expect(mockProcessExit).not.toHaveBeenCalled(); + }); + + it('should log an error and exit with code 1 for an invalid commit message', () => { + // Arrange + const argv = { message: 'invalid message', _: [], $0: 'stc' }; + mockedCore.validateCommitMessage.mockReturnValue(false); + + // Act + handler(argv); + + // Assert + expect(mockedCore.validateCommitMessage).toHaveBeenCalledWith(argv.message); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('validate.error_invalid')); + expect(mockProcessExit).toHaveBeenCalledOnce(); + expect(mockProcessExit).toHaveBeenCalledWith(1); + }); + + it.todo('yargs should enforce the message argument'); +}); \ No newline at end of file