diff --git a/package-lock.json b/package-lock.json index 06ecaf1564..9286ab564f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19336,6 +19336,12 @@ } } }, + "mock-fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.0.0.tgz", + "integrity": "sha512-A5mm/SpSDwwc/klSaEvvKMGQQtiGiQy8UcDAd/vpVO1fV+4zaHjt39yKgCSErFzv2zYxZIUx9Ud/7ybeHBf8Fg==", + "dev": true + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", diff --git a/package.json b/package.json index 468d0fec18..261fc49b92 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "lint-staged": "11.0.0", "lodash": "4.17.21", "markdownlint": "0.23.1", + "mock-fs": "^5.0.0", "npm-run-all": "4.1.5", "ora": "5.4.1", "prettier": "2.3.2", diff --git a/tools/challenge-helper-scripts/create-empty-steps.js b/tools/challenge-helper-scripts/create-empty-steps.js index 7a7062cc63..1d5179f5b3 100644 --- a/tools/challenge-helper-scripts/create-empty-steps.js +++ b/tools/challenge-helper-scripts/create-empty-steps.js @@ -1,10 +1,7 @@ -const { - reorderSteps, - createStepFile, - getExistingStepNums, - getProjectPath, - getArgValues -} = require('./utils'); +const { getArgValues } = require('./helpers/get-arg-values'); +const { getExistingStepNums } = require('./helpers/get-existing-step-nums'); +const { getProjectPath } = require('./helpers/get-project-path'); +const { createStepFile, reorderSteps } = require('./utils'); const anyStepExists = (steps, stepsToFind) => stepsToFind.some(num => steps.includes(num)); diff --git a/tools/challenge-helper-scripts/create-next-step.js b/tools/challenge-helper-scripts/create-next-step.js index c59bec66bc..4483f55002 100644 --- a/tools/challenge-helper-scripts/create-next-step.js +++ b/tools/challenge-helper-scripts/create-next-step.js @@ -1,39 +1,10 @@ -const fs = require('fs'); -const path = require('path'); - +const { getProjectPath } = require('./helpers/get-project-path'); const { - reorderSteps, - createStepFile, - getChallengeSeeds, - getProjectPath -} = require('./utils'); - -const getLastStepFileContent = () => { - const filesArr = []; - fs.readdirSync(projectPath).forEach(fileName => { - if ( - path.extname(fileName).toLowerCase() === '.md' && - !fileName.endsWith('final.md') - ) { - filesArr.push(fileName); - } - }); - - const fileName = filesArr[filesArr.length - 1]; - let lastStepFileNum = fileName.split('.')[0].split('-')[1]; - lastStepFileNum = parseInt(lastStepFileNum, 10); - if (filesArr.length !== lastStepFileNum) { - throw `Error: The last file step is ${lastStepFileNum} and there are ${filesArr.length} files.`; - } - - return { - nextStepNum: lastStepFileNum + 1, - challengeSeeds: getChallengeSeeds(projectPath + fileName) - }; -}; + getLastStepFileContent +} = require('./helpers/get-last-step-file-content'); +const { reorderSteps, createStepFile } = require('./utils'); const projectPath = getProjectPath(); - const { nextStepNum, challengeSeeds } = getLastStepFileContent(); createStepFile({ stepNum: nextStepNum, projectPath, challengeSeeds }); diff --git a/tools/challenge-helper-scripts/create-step-between.js b/tools/challenge-helper-scripts/create-step-between.js index a9f4329939..91caa916be 100644 --- a/tools/challenge-helper-scripts/create-step-between.js +++ b/tools/challenge-helper-scripts/create-step-between.js @@ -1,12 +1,8 @@ -const { - reorderSteps, - createStepFile, - getChallengeSeeds, - padWithLeadingZeros, - getExistingStepNums, - getProjectPath, - getArgValues -} = require('./utils'); +const { getArgValues } = require('./helpers/get-arg-values'); +const { getExistingStepNums } = require('./helpers/get-existing-step-nums'); +const { getProjectPath } = require('./helpers/get-project-path'); +const { padWithLeadingZeros } = require('./helpers/pad-with-leading-zeros'); +const { createStepFile, getChallengeSeeds, reorderSteps } = require('./utils'); const allStepsExist = (steps, stepsToFind) => stepsToFind.every(num => steps.includes(num)); diff --git a/tools/challenge-helper-scripts/delete-step.js b/tools/challenge-helper-scripts/delete-step.js index db92137cbc..8f1e4b0d9c 100644 --- a/tools/challenge-helper-scripts/delete-step.js +++ b/tools/challenge-helper-scripts/delete-step.js @@ -1,12 +1,9 @@ const fs = require('fs'); - -const { - reorderSteps, - padWithLeadingZeros, - getExistingStepNums, - getProjectPath, - getArgValues -} = require('./utils'); +const { getArgValues } = require('./helpers/get-arg-values'); +const { getExistingStepNums } = require('./helpers/get-existing-step-nums'); +const { getProjectPath } = require('./helpers/get-project-path'); +const { padWithLeadingZeros } = require('./helpers/pad-with-leading-zeros'); +const { reorderSteps } = require('./utils'); const stepExists = (steps, stepToFind) => steps.includes(stepToFind); diff --git a/tools/challenge-helper-scripts/helpers/get-arg-values.d.ts b/tools/challenge-helper-scripts/helpers/get-arg-values.d.ts new file mode 100644 index 0000000000..30452a290e --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-arg-values.d.ts @@ -0,0 +1 @@ +export declare function getArgValues(argv: string[]): Record; diff --git a/tools/challenge-helper-scripts/helpers/get-arg-values.js b/tools/challenge-helper-scripts/helpers/get-arg-values.js new file mode 100644 index 0000000000..3141f91058 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-arg-values.js @@ -0,0 +1,15 @@ +// Creates an object with the values starting at the third position of argv, +// ['lorem', 'ipsum', 'one=1', 'two=2', ...] => { one: 1, two: 2, ...} +function getArgValues(argv = []) { + return argv.slice(2).reduce((argsObj, arg) => { + const [argument, value] = arg.replace(/\s/g, '').split('='); + + if (!argument || !value) { + throw `Invalid argument/value specified: ${arg}`; + } + + return { ...argsObj, [argument]: value }; + }, {}); +} + +exports.getArgValues = getArgValues; diff --git a/tools/challenge-helper-scripts/helpers/get-arg-values.test.js b/tools/challenge-helper-scripts/helpers/get-arg-values.test.js new file mode 100644 index 0000000000..61fd88073e --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-arg-values.test.js @@ -0,0 +1,34 @@ +const { getArgValues } = require('./get-arg-values'); + +describe('getArgValues helper', () => { + it('should be able to run if there are no values to process', () => { + const args = ['/Path/to/node', '/Path/to/script']; + expect(getArgValues(args)).toEqual({}); + expect(getArgValues()).toEqual({}); + }); + + it('should parse the third element (key/value) if provided', () => { + const args = ['/Path/to/node', '/Path/to/script', 'num=4']; + expect(getArgValues(args)).toEqual({ num: '4' }); + }); + + it('should parse multiple arguments (key/value) if provided', () => { + const args = ['/Path/to/node', '/Path/to/script', 'num=4', 'another=5']; + expect(getArgValues(args)).toEqual({ another: '5', num: '4' }); + }); + + it('should parse the arguments with spaces (key/value) if provided', () => { + const args = ['/Path/to/node', '/Path/to/script', 'num = 3']; + expect(getArgValues(args)).toEqual({ num: '3' }); + }); + + it('should throw error on invalid key/value arguments', () => { + const items = [ + ['/Path/to/node', '/Path/to/script', 'num='], + ['/Path/to/node', '/Path/to/script', '='], + ['/Path/to/node', '/Path/to/script', 'num=2', '= 3'] + ]; + + items.forEach(item => expect(() => getArgValues(item)).toThrow()); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/get-existing-step-nums.d.ts b/tools/challenge-helper-scripts/helpers/get-existing-step-nums.d.ts new file mode 100644 index 0000000000..3324fe4836 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-existing-step-nums.d.ts @@ -0,0 +1 @@ +export declare function getExistingStepNums(options: string[]): number[]; diff --git a/tools/challenge-helper-scripts/helpers/get-existing-step-nums.js b/tools/challenge-helper-scripts/helpers/get-existing-step-nums.js new file mode 100644 index 0000000000..02c7c2938b --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-existing-step-nums.js @@ -0,0 +1,30 @@ +const fs = require('fs'); +const path = require('path'); + +// Generates an array with the output of processing filenames with an expected +// format (`part-###.md`). +// ['part-001.md', 'part-002.md'] => [1, 2] +function getExistingStepNums(projectPath) { + return fs.readdirSync(projectPath).reduce((stepNums, fileName) => { + if ( + path.extname(fileName).toLowerCase() === '.md' && + !fileName.endsWith('final.md') + ) { + let stepNum = fileName.split('.')[0].split('-')[1]; + + if (!/^\d{3}$/.test(stepNum)) { + throw ( + `Step not created. File ${fileName} has a step number containing non-digits.` + + ' Please run reorder-steps script first.' + ); + } + + stepNum = parseInt(stepNum, 10); + stepNums.push(stepNum); + } + + return stepNums; + }, []); +} + +exports.getExistingStepNums = getExistingStepNums; diff --git a/tools/challenge-helper-scripts/helpers/get-existing-step-nums.test.js b/tools/challenge-helper-scripts/helpers/get-existing-step-nums.test.js new file mode 100644 index 0000000000..e0b57dd403 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-existing-step-nums.test.js @@ -0,0 +1,74 @@ +const mock = require('mock-fs'); +const { getExistingStepNums } = require('./get-existing-step-nums'); + +// NOTE: +// Use `console.log()` before mocking the filesystem or use +// `process.stdout.write()` instead. There are issues when using `mock-fs` and +// `require`. + +describe('getExistingStepNums helper', () => { + it('should return the number portion of the project paths', () => { + mock({ + 'mock-project': { + 'part-001.md': 'Lorem ipsum...', + 'part-002.md': 'Lorem ipsum...' + } + }); + + const folder = `${process.cwd()}/mock-project/`; + const steps = getExistingStepNums(folder); + const expected = [1, 2]; + + expect(steps).toEqual(expected); + }); + + it('should ignore text formatting and files named final.md', () => { + mock({ + 'mock-project': { + 'final.md': 'Lorem ipsum...', + 'part-001.md': 'Lorem ipsum...' + } + }); + + const folder = `${process.cwd()}/mock-project/`; + const steps = getExistingStepNums(folder); + const expected = [1]; + + expect(steps).toEqual(expected); + }); + + it('should throw if file names do not follow naming convention', () => { + mock({ + 'mock-project': { + 'part-001.md': 'Lorem ipsum...', + 'part-002.md': 'Lorem ipsum...', + 'part002.md': 'Lorem ipsum...' + } + }); + + const folder = `${process.cwd()}/mock-project/`; + + expect(() => { + getExistingStepNums(folder); + }).toThrow(); + }); + + it('should return empty array if there are no markdown files', () => { + mock({ + 'mock-project': { + 'part-001.js': 'Lorem ipsum...', + 'part-002.css': 'Lorem ipsum...' + } + }); + + const folder = `${process.cwd()}/mock-project/`; + const steps = getExistingStepNums(folder); + const expected = []; + + expect(steps).toEqual(expected); + }); + + afterAll(() => { + mock.restore(); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/get-last-step-file-content.d.ts b/tools/challenge-helper-scripts/helpers/get-last-step-file-content.d.ts new file mode 100644 index 0000000000..6956b41ea0 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-last-step-file-content.d.ts @@ -0,0 +1 @@ +export declare function getLastStepFileContent(): Record; diff --git a/tools/challenge-helper-scripts/helpers/get-last-step-file-content.js b/tools/challenge-helper-scripts/helpers/get-last-step-file-content.js new file mode 100644 index 0000000000..022c153037 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-last-step-file-content.js @@ -0,0 +1,35 @@ +const fs = require('fs'); +const path = require('path'); +const { getChallengeSeeds } = require('../utils'); +const { getProjectPath } = require('./get-project-path'); + +// Looks up the last file found with format `part-###.md` in a directory and +// returns associated information to it. At the same time validates that the +// number of files match the names used to name these. +function getLastStepFileContent() { + const filesArr = []; + const projectPath = getProjectPath(); + + fs.readdirSync(projectPath).forEach(fileName => { + if ( + path.extname(fileName).toLowerCase() === '.md' && + !fileName.endsWith('final.md') + ) { + filesArr.push(fileName); + } + }); + + const fileName = filesArr[filesArr.length - 1]; + let lastStepFileNum = fileName.split('.')[0].split('-')[1]; + lastStepFileNum = parseInt(lastStepFileNum, 10); + if (filesArr.length !== lastStepFileNum) { + throw `Error: The last file step is ${lastStepFileNum} and there are ${filesArr.length} files.`; + } + + return { + challengeSeeds: getChallengeSeeds(projectPath + fileName), + nextStepNum: lastStepFileNum + 1 + }; +} + +exports.getLastStepFileContent = getLastStepFileContent; diff --git a/tools/challenge-helper-scripts/helpers/get-last-step-file-content.test.js b/tools/challenge-helper-scripts/helpers/get-last-step-file-content.test.js new file mode 100644 index 0000000000..6eadf6604c --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-last-step-file-content.test.js @@ -0,0 +1,57 @@ +const mock = require('mock-fs'); +const { getLastStepFileContent } = require('./get-last-step-file-content'); + +jest.mock('./get-project-path', () => { + return { + getProjectPath: jest.fn(() => 'mock-project/') + }; +}); + +jest.mock('../utils', () => { + return { + getChallengeSeeds: jest.fn(() => { + return { + lorem: 'ipsum' + }; + }) + }; +}); + +describe('getLastStepFileContent helper', () => { + it('should throw if last step count does not match with numbers of steps', () => { + mock({ + 'mock-project/': { + 'part-001.md': 'Lorem ipsum...', + 'part-004.md': 'Lorem ipsum...', + 'final.md': 'Lorem ipsum...' + } + }); + + expect(() => { + getLastStepFileContent(); + }).toThrow(); + + mock.restore(); + }); + + it('should return information if steps count is correct', () => { + mock({ + 'mock-project': { + 'part-001.md': 'Lorem ipsum...', + 'part-002.md': 'Lorem ipsum...', + 'final.md': 'Lorem ipsum...' + } + }); + + const expected = { + nextStepNum: 3, + challengeSeeds: { + lorem: 'ipsum' + } + }; + + expect(getLastStepFileContent()).toEqual(expected); + + mock.restore(); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/get-project-meta-path.d.ts b/tools/challenge-helper-scripts/helpers/get-project-meta-path.d.ts new file mode 100644 index 0000000000..4fa2df9552 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-meta-path.d.ts @@ -0,0 +1,4 @@ +export declare function getProjectMetaPath( + curriculumPath: string, + projectName: string +): string; diff --git a/tools/challenge-helper-scripts/helpers/get-project-meta-path.js b/tools/challenge-helper-scripts/helpers/get-project-meta-path.js new file mode 100644 index 0000000000..ae01c98deb --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-meta-path.js @@ -0,0 +1,22 @@ +const path = require('path'); + +// Returns the path of the meta file associated to arguments provided. +const getProjectMetaPath = (curriculumPath, projectName) => { + if (typeof curriculumPath !== 'string' || typeof projectName !== 'string') { + throw `${curriculumPath} and ${projectName} should be of type string`; + } + + if (!projectName) { + throw `${projectName} can't be an empty string`; + } + + return path.resolve( + curriculumPath, + 'challenges', + '_meta', + projectName, + 'meta.json' + ); +}; + +exports.getProjectMetaPath = getProjectMetaPath; diff --git a/tools/challenge-helper-scripts/helpers/get-project-meta-path.test.js b/tools/challenge-helper-scripts/helpers/get-project-meta-path.test.js new file mode 100644 index 0000000000..94fab401f7 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-meta-path.test.js @@ -0,0 +1,31 @@ +const { getProjectMetaPath } = require('./get-project-meta-path'); + +describe('getProjectMetaPath helper', () => { + it('should throw if args are invalid', () => { + expect(() => { + getProjectMetaPath(); + }).toThrow(); + + expect(() => { + getProjectMetaPath('test-curriculum', {}); + }).toThrow(); + + expect(() => { + getProjectMetaPath([], []); + }).toThrow(); + + expect(() => { + getProjectMetaPath('', ''); + }).toThrow(); + }); + + it('should return the meta path', () => { + const curriculum = 'test-curriculum'; + const project = 'test-project'; + const expected = `${process.cwd()}/${curriculum}/challenges/_meta/${project}/meta.json`; + const expectedB = `${process.cwd()}/challenges/_meta/${project}/meta.json`; + + expect(getProjectMetaPath(curriculum, project)).toEqual(expected); + expect(getProjectMetaPath('', project)).toEqual(expectedB); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/get-project-path-metadata.d.ts b/tools/challenge-helper-scripts/helpers/get-project-path-metadata.d.ts new file mode 100644 index 0000000000..00488a3241 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-path-metadata.d.ts @@ -0,0 +1 @@ +export declare function getMetaData(string): Record; diff --git a/tools/challenge-helper-scripts/helpers/get-project-path-metadata.js b/tools/challenge-helper-scripts/helpers/get-project-path-metadata.js new file mode 100644 index 0000000000..591d919aeb --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-path-metadata.js @@ -0,0 +1,16 @@ +const fs = require('fs'); + +// Process the contents of a argument (json) to an Object +function getMetaData(file) { + let metaData; + + try { + metaData = fs.readFileSync(file); + } catch (err) { + throw `No _meta.json file exists at ${file}`; + } + + return JSON.parse(metaData); +} + +exports.getMetaData = getMetaData; diff --git a/tools/challenge-helper-scripts/helpers/get-project-path-metadata.test.js b/tools/challenge-helper-scripts/helpers/get-project-path-metadata.test.js new file mode 100644 index 0000000000..e9cf6c67bc --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-path-metadata.test.js @@ -0,0 +1,31 @@ +jest.mock('fs', () => { + return { + readFileSync: jest.fn() + }; +}); + +const mockPath = '/mock/path'; +const { readFileSync } = require('fs'); +const { getMetaData } = require('./get-project-path-metadata'); + +describe('getMetaData helper', () => { + it('should process requested file', () => { + readFileSync.mockImplementation(() => '{"name": "Test Project"}'); + + const expected = { + name: 'Test Project' + }; + + expect(getMetaData(mockPath)).toEqual(expected); + }); + + it('should throw if file is not found', () => { + readFileSync.mockImplementation(() => { + throw new Error(); + }); + + expect(() => { + getMetaData(mockPath); + }).toThrowError(new Error(`No _meta.json file exists at ${mockPath}`)); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/get-project-path.d.ts b/tools/challenge-helper-scripts/helpers/get-project-path.d.ts new file mode 100644 index 0000000000..3e440c2fd3 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-path.d.ts @@ -0,0 +1 @@ +export declare function getProjectPath(): string; diff --git a/tools/challenge-helper-scripts/helpers/get-project-path.js b/tools/challenge-helper-scripts/helpers/get-project-path.js new file mode 100644 index 0000000000..6e75fc8db3 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-path.js @@ -0,0 +1,8 @@ +const path = require('path'); + +// Returns the path of a project +function getProjectPath() { + return (process.env.CALLING_DIR || process.cwd()) + path.sep; +} + +exports.getProjectPath = getProjectPath; diff --git a/tools/challenge-helper-scripts/helpers/get-project-path.test.js b/tools/challenge-helper-scripts/helpers/get-project-path.test.js new file mode 100644 index 0000000000..5df0719fe1 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-project-path.test.js @@ -0,0 +1,22 @@ +const { getProjectPath } = require('./get-project-path'); + +describe('getProjectPath helper', () => { + it('should return the calling dir path', () => { + const mockCallingDir = 'calling/dir'; + const expected = `${mockCallingDir}/`; + + // Add mock to test condition + process.env.CALLING_DIR = mockCallingDir; + + expect(getProjectPath()).toEqual(expected); + + // Remove mock to not affect other tests + delete process.env.CALLING_DIR; + }); + + it('should return the projects absolute path', () => { + const expected = `${process.cwd()}/`; + + expect(getProjectPath()).toEqual(expected); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/get-step-template.d.ts b/tools/challenge-helper-scripts/helpers/get-step-template.d.ts new file mode 100644 index 0000000000..35bc484a08 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-step-template.d.ts @@ -0,0 +1,8 @@ +type StepOptions = { + challengeId: string; + challengeSeeds: unknown; + stepBetween: boolean; + stepNum: number; +}; + +export declare function getStepTemplate(options: StepOptions): string; diff --git a/tools/challenge-helper-scripts/helpers/get-step-template.js b/tools/challenge-helper-scripts/helpers/get-step-template.js new file mode 100644 index 0000000000..981ffdfa98 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-step-template.js @@ -0,0 +1,80 @@ +const { insertErms } = require('./insert-erms'); + +// Builds a block +function getCodeBlock(label, content) { + return `\`\`\`${label} +${typeof content !== 'undefined' ? content : ''} +\`\`\``; +} + +// Builds a section +function getSeedSection(content, label) { + return content + ? ` + +## --${label}-- + +${content}` + : ''; +} + +// Build the base markdown for a step +function getStepTemplate({ + challengeId, + challengeSeeds, + stepBetween, + stepNum +}) { + const seedTexts = Object.values(challengeSeeds) + .map(({ contents, ext, editableRegionBoundaries }) => { + const fullContents = insertErms(contents, editableRegionBoundaries); + return getCodeBlock(ext, fullContents); + }) + .join('\n'); + + const seedHeads = Object.values(challengeSeeds) + .filter(({ head }) => head) + .map(({ ext, head }) => getCodeBlock(ext, head)) + .join('\n'); + + const seedTails = Object.values(challengeSeeds) + .filter(({ tail }) => tail) + .map(({ ext, tail }) => getCodeBlock(ext, tail)) + .join('\n'); + + const descStepNum = stepBetween ? stepNum + 1 : stepNum; + + const stepDescription = `${ + stepBetween ? 'new ' : '' + }step ${descStepNum} instructions`; + + const seedChallengeSection = getSeedSection(seedTexts, 'seed-contents'); + const seedHeadSection = getSeedSection(seedHeads, 'before-user-code'); + const seedTailSection = getSeedSection(seedTails, 'after-user-code'); + + return ( + `--- +id: ${challengeId} +title: Part ${stepNum} +challengeType: 0 +dashedName: part-${stepNum} +--- + +# --description-- + +${stepDescription} + +# --hints-- + +Test 1 + +${getCodeBlock('js')} + +# --seed--` + + seedChallengeSection + + seedHeadSection + + seedTailSection + ); +} + +exports.getStepTemplate = getStepTemplate; diff --git a/tools/challenge-helper-scripts/helpers/get-step-template.test.js b/tools/challenge-helper-scripts/helpers/get-step-template.test.js new file mode 100644 index 0000000000..29bfca40ff --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/get-step-template.test.js @@ -0,0 +1,56 @@ +const { getStepTemplate } = require('./get-step-template'); + +// Note: evaluates at highlevel the process, but seedHeads and seedTails could +// be tested if more specifics are needed. +describe('getStepTemplate util', () => { + it('should be able to create a markdown', () => { + const baseOutput = `--- +id: 60d4ebe4801158d1abe1b18f +title: Part 5 +challengeType: 0 +dashedName: part-5 +--- + +# --description-- + +step 5 instructions + +# --hints-- + +Test 1 + +\`\`\`js + +\`\`\` + +# --seed-- + +## --seed-contents-- + +\`\`\`html +--fcc-editable-region-- + +--fcc-editable-region-- +\`\`\``; + + const props = { + challengeId: '60d4ebe4801158d1abe1b18f', + challengeSeeds: { + indexhtml: { + contents: '', + editableRegionBoundaries: [0, 2], + ext: 'html', + head: '', + id: '', + key: 'indexhtml', + name: 'index', + tail: '' + } + }, + stepBetween: false, + stepNum: 5 + }; + + expect(getStepTemplate(props)).toEqual(baseOutput); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/insert-erms.d.ts b/tools/challenge-helper-scripts/helpers/insert-erms.d.ts new file mode 100644 index 0000000000..047a279a8e --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/insert-erms.d.ts @@ -0,0 +1 @@ +export declare function insertErms(seedCode: string, erms: number[]): string; diff --git a/tools/challenge-helper-scripts/helpers/insert-erms.js b/tools/challenge-helper-scripts/helpers/insert-erms.js new file mode 100644 index 0000000000..2a96ebef2f --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/insert-erms.js @@ -0,0 +1,30 @@ +// Update given value with markers (labels) +const insertErms = (seedCode, erms) => { + if (!erms || erms.length <= 1) { + throw `erms should be provided`; + } + + if (erms.length <= 1) { + throw `erms should contain 2 elements`; + } + + const separator = '\n'; + const lines = seedCode.split(separator); + const markerLabel = '--fcc-editable-region--'; + + // Generate a version of seed code with the erm markers + const newSeedCode = erms + .slice(0, 2) + .reduce((acc, erm) => { + if (Number.isInteger(erm)) { + acc.splice(erm, 0, markerLabel); + } + + return acc; + }, lines) + .join(separator); + + return newSeedCode; +}; + +exports.insertErms = insertErms; diff --git a/tools/challenge-helper-scripts/helpers/insert-erms.test.js b/tools/challenge-helper-scripts/helpers/insert-erms.test.js new file mode 100644 index 0000000000..32dbb54b5f --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/insert-erms.test.js @@ -0,0 +1,55 @@ +const { insertErms } = require('./insert-erms'); + +describe('insertErms helper', () => { + const code = `

Hello World

+
+

CatPhotoApp

+A cute orange cat lying on its back. +

Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.

+

Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.

+
`; + + it('should throw error if erm is undefined', () => { + expect(() => { + insertErms(code); + }).toThrow(); + }); + + it('should throw error if erm length is less than 2', () => { + const items = [[], [1]]; + + items.forEach(item => { + expect(() => { + insertErms(code, item); + }).toThrow(); + }); + }); + + it('should update code with markers if provided', () => { + const newCode = `--fcc-editable-region-- +

Hello World

+
+

CatPhotoApp

+A cute orange cat lying on its back. +

Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.

+

Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.

+--fcc-editable-region-- +
`; + + expect(insertErms(code, [0, 7])).toEqual(newCode); + }); + + it('should update code with 2 markers if more are provided', () => { + const newCode = `

Hello World

+
+--fcc-editable-region-- +

CatPhotoApp

+--fcc-editable-region-- +A cute orange cat lying on its back. +

Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.

+

Purr jump eat the grass rip the couch scratched sunbathe, shed everywhere rip the couch sleep in the sink fluffy fur catnip scratched.

+
`; + + expect(insertErms(code, [2, 4, 6, 7])).toEqual(newCode); + }); +}); diff --git a/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.d.ts b/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.d.ts new file mode 100644 index 0000000000..3158de7a2f --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.d.ts @@ -0,0 +1 @@ +export declare function padWithLeadingZeros(value: number | string): string; diff --git a/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.js b/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.js new file mode 100644 index 0000000000..69d0cc1d68 --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.js @@ -0,0 +1,18 @@ +// Prepends zero's to the given value until length is equal to 3: +// '1' -> '001', '12' -> '012', ... +// Note: always want file step numbers 3 digits +function padWithLeadingZeros(value) { + if (!(typeof value === 'number' || typeof value === 'string')) { + throw `${value} should be of type number or string`; + } + + const newValue = '' + value; + + if (newValue.length > 3) { + throw `${newValue} should be less than 4 characters`; + } + + return newValue.padStart(3, '0'); +} + +exports.padWithLeadingZeros = padWithLeadingZeros; diff --git a/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.test.js b/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.test.js new file mode 100644 index 0000000000..e195c06d8b --- /dev/null +++ b/tools/challenge-helper-scripts/helpers/pad-with-leading-zeros.test.js @@ -0,0 +1,35 @@ +const { padWithLeadingZeros } = require('./pad-with-leading-zeros'); + +describe('padWithLeadingZeros helper', () => { + it('should return a string of 3 digits for valid values', () => { + const items = ['1', '11', '111']; + + items.forEach(item => expect(padWithLeadingZeros(item).length).toEqual(3)); + }); + + it('should prepend 0s on valid values while length is less than 3', () => { + expect(padWithLeadingZeros('1')).toEqual('001'); + expect(padWithLeadingZeros('11')).toEqual('011'); + expect(padWithLeadingZeros('111')).toEqual('111'); + }); + + it('should throw on invalid values', () => { + const items = ['undefined', null, []]; + + items.forEach(item => + expect(() => { + if (item !== 'undefined') { + padWithLeadingZeros(item); + } else { + padWithLeadingZeros(); + } + }).toThrow() + ); + }); + + it('should throw on valid values that are longer that 3 characters', () => { + expect(() => { + padWithLeadingZeros('19850809'); + }).toThrow(); + }); +}); diff --git a/tools/challenge-helper-scripts/utils.d.ts b/tools/challenge-helper-scripts/utils.d.ts index 39eefcdd0e..e0859d6edd 100644 --- a/tools/challenge-helper-scripts/utils.d.ts +++ b/tools/challenge-helper-scripts/utils.d.ts @@ -1,10 +1,10 @@ type CreateStepOptions = { - projectPath: string; - stepNum: number; challengeSeeds: Record; + projectPath: string; stepBetween: boolean; + stepNum: number; }; export declare function createStepFile(options: CreateStepOptions): string; - -// TODO: the rest of the functions +export declare function reorderSteps(): void; +export declare function getChallengeSeeds(string): Record; diff --git a/tools/challenge-helper-scripts/utils.js b/tools/challenge-helper-scripts/utils.js index 975e37ea17..b6cdc68df1 100644 --- a/tools/challenge-helper-scripts/utils.js +++ b/tools/challenge-helper-scripts/utils.js @@ -3,22 +3,13 @@ const path = require('path'); const matter = require('gray-matter'); const ObjectID = require('bson-objectid'); const { parseMDSync } = require('../challenge-parser/parser'); - -const padWithLeadingZeros = originalNum => { - /* always want file step numbers 3 digits */ - return ('' + originalNum).padStart(3, '0'); -}; - -const insertErms = (seedCode, erms) => { - const lines = seedCode.split('\n'); - if (Number.isInteger(erms[0])) { - lines.splice(erms[0], 0, '--fcc-editable-region--'); - } - if (Number.isInteger(erms[1])) { - lines.splice(erms[1], 0, '--fcc-editable-region--'); - } - return lines.join('\n'); -}; +const { + getMetaData +} = require('../challenge-helper-scripts/helpers/get-project-path-metadata'); +const { getStepTemplate } = require('./helpers/get-step-template'); +const { getProjectMetaPath } = require('./helpers/get-project-meta-path'); +const { getProjectPath } = require('./helpers/get-project-path'); +const { padWithLeadingZeros } = require('./helpers/pad-with-leading-zeros'); const createStepFile = ({ projectPath, @@ -26,88 +17,20 @@ const createStepFile = ({ challengeSeeds = {}, stepBetween = false }) => { - const seedTexts = Object.values(challengeSeeds).map( - ({ contents, ext, editableRegionBoundaries }) => { - const fullContents = insertErms(contents, editableRegionBoundaries); - return `\`\`\`${ext} -${fullContents} -\`\`\``; - } - ); - - const seedHeads = Object.values(challengeSeeds) - .filter(({ head }) => head) - .map( - ({ ext, head }) => `\`\`\`${ext} -${head} -\`\`\`` - ) - .join('\n'); - - const seedTails = Object.values(challengeSeeds) - .filter(({ tail }) => tail) - .map( - ({ ext, tail }) => `\`\`\`${ext} -${tail} -\`\`\`` - ) - .join('\n'); - - const descStepNum = stepBetween ? stepNum + 1 : stepNum; - const stepDescription = `${ - stepBetween ? 'new' : '' - } step ${descStepNum} instructions`; - const challengeSeedSection = ` -# --seed-- - -## --seed-contents-- - -${seedTexts.join('\n')}`; - - const seedHeadSection = seedHeads - ? ` - -## --before-user-code-- - -${seedHeads}` - : ''; - - const seedTailSection = seedTails - ? ` - -## --after-user-code-- - -${seedTails}` - : ''; const challengeId = ObjectID(); - const template = - `--- -id: ${challengeId} -title: Part ${stepNum} -challengeType: 0 -dashedName: part-${stepNum} ---- - -# --description-- - -${stepDescription} - -# --hints-- - -Test 1 - -\`\`\`js - -\`\`\` -` + - challengeSeedSection + - seedHeadSection + - seedTailSection; - let finalStepNum = padWithLeadingZeros(stepNum); finalStepNum += stepBetween ? 'a' : ''; + + const template = getStepTemplate({ + challengeId, + challengeSeeds, + stepBetween, + stepNum + }); + fs.writeFileSync(`${projectPath}part-${finalStepNum}.md`, template); + return challengeId; }; @@ -122,20 +45,9 @@ const reorderSteps = () => { ? '' : path.join(__dirname, '../'); - const projectMetaPath = path.resolve( - curriculumPath, - 'challenges', - '_meta', - projectName, - 'meta.json' - ); + const projectMetaPath = getProjectMetaPath(curriculumPath, projectName); - let metaData; - try { - metaData = fs.readFileSync(projectMetaPath); - } catch (err) { - throw `No _meta.json file exists at ${projectMetaPath}`; - } + const parsedData = getMetaData(projectMetaPath); let foundFinal = false; const filesArr = []; @@ -167,7 +79,6 @@ const reorderSteps = () => { }); const challengeOrder = []; - const parsedData = JSON.parse(metaData); filesToReorder.forEach(({ oldFileName, newFileName, newStepNum }) => { fs.renameSync( @@ -199,52 +110,14 @@ const reorderSteps = () => { const newMeta = { ...parsedData, challengeOrder }; fs.writeFileSync(projectMetaPath, JSON.stringify(newMeta, null, 2)); - console.log('Reordered steps'); }; const getChallengeSeeds = challengeFilePath => { return parseMDSync(challengeFilePath).files; }; -const getExistingStepNums = projectPath => { - return fs.readdirSync(projectPath).reduce((stepNums, fileName) => { - if ( - path.extname(fileName).toLowerCase() === '.md' && - !fileName.endsWith('final.md') - ) { - let stepNum = fileName.split('.')[0].split('-')[1]; - if (!/^\d{3}$/.test(stepNum)) { - throw ( - `Step not created. File ${fileName} has a step number containing non-digits.` + - ' Please run reorder-steps script first.' - ); - } - stepNum = parseInt(stepNum, 10); - stepNums.push(stepNum); - } - return stepNums; - }, []); -}; - -const getProjectPath = () => - (process.env.CALLING_DIR || process.cwd()) + path.sep; - -const getArgValues = argv => { - return argv.slice(2).reduce((argsObj, arg) => { - const [argument, value] = arg.replace(/\s/g, '').split('='); - if (!argument || !value) { - throw `Invalid argument/value specified: ${arg}`; - } - return { ...argsObj, [argument]: value }; - }, {}); -}; - module.exports = { createStepFile, getChallengeSeeds, - padWithLeadingZeros, - reorderSteps, - getExistingStepNums, - getProjectPath, - getArgValues + reorderSteps }; diff --git a/tools/challenge-helper-scripts/utils.test.js b/tools/challenge-helper-scripts/utils.test.js new file mode 100644 index 0000000000..c49797785e --- /dev/null +++ b/tools/challenge-helper-scripts/utils.test.js @@ -0,0 +1,139 @@ +const fs = require('fs'); +const mock = require('mock-fs'); +const glob = require('glob'); + +// NOTE: +// Use `console.log()` before mocking the filesystem or use +// `process.stdout.write()` instead. There are issues when using `mock-fs` and +// `require`. + +jest.mock('bson-objectid', () => { + return jest.fn(() => mockChallengeId); +}); + +jest.mock('./helpers/get-step-template', () => { + return { + getStepTemplate: jest.fn(() => 'Mock template...') + }; +}); + +jest.mock('./helpers/get-project-meta-path', () => { + return { + getProjectMetaPath: jest.fn(() => '_meta/project/meta.json') + }; +}); + +jest.mock('./helpers/get-project-path', () => { + return { + getProjectPath: jest.fn(() => 'project/') + }; +}); + +jest.mock('gray-matter', () => { + return { + read: jest.fn(() => ({ + data: { id: mockChallengeId }, + stringify: jest.fn(() => 'Lorem ipsum...') + })) + }; +}); + +jest.mock( + '../challenge-helper-scripts/helpers/get-project-path-metadata', + () => ({ + getMetaData: jest.fn(() => ({ + id: 'mock-id' + })) + }) +); + +const mockChallengeId = '60d35cf3fe32df2ce8e31b03'; +const ObjectID = require('bson-objectid'); +const { getStepTemplate } = require('./helpers/get-step-template'); +const { createStepFile, reorderSteps } = require('./utils'); + +describe('Challenge utils helper scripts', () => { + describe('createStepFile util', () => { + it('should create next step and return its identifier', () => { + mock({ + 'project/': { + 'part-001.md': 'Lorem ipsum...', + 'part-002.md': 'Lorem ipsum...' + } + }); + + const step = createStepFile({ + projectPath: 'project/', + stepNum: 3 + }); + + expect(step).toEqual(mockChallengeId); + expect(ObjectID).toHaveBeenCalledTimes(1); + + // Internal tasks + // - Should generate a template for the step that is being created + expect(getStepTemplate).toHaveBeenCalledTimes(1); + + // - Should write a file with a given name and template + const files = glob.sync(`project/*.md`); + + expect(files).toEqual([ + `project/part-001.md`, + `project/part-002.md`, + `project/part-003.md` + ]); + + mock.restore(); + }); + }); + + describe('reorderSteps util', () => { + it('should sort files found in given path', () => { + mock({ + '_meta/project/': { + 'meta.json': 'Lorem ipsum meta content...' + }, + 'project/': { + 'part-001.md': 'Lorem ipsum 1...', + 'part-002.md': 'Lorem ipsum 2...', + 'part-002b.md': 'Lorem ipsum 3...' + } + }); + + reorderSteps(); + + // - Should write a file with a given name and template + const files = glob.sync(`project/*.md`); + + expect(files).toEqual([ + 'project/part-001.md', + 'project/part-002.md', + 'project/part-003.md' + ]); + + const result = fs.readFileSync('_meta/project/meta.json', 'utf8'); + + const expectedResult = `{ + "id": "mock-id", + "challengeOrder": [ + [ + "60d35cf3fe32df2ce8e31b03", + "Part 1" + ], + [ + "60d35cf3fe32df2ce8e31b03", + "Part 2" + ], + [ + "60d35cf3fe32df2ce8e31b03", + "Part 3" + ] + ] +}`; + + expect(result).toEqual(expectedResult); + + mock.restore(); + }); + }); +});