diff --git a/README.md b/README.md index 56e82c5..b3c4344 100644 --- a/README.md +++ b/README.md @@ -76,13 +76,16 @@ npm install --global programming-challenges --help # Generate a new challenge -programming-challenges generate challenge --github-user="YourGitHubName" --challenge="challenge-name" +programming-challenges generate challenge --github-user="YourGitHubName" --challenge="hello-world" # Generate a new solution -programming-challenges generate solution --github-user="YourGitHubName" --challenge="challenge-name" --solution="function" --language="python" +programming-challenges generate solution --github-user="YourGitHubName" --challenge="hello-world" --solution="function" --language="python" # Test a solution -programming-challenges run test --challenge="challenge-name" --solution="function" --language="python" +programming-challenges run test --challenge="hello-world" --solution="function" --language="python" + +# Run a solution with specific `input.txt` file +programming-challenges run solution --challenge="hello-world" --solution="function" --language="python" --input-path="./challenges/hello-world/test/1/input.txt" --output ``` ## 💡 Contributing diff --git a/cli/cli.ts b/cli/cli.ts index 6e35f1f..07a146e 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -2,6 +2,7 @@ import { Builtins, Cli } from 'clipanion' import { GenerateChallengeCommand } from './commands/generate/challenge.js' import { GenerateSolutionCommand } from './commands/generate/solution.js' +import { RunSolutionCommand } from './commands/run/solution.js' import { RunTestCommand } from './commands/run/test.js' export const cli = new Cli({ @@ -14,3 +15,4 @@ cli.register(Builtins.VersionCommand) cli.register(GenerateChallengeCommand) cli.register(GenerateSolutionCommand) cli.register(RunTestCommand) +cli.register(RunSolutionCommand) diff --git a/cli/commands/run/__test__/solution.test.ts b/cli/commands/run/__test__/solution.test.ts new file mode 100644 index 0000000..ab6b846 --- /dev/null +++ b/cli/commands/run/__test__/solution.test.ts @@ -0,0 +1,148 @@ +import { PassThrough } from 'node:stream' +import path from 'node:path' + +import tap from 'tap' +import sinon from 'sinon' +import chalk from 'chalk' + +import { cli } from '../../../cli.js' + +const input = ['run', 'solution'] +const challenge = 'hello-world' +const language = 'c' +const solution = 'function' +const inputPath = path.join( + process.cwd(), + 'challenges', + challenge, + 'test', + '1', + 'input.txt' +) +const inputChallenge = `--challenge=${challenge}` +const inputLanguage = `--language=${language}` +const inputSolution = `--solution=${solution}` +const inputInputPath = `--input-path=${inputPath}` + +await tap.test('programming-challenges run solution', async (t) => { + t.afterEach(() => { + sinon.restore() + }) + + await t.test('succeeds', async (t) => { + sinon.stub(console, 'log').value(() => {}) + const consoleLogSpy = sinon.spy(console, 'log') + const stream = new PassThrough() + const exitCode = await cli.run( + [ + ...input, + inputChallenge, + inputSolution, + inputLanguage, + inputInputPath, + '--output' + ], + { + stdin: process.stdin, + stdout: stream, + stderr: stream + } + ) + stream.end() + t.equal(exitCode, 0) + t.equal(consoleLogSpy.calledWith(`Hello, world!`), true) + }) + + await t.test("fails with solution that doesn't exist", async (t) => { + sinon.stub(console, 'error').value(() => {}) + const consoleErrorSpy = sinon.spy(console, 'error') + const stream = new PassThrough() + const invalidSolution = 'invalid' + const inputInvalidSolution = `--solution=${invalidSolution}` + const exitCode = await cli.run( + [ + ...input, + inputChallenge, + inputInvalidSolution, + inputLanguage, + inputInputPath + ], + { + stdin: process.stdin, + stdout: stream, + stderr: stream + } + ) + stream.end() + t.equal(exitCode, 1) + t.equal( + consoleErrorSpy.calledWith( + chalk.bold.red('Error:') + ' The solution was not found.' + ), + true + ) + }) + + await t.test('fails with invalid language', async (t) => { + sinon.stub(console, 'error').value(() => {}) + const consoleErrorSpy = sinon.spy(console, 'error') + const stream = new PassThrough() + const invalidLanguage = 'invalid' + const inputInvalidLanguage = `--language=${invalidLanguage}` + const exitCode = await cli.run( + [ + ...input, + inputChallenge, + inputSolution, + inputInvalidLanguage, + inputInputPath + ], + { + stdin: process.stdin, + stdout: stream, + stderr: stream + } + ) + stream.end() + t.equal(exitCode, 1) + t.equal( + consoleErrorSpy.calledWith( + chalk.bold.red('Error:') + + ' This programming language is not supported yet.' + ), + true + ) + }) + + await t.test('fails with invalid `input-path`', async (t) => { + sinon.stub(console, 'error').value(() => {}) + const consoleErrorSpy = sinon.spy(console, 'error') + const stream = new PassThrough() + const invalidInputPath = 'invalid' + const inputInvalidInputPath = `--input-path=${invalidInputPath}` + const inputPath = path.resolve(process.cwd(), invalidInputPath) + const exitCode = await cli.run( + [ + ...input, + inputChallenge, + inputSolution, + inputLanguage, + inputInvalidInputPath + ], + { + stdin: process.stdin, + stdout: stream, + stderr: stream + } + ) + stream.end() + t.equal(exitCode, 1) + t.equal( + consoleErrorSpy.calledWith( + chalk.bold.red('Error:') + + ` The \`input-path\` doesn't exist: ${inputPath}.` + ), + true + ) + }) +}) diff --git a/cli/commands/run/solution.ts b/cli/commands/run/solution.ts new file mode 100644 index 0000000..ca46017 --- /dev/null +++ b/cli/commands/run/solution.ts @@ -0,0 +1,72 @@ +import path from 'node:path' +import fs from 'node:fs' + +import { Command, Option } from 'clipanion' +import * as typanion from 'typanion' +import chalk from 'chalk' + +import { isExistingPath } from '../../utils/isExistingPath.js' +import { template } from '../../services/Template.js' +import { Solution } from '../../services/Solution.js' + +export class RunSolutionCommand extends Command { + static paths = [['run', 'solution']] + + static usage = { + description: 'Run the solution with the given `input.txt` file.' + } + + public programmingLanguage = Option.String('--language', { + description: 'The programming language used to solve the challenge.', + required: true, + validator: typanion.isString() + }) + + public challenge = Option.String('--challenge', { + description: 'The challenge name where you want to run your solution.', + required: true, + validator: typanion.isString() + }) + + public solutionName = Option.String('--solution', { + description: 'The solution name to run.', + required: true, + validator: typanion.isString() + }) + + public inputPathUser = Option.String('--input-path', { + description: 'The input file path to use.', + required: true, + validator: typanion.isString() + }) + + public output = Option.Boolean('--output', false, { + description: 'Display the output of the solution.' + }) + + async execute(): Promise { + console.log() + try { + await template.verifySupportedProgrammingLanguage( + this.programmingLanguage + ) + const solution = await Solution.get({ + name: this.solutionName, + challengeName: this.challenge, + programmingLanguageName: this.programmingLanguage + }) + const inputPath = path.resolve(process.cwd(), this.inputPathUser) + if (!(await isExistingPath(inputPath))) { + throw new Error(`The \`input-path\` doesn't exist: ${inputPath}.`) + } + const input = await fs.promises.readFile(inputPath, { encoding: 'utf-8' }) + await solution.run(input, this.output) + return 0 + } catch (error) { + if (error instanceof Error) { + console.error(`${chalk.bold.red('Error:')} ${error.message}`) + } + return 1 + } + } +} diff --git a/cli/commands/run/test.ts b/cli/commands/run/test.ts index fb6a52b..54ab592 100644 --- a/cli/commands/run/test.ts +++ b/cli/commands/run/test.ts @@ -26,7 +26,7 @@ export class RunTestCommand extends Command { }) public solutionName = Option.String('--solution', { - description: 'solution', + description: 'The solution name to run.', validator: typanion.isString() }) diff --git a/cli/services/Docker.ts b/cli/services/Docker.ts index e1c1e78..432b7c0 100644 --- a/cli/services/Docker.ts +++ b/cli/services/Docker.ts @@ -1,7 +1,14 @@ +import { performance } from 'node:perf_hooks' + import { execaCommand } from 'execa' import ora from 'ora' import ms from 'ms' +export interface DockerRunResult { + stdout: string + elapsedTimeMilliseconds: number +} + export class Docker { static CONTAINER_TAG = 'programming-challenges' static SIGSEGV_EXIT_CODE = 139 @@ -19,7 +26,7 @@ export class Docker { } } - public async run(input: string): Promise { + public async run(input: string): Promise { const subprocess = execaCommand( `docker run --interactive --rm ${Docker.CONTAINER_TAG}`, { @@ -32,12 +39,17 @@ export class Docker { isValid = false }, Docker.MAXIMUM_TIMEOUT_MILLISECONDS) try { + const start = performance.now() const { stdout, stderr } = await subprocess + const end = performance.now() if (stderr.length !== 0) { throw new Error(stderr) } clearTimeout(timeout) - return stdout + return { + stdout, + elapsedTimeMilliseconds: end - start + } } catch (error: any) { if (!isValid) { throw new Error( diff --git a/cli/services/Solution.ts b/cli/services/Solution.ts index 80c99b6..e380d96 100644 --- a/cli/services/Solution.ts +++ b/cli/services/Solution.ts @@ -2,6 +2,9 @@ import { fileURLToPath } from 'node:url' import path from 'node:path' import fs from 'node:fs' +import chalk from 'chalk' +import ora from 'ora' + import { createTemporaryEmptyFolder, TEMPORARY_PATH @@ -64,6 +67,24 @@ export class Solution implements SolutionOptions { await Test.runAll(this) } + public async run(input: string, output: boolean = false): Promise { + await this.prepareTemporaryFolder() + await docker.build() + const loader = ora('Running...').start() + try { + const { stdout, elapsedTimeMilliseconds } = await docker.run(input) + loader.succeed(chalk.bold.green('Success!')) + Test.printBenchmark(elapsedTimeMilliseconds) + if (output) { + console.log(`${chalk.bold('Output:')}`) + console.log(stdout) + } + } catch (error) { + loader.fail() + throw error + } + } + static async generate(options: GenerateSolutionOptions): Promise { const { name, challengeName, programmingLanguageName, githubUser } = options const challenge = new Challenge({ name: challengeName }) diff --git a/cli/services/Test.ts b/cli/services/Test.ts index 610a773..aacd097 100644 --- a/cli/services/Test.ts +++ b/cli/services/Test.ts @@ -1,6 +1,5 @@ import fs from 'node:fs' import path from 'node:path' -import { performance } from 'node:perf_hooks' import ora from 'ora' import chalk from 'chalk' @@ -84,17 +83,21 @@ export class Test implements TestOptions { console.log() console.log(table(tableResult)) } - const elapsedTime = totalElapsedTimeMilliseconds / 1000 const testsResult = isSuccess ? chalk.bold.green(`${totalCorrectTests} passed`) : chalk.bold.red(`${totalFailedTests} failed`) console.log(`${chalk.bold('Tests:')} ${testsResult}, ${tests.length} total`) - console.log(`${chalk.bold('Benchmark:')} ${elapsedTime} seconds`) + Test.printBenchmark(totalElapsedTimeMilliseconds) if (!isSuccess) { throw new Error('Tests failed, try again!') } } + static printBenchmark(elapsedTimeMilliseconds: number): void { + const elapsedTime = elapsedTimeMilliseconds / 1000 + console.log(`${chalk.bold('Benchmark:')} ${elapsedTime} seconds`) + } + static async runAll(solution: Solution): Promise { const name = `${solution.challenge.name}/${solution.programmingLanguageName}/${solution.name}` const testsPath = path.join(solution.challenge.path, 'test') @@ -154,9 +157,7 @@ export class Test implements TestOptions { static async run(options: TestRunOptions): Promise { const { input, output } = await Test.getInputOutput(options.path) - const start = performance.now() - const stdout = await docker.run(input) - const end = performance.now() + const { stdout, elapsedTimeMilliseconds } = await docker.run(input) const test = new Test({ path: options.path, index: options.index, @@ -164,7 +165,7 @@ export class Test implements TestOptions { output, stdout, isSuccess: stdout === output, - elapsedTimeMilliseconds: end - start + elapsedTimeMilliseconds }) return test }