diff --git a/.eslintignore b/.eslintignore index 3eedfaf4..cc47132b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ elm-stuff fixtures templates +flow-typed diff --git a/elm/src/Test/Runner/Node.elm b/elm/src/Test/Runner/Node.elm index a220da2e..8f47728c 100644 --- a/elm/src/Test/Runner/Node.elm +++ b/elm/src/Test/Runner/Node.elm @@ -47,7 +47,7 @@ type alias InitArgs = type alias RunnerOptions = { seed : Int - , runs : Maybe Int + , runs : Int , report : Report , globs : List String , paths : List String @@ -385,11 +385,8 @@ run { runs, seed, report, globs, paths, processes } possiblyTests = else let - fuzzRuns = - Maybe.withDefault defaultRunCount runs - runners = - Test.Runner.fromTest fuzzRuns (Random.initialSeed seed) (Test.concat tests) + Test.Runner.fromTest runs (Random.initialSeed seed) (Test.concat tests) wrappedInit = init @@ -397,7 +394,7 @@ run { runs, seed, report, globs, paths, processes } possiblyTests = , processes = processes , globs = globs , paths = paths - , fuzzRuns = fuzzRuns + , fuzzRuns = runs , runners = runners , report = report } @@ -437,9 +434,4 @@ If there are – are they exposed? |> String.replace "%globs" (String.join "\n" globs) -defaultRunCount : Int -defaultRunCount = - 100 - - port receive : (Decode.Value -> msg) -> Sub msg diff --git a/flow-typed/Result.js b/flow-typed/Result.js new file mode 100644 index 00000000..fc975735 --- /dev/null +++ b/flow-typed/Result.js @@ -0,0 +1,18 @@ +// @flow + +// We can’t use /*:: type Result = ... */ because of: +// https://github.com/prettier/prettier/issues/2597 +// +// Because of the type arguments we can’t use the regular trick of making a type +// annotation instead and then use `typeof Result`. The workaround is to define +// `Result` globally – this file is not included during runtime. +// +// This also lets us use `Result` in several files without having to define it +// multiple times or figure out some way to import types. +// +// If you wonder why we use a weird mix of “real” syntax and comment syntax here +// – it’s because of Prettier again. If you “uncomment” the `` +// part, Prettier adds `/*::` and `*/` back. +type Result/*:: */ = + | { tag: 'Ok', value: Value } + | { tag: 'Error', error: Error }; diff --git a/lib/Compile.js b/lib/Compile.js index f6394932..5854212d 100644 --- a/lib/Compile.js +++ b/lib/Compile.js @@ -1,22 +1,21 @@ //@flow const path = require('path'), - elmCompiler = require('./ElmCompiler'), + ElmCompiler = require('./ElmCompiler'), spawn = require('cross-spawn'), - packageInfo = require('../package.json'); + packageInfo = require('../package.json'), + Report = require('./Report'); function compile( testFile /*: string */, dest /*: string */, - verbose /*: boolean */, pathToElmBinary /*: string */, - report /*: string */ + report /*: typeof Report.Report */ ) /*: Promise */ { return new Promise((resolve, reject) => { - const compileProcess = elmCompiler.compile([testFile], { + const compileProcess = ElmCompiler.compile([testFile], { output: dest, - verbose: verbose, - spawn: spawnCompiler(report), + spawn: spawnCompiler({ ignoreStdout: true }), pathToElm: pathToElmBinary, processOpts: processOptsForReporter(report), }); @@ -49,18 +48,16 @@ function getTestRootDir(projectRootDir /*: string */) /*: string */ { function compileSources( testFilePaths /*: Array */, projectRootDir /*: string */, - verbose /*: boolean */, pathToElmBinary /*: string */, - report /*: string */ + report /*: typeof Report.Report */ ) /*: Promise */ { return new Promise((resolve, reject) => { const compilerReport = report === 'json' ? report : undefined; - const compileProcess = elmCompiler.compile(testFilePaths, { + const compileProcess = ElmCompiler.compile(testFilePaths, { output: '/dev/null', cwd: projectRootDir, - verbose: verbose, - spawn: spawnCompiler(report), + spawn: spawnCompiler({ ignoreStdout: false }), pathToElm: pathToElmBinary, report: compilerReport, processOpts: processOptsForReporter(report), @@ -76,7 +73,7 @@ function compileSources( }); } -function spawnCompiler(report /*: string */) { +function spawnCompiler({ ignoreStdout }) { return ( pathToElm /*: string */, processArgs /*: Array */, @@ -85,7 +82,7 @@ function spawnCompiler(report /*: string */) { const finalOpts = Object.assign({ env: process.env }, processOpts, { stdio: [ process.stdin, - report === 'console' ? process.stdout : 'ignore', + ignoreStdout ? 'ignore' : process.stdout, process.stderr, ], }); @@ -94,22 +91,17 @@ function spawnCompiler(report /*: string */) { }; } -function processOptsForReporter(reporter) { - if (isMachineReadableReporter(reporter)) { +function processOptsForReporter(report /*: typeof Report.Report */) { + if (Report.isMachineReadable(report)) { return { env: process.env, stdio: ['ignore', 'ignore', process.stderr] }; } else { return { env: process.env }; } } -function isMachineReadableReporter(reporter /*: string */) /*: boolean */ { - return reporter === 'json' || reporter === 'junit'; -} - module.exports = { compile, compileSources, getTestRootDir, getGeneratedCodeDir, - isMachineReadableReporter, }; diff --git a/lib/ElmCompiler.js b/lib/ElmCompiler.js index 421de8fa..5a8669a0 100644 --- a/lib/ElmCompiler.js +++ b/lib/ElmCompiler.js @@ -10,7 +10,6 @@ var defaultOptions = { pathToElm: 'elm', output: undefined, report: undefined, - verbose: false, processOpts: undefined, }; @@ -44,10 +43,6 @@ function runCompiler(sources, options) { options.processOpts ); - if (options.verbose) { - console.log(['Running', pathToElm].concat(processArgs).join(' ')); - } - return options.spawn(pathToElm, processArgs, processOpts); } @@ -93,7 +88,6 @@ function compile( pathToElm?: string, output?: string, report?: 'json', - verbose?: boolean, processOpts?: child_process$spawnOpts, |} */ ) /*: child_process$ChildProcess */ { diff --git a/lib/Flags.js b/lib/Flags.js new file mode 100644 index 00000000..cb879fd4 --- /dev/null +++ b/lib/Flags.js @@ -0,0 +1,284 @@ +// @flow + +const Report = require('./Report.js'); + +function help() /*: string */ { + return ` +elm-test init + Create example tests + +elm-test + Run tests in the tests/ folder + +elm-test TESTFILES + Run TESTFILES, for example "src/**/*Tests.elm" + +elm-test install PACKAGE + Like \`elm install PACKAGE\`, except it installs to + "test-dependencies" in your elm.json + +Options: + --compiler /path/to/compiler + Use a custom path to an Elm executable. Default: elm + --seed INT + Run with a previous fuzzer seed. Default: A random seed + --fuzz INT + Run with each fuzz test performing this many iterations. Default: 100 + --report json + --report junit + --report console + Print results to stdout in the given format. Default: console + --version + Print version and exit + --watch + Run tests on file changes + `.trim(); +} + +const longOptionWithValue = /^(--[^-=][^=]*)=([^]*)$/; +const looksLikeOption = /^--?[^-]/; + +// Poor man’s type alias. We can’t use /*:: type Command = ... */ because of: +// https://github.com/prettier/prettier/issues/2597 +const Command /*: + | { tag: 'help' } + | { tag: 'version' } + | { tag: 'init' } + | { tag: 'install', packageName: string } + | { tag: 'make', testFileGlobs: Array } + | { tag: 'test', testFileGlobs: Array } */ = { tag: 'help' }; +void Command; + +function parseArgv( + argv /*: Array */ +) /*: Result< + string, + { + command: typeof Command, + options: { + compiler: string | void, + fuzz: number, + report: typeof Report.Report, + seed: number, + watch: boolean, + }, + } +> */ { + const options = { + help: false, + version: false, + watch: false, + compiler: undefined, + seed: Math.floor(Math.random() * 407199254740991) + 1000, + fuzz: 100, + report: 'console', + }; + + const rest = []; + let raw = []; + + for (let index = 0; index < argv.length; index++) { + const fullArg = argv[index]; + const match = longOptionWithValue.exec(fullArg); + const arg = match === null ? fullArg : match[1]; + + // Get the value passed to a long flag, or `fallback` if there is none. + // This is either the part after the equal sign: `--flag=value`. + // Or the next argument: `--flag value`. + const getValue = (fallback /*: string */) /*: string */ => { + if (match === null) { + index++; + return index >= argv.length ? fallback : argv[index]; + } else { + return match[2]; + } + }; + + const takesNoValueError = { tag: 'Error', error: `${arg} takes no value.` }; + + switch (arg) { + case '-h': + case '-help': + case '--help': + if (match !== null) { + return takesNoValueError; + } + options.help = true; + break; + + case '--version': + if (match !== null) { + return takesNoValueError; + } + options.version = true; + break; + + case '--watch': + if (match !== null) { + return takesNoValueError; + } + options.watch = true; + break; + + case '--compiler': { + const value = getValue(''); + if (value === '') { + return { + tag: 'Error', + error: `You must pass a path after ${arg}`, + }; + } + options.compiler = value; + break; + } + + case '--seed': { + const result = parsePositiveInteger(getValue('nothing')); + switch (result.tag) { + case 'Ok': + options.seed = result.value; + break; + case 'Error': + return { + tag: 'Error', + error: `You must pass a number after ${arg}: ${result.error}`, + }; + } + break; + } + + case '--fuzz': { + const result = parsePositiveInteger(getValue('nothing')); + switch (result.tag) { + case 'Ok': + options.fuzz = result.value; + break; + case 'Error': + return { + tag: 'Error', + error: `You must pass a number after ${arg}: ${result.error}`, + }; + } + break; + } + + case '--report': { + const result = Report.parse(getValue('nothing')); + switch (result.tag) { + case 'Ok': + options.report = result.value; + break; + case 'Error': + return { + tag: 'Error', + error: `You must pass a reporter after ${arg}: ${result.error}`, + }; + } + break; + } + + case '--': + raw = argv.slice(index + 1); + index = argv.length; + break; + + default: + if (looksLikeOption.test(arg)) { + return { tag: 'Error', error: `Unknown option: ${arg}` }; + } + rest.push(arg); + } + } + + const command = + options.help || rest[0] === 'help' + ? { tag: 'Ok', value: { tag: 'help' } } + : options.version + ? { tag: 'Ok', value: { tag: 'version' } } + : parseCommand(rest, raw); + + if (command.tag === 'Error') { + return command; + } + + return { + tag: 'Ok', + value: { + command: command.value, + options: { + // fuzz, seed and report don’t make sense for _all_ commands, but I’m + // not sure we gain anything by disallowing them rather than ignoring + // them. + compiler: options.compiler, + fuzz: options.fuzz, + report: options.report, + seed: options.seed, + watch: options.watch, + }, + }, + }; +} + +function parsePositiveInteger( + string /*: string */ +) /*: Result */ { + const number = Number(string); + return !/^\d+$/.test(string) + ? { + tag: 'Error', + error: `Expected one or more digits, but got: ${string}`, + } + : !Number.isFinite(number) + ? { + tag: 'Error', + error: `Expected a finite number, but got: ${number}`, + } + : { tag: 'Ok', value: number }; +} + +function parseCommand( + args /*: Array */, + raw /*: Array */ +) /*: Result */ { + const first = args[0]; + const rest = args.slice(1).concat(raw); + const got = `${rest.length}: ${rest.join(' ')}`; + + switch (first) { + case 'init': + return rest.length > 0 + ? { + tag: 'Error', + error: `init takes no arguments, but got ${got}`, + } + : { tag: 'Ok', value: { tag: 'init' } }; + + case 'install': + return rest.length === 0 + ? { + tag: 'Error', + error: + 'You need to provide the package you want to install. For example: elm-test install elm/regex', + } + : rest.length === 1 + ? { tag: 'Ok', value: { tag: 'install', packageName: rest[0] } } + : { + tag: 'Error', + error: `install takes one single argument, but got ${got}`, + }; + + case 'make': + return { tag: 'Ok', value: { tag: 'make', testFileGlobs: rest } }; + + default: + return { + tag: 'Ok', + value: { tag: 'test', testFileGlobs: args.concat(raw) }, + }; + } +} + +module.exports = { + help, + parseArgv, +}; diff --git a/lib/Generate.js b/lib/Generate.js index d9f179cd..2c7d57e8 100644 --- a/lib/Generate.js +++ b/lib/Generate.js @@ -5,8 +5,11 @@ const path = require('path'), Murmur = require('murmur-hash-js'), Solve = require('./Solve.js'), Compile = require('./Compile.js'), + Report = require('./Report.js'), supportsColor = require('chalk').supportsColor; +void Report; + function prepareCompiledJsFile( pipeFilename /*: string */, dest /*: string */ @@ -195,7 +198,7 @@ function generateElmJson( function generateMainModule( fuzz /*: number */, seed /*: number */, - report /*: string */, + report /*: typeof Report.Report */, testFileGlobs /*: Array */, testFilePaths /*: Array */, testModules /*: Array<{ @@ -305,20 +308,15 @@ function indentAllButFirstLine(indent, string) { function makeOptsCode( fuzz /*: number */, seed /*: number */, - report /*: string */, + report /*: typeof Report.Report */, testFileGlobs /*: Array */, testFilePaths /*: Array */, processes /*: number */ ) /*: string */ { - // TODO: CLI args should be parsed, validated and defaulted properly in elm-test.js. - const finalSeed = isNaN(seed) - ? Math.floor(Math.random() * 407199254740991) + 1000 - : seed; - return ` -{ runs = ${isNaN(fuzz) ? 'Nothing' : `Just ${fuzz}`} +{ runs = ${fuzz} , report = ${generateElmReportVariant(report)} -, seed = ${finalSeed} +, seed = ${seed} , processes = ${processes} , globs = ${indentAllButFirstLine(' ', makeList(testFileGlobs.map(makeElmString)))} @@ -328,13 +326,15 @@ function makeOptsCode( `.trim(); } -function generateElmReportVariant(report) { +function generateElmReportVariant( + report /*: typeof Report.Report */ +) /*: string */ { switch (report) { case 'json': return 'JsonReport'; case 'junit': return 'JUnitReport'; - default: + case 'console': if (supportsColor) { return 'ConsoleReport UseColor'; } else { diff --git a/lib/Report.js b/lib/Report.js new file mode 100644 index 00000000..e0da1ec5 --- /dev/null +++ b/lib/Report.js @@ -0,0 +1,35 @@ +// @flow + +// Poor man’s type alias. We can’t use /*:: type Report = ... */ because of: +// https://github.com/prettier/prettier/issues/2597 +const Report /*: 'console' | 'json' | 'junit' */ = 'console'; + +function parse(string /*: string */) /*: Result */ { + switch (string) { + case 'console': + case 'json': + case 'junit': + return { tag: 'Ok', value: string }; + default: + return { + tag: 'Error', + error: `Expected console, json or junit, but got: ${string}`, + }; + } +} + +function isMachineReadable(report /*: typeof Report */) /*: boolean */ { + switch (report) { + case 'json': + case 'junit': + return true; + case 'console': + return false; + } +} + +module.exports = { + Report, + parse, + isMachineReadable, +}; diff --git a/lib/Runner.js b/lib/Runner.js index 8ea0c4d9..686b6af4 100644 --- a/lib/Runner.js +++ b/lib/Runner.js @@ -9,7 +9,7 @@ function findTests( isPackageProject /*: boolean */ ) /*: Promise }>> */ { return Promise.all( - testFilePaths.map((filePath) => { + testFilePaths.map(async (filePath) => { const matchingSourceDirs = sourceDirs.filter((dir) => filePath.startsWith(`${dir}${path.sep}`) ); @@ -51,12 +51,11 @@ function findTests( ); } - return Parser.extractExposedPossiblyTests(filePath).then( - (possiblyTests) => ({ - moduleName, - possiblyTests, - }) - ); + const possiblyTests = await Parser.extractExposedPossiblyTests(filePath); + return { + moduleName, + possiblyTests, + }; }) ); } diff --git a/lib/Supervisor.js b/lib/Supervisor.js index ce2c5ac0..9f167e8a 100644 --- a/lib/Supervisor.js +++ b/lib/Supervisor.js @@ -5,16 +5,17 @@ var chalk = require('chalk'), fs = require('fs-extra'), net = require('net'), child_process = require('child_process'), - split = require('split'); + split = require('split'), + // $FlowFixMe: Flow marks this line as an error (and only in this file!) but then it knows the types of `Report` anyway. This is the “error”: Cannot build a typed interface for this module. You should annotate the exports of this module with types. Missing type annotation at declaration of variable `Report`: [signature-verification-failure] + Report = require('./Report'); function run( elmTestVersion /*: string */, pipeFilename /*: string */, - format /*: string */, + report /*: typeof Report.Report */, processes /*: number */, dest /*: string */, - watch /*: boolean */, - isMachineReadable /*: boolean */ + watch /*: boolean */ ) /*: Promise */ { return new Promise(function (resolve) { var nextResultToPrint = null; @@ -29,22 +30,29 @@ function run( var workers = []; function printResult(result, exitCode /*: number | void */ = undefined) { - if (format === 'console') { - // todos are objects, and will be shown in the SUMMARY only. - // passed tests are nulls, and should not be printed. - // failed tests are strings. - if (typeof result === 'string') { - const message = makeWindowsSafe(result); - if (exitCode === 1) { - console.error(message); - } else { - console.log(message); + switch (report) { + case 'console': + // todos are objects, and will be shown in the SUMMARY only. + // passed tests are nulls, and should not be printed. + // failed tests are strings. + if (typeof result === 'string') { + const message = makeWindowsSafe(result); + if (exitCode === 1) { + console.error(message); + } else { + console.log(message); + } } - } - } else if (format === 'json') { - console.log(JSON.stringify(result)); + break; + + case 'json': + console.log(JSON.stringify(result)); + break; + + case 'junit': + // JUnit does everything at once in SUMMARY, elsewhere + break; } - // JUnit does everything at once in SUMMARY, elsewhere } function flushResults() { @@ -87,7 +95,7 @@ function run( var result = response.results[index]; results.set(parseInt(index), result); - switch (format) { + switch (report) { case 'console': if (result === null) { // It's a PASS; no need to take any action. @@ -156,7 +164,7 @@ function run( printResult(response.message, response.exitCode); - if (format === 'junit') { + if (report === 'junit') { var xml = response.message; var values = Array.from(results.values()); @@ -178,7 +186,7 @@ function run( case 'BEGIN': testsToRun = response.testCount; - if (!isMachineReadable) { + if (!Report.isMachineReadable(report)) { var headline = 'elm-test ' + elmTestVersion; var bar = '-'.repeat(headline.length); @@ -227,7 +235,7 @@ function run( // code can be null. var hasNonZeroExitCode = typeof code === 'number' && code !== 0; - if (watch && !isMachineReadable) { + if (watch && !Report.isMachineReadable(report)) { if (hasNonZeroExitCode) { // Queue up complaining about an exception. // Don't print it immediately, or else it might print N times diff --git a/lib/elm-test.js b/lib/elm-test.js index f112604c..bfe6343a 100644 --- a/lib/elm-test.js +++ b/lib/elm-test.js @@ -1,250 +1,60 @@ // @flow -var packageInfo = require('../package.json'); -var chalk = require('chalk'); -var Install = require('./Install.js'); -var Compile = require('./Compile.js'); -var Generate = require('./Generate.js'); -var processTitle = 'elm-test'; -var which = require('which'); - -process.title = processTitle; - -function clearConsole() { - process.stdout.write( - process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H' - ); -} - -process.on('uncaughtException', function (error) { - if (/ an argument in Javascript/.test(error)) { - // Handle arg mismatch between js and elm code. Expected message from Elm: - // "You are giving module `Main` an argument in JavaScript. - // This module does not take arguments though! You probably need to change the - // initialization code to something like `Elm.Test.Generated.Main.fullscreen()`]" - console.error('Error starting the node-test-runner.'); - console.error( - "Please check your Javascript 'elm-test' and Elm 'node-test-runner' package versions are compatible" - ); - process.exit(1); - } else { - console.error('Unhandled exception while running the tests:', error); - process.exit(1); - } -}); - -var fs = require('fs-extra'), - os = require('os'), - glob = require('glob'), - path = require('path'), - minimist = require('minimist'), - chokidar = require('chokidar'), - Runner = require('./Runner.js'), - Supervisor = require('./Supervisor.js'); - -// Check Node.js version. -const nodeVersionMin = '10.13.0'; -const nodeVersionString = process.versions.node; - -if ( - nodeVersionString.localeCompare(nodeVersionMin, 'en', { numeric: true }) < 0 -) { - console.error(`You are using Node.js v${nodeVersionString}.`); - console.error( - `elm-test requires Node.js v${nodeVersionMin} or greater - upgrade the installed version of Node.js and try again!` - ); - process.exit(1); -} - -var args = minimist(process.argv.slice(2), { - boolean: ['warn', 'version', 'help', 'watch'], - string: ['compiler', 'seed', 'report', 'fuzz'], -}); -var processes = Math.max(1, os.cpus().length); - -function flatMap(array, f) { - return array.reduce((result, item) => result.concat(f(item)), []); -} - -// Recursively search directories for *.elm files, excluding elm-stuff/ -function resolveFilePath(filename) { - var candidates; - - if (!fs.existsSync(filename)) { - candidates = []; - } else if (fs.lstatSync(filename).isDirectory()) { - candidates = flatMap( - glob.sync('/**/*.elm', { - root: filename, - nocase: true, - ignore: '/**/elm-stuff/**', - nodir: true, - }), - resolveFilePath - ); - } else { - candidates = [path.resolve(filename)]; - } - - // Exclude everything having anything to do with elm-stuff - return candidates.filter(function (candidate) { - return candidate.split(path.sep).indexOf('elm-stuff') === -1; - }); -} - -let pathToElmBinary; - -if (args.compiler === undefined) { - try { - pathToElmBinary = which.sync('elm'); - } catch (error) { - console.error( - `Cannot find elm executable, make sure it is installed. -(If elm is not on your path or is called something different the --compiler flag might help.)` - ); - // Flow does not understand that `process.exit()` diverges. We add an - // unconditional throw here (that can never run) to help flow out. - throw process.exit(1); - } -} else { +const chalk = require('chalk'); +const chokidar = require('chokidar'); +const fs = require('fs-extra'); +const glob = require('glob'); +const os = require('os'); +const path = require('path'); +const which = require('which'); +const packageInfo = require('../package.json'); +const Compile = require('./Compile.js'); +const Flags = require('./Flags.js'); +const Generate = require('./Generate.js'); +const Install = require('./Install.js'); +const Report = require('./Report.js'); +const Runner = require('./Runner.js'); +const Supervisor = require('./Supervisor.js'); + +void Report; + +function getPathToElmBinary( + compiler /*: string | void */ +) /*: Result */ { + const name = compiler === undefined ? 'elm' : compiler; try { - pathToElmBinary = path.resolve(which.sync(args.compiler)); - } catch (error) { - console.error('The elm executable passed to --compiler must exist.'); - // See above. - throw process.exit(1); - } -} - -function printUsage(str) { - console.log('Usage: elm-test ' + str + '\n'); -} - -if (args.help) { - var exampleGlob = path.join('tests', '**', '*.elm'); - - [ - 'init # Create example tests', - 'install PACKAGE # Like `elm install PACKAGE`, except it installs to "test-dependencies" in your elm.json', - 'TESTFILES # Run TESTFILES, for example ' + exampleGlob, - '[--compiler /path/to/compiler] # Run tests', - '[--seed integer] # Run with initial fuzzer seed', - '[--fuzz integer] # Run with each fuzz test performing this many iterations', - '[--report json, junit, or console (default)] # Print results to stdout in given format', - '[--version] # Print version string and exit', - '[--watch] # Run tests on file changes', - ].forEach(printUsage); - - process.exit(0); -} - -if (args.version) { - console.log(require(path.join(__dirname, '..', 'package.json')).version); - process.exit(0); -} - -if (args._[0] === 'install') { - var packageName = args._[1]; - - if (typeof packageName === 'string') { - if (!fs.existsSync('elm.json')) { - console.error( - '`elm-test install` must be run in the same directory as an existing elm.json file!' - ); - process.exit(1); - } - - Install.install(pathToElmBinary, packageName); - - process.exit(0); - } else { - console.error( - 'What package should I install? I was expecting something like this:\n\n elm-test install elm/regex\n' - ); - process.exit(1); - } -} else if (args._[0] == 'init') { - if (!fs.existsSync('elm.json')) { - console.error( - '`elm-test init` must be run in the same directory as an existing elm.json file! You can run `elm init` to initialize one.' - ); - process.exit(1); + return { tag: 'Ok', value: path.resolve(which.sync(name)) }; + } catch (_error) { + return compiler === undefined + ? { + tag: 'Error', + error: `Cannot find elm executable, make sure it is installed. +(If elm is not on your path or is called something different the --compiler flag might help.)`, + } + : { + tag: 'Error', + error: `The elm executable passed to --compiler must exist and be exectuble. Got: ${compiler}`, + }; } - - Install.install(pathToElmBinary, 'elm-explorations/test'); - fs.mkdirpSync('tests'); - fs.copySync( - path.join(__dirname, '..', 'templates', 'tests', 'Example.elm'), - 'tests/Example.elm' - ); - - console.log( - '\nCheck out the documentation for getting started at https://package.elm-lang.org/packages/elm-explorations/test/latest' - ); - - process.exit(0); } -let runsExecuted = 0; - -function runTests(generatedCodeDir /*: string */, testFile /*: string */) { - const dest = path.resolve(path.join(generatedCodeDir, 'elmTestOutput.js')); - - // Incorporate the process PID into the socket name, so elm-test processes can - // be run parallel without accidentally sharing each others' sockets. - // - // See https://github.com/rtfeldman/node-test-runner/pull/231 - // Also incorporate a salt number into it on Windows, to avoid EADDRINUSE - - // see https://github.com/rtfeldman/node-test-runner/issues/275 - because the - // alternative approach of deleting the file before creating a new one doesn't - // work on Windows. We have to let Windows clean up the named pipe. This is - // essentially a band-aid fix. The alternative is to rewrite a ton of stuff. - runsExecuted++; - const pipeFilename = - process.platform === 'win32' - ? '\\\\.\\pipe\\elm_test-' + process.pid + '-' + runsExecuted - : '/tmp/elm_test-' + process.pid + '.sock'; - - return Compile.compile( - testFile, - dest, - args.verbose, - pathToElmBinary, - args.report - ) - .then(function () { - return Generate.prepareCompiledJsFile(pipeFilename, dest).then( - function () { - return Supervisor.run( - packageInfo.version, - pipeFilename, - report, - processes, - dest, - args.watch, - Compile.isMachineReadableReporter(report) - ); - } - ); - }) - .catch(function (error) { - console.error('Compilation failed for', testFile); - return Promise.reject(error); - }); +function resolveGlobs(fileGlobs /*: Array */) /*: Array */ { + const results = + fileGlobs.length > 0 + ? flatMap(fileGlobs, globify) + : globify('test?(s)/**/*.elm'); + return flatMap(results, resolveFilePath); } -function globify(filename) { - return glob.sync(filename, { - nocase: true, - ignore: '**/elm-stuff/**', - nodir: false, - absolute: true, - }); +function flatMap/*:: */( + array /*: Array */, + f /*: (T) => Array */ +) /*: Array */ { + return array.reduce((result, item) => result.concat(f(item)), []); } -function globifyWithRoot(root, filename) { - return glob.sync(filename, { - root: root, +function globify(globString /*: string */) /*: Array */ { + return glob.sync(globString, { nocase: true, ignore: '**/elm-stuff/**', nodir: false, @@ -252,79 +62,102 @@ function globifyWithRoot(root, filename) { }); } -function resolveGlobs(fileGlobs) { - let globs; - - if (fileGlobs.length > 0) { - globs = flatMap(fileGlobs, globify); - } else { - const root = process.cwd(); - - globs = globifyWithRoot(root, 'test?(s)/**/*.elm'); - } - - return flatMap(globs, resolveFilePath); -} +// Recursively search directories for *.elm files, excluding elm-stuff/ +function resolveFilePath(elmFilePathOrDir /*: string */) /*: Array */ { + const candidates = !fs.existsSync(elmFilePathOrDir) + ? [] + : fs.lstatSync(elmFilePathOrDir).isDirectory() + ? flatMap( + glob.sync('/**/*.elm', { + root: elmFilePathOrDir, + nocase: true, + ignore: '/**/elm-stuff/**', + nodir: true, + }), + resolveFilePath + ) + : [path.resolve(elmFilePathOrDir)]; -function getGlobsToWatch(elmJson) { - let sourceDirectories; - if (elmJson['type'] === 'package') { - sourceDirectories = ['src']; - } else { - sourceDirectories = elmJson['source-directories']; - } - return [...sourceDirectories, 'tests'].map(function (sourceDirectory) { - return path.posix.join(sourceDirectory, '**', '*.elm'); - }); + // Exclude everything having anything to do with elm-stuff + return candidates.filter( + (candidate) => !candidate.split(path.sep).includes('elm-stuff') + ); } -let report; - -if ( - args.report === 'console' || - args.report === 'json' || - args.report === 'junit' -) { - report = args.report; -} else if (args.report !== undefined) { - console.error( - "The --report option must be given either 'console', 'junit', or 'json'" +function getGlobsToWatch(elmJson /*: any */) /*: Array */ { + const sourceDirectories = + elmJson.type === 'package' ? ['src'] : elmJson['source-directories']; + return [...sourceDirectories, 'tests'].map((sourceDirectory) => + path.posix.join(sourceDirectory, '**', '*.elm') ); - process.exit(1); -} else { - report = 'console'; } -function infoLog(msg) { +function infoLog( + report /*: typeof Report.Report */, + msg /*: string */ +) /*: void */ { if (report === 'console') { console.log(msg); } } -// It's important to globify all the arguments. -// On Bash 4.x (or zsh), if you give it a glob as its last argument, Bash -// translates that into a list of file paths. On bash 3.x it's just a string. -// Ergo, globify all the arguments we receive. -const isMake = args._[0] === 'make'; -const testFileGlobs = isMake ? args._.slice(1) : args._; -const testFilePaths = resolveGlobs(testFileGlobs); -const projectRootDir = process.cwd(); -const generatedCodeDir = Compile.getGeneratedCodeDir(projectRootDir); -const hasBeenGivenCustomGlobs = testFileGlobs.length > 0; - -const elmJsonPath = path.resolve(path.join(projectRootDir, 'elm.json')); -let projectElmJson; - -try { - projectElmJson = fs.readJsonSync(elmJsonPath); -} catch (err) { - console.error('Error reading elm.json: ' + err.message); - throw process.exit(1); +function clearConsole() { + process.stdout.write( + process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H' + ); } -const isPackageProject = projectElmJson.type === 'package'; +function makeAndTestHelper( + testFileGlobs /*: Array */, + compiler /*: string | void */ +) /*: Result */ { + // Resolve arguments that look like globs for shells that don’t support globs. + const testFilePaths = resolveGlobs(testFileGlobs); + const projectRootDir = process.cwd(); + const generatedCodeDir = Compile.getGeneratedCodeDir(projectRootDir); + const hasBeenGivenCustomGlobs = testFileGlobs.length > 0; + const elmJsonPath = path.resolve(path.join(projectRootDir, 'elm.json')); + + const pathToElmBinary = getPathToElmBinary(compiler); + if (pathToElmBinary.tag === 'Error') { + return pathToElmBinary; + } + + try { + const projectElmJson = fs.readJsonSync(elmJsonPath); + return { + tag: 'Ok', + value: { + pathToElmBinary: pathToElmBinary.value, + testFilePaths, + projectRootDir, + generatedCodeDir, + hasBeenGivenCustomGlobs, + elmJsonPath, + projectElmJson, + isPackageProject: projectElmJson.type === 'package', + }, + }; + } catch (error) { + return { + tag: 'Error', + error: `Error reading elm.json: ${error.message}`, + }; + } +} -if (isMake) { +function make( + report, + { + pathToElmBinary, + testFilePaths, + projectRootDir, + generatedCodeDir, + hasBeenGivenCustomGlobs, + elmJsonPath, + projectElmJson, + } +) { Generate.generateElmJson( projectRootDir, generatedCodeDir, @@ -333,25 +166,29 @@ if (isMake) { projectElmJson ); - Compile.compileSources( + return Compile.compileSources( testFilePaths, generatedCodeDir, - args.verbose, pathToElmBinary, - args.report - ) - .then(function () { - process.exit(0); - }) - .catch(function () { - process.exit(1); - }); -} else { - if (testFilePaths.length === 0) { - console.error(noFilesFoundError(testFileGlobs)); - process.exit(1); - } + report + ); +} +function test( + testFileGlobs, + processes, + { + pathToElmBinary, + testFilePaths, + projectRootDir, + generatedCodeDir, + hasBeenGivenCustomGlobs, + elmJsonPath, + projectElmJson, + isPackageProject, + }, + { watch, report, seed, fuzz } +) { const [generatedSrc, sourceDirs] = Generate.generateElmJson( projectRootDir, generatedCodeDir, @@ -360,45 +197,52 @@ if (isMake) { projectElmJson ); - function run() { + async function run() { // This compiles all the tests so that we generate *.elmi files for them, // which we can then read to determine which tests need to be run. - return Runner.findTests(testFilePaths, sourceDirs, isPackageProject) - .then(function (testModules) { - process.chdir(generatedCodeDir); - - const mainFile = Generate.generateMainModule( - parseInt(args.fuzz), - parseInt(args.seed), - args.report, - testFileGlobs, - testFilePaths, - testModules, - generatedSrc, - processes - ); - - return runTests(generatedCodeDir, mainFile); - }) - .catch(function (err) { - console.error(err.message); - if (!args.watch) { - process.exit(1); - } - }) - .then(function () { - console.log(chalk.blue('Watching for changes...')); - }); + try { + const testModules = await Runner.findTests( + testFilePaths, + sourceDirs, + isPackageProject + ); + process.chdir(generatedCodeDir); + + const mainFile = Generate.generateMainModule( + parseInt(fuzz), + parseInt(seed), + report, + testFileGlobs, + testFilePaths, + testModules, + generatedSrc, + processes + ); + await runTests( + generatedCodeDir, + mainFile, + pathToElmBinary, + report, + watch, + processes + ); + } catch (err) { + console.error(err.message); + if (!watch) { + process.exit(1); + } + } + console.log(chalk.blue('Watching for changes...')); } - var currentRun = run(); + let currentRun = run(); - if (args.watch) { + if (watch) { clearConsole(); - infoLog('Running in watch mode'); + infoLog(report, 'Running in watch mode'); - var globsToWatch = getGlobsToWatch(projectElmJson); - var watcher = chokidar.watch(globsToWatch, { + const globsToWatch = getGlobsToWatch(projectElmJson); + const watcher = chokidar.watch(globsToWatch, { awaitWriteFinish: { stabilityThreshold: 500, }, @@ -407,7 +251,7 @@ if (isMake) { cwd: projectRootDir, }); - var eventNameMap = { + const eventNameMap = { add: 'added', addDir: 'added', change: 'changed', @@ -415,10 +259,10 @@ if (isMake) { unlinkDir: 'removed', }; - watcher.on('all', function (event, filePath) { - var eventName = eventNameMap[event] || event; + watcher.on('all', (event, filePath) => { + const eventName = eventNameMap[event] || event; clearConsole(); - infoLog('\n' + filePath + ' ' + eventName + '. Rebuilding!'); + infoLog(report, '\n' + filePath + ' ' + eventName + '. Rebuilding!'); // TODO if a previous run is in progress, wait until it's done. currentRun = currentRun.then(run); @@ -426,6 +270,45 @@ if (isMake) { } } +let runsExecuted = 0; + +async function runTests( + generatedCodeDir /*: string */, + testFile /*: string */, + pathToElmBinary /*: string */, + report /*: typeof Report.Report */, + watch /*: boolean */, + processes /*: number */ +) { + const dest = path.resolve(path.join(generatedCodeDir, 'elmTestOutput.js')); + + // Incorporate the process PID into the socket name, so elm-test processes can + // be run parallel without accidentally sharing each others' sockets. + // + // See https://github.com/rtfeldman/node-test-runner/pull/231 + // Also incorporate a salt number into it on Windows, to avoid EADDRINUSE - + // see https://github.com/rtfeldman/node-test-runner/issues/275 - because the + // alternative approach of deleting the file before creating a new one doesn't + // work on Windows. We have to let Windows clean up the named pipe. This is + // essentially a band-aid fix. The alternative is to rewrite a ton of stuff. + runsExecuted++; + const pipeFilename = + process.platform === 'win32' + ? `\\\\.\\pipe\\elm_test-${process.pid}-${runsExecuted}` + : `/tmp/elm_test-${process.pid}.sock`; + + await Compile.compile(testFile, dest, pathToElmBinary, report); + await Generate.prepareCompiledJsFile(pipeFilename, dest); + await Supervisor.run( + packageInfo.version, + pipeFilename, + report, + processes, + dest, + watch + ); +} + function noFilesFoundError(testFileGlobs) { return testFileGlobs.length === 0 ? ` @@ -444,3 +327,128 @@ ${testFileGlobs.join('\n')} Are the above patterns correct? Maybe try running elm-test with no arguments? `.trim(); } + +function main() /*: number | null */ { + process.title = 'elm-test'; + + const parseResult = Flags.parseArgv(process.argv.slice(2)); + if (parseResult.tag === 'Error') { + console.error(parseResult.error); + return 1; + } + const parsed = parseResult.value; + + const processes = Math.max(1, os.cpus().length); + + const requireElmJsonFile = () => { + if (!fs.existsSync('elm.json')) { + console.error( + `\`elm-test ${parsed.command.tag}\` must be run in the same directory as an existing elm.json file! To make one: elm init` + ); + return false; + } + return true; + }; + + switch (parsed.command.tag) { + case 'help': + console.log(Flags.help()); + return 0; + + case 'version': + console.log(packageInfo.version); + return 0; + + case 'init': { + if (!requireElmJsonFile()) { + return 1; + } + const pathToElmBinary = getPathToElmBinary(parsed.options.compiler); + switch (pathToElmBinary.tag) { + case 'Ok': + Install.install(pathToElmBinary.value, 'elm-explorations/test'); + fs.mkdirpSync('tests'); + fs.copySync( + path.join(__dirname, '..', 'templates', 'tests', 'Example.elm'), + 'tests/Example.elm' + ); + console.log( + '\nCheck out the documentation for getting started at https://package.elm-lang.org/packages/elm-explorations/test/latest' + ); + return 0; + case 'Error': + console.error(pathToElmBinary.error); + return 1; + } + // `pathToElmBinary.tag` has the type `empty` at this point. It’s + // necessary to return here to avoid fallthrough warnings from ESLint and + // to make Flow understand that we cannot reach past the outer `switch` + // and implicitly return undefined. I wish exhaustiveness checking was as + // easy as in Elm. + return pathToElmBinary.tag; + } + + case 'install': { + const { packageName } = parsed.command; + if (!requireElmJsonFile()) { + return 1; + } + const pathToElmBinary = getPathToElmBinary(parsed.options.compiler); + switch (pathToElmBinary.tag) { + case 'Ok': + Install.install(pathToElmBinary.value, packageName); + return 0; + case 'Error': + console.error(pathToElmBinary.error); + return 1; + } + return pathToElmBinary.tag; + } + + case 'make': { + const { testFileGlobs } = parsed.command; + if (!requireElmJsonFile()) { + return 1; + } + const result = makeAndTestHelper(testFileGlobs, parsed.options.compiler); + switch (result.tag) { + case 'Ok': + make(parsed.options.report, result.value).then( + () => process.exit(0), + () => process.exit(1) + ); + return null; + case 'Error': + console.error(result.error); + return 1; + } + return result.tag; + } + + case 'test': { + const { testFileGlobs } = parsed.command; + if (!requireElmJsonFile()) { + return 1; + } + const result = makeAndTestHelper(testFileGlobs, parsed.options.compiler); + switch (result.tag) { + case 'Ok': + if (result.value.testFilePaths.length === 0) { + console.error(noFilesFoundError(testFileGlobs)); + return 1; + } + test(testFileGlobs, processes, result.value, parsed.options); + return null; + case 'Error': + console.error(result.error); + return 1; + } + return result.tag; + } + } +} + +const exitCode = main(); +if (exitCode !== null) { + process.exit(exitCode); +} diff --git a/lib/pipe-filename.js b/lib/pipe-filename.js deleted file mode 100644 index c3ddad95..00000000 --- a/lib/pipe-filename.js +++ /dev/null @@ -1,12 +0,0 @@ -//@flow - -// Incorporate the process PID into the socket name, so elm-test processes can -// be run parallel without accidentally sharing each others' sockets. -// -// See https://github.com/rtfeldman/node-test-runner/pull/231 -const name /*: string */ = - process.platform === 'win32' - ? '\\\\.\\pipe\\elm_test-' + process.pid - : '/tmp/elm_test-' + process.pid + '.sock'; - -module.exports = name; diff --git a/package.json b/package.json index 7bca2614..7d9bf793 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "elm-json": "^0.2.8", "fs-extra": "^8.1.0", "glob": "^7.1.6", - "minimist": "^1.2.5", "murmur-hash-js": "^1.0.0", "split": "^1.0.1", "which": "^2.0.2", diff --git a/tests/flags.js b/tests/flags.js index 2e8fd103..6433180b 100644 --- a/tests/flags.js +++ b/tests/flags.js @@ -45,6 +45,12 @@ describe('flags', () => { fs.ensureDirSync(scratchDir); }); + it('Should fail if given extra arguments', () => { + const runResult = execElmTest(['init', 'frontend/elm']); + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(60000); + describe('for a PACKAGE', () => { it('Adds elm-explorations/test', (done) => { fs.copyFileSync( @@ -110,6 +116,18 @@ describe('flags', () => { fs.ensureDirSync(scratchDir); }); + it('should fail if given no arguments', () => { + const runResult = execElmTest(['install']); + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(60000); + + it('should fail if given extra arguments', () => { + const runResult = execElmTest(['install', 'elm/regex', 'elm/time']); + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(60000); + it('should fail if the current directory does not contain an elm.json', () => { const runResult = execElmTest(['install', 'elm/regex'], scratchDir); assert.ok(Number.isInteger(runResult.status)); @@ -134,12 +152,33 @@ describe('flags', () => { }).timeout(60000); }); + describe('elm-test make', () => { + it('should exit with success for valid Elm code', () => { + const runResult = execElmTest(['make', 'tests/Passing/One.elm']); + assert.strictEqual(runResult.status, 0); + }).timeout(60000); + + it('should exit with non-success for invalid Elm code', () => { + const runResult = execElmTest([ + 'make', + 'tests/CompileError/InvalidSyntax.elm', + ]); + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(60000); + }); + describe('--help', () => { it('Should print the usage', () => { const runResult = execElmTest(['--help']); // Checking against a fixture is brittle here // For now, check that the output is non-empty. assert.ok(runResult.stdout.length > 0); + + // Helpful aliases. + assert.strictEqual(execElmTest(['-h']).stdout, runResult.stdout); + assert.strictEqual(execElmTest(['-help']).stdout, runResult.stdout); + assert.strictEqual(execElmTest(['help']).stdout, runResult.stdout); }).timeout(60000); it('Should exit indicating success (see #359)', () => { @@ -149,6 +188,17 @@ describe('flags', () => { }); describe('--report', () => { + it('Should fail if given an unknown reporter', () => { + const runResult = execElmTest([ + '--report', + 'rune-stone', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + it('Should be able to report json lines', () => { const runResult = execElmTest([ '--report=json', @@ -208,6 +258,28 @@ describe('flags', () => { }); describe('--seed', () => { + it('Should fail if given a non-integer', () => { + const runResult = execElmTest([ + '--seed', + '1.5', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + + it('Should fail if given a negative integer', () => { + const runResult = execElmTest([ + '--seed', + '-5', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + it('Should use and, thus, show the proper seed in the JSON report', () => { const runResult = execElmTest([ '--report=json', @@ -221,6 +293,28 @@ describe('flags', () => { }); describe('--fuzz', () => { + it('Should fail if given a non-digits', () => { + const runResult = execElmTest([ + '--fuzz', + '0xaf', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + + it('Should fail if given a negative integer', () => { + const runResult = execElmTest([ + '--fuzz', + '-5', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + it('Should default to 100', () => { const runResult = execElmTest([ '--report=json', @@ -253,9 +347,18 @@ describe('flags', () => { fs.copyFileSync(elmExe, path.join(dummyBinPath, 'different-elm' + ext)); }); + it('Should fail if given no value', () => { + const runResult = execElmTest([ + '--compiler', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + it("Should fail if the given compiler can't be executed", () => { const runResult = execElmTest([ - 'elm-test', '--compiler=foobar', path.join('tests', 'Passing', 'One.elm'), ]); @@ -266,7 +369,6 @@ describe('flags', () => { it('Should work with different elm on PATH', () => { const runResult = execElmTest([ - 'elm-test', '--compiler=different-elm', path.join('tests', 'Passing', 'One.elm'), ]); @@ -276,7 +378,6 @@ describe('flags', () => { it('Should work with local different elm', () => { const runResult = execElmTest([ - 'elm-test', '--compiler=./dummy-bin/different-elm', path.join('tests', 'Passing', 'One.elm'), ]); @@ -286,6 +387,16 @@ describe('flags', () => { }); describe('--watch', () => { + it('Should fail if given a value', () => { + const runResult = execElmTest([ + '--watch=always', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + it('Should re-run tests if a test file is touched', (done) => { const child = spawn( elmTestPath, @@ -328,4 +439,26 @@ describe('flags', () => { }); }).timeout(60000); }); + + describe('unknown flags', () => { + it('Should fail on unknown short flag', () => { + const runResult = execElmTest([ + '-ä', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + + it('Should fail on unknown long flag', () => { + const runResult = execElmTest([ + '--unknown-flag', + path.join('tests', 'Passing', 'One.elm'), + ]); + + assert.ok(Number.isInteger(runResult.status)); + assert.notStrictEqual(runResult.status, 0); + }).timeout(5000); + }); });