qtests
Version:
Comprehensive Node.js testing framework with method stubbing, console mocking, environment management, and automatic stub resolution for axios, winston, and other modules
402 lines (370 loc) • 15.4 kB
JavaScript
// qtests-generate: Node-native CLI (no external runtime) that invokes the compiled TestGenerator
// Implementation mirrors the TypeScript CLI but imports from compiled JS in dist/
// NOTE: This CLI is also responsible for ALWAYS creating/overwriting
// the client project's qtests-runner.mjs at the project root, so that
// client apps consistently get a stable runner without any extra flags.
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
// Parse command line arguments
function parseArgs(argv) {
const args = argv.slice(2);
const options = {
mode: 'heuristic',
unit: false,
integration: false,
dryRun: false,
force: false,
include: [],
exclude: [],
react: false,
skipReactComponents: true,
updatePackageScript: false,
withRouter: false,
yes: false,
noInteractive: false,
autoInstall: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case '--src':
case '-s':
options.SRC_DIR = args[++i];
break;
case '--test-dir':
case '-t':
options.TEST_DIR = args[++i];
break;
case '--mode': {
const mode = args[++i];
if (!['heuristic', 'ast'].includes(mode)) {
console.error(`Invalid mode: ${mode}. Use 'heuristic' or 'ast'`);
process.exit(1);
}
options.mode = mode;
break;
}
case '--unit':
options.unit = true;
break;
case '--integration':
options.integration = true;
break;
case '--dry-run':
options.dryRun = true;
break;
case '--force':
options.force = true;
break;
case '--react':
options.react = true;
break;
case '--react-components':
options.skipReactComponents = false;
break;
case '--no-react-components':
options.skipReactComponents = true;
break;
case '--update-pkg-script':
options.updatePackageScript = true;
break;
case '--with-router':
options.withRouter = true;
break;
case '--yes':
case '-y':
options.yes = true;
options.noInteractive = true;
break;
case '--no-interactive':
options.noInteractive = true;
break;
case '--auto-install':
options.autoInstall = true;
break;
case '--migrate-generated-tests':
options.migrateGeneratedTests = true;
break;
case '--include':
options.include.push(args[++i]);
break;
case '--exclude':
options.exclude.push(args[++i]);
break;
case '--help':
case '-h':
showHelp();
process.exit(0);
break;
case '--version':
case '-v':
showVersion();
process.exit(0);
break;
default:
if (arg.startsWith('-')) {
console.error(`Unknown option: ${arg}`);
process.exit(1);
}
}
}
return options;
}
function showHelp() {
console.log(`
qtests Test Generator (Node ESM)
USAGE:
qtests-generate [OPTIONS]
(alias: qtests-ts-generate)
OPTIONS:
-s, --src <dir> Source directory to scan (default: .)
-t, --test-dir <dir> Generated test directory (default: tests/generated-tests)
--mode <mode> Analysis mode: 'heuristic' or 'ast' (default: heuristic)
--unit Generate only unit tests
--integration Generate only integration tests
--include <glob> Include files matching glob (repeatable)
--exclude <glob> Exclude files matching glob (repeatable)
--dry-run Show planned files without writing
--force Allow overwriting generated test files
--react Force React template mode (use jsdom, React templates)
--react-components Opt-in: generate React component tests (hooks are always supported)
--no-react-components Skip generating tests for React components (default)
--with-router Wrap React tests with MemoryRouter when React Router is detected
--update-pkg-script Update package.json test script to use Jest with project config
-h, --help Show this help message
-v, --version Show version information
EXAMPLES:
qtests-generate # Scan current directory with defaults
qtests-generate --src lib # Scan 'lib' directory instead
qtests-generate --unit --dry-run # Preview unit tests only
qtests-generate --mode ast --force # Use TypeScript AST analysis, overwrite existing
qtests-generate --include "**/*.ts" # Only process TypeScript files
qtests-generate --exclude "**/demo/**" # Skip demo directories
`);
}
function showVersion() {
try {
const packageJsonPath = path.join(process.cwd(), 'node_modules', 'qtests', 'package.json');
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
console.log(`qtests v${pkg.version}`);
} catch {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
console.log(`qtests v${pkg.version || 'unknown'}`);
} catch {
console.log('qtests');
}
}
}
// Utility: parse env truthy values ('1'|'true'|'yes')
function isEnvTruthy(name) {
const v = process.env[name];
if (!v) return false;
const s = String(v).trim().toLowerCase();
return s === '1' || s === 'true' || s === 'yes';
}
// Utility: safe exists
function exists(p) {
try { return fs.existsSync(p); } catch { return false; }
}
// Utility: read file
function read(p) {
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
}
// Decide client project root: prefer npm's INIT_CWD if present and sensible
function resolveClientRoot() {
const icwd = process.env.INIT_CWD && String(process.env.INIT_CWD).trim();
if (icwd && exists(icwd) && !icwd.includes(`${path.sep}node_modules${path.sep}`)) return icwd;
return process.cwd();
}
// Resolve this module's root (node_modules/qtests or repo root during dev)
function resolveModuleRoot() {
// __dirname of this file is .../qtests/bin
const thisFile = fileURLToPath(import.meta.url);
const binDir = path.dirname(thisFile);
return path.resolve(binDir, '..');
}
// Validate runner template content to avoid writing wrong files
function isValidRunnerTemplate(content) {
try {
if (!content) return false;
// Key invariants per Runner Policies
return /API Mode/.test(content) && /runCLI/.test(content);
} catch {
return false;
}
}
// Compute required dev dependencies based on project type
function computeRequiredDevDeps() {
const required = new Map();
// Core Jest + TS + Babel chain used by our Jest config
required.set('jest', '^29');
required.set('ts-jest', '^29');
required.set('typescript', '^5');
required.set('babel-jest', '^30');
required.set('@babel/core', '^7');
required.set('@babel/preset-env', '^7');
// React detection: add jsdom env if React present
try {
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
const all = { ...(pkg.dependencies||{}), ...(pkg.devDependencies||{}), ...(pkg.peerDependencies||{}) };
const isReact = Boolean(all.react || all['@types/react'] || all['react-dom'] || all['@types/react-dom']);
if (isReact) required.set('jest-environment-jsdom', '^29');
} catch {}
return required;
}
// Ensure devDependencies exist in package.json and optionally install
function ensureDevDeps({ autoInstall=false } = {}) {
const pkgPath = path.join(process.cwd(), 'package.json');
if (!exists(pkgPath)) {
console.log('⚠️ No package.json found; skipping devDependencies setup');
return { installed: false, toInstall: [] };
}
let pkg;
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); } catch {
console.log('⚠️ Could not read package.json; skipping devDependencies setup');
return { installed: false, toInstall: [] };
}
pkg.devDependencies = pkg.devDependencies || {};
const need = [];
for (const [name, ver] of computeRequiredDevDeps()) {
if (!pkg.devDependencies[name] && !(pkg.dependencies && pkg.dependencies[name])) {
pkg.devDependencies[name] = ver;
need.push(name);
}
}
if (need.length) {
try {
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2), 'utf8');
console.log(`✅ Updated package.json devDependencies: ${need.join(', ')}`);
} catch {
console.log('⚠️ Failed to write updated package.json devDependencies');
}
} else {
console.log('ℹ️ Dev dependencies already satisfied');
}
if (autoInstall && need.length) {
console.log('📦 Installing missing devDependencies...');
const r = spawnSync('npm', ['install', '-D', ...need], { stdio: 'inherit' });
if (r.status === 0) {
console.log('✅ DevDependencies installed');
return { installed: true, toInstall: [] };
} else {
console.log('⚠️ Automatic install failed or was blocked. You can install manually:');
console.log(` npm install -D ${need.join(' ')}`);
return { installed: false, toInstall: need };
}
}
if (!autoInstall && need.length) {
console.log('💡 Next step: install missing devDependencies:');
console.log(` npm install -D ${need.join(' ')}`);
}
return { installed: false, toInstall: need };
}
// Obtain runner template content from shipped template or transform fallback
function getRunnerTemplateContent() {
const moduleRoot = resolveModuleRoot();
const candidates = [
path.join(moduleRoot, 'templates', 'qtests-runner.mjs.template'),
path.join(moduleRoot, 'lib', 'templates', 'qtests-runner.mjs.template')
];
for (const p of candidates) {
const c = read(p);
if (c && isValidRunnerTemplate(c)) return c;
}
// Fallback: attempt to transform the sacrosanct bin into a standalone runner template
const binPath = path.join(moduleRoot, 'bin', 'qtests-ts-runner');
const raw = read(binPath);
if (raw) {
const transformed = raw
.replace(/^#!\/usr\/bin\/env node/m, '#!/usr/bin/env node')
.replace(/\/\/ IMPORTANT: This CLI is sacrosanct and not generated\. Do not overwrite\./,
'// GENERATED RUNNER: qtests-runner.mjs - auto-created by qtests TestGenerator\n// Safe to delete; will be recreated as needed.\n// Mirrors bin/qtests-ts-runner behavior (batching, DEBUG_TESTS.md, stable exits).');
if (isValidRunnerTemplate(transformed)) return transformed;
}
return null;
}
// ALWAYS write/overwrite qtests-runner.mjs at client root
function writeRunnerAtClientRoot() {
const quiet = isEnvTruthy('QTESTS_SILENT');
const targetRoot = resolveClientRoot();
const target = path.join(targetRoot, 'qtests-runner.mjs');
const templateContent = getRunnerTemplateContent();
if (!templateContent) {
// Do not fail the generator if template can't be found; just warn once.
if (!quiet) console.error('qtests: no runner template found; skipped runner write');
return;
}
try {
fs.writeFileSync(target, templateContent, 'utf8');
if (!quiet) process.stdout.write('qtests: wrote qtests-runner.mjs at project root (overwritten)\n');
// Remove legacy runner if present to prevent accidental usage
try { const legacy = path.join(targetRoot, 'qtests-runner.js'); if (fs.existsSync(legacy)) fs.rmSync(legacy, { force: true }); } catch {}
} catch (err) {
// Non-fatal: keep generator usable even if FS writes fail
if (!quiet) console.error('qtests: failed to write qtests-runner.mjs:', (err && (err.message || err.stack)) || String(err));
}
}
async function main() {
try {
console.log('🔧 qtests Test Generator (Node ESM)\n');
const options = parseArgs(process.argv);
console.log('Configuration:');
console.log(` Source directory: ${options.SRC_DIR || '.'}`);
console.log(` Test directory: ${options.TEST_DIR || 'tests/generated-tests'}`);
console.log(` Analysis mode: ${options.mode}`);
console.log(` Scope: ${options.unit ? 'unit only' : options.integration ? 'integration only' : 'both'}`);
console.log(` Dry run: ${options.dryRun ? 'yes' : 'no'}`);
console.log(` Force overwrite: ${options.force ? 'yes' : 'no'}`);
console.log(` Force React mode: ${options.react ? 'yes' : 'no'}`);
console.log(` Generate component tests: ${options.skipReactComponents === false ? 'yes' : 'no'}`);
console.log(` Update package.json script: ${options.updatePackageScript ? 'yes' : 'no'}`);
// Non-interactive by default; auto-install attempted unless in CI
if (options.include && options.include.length) console.log(` Include patterns: ${options.include.join(', ')}`);
if (options.exclude && options.exclude.length) console.log(` Exclude patterns: ${options.exclude.join(', ')}`);
console.log(` Module system: TypeScript ES Modules (only)`);
console.log(` Jest config path: config/jest.config.mjs (auto)\n`);
// Always scaffold/overwrite the runner at the client root, independent of dry-run
writeRunnerAtClientRoot();
// Import compiled TestGenerator from dist, which is shipped in the published package
const { TestGenerator } = await import('../dist/lib/testGenerator.js');
const generator = new TestGenerator(options);
if (options.dryRun) {
console.log('🔍 Dry run - showing planned test files...\n');
await generator.generateTestFiles(true);
} else {
await generator.generateTestFiles();
}
const results = generator.getResults();
console.log('\n📊 Generation Summary:');
const unitTests = results.filter(r => r.type === 'unit').length;
const apiTests = results.filter(r => r.type === 'api').length;
console.log(` Unit tests: ${unitTests}`);
console.log(` API tests: ${apiTests}`);
console.log(` Total files: ${results.length}`);
// Ensure devDependencies and optionally install
const autoInstallDefault = !isEnvTruthy('CI');
const { toInstall } = ensureDevDeps({ autoInstall: options.autoInstall || options.yes || options.noInteractive || autoInstallDefault });
// Actionable next steps
console.log('\n💡 Next steps:');
if (results.length > 0) console.log(' • Review generated test files and flesh out assertions');
console.log(' • Run tests: npm test');
if (toInstall && toInstall.length) console.log(` • Install missing devDeps: npm install -D ${toInstall.join(' ')}`);
console.log(' • Jest config created at config/jest.config.mjs (customize as needed)');
console.log(' • Scripts updated: pretest (ensure-runner, clean-dist), test (qtests-runner)');
// Defensive cleanup
try { const legacy = path.join(process.cwd(), 'qtests-runner.js'); if (fs.existsSync(legacy)) fs.rmSync(legacy, { force: true }); } catch {}
if (options.mode === 'ast') console.log(' • AST mode enabled: more detailed route and export detection applied');
} catch (error) {
console.error('❌ Error generating tests:', error && (error.stack || error.message) || String(error));
process.exit(1);
}
}
// Run when executed directly
main().catch(err => {
console.error('❌ Unexpected error:', err && (err.stack || err.message) || String(err));
process.exit(1);
});