mirror of
https://github.com/theoludwig/programming-challenges.git
synced 2025-05-18 12:02:53 +02:00
feat: rewrite programming-challenges CLI (#3)
This commit is contained in:
45
cli/services/Challenge.ts
Normal file
45
cli/services/Challenge.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
import validateProjectName from 'validate-npm-package-name'
|
||||
|
||||
import { isExistingPath } from '../utils/isExistingPath'
|
||||
import { template } from './Template'
|
||||
|
||||
export interface ChallengeOptions {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface GenerateChallengeOptions extends ChallengeOptions {
|
||||
githubUser: string
|
||||
}
|
||||
|
||||
export class Challenge implements ChallengeOptions {
|
||||
public name: string
|
||||
public path: string
|
||||
|
||||
constructor (options: ChallengeOptions) {
|
||||
const { name } = options
|
||||
this.name = name
|
||||
this.path = path.join(__dirname, '..', '..', 'challenges', name)
|
||||
}
|
||||
|
||||
static async generate (options: GenerateChallengeOptions): Promise<Challenge> {
|
||||
const { name, githubUser } = options
|
||||
const challenge = new Challenge({ name })
|
||||
if (await isExistingPath(challenge.path)) {
|
||||
throw new Error(`The challenge already exists: ${name}.`)
|
||||
}
|
||||
const isValidName = validateProjectName(name).validForNewPackages
|
||||
if (!isValidName) {
|
||||
throw new Error('Invalid challenge name.')
|
||||
}
|
||||
await fs.promises.mkdir(challenge.path)
|
||||
await template.challenge({
|
||||
destination: challenge.path,
|
||||
githubUser,
|
||||
name
|
||||
})
|
||||
return challenge
|
||||
}
|
||||
}
|
33
cli/services/Docker.ts
Normal file
33
cli/services/Docker.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import execa from 'execa'
|
||||
import ora from 'ora'
|
||||
|
||||
const CONTAINER_TAG = 'programming-challenges'
|
||||
|
||||
class Docker {
|
||||
public async build (): Promise<void> {
|
||||
const loader = ora('Building the Docker image').start()
|
||||
try {
|
||||
await execa.command(`docker build --tag=${CONTAINER_TAG} ./`)
|
||||
loader.stop()
|
||||
} catch (error) {
|
||||
loader.fail()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async run (input: string): Promise<string> {
|
||||
const subprocess = execa.command(
|
||||
`docker run --interactive --rm ${CONTAINER_TAG}`,
|
||||
{
|
||||
input
|
||||
}
|
||||
)
|
||||
const { stdout, stderr } = await subprocess
|
||||
if (stderr.length !== 0) {
|
||||
throw new Error(stderr)
|
||||
}
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
export const docker = new Docker()
|
33
cli/services/GitAffected.ts
Normal file
33
cli/services/GitAffected.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import simpleGit from 'simple-git'
|
||||
|
||||
import { Challenge } from './Challenge'
|
||||
import { Solution } from './Solution'
|
||||
|
||||
const git = simpleGit()
|
||||
|
||||
const solutionsRegex = new RegExp(
|
||||
/challenges\/[\s\S]*\/solutions\/(c|cpp|dart|javascript|python|rust|typescript)\/[\s\S]*\/solution.(c|cpp|dart|js|py|rs|ts)/
|
||||
)
|
||||
|
||||
class GitAffected {
|
||||
public async getAffectedSolutions (): Promise<Solution[]> {
|
||||
await git.add('.')
|
||||
const diff = await git.diff(['--name-only', '--staged'])
|
||||
const affectedSolutionsPaths = diff.split('\n').filter((currentDiff) => {
|
||||
return solutionsRegex.test(currentDiff)
|
||||
})
|
||||
return affectedSolutionsPaths.map((solution) => {
|
||||
const [, challengeName, , programmingLanguageName, solutionName] =
|
||||
solution.split('/')
|
||||
return new Solution({
|
||||
challenge: new Challenge({
|
||||
name: challengeName
|
||||
}),
|
||||
name: solutionName,
|
||||
programmingLanguageName
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const gitAffected = new GitAffected()
|
104
cli/services/Solution.ts
Normal file
104
cli/services/Solution.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import path from 'path'
|
||||
|
||||
import {
|
||||
createTemporaryEmptyFolder,
|
||||
TEMPORARY_PATH
|
||||
} from '../utils/createTemporaryEmptyFolder'
|
||||
import { isExistingPath } from '../utils/isExistingPath'
|
||||
import { Challenge } from './Challenge'
|
||||
import { copyDirectory } from '../utils/copyDirectory'
|
||||
import { template } from './Template'
|
||||
import { docker } from './Docker'
|
||||
import { Test } from './Test'
|
||||
|
||||
export interface GetSolutionOptions {
|
||||
programmingLanguageName: string
|
||||
challengeName: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface GenerateSolutionOptions extends GetSolutionOptions {
|
||||
githubUser: string
|
||||
}
|
||||
|
||||
export interface SolutionOptions {
|
||||
programmingLanguageName: string
|
||||
challenge: Challenge
|
||||
name: string
|
||||
}
|
||||
|
||||
export class Solution implements SolutionOptions {
|
||||
public programmingLanguageName: string
|
||||
public challenge: Challenge
|
||||
public name: string
|
||||
public path: string
|
||||
|
||||
constructor (options: SolutionOptions) {
|
||||
const { programmingLanguageName, challenge, name } = options
|
||||
this.programmingLanguageName = programmingLanguageName
|
||||
this.challenge = challenge
|
||||
this.name = name
|
||||
this.path = path.join(
|
||||
challenge.path,
|
||||
'solutions',
|
||||
programmingLanguageName,
|
||||
name
|
||||
)
|
||||
}
|
||||
|
||||
private async prepareTemporaryFolder (): Promise<void> {
|
||||
await createTemporaryEmptyFolder()
|
||||
await copyDirectory(this.path, TEMPORARY_PATH)
|
||||
await template.docker({
|
||||
programmingLanguage: this.programmingLanguageName,
|
||||
destination: TEMPORARY_PATH
|
||||
})
|
||||
process.chdir(TEMPORARY_PATH)
|
||||
}
|
||||
|
||||
public async test (): Promise<void> {
|
||||
await this.prepareTemporaryFolder()
|
||||
await docker.build()
|
||||
await Test.runAll(this)
|
||||
}
|
||||
|
||||
static async generate (options: GenerateSolutionOptions): Promise<Solution> {
|
||||
const { name, challengeName, programmingLanguageName, githubUser } = options
|
||||
const challenge = new Challenge({ name: challengeName })
|
||||
if (!(await isExistingPath(challenge.path))) {
|
||||
throw new Error(`The challenge doesn't exist yet: ${name}.`)
|
||||
}
|
||||
const solution = new Solution({
|
||||
name,
|
||||
challenge,
|
||||
programmingLanguageName
|
||||
})
|
||||
if (await isExistingPath(solution.path)) {
|
||||
throw new Error('The solution already exists.')
|
||||
}
|
||||
await template.solution({
|
||||
challengeName: challenge.name,
|
||||
destination: solution.path,
|
||||
githubUser,
|
||||
programmingLanguageName: solution.programmingLanguageName,
|
||||
name: solution.name
|
||||
})
|
||||
return solution
|
||||
}
|
||||
|
||||
static async get (options: GetSolutionOptions): Promise<Solution> {
|
||||
const { name, challengeName, programmingLanguageName } = options
|
||||
const challenge = new Challenge({
|
||||
name: challengeName
|
||||
})
|
||||
const solution = new Solution({
|
||||
name,
|
||||
challenge,
|
||||
programmingLanguageName
|
||||
})
|
||||
if (!(await isExistingPath(solution.path))) {
|
||||
throw new Error('The solution was not found.')
|
||||
}
|
||||
return solution
|
||||
}
|
||||
}
|
100
cli/services/Template.ts
Normal file
100
cli/services/Template.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
import { replaceInFile } from 'replace-in-file'
|
||||
import date from 'date-and-time'
|
||||
|
||||
import { copyDirectory } from '../utils/copyDirectory'
|
||||
import { isExistingPath } from '../utils/isExistingPath'
|
||||
|
||||
const TEMPLATE_PATH = path.join(__dirname, '..', '..', 'templates')
|
||||
const TEMPLATE_DOCKER_PATH = path.join(TEMPLATE_PATH, 'docker')
|
||||
const TEMPLATE_CHALLENGE_PATH = path.join(TEMPLATE_PATH, 'challenge')
|
||||
const TEMPLATE_SOLUTION_PATH = path.join(TEMPLATE_PATH, 'solution')
|
||||
const TEMPLATE_SOLUTION_BASE_PATH = path.join(TEMPLATE_SOLUTION_PATH, 'base')
|
||||
|
||||
export interface TemplateDockerOptions {
|
||||
programmingLanguage: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
export interface TemplateChallengeOptions {
|
||||
name: string
|
||||
githubUser?: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
export interface TemplateSolutionOptions {
|
||||
challengeName: string
|
||||
programmingLanguageName: string
|
||||
name: string
|
||||
githubUser?: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
export interface ReplaceInDestinationOptions {
|
||||
destination: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
class Template {
|
||||
private getDescription (githubUser?: string): string {
|
||||
const dateString = date.format(new Date(), 'D MMMM Y', true)
|
||||
let description = 'Created'
|
||||
if (githubUser != null) {
|
||||
description += ` by [@${githubUser}](https://github.com/${githubUser})`
|
||||
}
|
||||
description += ` on ${dateString}.`
|
||||
return description
|
||||
}
|
||||
|
||||
private async replaceInDestination (options: ReplaceInDestinationOptions): Promise<void> {
|
||||
const { name, description, destination } = options
|
||||
const readmePath = path.join(destination, 'README.md')
|
||||
await replaceInFile({
|
||||
files: [readmePath],
|
||||
from: /{{ name }}/g,
|
||||
to: name
|
||||
})
|
||||
await replaceInFile({
|
||||
files: [readmePath],
|
||||
from: /{{ description }}/g,
|
||||
to: description
|
||||
})
|
||||
}
|
||||
|
||||
public async docker (options: TemplateDockerOptions): Promise<void> {
|
||||
const { programmingLanguage, destination } = options
|
||||
const sourcePath = path.join(TEMPLATE_DOCKER_PATH, programmingLanguage)
|
||||
await copyDirectory(sourcePath, destination)
|
||||
}
|
||||
|
||||
public async solution (options: TemplateSolutionOptions): Promise<void> {
|
||||
const { destination, githubUser, name, challengeName, programmingLanguageName } = options
|
||||
const templateLanguagePath = path.join(TEMPLATE_SOLUTION_PATH, programmingLanguageName)
|
||||
if (!(await isExistingPath(templateLanguagePath))) {
|
||||
throw new Error('This programming language is not supported yet.')
|
||||
}
|
||||
await fs.promises.mkdir(destination, { recursive: true })
|
||||
await copyDirectory(templateLanguagePath, destination)
|
||||
await copyDirectory(TEMPLATE_SOLUTION_BASE_PATH, destination)
|
||||
await this.replaceInDestination({
|
||||
name: `${challengeName}/${programmingLanguageName}/${name}`,
|
||||
description: this.getDescription(githubUser),
|
||||
destination
|
||||
})
|
||||
}
|
||||
|
||||
public async challenge (options: TemplateChallengeOptions): Promise<void> {
|
||||
const { destination, githubUser, name } = options
|
||||
await copyDirectory(TEMPLATE_CHALLENGE_PATH, destination)
|
||||
await this.replaceInDestination({
|
||||
name: name,
|
||||
description: this.getDescription(githubUser),
|
||||
destination
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const template = new Template()
|
151
cli/services/Test.ts
Normal file
151
cli/services/Test.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { performance } from 'perf_hooks'
|
||||
|
||||
import ora from 'ora'
|
||||
import chalk from 'chalk'
|
||||
import { table } from 'table'
|
||||
|
||||
import { Solution } from './Solution'
|
||||
import { docker } from './Docker'
|
||||
|
||||
export interface InputOutput {
|
||||
input: string
|
||||
output: string
|
||||
}
|
||||
|
||||
export interface TestRunOptions {
|
||||
index: number
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface TestOptions {
|
||||
index: number
|
||||
path: string
|
||||
isSuccess: boolean
|
||||
input: string
|
||||
output: string
|
||||
stdout: string
|
||||
elapsedTimeMilliseconds: number
|
||||
}
|
||||
|
||||
export class Test implements TestOptions {
|
||||
public index: number
|
||||
public path: string
|
||||
public isSuccess: boolean
|
||||
public input: string
|
||||
public output: string
|
||||
public stdout: string
|
||||
public elapsedTimeMilliseconds: number
|
||||
|
||||
constructor (options: TestOptions) {
|
||||
this.index = options.index
|
||||
this.path = options.path
|
||||
this.isSuccess = options.isSuccess
|
||||
this.input = options.input
|
||||
this.output = options.output
|
||||
this.stdout = options.stdout
|
||||
this.elapsedTimeMilliseconds = options.elapsedTimeMilliseconds
|
||||
}
|
||||
|
||||
static async printResult (tests: Test[]): Promise<void> {
|
||||
const tableResult = [
|
||||
[
|
||||
chalk.bold('N°'),
|
||||
chalk.bold('Input'),
|
||||
chalk.bold('Expected'),
|
||||
chalk.bold('Received')
|
||||
]
|
||||
]
|
||||
let totalFailedTests = 0
|
||||
let totalCorrectTests = 0
|
||||
let totalElapsedTimeMilliseconds = 0
|
||||
for (const test of tests) {
|
||||
if (!test.isSuccess) {
|
||||
const expected = test.output.split('\n').join('"\n"')
|
||||
const received = test.stdout.split('\n').join('"\n"')
|
||||
tableResult.push([
|
||||
test.index.toString(),
|
||||
`"${test.input}"`,
|
||||
`"${expected}"`,
|
||||
`"${received}"`
|
||||
])
|
||||
totalFailedTests += 1
|
||||
} else {
|
||||
totalCorrectTests += 1
|
||||
}
|
||||
totalElapsedTimeMilliseconds += test.elapsedTimeMilliseconds
|
||||
}
|
||||
const isSuccess = totalCorrectTests === tests.length
|
||||
if (isSuccess) {
|
||||
console.log()
|
||||
} else {
|
||||
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`)
|
||||
if (!isSuccess) {
|
||||
throw new Error('Tests failed, try again!')
|
||||
}
|
||||
}
|
||||
|
||||
static async runAll (solution: Solution): Promise<void> {
|
||||
const name = `${solution.challenge.name}/${solution.programmingLanguageName}/${solution.name}`
|
||||
const testsPath = path.join(solution.challenge.path, 'test')
|
||||
const testsFolders = await fs.promises.readdir(testsPath)
|
||||
const tests: Test[] = []
|
||||
console.log(`${chalk.bold('Name:')} ${name}\n`)
|
||||
for (let index = 0; index < testsFolders.length; index++) {
|
||||
const currentTestIndex = index + 1
|
||||
const loader = ora(`Test n°${currentTestIndex}`).start()
|
||||
try {
|
||||
const test = await Test.run({
|
||||
path: path.join(testsPath, testsFolders[index]),
|
||||
index: currentTestIndex
|
||||
})
|
||||
tests.push(test)
|
||||
if (test.isSuccess) {
|
||||
loader.succeed()
|
||||
} else {
|
||||
loader.fail()
|
||||
}
|
||||
} catch (error) {
|
||||
loader.fail()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await Test.printResult(tests)
|
||||
}
|
||||
|
||||
static async getInputOutput (testPath: string): Promise<InputOutput> {
|
||||
const inputPath = path.join(testPath, 'input.txt')
|
||||
const outputPath = path.join(testPath, 'output.txt')
|
||||
const input = await fs.promises.readFile(inputPath, { encoding: 'utf-8' })
|
||||
const output = await fs.promises.readFile(outputPath, {
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
return { input, output }
|
||||
}
|
||||
|
||||
static async run (options: TestRunOptions): Promise<Test> {
|
||||
const { input, output } = await Test.getInputOutput(options.path)
|
||||
const start = performance.now()
|
||||
const stdout = await docker.run(input)
|
||||
const end = performance.now()
|
||||
const test = new Test({
|
||||
path: options.path,
|
||||
index: options.index,
|
||||
input,
|
||||
output,
|
||||
stdout,
|
||||
isSuccess: stdout === output,
|
||||
elapsedTimeMilliseconds: end - start
|
||||
})
|
||||
return test
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user