diff --git a/packages/cli/README.md b/packages/cli/README.md index d23fea3b2..94c5d0731 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -261,6 +261,43 @@ cnc kill cnc kill --no-drop ``` +### Testing + +#### `cnc test-packages` + +Run integration tests on all modules in a workspace. Creates a temporary database for each module, deploys, and optionally runs verify/revert/deploy cycles. + +```bash +# Test all modules in workspace (deploy only) +cnc test-packages + +# Run full deploy/verify/revert/deploy cycle +cnc test-packages --full-cycle + +# Stop on first failure +cnc test-packages --stop-on-fail + +# Exclude specific modules +cnc test-packages --exclude my-module,another-module + +# Combine options +cnc test-packages --full-cycle --stop-on-fail --exclude legacy-module +``` + +**Options:** + +- `--full-cycle` - Run full deploy/verify/revert/deploy cycle (default: deploy only) +- `--stop-on-fail` - Stop testing immediately when a module fails +- `--exclude ` - Comma-separated module names to exclude +- `--cwd ` - Working directory (default: current directory) + +**Notes:** + +- Discovers modules from workspace `pgpm.json` configuration +- Creates isolated test databases (`test_`) for each module +- Automatically cleans up test databases after each test +- Uses internal APIs for deploy/verify/revert operations + ## 💡 Common Workflows ### Starting a New Project diff --git a/pgpm/pgpm/README.md b/pgpm/pgpm/README.md index 9a9925b40..775fb90ea 100644 --- a/pgpm/pgpm/README.md +++ b/pgpm/pgpm/README.md @@ -90,6 +90,10 @@ Here are some useful commands for reference: - `pgpm plan` - Generate deployment plans for your modules - `pgpm package` - Package your module for distribution +### Testing + +- `pgpm test-packages` - Run integration tests on all modules in a workspace + ### Utilities - `pgpm add` - Add a new database change @@ -302,6 +306,43 @@ pgpm kill pgpm kill --no-drop ``` +### Testing + +#### `pgpm test-packages` + +Run integration tests on all modules in a workspace. Creates a temporary database for each module, deploys, and optionally runs verify/revert/deploy cycles. + +```bash +# Test all modules in workspace (deploy only) +pgpm test-packages + +# Run full deploy/verify/revert/deploy cycle +pgpm test-packages --full-cycle + +# Stop on first failure +pgpm test-packages --stop-on-fail + +# Exclude specific modules +pgpm test-packages --exclude my-module,another-module + +# Combine options +pgpm test-packages --full-cycle --stop-on-fail --exclude legacy-module +``` + +**Options:** + +- `--full-cycle` - Run full deploy/verify/revert/deploy cycle (default: deploy only) +- `--stop-on-fail` - Stop testing immediately when a module fails +- `--exclude ` - Comma-separated module names to exclude +- `--cwd ` - Working directory (default: current directory) + +**Notes:** + +- Discovers modules from workspace `pgpm.json` configuration +- Creates isolated test databases (`test_`) for each module +- Automatically cleans up test databases after each test +- Uses internal APIs for deploy/verify/revert operations + ## ⚙️ Configuration ### Environment Variables diff --git a/pgpm/pgpm/src/commands.ts b/pgpm/pgpm/src/commands.ts index 31c68e15d..940827698 100644 --- a/pgpm/pgpm/src/commands.ts +++ b/pgpm/pgpm/src/commands.ts @@ -24,6 +24,7 @@ import remove from './commands/remove'; import renameCmd from './commands/rename'; import revert from './commands/revert'; import tag from './commands/tag'; +import testPackages from './commands/test-packages'; import verify from './commands/verify'; import { extractFirst, usageText } from './utils'; import { cliExitWithError } from './utils/cli-error'; @@ -62,6 +63,7 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record Comma-separated module names to exclude + --stop-on-fail Stop testing immediately when a package fails + --full-cycle Run full deploy/verify/revert/deploy cycle (default: deploy only) + --cwd Working directory (default: current directory) + +Examples: + pgpm test-packages Test all packages in workspace + pgpm test-packages --full-cycle Run full test cycle with verify/revert + pgpm test-packages --stop-on-fail Stop on first failure + pgpm test-packages --exclude my-module Exclude specific modules +`; + +interface TestResult { + moduleName: string; + modulePath: string; + success: boolean; + error?: string; +} + +function dbSafeName(moduleName: string): string { + return `test_${moduleName}`.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); +} + +async function createDatabase(dbname: string, adminDb: string = 'postgres'): Promise { + try { + const pgEnv = getPgEnvOptions({ database: adminDb }); + const pool = getPgPool(pgEnv); + + // Sanitize database name (only allow alphanumeric and underscore) + const safeName = dbname.replace(/[^a-zA-Z0-9_]/g, '_'); + + await pool.query(`CREATE DATABASE "${safeName}"`); + log.debug(`Created database: ${safeName}`); + return true; + } catch (error: any) { + if (error.code === '42P04') { + // Database already exists, that's fine + log.debug(`Database ${dbname} already exists`); + return true; + } + log.error(`Failed to create database ${dbname}: ${error.message}`); + return false; + } +} + +async function dropDatabase(dbname: string, adminDb: string = 'postgres'): Promise { + try { + const pgEnv = getPgEnvOptions({ database: adminDb }); + const pool = getPgPool(pgEnv); + + // Sanitize database name + const safeName = dbname.replace(/[^a-zA-Z0-9_]/g, '_'); + + // Terminate all connections to the database first + await pool.query(` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid() + `, [safeName]); + + await pool.query(`DROP DATABASE IF EXISTS "${safeName}"`); + log.debug(`Dropped database: ${safeName}`); + } catch (error: any) { + // Ignore errors when dropping (database might not exist) + log.debug(`Could not drop database ${dbname}: ${error.message}`); + } +} + +async function checkPostgresConnection(): Promise { + try { + const pgEnv = getPgEnvOptions({ database: 'postgres' }); + const pool = getPgPool(pgEnv); + await pool.query('SELECT 1'); + return true; + } catch { + return false; + } +} + +async function testModule( + workspacePkg: PgpmPackage, + modulePkg: PgpmPackage, + fullCycle: boolean +): Promise { + const moduleName = modulePkg.getModuleName(); + const modulePath = modulePkg.getModulePath() || ''; + const dbname = dbSafeName(moduleName); + + console.log(`${YELLOW}Testing module: ${moduleName}${NC}`); + console.log(` Module path: ${modulePath}`); + console.log(` Database name: ${dbname}`); + + // Clean up any existing test database + await dropDatabase(dbname); + + // Create fresh test database + console.log(` Creating database: ${dbname}`); + if (!await createDatabase(dbname)) { + return { + moduleName, + modulePath, + success: false, + error: `Could not create database ${dbname}` + }; + } + + try { + // Create options for this test database + const opts = getEnvOptions({ + pg: getPgEnvOptions({ database: dbname }), + deployment: { + useTx: true, + fast: false, + usePlan: true, + cache: false, + logOnly: false + } + }); + + // Deploy + console.log(' Running deploy...'); + try { + await workspacePkg.deploy(opts, moduleName, true); + } catch (error: any) { + await dropDatabase(dbname); + return { + moduleName, + modulePath, + success: false, + error: `Deploy failed: ${error.message}` + }; + } + + if (fullCycle) { + // Verify + console.log(' Running verify...'); + try { + await workspacePkg.verify(opts, moduleName, true); + } catch (error: any) { + await dropDatabase(dbname); + return { + moduleName, + modulePath, + success: false, + error: `Verify failed: ${error.message}` + }; + } + + // Revert + console.log(' Running revert...'); + try { + await workspacePkg.revert(opts, moduleName, true); + } catch (error: any) { + await dropDatabase(dbname); + return { + moduleName, + modulePath, + success: false, + error: `Revert failed: ${error.message}` + }; + } + + // Deploy again + console.log(' Running deploy (second time)...'); + try { + await workspacePkg.deploy(opts, moduleName, true); + } catch (error: any) { + await dropDatabase(dbname); + return { + moduleName, + modulePath, + success: false, + error: `Deploy (second time) failed: ${error.message}` + }; + } + } + + // Clean up test database + await dropDatabase(dbname); + + console.log(`${GREEN}SUCCESS: Module ${moduleName} passed all tests${NC}`); + return { + moduleName, + modulePath, + success: true + }; + } catch (error: any) { + // Ensure cleanup on any unexpected error + await dropDatabase(dbname); + return { + moduleName, + modulePath, + success: false, + error: `Unexpected error: ${error.message}` + }; + } +} + +export default async ( + argv: Partial, + _prompter: Inquirerer, + _options: CLIOptions +) => { + // Show usage if explicitly requested + if (argv.help || argv.h) { + console.log(testPackagesUsageText); + process.exit(0); + } + + // Parse options + const stopOnFail = argv['stop-on-fail'] === true || argv.stopOnFail === true; + const fullCycle = argv['full-cycle'] === true || argv.fullCycle === true; + const cwd = argv.cwd || process.cwd(); + + // Parse excludes + let excludes: string[] = []; + if (argv.exclude) { + excludes = (argv.exclude as string).split(',').map(e => e.trim()); + } + + console.log('=== PGPM Package Integration Test ==='); + console.log(`Testing all packages with ${fullCycle ? 'deploy/verify/revert/deploy cycle' : 'deploy only'}`); + if (stopOnFail) { + console.log('Mode: Stop on first failure'); + } else { + console.log('Mode: Test all packages (collect all failures)'); + } + console.log(''); + + // Check PostgreSQL connection + console.log('Checking PostgreSQL connection...'); + if (!await checkPostgresConnection()) { + log.error('PostgreSQL not accessible.'); + console.log('Ensure PostgreSQL is running and connection settings are correct.'); + console.log('For local development: docker-compose up -d'); + console.log('For CI: ensure PostgreSQL service is running'); + process.exit(1); + } + console.log('PostgreSQL connection successful'); + console.log(''); + + // Initialize workspace package + const projectRoot = path.resolve(cwd); + const workspacePkg = new PgpmPackage(projectRoot); + + if (!workspacePkg.getWorkspacePath()) { + log.error('Not in a PGPM workspace. Run this command from a workspace root.'); + process.exit(1); + } + + // Get all modules from workspace using internal API + console.log('Finding all PGPM modules in workspace...'); + const modules = await workspacePkg.getModules(); + + if (modules.length === 0) { + log.warn('No modules found in workspace.'); + process.exit(0); + } + + // Filter out excluded modules + let filteredModules = modules; + if (excludes.length > 0) { + filteredModules = modules.filter(mod => { + const moduleName = mod.getModuleName(); + return !excludes.includes(moduleName); + }); + console.log(`Excluding: ${excludes.join(', ')}`); + } + + console.log(`Found ${filteredModules.length} modules to test:`); + for (const mod of filteredModules) { + console.log(` - ${mod.getModuleName()}`); + } + console.log(''); + + const failedPackages: TestResult[] = []; + const successfulPackages: TestResult[] = []; + + for (const modulePkg of filteredModules) { + const result = await testModule(workspacePkg, modulePkg, fullCycle); + + if (result.success) { + successfulPackages.push(result); + } else { + failedPackages.push(result); + + if (stopOnFail) { + console.log(''); + console.error(`${RED}STOPPING: Test failed for module ${result.moduleName} and --stop-on-fail was specified${NC}`); + console.log(''); + console.log('=== TEST SUMMARY (PARTIAL) ==='); + if (successfulPackages.length > 0) { + console.log(`${GREEN}Successful modules before failure (${successfulPackages.length}):${NC}`); + for (const pkg of successfulPackages) { + console.log(` ${GREEN}✓${NC} ${pkg.moduleName}`); + } + console.log(''); + } + console.error(`${RED}Failed module: ${result.moduleName}${NC}`); + if (result.error) { + console.error(` Error: ${result.error}`); + } + console.log(''); + console.error(`${RED}OVERALL RESULT: FAILED (stopped on first failure)${NC}`); + process.exit(1); + } + } + console.log(''); + } + + console.log('=== TEST SUMMARY ==='); + console.log(`${GREEN}Successful modules (${successfulPackages.length}):${NC}`); + for (const pkg of successfulPackages) { + console.log(` ${GREEN}✓${NC} ${pkg.moduleName}`); + } + + if (failedPackages.length > 0) { + console.log(''); + console.error(`${RED}Failed modules (${failedPackages.length}):${NC}`); + for (const pkg of failedPackages) { + console.error(` ${RED}✗${NC} ${pkg.moduleName}`); + if (pkg.error) { + console.error(` Error: ${pkg.error}`); + } + } + console.log(''); + console.error(`${RED}OVERALL RESULT: FAILED${NC}`); + process.exit(1); + } else { + console.log(''); + console.log(`${GREEN}OVERALL RESULT: ALL MODULES PASSED${NC}`); + process.exit(0); + } +}; diff --git a/pgpm/pgpm/src/index.ts b/pgpm/pgpm/src/index.ts index b8534a198..c9096674a 100644 --- a/pgpm/pgpm/src/index.ts +++ b/pgpm/pgpm/src/index.ts @@ -26,6 +26,7 @@ export { default as remove } from './commands/remove'; export { default as renameCmd } from './commands/rename'; export { default as revert } from './commands/revert'; export { default as tag } from './commands/tag'; +export { default as testPackages } from './commands/test-packages'; export { default as verify } from './commands/verify'; export * from './utils';