leetkick
Version:
A CLI tool for scaffolding LeetCode exercises with language-specific testing setups
436 lines • 16.8 kB
JavaScript
import { Command } from 'commander';
import { findWorkspaceRoot } from '../utils/workspace.js';
import { getAvailableLanguages } from '../utils/templates.js';
import { existsSync } from 'fs';
import { join } from 'path';
import { spawn } from 'child_process';
import { execSync } from 'child_process';
import { readdir, readFile } from 'fs/promises';
function findPythonCommand(languageDir) {
// 1. Try virtual environment first
const venvPython = join(languageDir, 'venv', 'bin', 'python');
const venvPython3 = join(languageDir, 'venv', 'bin', 'python3');
if (existsSync(venvPython))
return venvPython;
if (existsSync(venvPython3))
return venvPython3;
// 2. Try system commands in order of preference
const pythonCommands = ['python3', 'python'];
for (const cmd of pythonCommands) {
try {
execSync(`which ${cmd}`, { stdio: 'ignore' });
return cmd;
}
catch {
// Command not found, continue
}
}
// 3. Fallback - let spawn handle the error
return 'python3';
}
export const testCommand = new Command('test')
.description('Run tests for a LeetCode problem or all problems')
.argument('[problem]', 'Problem number (e.g., "1") or slug (e.g., "two-sum"). If not provided, runs all tests.')
.option('-l, --language <language>', 'Programming language')
.action(async (problem, options) => {
try {
const workspaceRoot = findWorkspaceRoot();
if (!workspaceRoot) {
console.log('No leetkick workspace found. Run "leetkick init" first.');
console.log('Make sure you are in a directory that contains .leetkick.json or run the command from within a leetkick workspace.');
return;
}
const availableLanguages = await getAvailableLanguages();
if (!options.language) {
console.log('Available languages:', availableLanguages.join(', '));
throw new Error('Please specify a language with --language <lang>');
}
if (!availableLanguages.includes(options.language)) {
console.log('Available languages:', availableLanguages.join(', '));
throw new Error(`Language '${options.language}' not supported.`);
}
const languageDir = join(workspaceRoot, options.language);
if (!existsSync(languageDir)) {
throw new Error(`${options.language} workspace not found. Run "leetkick add ${options.language}" first.`);
}
if (problem) {
// Find the specific problem directory
const problemDir = await findProblemDirectory(languageDir, problem);
if (!problemDir) {
throw new Error(`Problem '${problem}' not found in ${options.language} workspace.`);
}
console.log(`Running tests for: ${problemDir}...`);
const testPassed = await runTests(languageDir, problemDir, options.language);
if (!testPassed) {
throw new Error('Tests failed');
}
}
else {
// Run all tests
console.log(`Running all tests for ${options.language}...`);
await runAllTests(languageDir, options.language);
}
}
catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
throw error;
}
});
async function findProblemDirectory(languageDir, problem) {
try {
// For Kotlin and Java, we need to check under src/main/{language}
const isKotlin = languageDir.endsWith('kotlin');
const isJava = languageDir.endsWith('java');
const isRust = languageDir.endsWith('rust');
// For Rust, problems are files in src/ directory, not separate directories
if (isRust) {
const srcDir = join(languageDir, 'src');
if (!existsSync(srcDir)) {
return null;
}
const entries = await readdir(srcDir);
const problemFiles = entries.filter(file => file.startsWith('problem_') && file.endsWith('.rs'));
// Try to match by problem number
const paddedProblem = problem.padStart(4, '0');
const targetFile = `problem_${paddedProblem}.rs`;
if (problemFiles.includes(targetFile)) {
return `problem_${paddedProblem}`;
}
// Try exact match
const exactFile = `${problem}.rs`;
if (problemFiles.includes(exactFile)) {
return problem;
}
// For numeric search
if (/^\d+$/.test(problem)) {
const numericMatch = problemFiles.find(file => file.includes(paddedProblem));
if (numericMatch) {
return numericMatch.replace('.rs', '');
}
}
// For slug matching, read problem metadata from files
for (const file of problemFiles) {
const filePath = join(srcDir, file);
const problemInfo = await extractProblemInfoFromFile(filePath);
if (problemInfo && matchesProblemSlug(problem, problemInfo)) {
return file.replace('.rs', '');
}
}
return null;
}
const isPython = languageDir.endsWith('python');
const searchDir = isKotlin || isJava
? join(languageDir, 'src', 'main', isKotlin ? 'kotlin' : 'java')
: isPython
? join(languageDir, 'src')
: languageDir;
if (!existsSync(searchDir)) {
return null;
}
const entries = await readdir(searchDir, { withFileTypes: true });
const directories = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
// Try exact match first
if (directories.includes(problem)) {
return problem;
}
// Try to match by problem number (e.g., "1" matches "problem_0001" or "problem0001" for Kotlin)
const paddedProblem = problem.padStart(4, '0');
const byNumber = directories.find(dir => dir === `problem_${paddedProblem}` || dir === `problem${paddedProblem}`);
if (byNumber) {
return byNumber;
}
// For backward compatibility and flexible matching, also check if it's a number
// and find any directory containing that number
if (/^\d+$/.test(problem)) {
const numericMatch = directories.find(dir => dir.includes(paddedProblem));
if (numericMatch) {
return numericMatch;
}
}
// For slug matching, read problem metadata from files
for (const dir of directories) {
if (dir.startsWith('problem')) {
const problemInfo = await extractProblemInfoFromDirectory(join(searchDir, dir));
if (problemInfo && matchesProblemSlug(problem, problemInfo)) {
return dir;
}
}
}
return null;
}
catch (error) {
return null;
}
}
async function extractProblemInfoFromFile(filePath) {
try {
const content = await readFile(filePath, 'utf-8');
// Extract problem info from comment header
// C-style comments: /* * [1] Title */
let titleMatch = content.match(/\* \[\d+\] (.+)/);
// Python-style comments: """ [1] Title """
if (!titleMatch) {
titleMatch = content.match(/\[\d+\] (.+)/);
}
if (titleMatch) {
const title = titleMatch[1];
const slug = titleToSlug(title);
return { title, slug };
}
return null;
}
catch (error) {
return null;
}
}
async function extractProblemInfoFromDirectory(problemDir) {
try {
const files = await readdir(problemDir);
// Look for any files that might contain problem metadata
for (const file of files) {
if (file === 'catch_amalgamated.hpp') {
continue;
}
const filePath = join(problemDir, file);
const content = await readFile(filePath, 'utf-8');
// Extract problem info from comment header
// C-style comments: /* * [1] Title */
let titleMatch = content.match(/\* \[\d+\] (.+)/);
// Python-style comments: """ [1] Title """
if (!titleMatch) {
titleMatch = content.match(/\[\d+\] (.+)/);
}
if (titleMatch) {
const title = titleMatch[1];
const slug = titleToSlug(title);
return { title, slug };
}
}
return null;
}
catch (error) {
return null;
}
}
function titleToSlug(title) {
return title
.toLowerCase()
.replace(/[^a-zA-Z0-9\s]/g, '')
.trim()
.replace(/\s+/g, '-');
}
function matchesProblemSlug(searchTerm, problemInfo) {
const normalizedSearch = searchTerm.toLowerCase().replace(/_/g, '-');
const normalizedSlug = problemInfo.slug.toLowerCase();
const normalizedTitle = problemInfo.title.toLowerCase();
return (normalizedSlug === normalizedSearch ||
normalizedSlug.includes(normalizedSearch) ||
normalizedTitle.includes(normalizedSearch.replace(/-/g, ' ')));
}
async function runTests(languageDir, problemDir, language) {
return new Promise((resolve, reject) => {
let command;
let args;
switch (language) {
case 'typescript':
command = 'npx';
args = ['vitest', 'run', `${problemDir}`];
break;
case 'javascript':
command = 'node';
args = ['--test', `${problemDir}/*.test.js`];
break;
case 'python':
command = findPythonCommand(languageDir);
args = ['-m', 'pytest', `tests/${problemDir}/`, '-v'];
break;
case 'java': {
// Use Gradle to run tests for specific package
// Check if gradlew exists, otherwise fall back to gradle
const gradlewPath = join(languageDir, 'gradlew');
if (existsSync(gradlewPath)) {
command = './gradlew';
}
else {
command = 'gradle';
}
args = ['test', '--tests', `${problemDir}.*`];
break;
}
case 'go':
// Run go test in the specific problem directory
command = 'sh';
args = ['-c', `cd "${problemDir}" && go test -v`];
break;
case 'rust':
// Run cargo test for specific module in workspace
command = 'cargo';
args = ['test', `${problemDir}::`];
break;
case 'cpp':
// Compile and run C++ test directly with include path for shared headers
command = 'sh';
args = [
'-c',
`cd "${problemDir}" && g++ -I.. -std=c++17 *.test.cpp -o test_runner && ./test_runner`,
];
break;
case 'kotlin': {
// Use Gradle to run tests for specific package
// Check if gradlew exists, otherwise fall back to gradle
const gradlewPath = join(languageDir, 'gradlew');
if (existsSync(gradlewPath)) {
command = './gradlew';
}
else {
command = 'gradle';
}
args = ['test', '--tests', `${problemDir}.*`];
break;
}
default:
reject(new Error(`Testing not implemented for language: ${language}`));
return;
}
const env = { ...process.env };
if (language === 'python') {
env.PYTHONPATH = 'src';
}
const child = spawn(command, args, {
cwd: languageDir,
stdio: 'inherit',
env,
});
child.on('close', code => {
if (code === 0) {
console.log('✓ Tests passed!');
resolve(true);
}
else {
// For test failures, don't reject - the test output already shows what failed
console.log(`❌ Tests failed (exit code ${code || 1})`);
resolve(false); // Return false to indicate failure
}
});
child.on('error', error => {
reject(new Error(`Failed to run tests: ${error.message}`));
});
});
}
async function runAllTests(languageDir, language) {
return new Promise((resolve, reject) => {
let command;
let args;
switch (language) {
case 'typescript':
command = 'npx';
args = ['vitest', 'run'];
break;
case 'javascript':
command = 'node';
args = ['--test', '**/*.test.js'];
break;
case 'python':
command = findPythonCommand(languageDir);
args = ['-m', 'pytest', '-v'];
break;
case 'java': {
// Use Gradle to run all tests
const gradlewPath = join(languageDir, 'gradlew');
if (existsSync(gradlewPath)) {
command = './gradlew';
}
else {
command = 'gradle';
}
args = ['test'];
break;
}
case 'go':
command = 'go';
args = ['test', './...'];
break;
case 'rust':
command = 'cargo';
args = ['test'];
break;
case 'cpp': {
// For C++, we need to find all problem directories and run each test
// This is more complex, so we'll handle it specially
runAllCppTests(languageDir).then(resolve).catch(reject);
return;
}
case 'kotlin': {
// Use Gradle to run all tests
const gradlewPath = join(languageDir, 'gradlew');
if (existsSync(gradlewPath)) {
command = './gradlew';
}
else {
command = 'gradle';
}
args = ['test'];
break;
}
default:
reject(new Error(`Testing not implemented for language: ${language}`));
return;
}
const env = { ...process.env };
if (language === 'python') {
env.PYTHONPATH = 'src';
}
const child = spawn(command, args, {
cwd: languageDir,
stdio: 'inherit',
env,
});
child.on('close', code => {
if (code === 0) {
console.log('✓ All tests passed!');
resolve();
}
else {
console.log(`❌ Some tests failed (exit code ${code || 1})`);
reject(new Error('Some tests failed'));
}
});
child.on('error', error => {
reject(new Error(`Failed to run tests: ${error.message}`));
});
});
}
async function runAllCppTests(languageDir) {
try {
const entries = await readdir(languageDir, { withFileTypes: true });
const problemDirs = entries
.filter(entry => entry.isDirectory() && entry.name.startsWith('problem'))
.map(entry => entry.name);
if (problemDirs.length === 0) {
console.log('No C++ problems found to test.');
return;
}
console.log(`Found ${problemDirs.length} C++ problem(s) to test...`);
let allPassed = true;
for (const problemDir of problemDirs) {
console.log(`\nTesting ${problemDir}...`);
const testPassed = await runTests(languageDir, problemDir, 'cpp');
if (!testPassed) {
allPassed = false;
}
}
if (allPassed) {
console.log('\n✓ All C++ tests passed!');
}
else {
console.log('\n❌ Some C++ tests failed');
throw new Error('Some C++ tests failed');
}
}
catch (error) {
throw new Error(`Failed to run all C++ tests: ${error instanceof Error ? error.message : error}`);
}
}
//# sourceMappingURL=test.js.map