mirror of
https://github.com/theoludwig/programming-challenges.git
synced 2024-12-08 00:45:29 +01:00
feat(cli): add commands/run/solution
This commit is contained in:
parent
6427b3d273
commit
d6a6c706ce
@ -76,13 +76,16 @@ npm install --global
|
|||||||
programming-challenges --help
|
programming-challenges --help
|
||||||
|
|
||||||
# Generate a new challenge
|
# 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
|
# 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
|
# 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
|
## 💡 Contributing
|
||||||
|
@ -2,6 +2,7 @@ import { Builtins, Cli } from 'clipanion'
|
|||||||
|
|
||||||
import { GenerateChallengeCommand } from './commands/generate/challenge.js'
|
import { GenerateChallengeCommand } from './commands/generate/challenge.js'
|
||||||
import { GenerateSolutionCommand } from './commands/generate/solution.js'
|
import { GenerateSolutionCommand } from './commands/generate/solution.js'
|
||||||
|
import { RunSolutionCommand } from './commands/run/solution.js'
|
||||||
import { RunTestCommand } from './commands/run/test.js'
|
import { RunTestCommand } from './commands/run/test.js'
|
||||||
|
|
||||||
export const cli = new Cli({
|
export const cli = new Cli({
|
||||||
@ -14,3 +15,4 @@ cli.register(Builtins.VersionCommand)
|
|||||||
cli.register(GenerateChallengeCommand)
|
cli.register(GenerateChallengeCommand)
|
||||||
cli.register(GenerateSolutionCommand)
|
cli.register(GenerateSolutionCommand)
|
||||||
cli.register(RunTestCommand)
|
cli.register(RunTestCommand)
|
||||||
|
cli.register(RunSolutionCommand)
|
||||||
|
148
cli/commands/run/__test__/solution.test.ts
Normal file
148
cli/commands/run/__test__/solution.test.ts
Normal file
@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
72
cli/commands/run/solution.ts
Normal file
72
cli/commands/run/solution.ts
Normal file
@ -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<number> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -26,7 +26,7 @@ export class RunTestCommand extends Command {
|
|||||||
})
|
})
|
||||||
|
|
||||||
public solutionName = Option.String('--solution', {
|
public solutionName = Option.String('--solution', {
|
||||||
description: 'solution',
|
description: 'The solution name to run.',
|
||||||
validator: typanion.isString()
|
validator: typanion.isString()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
|
import { performance } from 'node:perf_hooks'
|
||||||
|
|
||||||
import { execaCommand } from 'execa'
|
import { execaCommand } from 'execa'
|
||||||
import ora from 'ora'
|
import ora from 'ora'
|
||||||
import ms from 'ms'
|
import ms from 'ms'
|
||||||
|
|
||||||
|
export interface DockerRunResult {
|
||||||
|
stdout: string
|
||||||
|
elapsedTimeMilliseconds: number
|
||||||
|
}
|
||||||
|
|
||||||
export class Docker {
|
export class Docker {
|
||||||
static CONTAINER_TAG = 'programming-challenges'
|
static CONTAINER_TAG = 'programming-challenges'
|
||||||
static SIGSEGV_EXIT_CODE = 139
|
static SIGSEGV_EXIT_CODE = 139
|
||||||
@ -19,7 +26,7 @@ export class Docker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async run(input: string): Promise<string> {
|
public async run(input: string): Promise<DockerRunResult> {
|
||||||
const subprocess = execaCommand(
|
const subprocess = execaCommand(
|
||||||
`docker run --interactive --rm ${Docker.CONTAINER_TAG}`,
|
`docker run --interactive --rm ${Docker.CONTAINER_TAG}`,
|
||||||
{
|
{
|
||||||
@ -32,12 +39,17 @@ export class Docker {
|
|||||||
isValid = false
|
isValid = false
|
||||||
}, Docker.MAXIMUM_TIMEOUT_MILLISECONDS)
|
}, Docker.MAXIMUM_TIMEOUT_MILLISECONDS)
|
||||||
try {
|
try {
|
||||||
|
const start = performance.now()
|
||||||
const { stdout, stderr } = await subprocess
|
const { stdout, stderr } = await subprocess
|
||||||
|
const end = performance.now()
|
||||||
if (stderr.length !== 0) {
|
if (stderr.length !== 0) {
|
||||||
throw new Error(stderr)
|
throw new Error(stderr)
|
||||||
}
|
}
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
return stdout
|
return {
|
||||||
|
stdout,
|
||||||
|
elapsedTimeMilliseconds: end - start
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
@ -2,6 +2,9 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
|
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import ora from 'ora'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createTemporaryEmptyFolder,
|
createTemporaryEmptyFolder,
|
||||||
TEMPORARY_PATH
|
TEMPORARY_PATH
|
||||||
@ -64,6 +67,24 @@ export class Solution implements SolutionOptions {
|
|||||||
await Test.runAll(this)
|
await Test.runAll(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async run(input: string, output: boolean = false): Promise<void> {
|
||||||
|
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<Solution> {
|
static async generate(options: GenerateSolutionOptions): Promise<Solution> {
|
||||||
const { name, challengeName, programmingLanguageName, githubUser } = options
|
const { name, challengeName, programmingLanguageName, githubUser } = options
|
||||||
const challenge = new Challenge({ name: challengeName })
|
const challenge = new Challenge({ name: challengeName })
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { performance } from 'node:perf_hooks'
|
|
||||||
|
|
||||||
import ora from 'ora'
|
import ora from 'ora'
|
||||||
import chalk from 'chalk'
|
import chalk from 'chalk'
|
||||||
@ -84,17 +83,21 @@ export class Test implements TestOptions {
|
|||||||
console.log()
|
console.log()
|
||||||
console.log(table(tableResult))
|
console.log(table(tableResult))
|
||||||
}
|
}
|
||||||
const elapsedTime = totalElapsedTimeMilliseconds / 1000
|
|
||||||
const testsResult = isSuccess
|
const testsResult = isSuccess
|
||||||
? chalk.bold.green(`${totalCorrectTests} passed`)
|
? chalk.bold.green(`${totalCorrectTests} passed`)
|
||||||
: chalk.bold.red(`${totalFailedTests} failed`)
|
: chalk.bold.red(`${totalFailedTests} failed`)
|
||||||
console.log(`${chalk.bold('Tests:')} ${testsResult}, ${tests.length} total`)
|
console.log(`${chalk.bold('Tests:')} ${testsResult}, ${tests.length} total`)
|
||||||
console.log(`${chalk.bold('Benchmark:')} ${elapsedTime} seconds`)
|
Test.printBenchmark(totalElapsedTimeMilliseconds)
|
||||||
if (!isSuccess) {
|
if (!isSuccess) {
|
||||||
throw new Error('Tests failed, try again!')
|
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<void> {
|
static async runAll(solution: Solution): Promise<void> {
|
||||||
const name = `${solution.challenge.name}/${solution.programmingLanguageName}/${solution.name}`
|
const name = `${solution.challenge.name}/${solution.programmingLanguageName}/${solution.name}`
|
||||||
const testsPath = path.join(solution.challenge.path, 'test')
|
const testsPath = path.join(solution.challenge.path, 'test')
|
||||||
@ -154,9 +157,7 @@ export class Test implements TestOptions {
|
|||||||
|
|
||||||
static async run(options: TestRunOptions): Promise<Test> {
|
static async run(options: TestRunOptions): Promise<Test> {
|
||||||
const { input, output } = await Test.getInputOutput(options.path)
|
const { input, output } = await Test.getInputOutput(options.path)
|
||||||
const start = performance.now()
|
const { stdout, elapsedTimeMilliseconds } = await docker.run(input)
|
||||||
const stdout = await docker.run(input)
|
|
||||||
const end = performance.now()
|
|
||||||
const test = new Test({
|
const test = new Test({
|
||||||
path: options.path,
|
path: options.path,
|
||||||
index: options.index,
|
index: options.index,
|
||||||
@ -164,7 +165,7 @@ export class Test implements TestOptions {
|
|||||||
output,
|
output,
|
||||||
stdout,
|
stdout,
|
||||||
isSuccess: stdout === output,
|
isSuccess: stdout === output,
|
||||||
elapsedTimeMilliseconds: end - start
|
elapsedTimeMilliseconds
|
||||||
})
|
})
|
||||||
return test
|
return test
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user