diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5fa595f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18, 20, 22] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test diff --git a/bin/servergen.js b/bin/servergen.js index 39cb81b..7a78433 100644 --- a/bin/servergen.js +++ b/bin/servergen.js @@ -32,6 +32,7 @@ program .option('-v --view ', 'Name of View Engine: Pug | Jade | EJS | HBS') .option('--db', 'Install Mongoose & the Folder Directory for it') .option('-p, --port ', 'Set default port for the app', '3000') + .option('--skip-install', 'Skip npm install step') .option('--debug', 'Enable debug logging') .parse(process.argv); @@ -48,8 +49,9 @@ handleValidationErrors(validationResult); const appName = fileName.cleanAppName(options.name); const port = parseInt(options.port, 10) || 3000; +const skipInstall = options.skipInstall || false; -logger.debug('Parsed configuration', { appName, port, framework: options.framework }); +logger.debug('Parsed configuration', { appName, port, framework: options.framework, skipInstall }); /** * Main function to run the application generator. @@ -62,6 +64,7 @@ const main = async () => { view: options.view, db: options.db, port, + skipInstall, config, }, { diff --git a/lib/app_generator.js b/lib/app_generator.js index db2e604..eaf08b2 100644 --- a/lib/app_generator.js +++ b/lib/app_generator.js @@ -24,6 +24,7 @@ class AppGenerator { * @param {string|null} options.view - The view engine name. * @param {boolean} options.db - Whether to include database configuration. * @param {number} options.port - The port number for the app. + * @param {boolean} options.skipInstall - Whether to skip npm install. * @param {Object} options.config - Configuration object from lib/config. * @param {Object} dependencies - Injected dependencies. * @param {Object} dependencies.fsHelper - File system helper module. @@ -37,6 +38,7 @@ class AppGenerator { this.view = options.view; this.db = options.db; this.port = options.port || 3000; + this.skipInstall = options.skipInstall || false; this.config = options.config; this.fsHelper = dependencies.fsHelper; @@ -59,6 +61,7 @@ class AppGenerator { appName: this.appName, framework: this.framework, port: this.port, + skipInstall: this.skipInstall, }); this.createAppFolder(); @@ -67,7 +70,10 @@ class AppGenerator { this.setupViews(); this.setupDatabase(); this.addSupportFiles(); - await this.installDependencies(); + + if (!this.skipInstall) { + await this.installDependencies(); + } this.logger?.success(`Application ${this.appName} created successfully`); } diff --git a/package.json b/package.json index 1f45667..aa3dd51 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "main": "./bin/servergen.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "repository": { "type": "git", @@ -32,5 +34,8 @@ "chalk": "^5.4.1", "commander": "^14.0.2", "fs-extra": "^11.3.3" + }, + "devDependencies": { + "vitest": "^4.0.18" } } diff --git a/tests/integration/integration.test.js b/tests/integration/integration.test.js new file mode 100644 index 0000000..25942f0 --- /dev/null +++ b/tests/integration/integration.test.js @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.join(__dirname, '..', '..'); +const testOutput = path.join(__dirname, '.integration-output'); + +describe('CLI Integration', () => { + beforeEach(() => { + fs.ensureDirSync(testOutput); + }); + + afterEach(() => { + fs.removeSync(testOutput); + }); + + const runCLI = (args) => { + const cmd = `node ${path.join(projectRoot, 'bin', 'servergen.js')} ${args}`; + return execSync(cmd, { + cwd: testOutput, + encoding: 'utf-8', + timeout: 60000, + }); + }; + + describe('help command', () => { + it('displays help information', () => { + const output = runCLI('--help'); + expect(output).toContain('Usage:'); + expect(output).toContain('-n, --name'); + expect(output).toContain('-f, --framework'); + }); + + it('displays version', () => { + const output = runCLI('--version'); + expect(output).toMatch(/\d+\.\d+\.\d+/); + }); + }); + + describe('Express app generation', () => { + it('generates Express app with correct structure', () => { + runCLI('-n testapp -f express --skip-install'); + + const appDir = path.join(testOutput, 'testapp'); + expect(fs.existsSync(appDir)).toBe(true); + expect(fs.existsSync(path.join(appDir, 'index.js'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'routes'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'controllers'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'model'))).toBe(true); + }); + + it('generates package.json with express dependency', () => { + runCLI('-n expresstest -f express --skip-install'); + + const pkgPath = path.join(testOutput, 'expresstest', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + + expect(pkg.name).toBe('expresstest'); + expect(pkg.dependencies.express).toBeDefined(); + expect(pkg.dependencies.nodemon).toBeDefined(); + expect(pkg.dependencies.cors).toBeDefined(); + }); + + it('includes view engine when specified', () => { + runCLI('-n viewtest -f express -v ejs --skip-install'); + + const pkgPath = path.join(testOutput, 'viewtest', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + + expect(pkg.dependencies.ejs).toBeDefined(); + }); + + it('includes mongoose when --db flag used', () => { + runCLI('-n dbtest -f express --db --skip-install'); + + const pkgPath = path.join(testOutput, 'dbtest', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + + expect(pkg.dependencies.mongoose).toBeDefined(); + expect(fs.existsSync(path.join(testOutput, 'dbtest', 'config'))).toBe(true); + }); + }); + + describe('Node app generation', () => { + it('generates Node app with correct structure', () => { + runCLI('-n nodetest -f node --skip-install'); + + const appDir = path.join(testOutput, 'nodetest'); + expect(fs.existsSync(appDir)).toBe(true); + expect(fs.existsSync(path.join(appDir, 'index.js'))).toBe(true); + expect(fs.existsSync(path.join(appDir, 'package.json'))).toBe(true); + }); + + it('generates package.json without express dependency', () => { + runCLI('-n purenode -f node --skip-install'); + + const pkgPath = path.join(testOutput, 'purenode', 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + + expect(pkg.dependencies.express).toBeUndefined(); + expect(pkg.dependencies.nodemon).toBeDefined(); + }); + }); + + describe('support files', () => { + it('includes .gitignore', () => { + runCLI('-n gittest -f express --skip-install'); + expect(fs.existsSync(path.join(testOutput, 'gittest', '.gitignore'))).toBe(true); + }); + + it('includes Dockerfile', () => { + runCLI('-n dockertest -f express --skip-install'); + expect(fs.existsSync(path.join(testOutput, 'dockertest', 'Dockerfile'))).toBe(true); + }); + + it('includes .dockerignore', () => { + runCLI('-n dockertest2 -f express --skip-install'); + expect(fs.existsSync(path.join(testOutput, 'dockertest2', '.dockerignore'))).toBe(true); + }); + }); + + describe('custom port', () => { + it('configures custom port in index.js', () => { + runCLI('-n porttest -f express -p 8080 --skip-install'); + + const indexPath = path.join(testOutput, 'porttest', 'index.js'); + const content = fs.readFileSync(indexPath, 'utf-8'); + + expect(content).toContain('8080'); + }); + }); +}); diff --git a/tests/unit/build_helper.test.js b/tests/unit/build_helper.test.js new file mode 100644 index 0000000..9d7cb97 --- /dev/null +++ b/tests/unit/build_helper.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs-extra'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import * as buildHelper from '../../lib/build_helper.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const testDir = path.join(__dirname, '.test-output'); + +describe('build_helper', () => { + beforeEach(() => { + fs.ensureDirSync(testDir); + }); + + afterEach(() => { + fs.removeSync(testDir); + }); + + describe('createDir', () => { + it('creates a directory inside app directory', () => { + buildHelper.createDir(testDir, 'controllers'); + const dirPath = path.join(testDir, 'controllers'); + expect(fs.existsSync(dirPath)).toBe(true); + }); + + it('creates nested directory structure', () => { + buildHelper.createDir(testDir, 'config'); + buildHelper.createDir(testDir, 'routes'); + expect(fs.existsSync(path.join(testDir, 'config'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'routes'))).toBe(true); + }); + }); + + describe('buildFilewithContents', () => { + it('copies file content to destination', () => { + const sourceFile = path.join(testDir, 'source.txt'); + fs.writeFileSync(sourceFile, 'test content'); + + buildHelper.buildFilewithContents(sourceFile, testDir, 'dest.txt'); + + const destContent = fs.readFileSync(path.join(testDir, 'dest.txt'), 'utf-8'); + expect(destContent).toBe('test content'); + }); + }); + + describe('buildFolderforApp', () => { + it('creates a new folder', () => { + const newFolder = path.join(testDir, 'new-app'); + buildHelper.buildFolderforApp(newFolder); + expect(fs.existsSync(newFolder)).toBe(true); + }); + + it('exits if folder already exists', () => { + const existingFolder = path.join(testDir, 'existing'); + fs.mkdirSync(existingFolder); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + expect(() => { + buildHelper.buildFolderforApp(existingFolder); + }).toThrow('process.exit called'); + + mockExit.mockRestore(); + }); + }); +}); diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js new file mode 100644 index 0000000..8dac9b5 --- /dev/null +++ b/tests/unit/config.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { getConfig } from '../../lib/config.js'; +import path from 'path'; + +describe('getConfig', () => { + const baseDir = '/test/base'; + const cwd = '/test/cwd'; + + it('returns configuration object', () => { + const config = getConfig(baseDir, cwd); + expect(config).toBeDefined(); + expect(config.paths).toBeDefined(); + expect(config.validation).toBeDefined(); + expect(config.defaults).toBeDefined(); + }); + + describe('paths', () => { + it('sets express template path', () => { + const config = getConfig(baseDir, cwd); + expect(config.paths.templates.express).toBe( + path.join(baseDir, '..', 'templates', 'express') + ); + }); + + it('sets node template path', () => { + const config = getConfig(baseDir, cwd); + expect(config.paths.templates.node).toBe( + path.join(baseDir, '..', 'templates', 'node') + ); + }); + + it('sets views path', () => { + const config = getConfig(baseDir, cwd); + expect(config.paths.templates.views).toBe( + path.join(baseDir, '..', 'templates', 'express', 'views') + ); + }); + + it('sets cwd correctly', () => { + const config = getConfig(baseDir, cwd); + expect(config.paths.cwd).toBe(cwd); + }); + }); + + describe('validation rules', () => { + it('includes valid frameworks', () => { + const config = getConfig(baseDir, cwd); + expect(config.validation.frameworks).toContain('node'); + expect(config.validation.frameworks).toContain('express'); + }); + + it('includes valid views', () => { + const config = getConfig(baseDir, cwd); + expect(config.validation.views).toContain('ejs'); + expect(config.validation.views).toContain('pug'); + expect(config.validation.views).toContain('jade'); + expect(config.validation.views).toContain('hbs'); + }); + }); + + describe('defaults', () => { + it('sets default framework to express', () => { + const config = getConfig(baseDir, cwd); + expect(config.defaults.framework).toBe('express'); + }); + + it('sets default port to 3000', () => { + const config = getConfig(baseDir, cwd); + expect(config.defaults.port).toBe(3000); + }); + }); +}); diff --git a/tests/unit/constants.test.js b/tests/unit/constants.test.js new file mode 100644 index 0000000..1d17006 --- /dev/null +++ b/tests/unit/constants.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + LOG_LEVELS, + VIEW_ENGINES, + VALID_VIEWS, + DEPENDENCY_VERSIONS, +} from '../../lib/constants.js'; + +describe('constants', () => { + describe('LOG_LEVELS', () => { + it('defines ERROR as 0', () => { + expect(LOG_LEVELS.ERROR).toBe(0); + }); + + it('defines WARN as 1', () => { + expect(LOG_LEVELS.WARN).toBe(1); + }); + + it('defines INFO as 2', () => { + expect(LOG_LEVELS.INFO).toBe(2); + }); + + it('defines DEBUG as 3', () => { + expect(LOG_LEVELS.DEBUG).toBe(3); + }); + }); + + describe('VIEW_ENGINES', () => { + it('includes ejs', () => { + expect(VIEW_ENGINES.ejs).toBeDefined(); + }); + + it('includes pug', () => { + expect(VIEW_ENGINES.pug).toBeDefined(); + }); + + it('includes jade', () => { + expect(VIEW_ENGINES.jade).toBeDefined(); + }); + + it('includes hbs', () => { + expect(VIEW_ENGINES.hbs).toBeDefined(); + }); + }); + + describe('VALID_VIEWS', () => { + it('is array of view engine names', () => { + expect(VALID_VIEWS).toContain('ejs'); + expect(VALID_VIEWS).toContain('pug'); + expect(VALID_VIEWS).toContain('jade'); + expect(VALID_VIEWS).toContain('hbs'); + }); + + it('matches VIEW_ENGINES keys', () => { + expect(VALID_VIEWS).toEqual(Object.keys(VIEW_ENGINES)); + }); + }); + + describe('DEPENDENCY_VERSIONS', () => { + it('includes nodemon', () => { + expect(DEPENDENCY_VERSIONS.nodemon).toBeDefined(); + }); + + it('includes cors', () => { + expect(DEPENDENCY_VERSIONS.cors).toBeDefined(); + }); + + it('includes express', () => { + expect(DEPENDENCY_VERSIONS.express).toBeDefined(); + }); + + it('includes mongoose', () => { + expect(DEPENDENCY_VERSIONS.mongoose).toBeDefined(); + }); + }); +}); diff --git a/tests/unit/fileName.test.js b/tests/unit/fileName.test.js new file mode 100644 index 0000000..6660f77 --- /dev/null +++ b/tests/unit/fileName.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { cleanAppName } from '../../lib/fileName.js'; + +describe('cleanAppName', () => { + it('converts to lowercase', () => { + expect(cleanAppName('MyApp')).toBe('myapp'); + }); + + it('removes spaces', () => { + expect(cleanAppName('my app')).toBe('myapp'); + }); + + it('removes special characters', () => { + expect(cleanAppName('my-app_test!@#')).toBe('myapptest'); + }); + + it('removes hyphens and underscores', () => { + expect(cleanAppName('my-app_name')).toBe('myappname'); + }); + + it('handles numbers', () => { + expect(cleanAppName('app123')).toBe('app123'); + }); + + it('handles empty string', () => { + expect(cleanAppName('')).toBe(''); + }); + + it('handles only special characters', () => { + expect(cleanAppName('---___!!!')).toBe(''); + }); +}); diff --git a/tests/unit/validator.test.js b/tests/unit/validator.test.js new file mode 100644 index 0000000..73ea7ac --- /dev/null +++ b/tests/unit/validator.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { validateOptions } from '../../lib/validator.js'; + +const validationRules = { + frameworks: ['node', 'express'], + views: ['ejs', 'jade', 'pug', 'hbs'], +}; + +describe('validateOptions', () => { + describe('framework validation', () => { + it('accepts valid framework: express', () => { + const result = validateOptions({ framework: 'express' }, validationRules); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts valid framework: node', () => { + const result = validateOptions({ framework: 'node' }, validationRules); + expect(result.isValid).toBe(true); + }); + + it('rejects invalid framework', () => { + const result = validateOptions({ framework: 'invalid' }, validationRules); + expect(result.isValid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Invalid framework'); + }); + + it('passes when no framework specified', () => { + const result = validateOptions({}, validationRules); + expect(result.isValid).toBe(true); + }); + }); + + describe('view validation', () => { + it('accepts valid view: ejs', () => { + const result = validateOptions({ view: 'ejs' }, validationRules); + expect(result.isValid).toBe(true); + }); + + it('accepts valid view: pug', () => { + const result = validateOptions({ view: 'pug' }, validationRules); + expect(result.isValid).toBe(true); + }); + + it('rejects invalid view', () => { + const result = validateOptions({ view: 'invalid' }, validationRules); + expect(result.isValid).toBe(false); + expect(result.errors[0]).toContain('Invalid view engine'); + }); + + it('passes when no view specified', () => { + const result = validateOptions({}, validationRules); + expect(result.isValid).toBe(true); + }); + }); + + describe('combined validation', () => { + it('validates both framework and view', () => { + const result = validateOptions( + { framework: 'express', view: 'ejs' }, + validationRules + ); + expect(result.isValid).toBe(true); + }); + + it('returns multiple errors for multiple invalid options', () => { + const result = validateOptions( + { framework: 'bad', view: 'worse' }, + validationRules + ); + expect(result.isValid).toBe(false); + expect(result.errors).toHaveLength(2); + }); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..b0b9d4b --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/unit/**/*.test.js', 'tests/integration/**/*.test.js'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + exclude: ['tests/**', 'node_modules/**'], + thresholds: { + statements: 70, + branches: 60, + functions: 70, + lines: 70, + }, + }, + }, +});