diff --git a/package-lock.json b/package-lock.json index 5c96108..ea709e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@coderoad/cli", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -960,6 +960,15 @@ "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", "dev": true }, + "@types/fs-extra": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.1.tgz", + "integrity": "sha512-B42Sxuaz09MhC3DDeW5kubRcQ5by4iuVQ0cRRWM2lggLzAa/KVom0Aft/208NgMvNQQZ86s5rVcqDdn/SH0/mg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/glob": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", @@ -1269,6 +1278,11 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==" + }, "atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -2360,6 +2374,17 @@ "map-cache": "^0.2.2" } }, + "fs-extra": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.0.1.tgz", + "integrity": "sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ==", + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^1.0.0" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2463,8 +2488,7 @@ "graceful-fs": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz", - "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", - "dev": true + "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==" }, "growly": { "version": "1.3.0", @@ -4141,6 +4165,15 @@ "minimist": "^1.2.5" } }, + "jsonfile": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz", + "integrity": "sha512-jR2b5v7d2vIOust+w3wtFKZIfpC2pnRmFAhAC/BuweZFQR8qZzxH1OyrQ10HmdVYiXWkYUqPVsz91cG7EL2FBg==", + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^1.0.0" + } + }, "jsprim": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", @@ -5832,6 +5865,11 @@ "set-value": "^2.0.1" } }, + "universalify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", + "integrity": "sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug==" + }, "unset-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", diff --git a/package.json b/package.json index 5ac564c..6a9fee1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "dependencies": { "ajv": "^6.12.2", "esm": "^3.2.25", + "fs-extra": "^9.0.1", "js-yaml": "^3.14.0", "kleur": "^3.0.3", "lodash": "^4.17.15", @@ -58,6 +59,7 @@ "devDependencies": { "@babel/preset-typescript": "^7.10.1", "@types/ajv": "^1.0.0", + "@types/fs-extra": "^9.0.1", "@types/inquirer": "^6.5.0", "@types/jest": "^25.2.3", "@types/js-yaml": "^3.12.4", diff --git a/src/utils/exec.ts b/src/utils/exec.ts new file mode 100644 index 0000000..a209b2d --- /dev/null +++ b/src/utils/exec.ts @@ -0,0 +1,91 @@ +import * as T from "../../typings/tutorial"; +import { exec as cpExec } from "child_process"; +import * as path from "path"; +import { promisify } from "util"; + +const asyncExec = promisify(cpExec); + +export function createExec(cwd: string) { + return async function exec( + command: string + ): Promise<{ stdout: string | null; stderr: string }> { + try { + const result = await asyncExec(command, { cwd }); + return result; + } catch (e) { + return { stdout: null, stderr: e.message }; + } + }; +} + +export function createCherryPick(cwd: string) { + return async function cherryPick(commits: string[]): Promise { + for (const commit of commits) { + try { + const { stdout } = await createExec(cwd)( + `git cherry-pick -X theirs ${commit}` + ); + if (!stdout) { + console.warn(`No cherry-pick output for ${commit}`); + } + } catch (e) { + console.warn(`Cherry-pick failed for ${commit}`); + } + } + }; +} + +export function createCommandRunner(cwd: string) { + return async function runCommands( + commands: string[], + dir?: string + ): Promise { + let errors = []; + for (const command of commands) { + try { + console.log(`--> ${command}`); + let cwdDir = cwd; + if (dir) { + cwdDir = path.join(cwd, dir); + } + const { stdout, stderr } = await createExec(cwdDir)(command); + + console.warn(stderr); + } catch (e) { + console.error(`Command failed: "${command}"`); + console.warn(e.message); + errors.push(e.message); + } + } + return !!errors.length; + }; +} + +// function isAbsolute(p: string) { +// return path.normalize(p + "/") === path.normalize(path.resolve(p) + "/"); +// } + +export function createTestRunner(cwd: string, config: T.TestRunnerConfig) { + const { command, args, directory } = config; + + // const commandIsAbsolute = isAbsolute(command); + + let wd = cwd; + if (directory) { + wd = path.join(cwd, directory); + } + + const commandWithArgs = `${command} ${args.tap}`; + + return async function runTest(): Promise<{ + stdout: string | null; + stderr: string | null; + }> { + try { + // console.log(await createExec(wd)("ls -a node_modules/.bin")); + return await createExec(wd)(commandWithArgs); + } catch (e) { + return Promise.resolve({ stdout: null, stderr: e.message }); + } + }; +} diff --git a/src/validate.ts b/src/validate.ts index 4da3048..e5ed11e 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -1,16 +1,15 @@ import * as path from "path"; -import * as fs from "fs"; -import util from "util"; +import * as fs from "fs-extra"; import * as yamlParser from "js-yaml"; import { getArg } from "./utils/args"; import gitP, { SimpleGit } from "simple-git/promise"; +import { + createCommandRunner, + createCherryPick, + createTestRunner, +} from "./utils/exec"; import { getCommits, CommitLogObject } from "./utils/commits"; -const mkdir = util.promisify(fs.mkdir); -const exists = util.promisify(fs.exists); -const rmdir = util.promisify(fs.rmdir); -const read = util.promisify(fs.readFile); - async function validate(args: string[]) { // dir - default . const dir = !args.length || args[0].match(/^-/) ? "." : args[0]; @@ -21,14 +20,14 @@ async function validate(args: string[]) { yaml: getArg(args, { name: "yaml", alias: "y" }) || "coderoad.yaml", }; - const _yaml = await read(path.join(localDir, options.yaml), "utf8"); + const _yaml = await fs.readFile(path.join(localDir, options.yaml), "utf8"); // parse yaml config - let config; + let skeleton; try { - config = yamlParser.load(_yaml); - // TODO: validate yaml - if (!config || !config.length) { + skeleton = yamlParser.load(_yaml); + + if (!skeleton) { throw new Error("Invalid yaml file contents"); } } catch (e) { @@ -36,40 +35,131 @@ async function validate(args: string[]) { console.error(e.message); } - const codeBranch: string = config.config.repo.branch; + const codeBranch: string = skeleton.config.repo.branch; + + // validate commits + const commits: CommitLogObject = await getCommits({ localDir, codeBranch }); + + // setup tmp dir + const tmpDir = path.join(localDir, ".tmp"); - // VALIDATE SKELETON WITH COMMITS - const commits = getCommits({ localDir, codeBranch }); + try { + if (!(await fs.pathExists(tmpDir))) { + await fs.emptyDir(tmpDir); + } + const tempGit: SimpleGit = gitP(tmpDir); - // parse tutorial skeleton for order and commands + await tempGit.init(); + await tempGit.addRemote("origin", skeleton.config.repo.uri); + await tempGit.fetch("origin", skeleton.config.repo.branch); + // no js cherry pick implementation + const cherryPick = createCherryPick(tmpDir); + const runCommands = createCommandRunner(tmpDir); + const runTest = createTestRunner(tmpDir, skeleton.config.testRunner); - // on error, warn missing level/step + // setup + console.info("* Setup"); + if (commits.INIT) { + // load commits + console.info("-- Loading commits..."); + await cherryPick(commits.INIT); - // VALIDATE COMMIT ORDER - // list all commits in order - // validate that a level number doesn't come before another level - // validate that a step falls within a level - // validate that steps are in order + // run commands + if (skeleton.config?.testRunner?.setup?.commands) { + console.info("-- Running commands..."); + + await runCommands( + skeleton.config?.testRunner?.setup?.commands, + // add optional setup directory + skeleton.config?.testRunner?.directory + ); + } + } - // on error, show level/step out of order + for (const level of skeleton.levels) { + console.info(`* ${level.id}`); + if (level?.setup) { + // load commits + if (commits[`${level.id}`]) { + console.log(`-- Loading commits...`); + await cherryPick(commits[level.id]); + } + // run commands + if (level.setup?.commands) { + console.log(`-- Running commands...`); + await runCommands(level.setup.commands); + } + } + // steps + if (level.steps) { + for (const step of level.steps) { + console.info(`** ${step.id}`); + // load commits + const stepSetupCommits = commits[`${step.id}Q`]; + if (stepSetupCommits) { + console.info(`--- Loading setup commits...`); + await cherryPick(stepSetupCommits); + } + // run commands + if (step.setup.commands) { + console.info(`--- Running setup commands...`); + await runCommands(step.setup.commands); + } - // VALIDATE TUTORIAL TESTS - // load INIT commit(s) - // run test runner setup command(s) - // loop over commits: - // - load level commit - // - run level setup command(s) - // - load step setup commit(s) - // - run step setup command(s) - // - if next solution: - // - run test - expect fail - // - if solution - // - run test - expect pass + const stepSolutionCommits = commits[`${step.id}A`]; + const hasSolution = step.solution || stepSolutionCommits; - // log level/step - // on error, show level/step & error message + // ignore running tests on steps with no solution + if (hasSolution) { + // run test + console.info("--- Running setup test..."); + // expect fail + const { stdout, stderr } = await runTest(); + if (stdout) { + console.error( + `--- Expected ${step.id} setup tests to fail, but passed` + ); + // log tests + console.log(stdout); + } + } - // CLEANUP + if (stepSolutionCommits) { + console.info(`--- Loading solution commits...`); + await cherryPick(stepSolutionCommits); + } + + // run commands + if (step?.solution?.commands) { + console.info(`--- Running solution commands...`); + await runCommands(step.solution.commands); + } + + if (hasSolution) { + // run test + console.info("--- Running solution test..."); + // expect pass + const { stdout, stderr } = await runTest(); + if (stderr) { + console.error( + `--- Expected ${step.id} solution tests to pass, but failed` + ); + // log tests + console.log(stderr); + } + } + } + } + } + + console.info(`\n✔ Success!`); + } catch (e) { + console.error("\n✘ Fail!"); + console.error(e.message); + } finally { + // cleanup + await fs.emptyDir(tmpDir); + } } export default validate; diff --git a/tests/validate.test.ts b/tests/tutorial.test.ts similarity index 100% rename from tests/validate.test.ts rename to tests/tutorial.test.ts diff --git a/typings/tutorial.d.ts b/typings/tutorial.d.ts index f3fe401..82b1e2e 100644 --- a/typings/tutorial.d.ts +++ b/typings/tutorial.d.ts @@ -62,7 +62,7 @@ export interface TestRunnerArgs { export interface TestRunnerConfig { command: string; - args?: TestRunnerArgs; + args: TestRunnerArgs; directory?: string; setup?: StepActions; }