diff --git a/.flowconfig b/.flowconfig index d04a6a61..c1d6060f 100644 --- a/.flowconfig +++ b/.flowconfig @@ -1,11 +1,7 @@ -[ignore] - -.*elm.json - -[include] - -[libs] - [options] +include_warnings=true +exact_by_default=true -module.ignore_non_literal_requires=true +[lints] +all=error +untyped-import=off diff --git a/flow-typed/Result.js b/flow-typed/Result.js deleted file mode 100644 index fc975735..00000000 --- a/flow-typed/Result.js +++ /dev/null @@ -1,18 +0,0 @@ -// @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 8169d6cd..d57be4ab 100644 --- a/lib/Compile.js +++ b/lib/Compile.js @@ -1,12 +1,11 @@ //@flow const spawn = require('cross-spawn'); -const path = require('path'); -const packageInfo = require('../package.json'); const ElmCompiler = require('./ElmCompiler'); const Report = require('./Report'); function compile( + cwd /*: string */, testFile /*: string */, dest /*: string */, pathToElmBinary /*: string */, @@ -15,7 +14,7 @@ function compile( return new Promise((resolve, reject) => { const compileProcess = ElmCompiler.compile([testFile], { output: dest, - spawn: spawnCompiler({ ignoreStdout: true }), + spawn: spawnCompiler({ ignoreStdout: true, cwd }), pathToElm: pathToElmBinary, processOpts: processOptsForReporter(report), }); @@ -30,21 +29,6 @@ function compile( }); } -function getGeneratedCodeDir(projectRootDir /*: string */) /*: string */ { - return path.join( - projectRootDir, - 'elm-stuff', - 'generated-code', - 'elm-community', - 'elm-test', - packageInfo.version - ); -} - -function getTestRootDir(projectRootDir /*: string */) /*: string */ { - return path.resolve(path.join(projectRootDir, 'tests')); -} - function compileSources( testFilePaths /*: Array */, projectRootDir /*: string */, @@ -57,7 +41,7 @@ function compileSources( const compileProcess = ElmCompiler.compile(testFilePaths, { output: '/dev/null', cwd: projectRootDir, - spawn: spawnCompiler({ ignoreStdout: false }), + spawn: spawnCompiler({ ignoreStdout: false, cwd: projectRootDir }), pathToElm: pathToElmBinary, report: compilerReport, processOpts: processOptsForReporter(report), @@ -73,25 +57,30 @@ function compileSources( }); } -function spawnCompiler({ ignoreStdout }) { +function spawnCompiler({ ignoreStdout, cwd }) { return ( pathToElm /*: string */, processArgs /*: Array */, - processOpts /*: Object */ + processOpts /*: child_process$spawnOpts */ ) => { - const finalOpts = Object.assign({ env: process.env }, processOpts, { + const finalOpts = { + env: process.env, + ...processOpts, + cwd, stdio: [ process.stdin, ignoreStdout ? 'ignore' : process.stdout, process.stderr, ], - }); + }; return spawn(pathToElm, processArgs, finalOpts); }; } -function processOptsForReporter(report /*: typeof Report.Report */) { +function processOptsForReporter( + report /*: typeof Report.Report */ +) /*: child_process$spawnOpts */ { if (Report.isMachineReadable(report)) { return { env: process.env, stdio: ['ignore', 'ignore', process.stderr] }; } else { @@ -102,6 +91,4 @@ function processOptsForReporter(report /*: typeof Report.Report */) { module.exports = { compile, compileSources, - getTestRootDir, - getGeneratedCodeDir, }; diff --git a/lib/ElmJson.js b/lib/ElmJson.js new file mode 100644 index 00000000..c9784220 --- /dev/null +++ b/lib/ElmJson.js @@ -0,0 +1,193 @@ +// @flow + +const fs = require('fs'); +const path = require('path'); + +// Poor man’s type alias. We can’t use /*:: type Dependencies = ... */ because of: +// https://github.com/prettier/prettier/issues/2597 +const Dependencies /*: { [string]: string } */ = {}; + +const DirectAndIndirectDependencies /*: { + direct: typeof Dependencies, + indirect: typeof Dependencies, +} */ = { direct: {}, indirect: {} }; + +const ElmJson /*: + | { + type: 'application', + 'source-directories': Array, + dependencies: typeof DirectAndIndirectDependencies, + 'test-dependencies': typeof DirectAndIndirectDependencies, + [string]: mixed, + } + | { + type: 'package', + dependencies: typeof Dependencies, + 'test-dependencies': typeof Dependencies, + [string]: mixed, + } */ = { + type: 'package', + dependencies: Dependencies, + 'test-dependencies': Dependencies, +}; + +function getPath(dir /*: string */) /*: string */ { + return path.join(dir, 'elm.json'); +} + +function write(dir /*: string */, elmJson /*: typeof ElmJson */) /*: void */ { + const elmJsonPath = getPath(dir); + + try { + fs.writeFileSync(elmJsonPath, JSON.stringify(elmJson, null, 4) + '\n'); + } catch (error) { + throw new Error( + `${elmJsonPath}\nFailed to write elm.json:\n${error.message}` + ); + } +} + +function read(dir /*: string */) /*: typeof ElmJson */ { + const elmJsonPath = getPath(dir); + + try { + return readHelper(elmJsonPath); + } catch (error) { + throw new Error( + `${elmJsonPath}\nFailed to read elm.json:\n${error.message}` + ); + } +} + +function readHelper(elmJsonPath /*: string */) /*: typeof ElmJson */ { + const json = parseObject( + JSON.parse(fs.readFileSync(elmJsonPath, 'utf8')), + 'the file' + ); + + switch (json.type) { + case 'application': + return { + ...json, + type: 'application', + 'source-directories': parseSourceDirectories( + json['source-directories'] + ), + dependencies: parseDirectAndIndirectDependencies( + json.dependencies, + 'dependencies' + ), + 'test-dependencies': parseDirectAndIndirectDependencies( + json['test-dependencies'], + 'test-dependencies' + ), + }; + + case 'package': + return { + ...json, + type: 'package', + dependencies: parseDependencies(json.dependencies, 'dependencies'), + 'test-dependencies': parseDependencies( + json['test-dependencies'], + 'test-dependencies' + ), + }; + + default: + throw new Error( + `Expected "type" to be "application" or "package", but got: ${stringify( + json.type + )}` + ); + } +} + +function parseSourceDirectories(json /*: mixed */) /*: Array */ { + if (!Array.isArray(json)) { + throw new Error( + `Expected "source-directories" to be an array, but got: ${stringify( + json + )}` + ); + } + + const result = []; + + for (const [index, item] of json.entries()) { + if (typeof item !== 'string') { + throw new Error( + `Expected "source-directories"->${index} to be a string, but got: ${stringify( + item + )}` + ); + } + result.push(item); + } + + if (result.length === 0) { + throw new Error( + 'Expected "source-directories" to contain at least one item, but it is empty.' + ); + } + + return result; +} + +function parseDirectAndIndirectDependencies( + json /*: mixed */, + what /*: string */ +) /*: typeof DirectAndIndirectDependencies */ { + const jsonObject = parseObject(json, what); + return { + direct: parseDependencies(jsonObject.direct, `${what}->"direct"`), + indirect: parseDependencies(jsonObject.indirect, `${what}->"indirect"`), + }; +} + +function parseDependencies( + json /*: mixed */, + what /*: string */ +) /*: typeof Dependencies */ { + const jsonObject = parseObject(json, what); + const result = {}; + + for (const [key, value] of Object.entries(jsonObject)) { + if (typeof value !== 'string') { + throw new Error( + `Expected ${what}->${stringify( + key + )} to be a string, but got: ${stringify(value)}` + ); + } + result[key] = value; + } + + return result; +} + +function parseObject( + json /*: mixed */, + what /*: string */ +) /*: { +[string]: mixed } */ { + if (json == null || typeof json !== 'object' || Array.isArray(json)) { + throw new Error( + `Expected ${what} to be an object, but got: ${stringify(json)}` + ); + } + return json; +} + +function stringify(json /*: mixed */) /*: string */ { + const maybeString = JSON.stringify(json); + return maybeString === undefined ? 'undefined' : maybeString; +} + +module.exports = { + DirectAndIndirectDependencies, + ElmJson, + getPath, + parseDirectAndIndirectDependencies, + read, + write, +}; diff --git a/lib/FindTests.js b/lib/FindTests.js new file mode 100644 index 00000000..49058899 --- /dev/null +++ b/lib/FindTests.js @@ -0,0 +1,269 @@ +// @flow + +const gracefulFs = require('graceful-fs'); +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); +const Parser = require('./Parser'); +const Project = require('./Project'); + +void Project; + +// We can replace this with using `Array.prototype.flatMap` once Node.js 10 is +// EOL 2021-04-30 and support for Node.js 10 is dropped. +function flatMap/*:: */( + array /*: Array */, + f /*: (T) => Array */ +) /*: Array */ { + return array.reduce((result, item) => result.concat(f(item)), []); +} + +// Double stars at the start and end is the correct way to ignore directories in +// the `glob` package. +// https://github.com/isaacs/node-glob/issues/270#issuecomment-273949982 +// https://github.com/isaacs/node-glob/blob/f5a57d3d6e19b324522a3fa5bdd5075fd1aa79d1/common.js#L222-L231 +const ignoredDirsGlobs = ['**/elm-stuff/**', '**/node_modules/**']; + +function resolveGlobs( + fileGlobs /*: Array */, + projectRootDir /*: string */ +) /*: Array */ { + return Array.from( + new Set( + flatMap(fileGlobs, (fileGlob) => { + const absolutePath = path.resolve(fileGlob); + try { + const stat = fs.statSync(absolutePath); + // If the CLI arg exists… + return stat.isDirectory() + ? // …and it’s a directory, find all .elm files in there… + findAllElmFilesInDir(absolutePath) + : // …otherwise use it as-is. + [absolutePath]; + } catch (error) { + // If the CLI arg does not exist… + return error.code === 'ENOENT' + ? // …resolve it as a glob for shells that don’t support globs. + resolveCliArgGlob(absolutePath, projectRootDir) + : // The glob package ignores other types of stat errors. + []; + } + }) + ), + // The `glob` package returns absolute paths with slashes always, even on + // Windows. All other paths in elm-test use the native directory separator + // so normalize here. + (filePath) => path.normalize(filePath) + ); +} + +function resolveCliArgGlob( + fileGlob /*: string */, + projectRootDir /*: string */ +) /*: Array */ { + // Globs passed as CLI arguments are relative to CWD, while elm-test + // operates from the project root dir. + const globRelativeToProjectRoot = path.relative( + projectRootDir, + path.resolve(fileGlob) + ); + return flatMap( + glob.sync(globRelativeToProjectRoot, { + cwd: projectRootDir, + nocase: true, + absolute: true, + ignore: ignoredDirsGlobs, + // Match directories as well and mark them with a trailing slash. + nodir: false, + mark: true, + }), + (filePath) => + filePath.endsWith('/') ? findAllElmFilesInDir(filePath) : filePath + ); +} + +// Recursively search for *.elm files. +function findAllElmFilesInDir(dir /*: string */) /*: Array */ { + return glob.sync('**/*.elm', { + cwd: dir, + nocase: true, + absolute: true, + ignore: ignoredDirsGlobs, + nodir: true, + }); +} + +function findTests( + testFilePaths /*: Array */, + project /*: typeof Project.Project */ +) /*: Promise }>> */ { + return Promise.all( + testFilePaths.map((filePath) => { + const matchingSourceDirs = project.testsSourceDirs.filter((dir) => + filePath.startsWith(`${dir}${path.sep}`) + ); + + // Tests must be in tests/ or in source-directories – otherwise they won’t + // compile. Elm won’t be able to find imports. + switch (matchingSourceDirs.length) { + case 0: + return Promise.reject( + Error( + missingSourceDirectoryError( + filePath, + project.elmJson.type === 'package' + ) + ) + ); + + case 1: + // Keep going. + break; + + default: + // This shouldn’t be possible for package projects. + return Promise.reject( + new Error( + multipleSourceDirectoriesError( + filePath, + matchingSourceDirs, + project.testsDir + ) + ) + ); + } + + // By finding the module name from the file path we can import it even if + // the file is full of errors. Elm will then report what’s wrong. + const moduleNameParts = path + .relative(matchingSourceDirs[0], filePath) + .replace(/\.elm$/, '') + .split(path.sep); + const moduleName = moduleNameParts.join('.'); + + if (!moduleNameParts.every(Parser.isUpperName)) { + return Promise.reject( + new Error( + badModuleNameError(filePath, matchingSourceDirs[0], moduleName) + ) + ); + } + + return Parser.extractExposedPossiblyTests( + filePath, + // We’re reading files asynchronously in a loop here, so it makes sense + // to use graceful-fs to avoid “too many open files” errors. + gracefulFs.createReadStream + ).then((possiblyTests) => ({ + moduleName, + possiblyTests, + })); + }) + ); +} + +function missingSourceDirectoryError(filePath, isPackageProject) { + return ` +This file: + +${filePath} + +…matches no source directory! Imports won't work then. + +${ + isPackageProject + ? 'Move it to tests/ or src/ in your project root.' + : 'Move it to tests/ in your project root, or make sure it is covered by "source-directories" in your elm.json.' +} + `.trim(); +} + +function multipleSourceDirectoriesError( + filePath, + matchingSourceDirs, + testsDir +) { + const note = matchingSourceDirs.includes(testsDir) + ? "Note: The tests/ folder counts as a source directory too (even if it isn't listed in your elm.json)!" + : ''; + + return ` +This file: + +${filePath} + +…matches more than one source directory: + +${matchingSourceDirs.join('\n')} + +Edit "source-directories" in your elm.json and try to make it so no source directory contains another source directory! + +${note} + `.trim(); +} + +function badModuleNameError(filePath, sourceDir, moduleName) { + return ` +This file: + +${filePath} + +…located in this directory: + +${sourceDir} + +…is problematic. Trying to construct a module name from the parts after the directory gives: + +${moduleName} + +…but module names need to look like for example: + +Main +Http.Helpers + +Make sure that all parts start with an uppercase letter and don't contain any spaces or anything like that. + `.trim(); +} + +function noFilesFoundError( + projectRootDir /*: string */, + testFileGlobs /*: Array */ +) /*: string */ { + return testFileGlobs.length === 0 + ? ` +${noFilesFoundInTestsDir(projectRootDir)} + +To generate some initial tests to get things going: elm-test init + +Alternatively, if your project has tests in a different directory, +try calling elm-test with a glob such as: elm-test "src/**/*Tests.elm" + `.trim() + : ` +No files found matching: + +${testFileGlobs.join('\n')} + +Are the above patterns correct? Maybe try running elm-test with no arguments? + `.trim(); +} + +function noFilesFoundInTestsDir(projectRootDir) { + const testsDir = path.join(projectRootDir, 'tests'); + try { + const stats = fs.statSync(testsDir); + return stats.isDirectory() + ? 'No .elm files found in the tests/ directory.' + : `Expected a directory but found something else at: ${testsDir}\nCheck it out! Could you remove it?`; + } catch (error) { + return error.code === 'ENOENT' + ? 'The tests/ directory does not exist.' + : `Failed to read the tests/ directory: ${error.message}`; + } +} + +module.exports = { + findTests, + ignoredDirsGlobs, + noFilesFoundError, + resolveGlobs, +}; diff --git a/lib/Generate.js b/lib/Generate.js index edf35601..e623a9bf 100644 --- a/lib/Generate.js +++ b/lib/Generate.js @@ -2,12 +2,13 @@ const { supportsColor } = require('chalk'); const fs = require('fs'); -const Murmur = require('murmur-hash-js'); const path = require('path'); -const Compile = require('./Compile.js'); -const Solve = require('./Solve.js'); -const Report = require('./Report.js'); +const ElmJson = require('./ElmJson'); +const Project = require('./Project'); +const Report = require('./Report'); +const Solve = require('./Solve'); +void Project; void Report; const before = fs.readFileSync( @@ -59,110 +60,51 @@ function addKernelTestChecking(content) { ); } -function generateElmJson( - projectRootDir /*: string */, - generatedCodeDir /*: string */, - hasBeenGivenCustomGlobs /*: boolean */, - elmJsonPath /*: string */, - projectElmJson /*: any */ -) /*: [string, Array] */ { - const testRootDir = Compile.getTestRootDir(projectRootDir); - const generatedSrc = path.join(generatedCodeDir, 'src'); - - var isPackageProject = projectElmJson.type === 'package'; - - // if we were given file globs, we don't need to check the tests/ directory exists - // this is only for elm applications, which people may need to introduce slowly into their apps - // for packages, we stick with the existing behaviour and assume tests are in tests/ so do the check always - const needToCareAboutTestsDir = - hasBeenGivenCustomGlobs === false || isPackageProject === true; - - // we add the tests dir as a source if: - // - we decided above we need to care about it - // - if it exists on disk: this supports the case where we have all our tests in tests/ but - // want to pass a glob to only run one of the test files - const shouldAddTestsDirAsSource = - needToCareAboutTestsDir || - fs.existsSync(path.join(projectRootDir, 'tests')); - - if (needToCareAboutTestsDir) { - if (!fs.existsSync(testRootDir)) { - console.error( - 'Error: ' + - testRootDir + - ' does not exist. Please create a tests/ directory in your project root!' - ); - process.exit(1); - } - - if (!fs.lstatSync(testRootDir).isDirectory()) { - console.error( - 'Error: ' + - testRootDir + - ' exists, but it is not a directory. Please create a tests/ directory in your project root!' - ); - process.exit(1); - } - } +function getGeneratedSrcDir(generatedCodeDir /*: string */) /*: string */ { + return path.join(generatedCodeDir, 'src'); +} - fs.mkdirSync(generatedCodeDir, { recursive: true }); - fs.mkdirSync(generatedSrc, { recursive: true }); +function generateElmJson(project /*: typeof Project.Project */) /*: void */ { + const generatedSrc = getGeneratedSrcDir(project.generatedCodeDir); - let testElmJson = { - type: 'application', - 'source-directories': [], // these are added below - 'elm-version': '0.19.1', - dependencies: Solve.getDependenciesCached( - generatedCodeDir, - elmJsonPath, - projectElmJson - ), - 'test-dependencies': { - direct: {}, - indirect: {}, - }, - }; + fs.mkdirSync(generatedSrc, { recursive: true }); - // Make all the source-directories absolute, and introduce a new one. - var projectSourceDirs; - if (isPackageProject) { - projectSourceDirs = ['./src']; - } else { - projectSourceDirs = projectElmJson['source-directories']; - } - var sourceDirs /*: Array */ = projectSourceDirs - .map(function (src) { - return path.resolve(path.join(projectRootDir, src)); - }) - .concat(shouldAddTestsDirAsSource ? [testRootDir] : []); - - testElmJson['source-directories'] = [ - // Include elm-stuff/generated-sources - since we'll be generating sources in there. + const sourceDirs = [ + // Include the generated test application. generatedSrc, // NOTE: we must include node-test-runner's Elm source as a source-directory // instead of adding it as a dependency so that it can include port modules - path.resolve(path.join(__dirname, '..', 'elm', 'src')), + path.join(__dirname, '..', 'elm', 'src'), ] - .concat(sourceDirs) + .concat(project.testsSourceDirs) .filter( // When running node-test-runner's own test suite, the node-test-runner/src folder // will get added twice: once because it's the source-directory of the packge being tested, // and once because elm-test will always add it. // To prevent elm from being confused, we need to remove the duplicate when this happens. - function (value, index, self) { - return self.indexOf(value) === index; - } + (value, index, self) => self.indexOf(value) === index ) - .map(function (absolutePath) { + .map((absolutePath) => // Relative paths have the nice benefit that if the user moves their // directory, this doesn't break. - return path.relative(generatedCodeDir, absolutePath); - }); + path.relative(project.generatedCodeDir, absolutePath) + ); + + const testElmJson = { + type: 'application', + 'source-directories': sourceDirs, + 'elm-version': '0.19.1', + dependencies: Solve.getDependenciesCached(project), + 'test-dependencies': { + direct: {}, + indirect: {}, + }, + }; // Generate the new elm.json, if necessary. const generatedContents = JSON.stringify(testElmJson, null, 4); - const generatedPath = path.join(generatedCodeDir, 'elm.json'); + const generatedPath = ElmJson.getPath(project.generatedCodeDir); // Don't write a fresh elm.json if it's going to be the same. If we do, // it will update the timestamp on the file, which will cause `elm make` @@ -176,8 +118,20 @@ function generateElmJson( // we have so far, and run elm to see what it thinks is missing. fs.writeFileSync(generatedPath, generatedContents); } +} - return [generatedSrc, sourceDirs]; +function getMainModule( + generatedCodeDir /*: string */ +) /*: { moduleName: string, path: string } */ { + const moduleName = ['Test', 'Generated', 'Main']; + return { + moduleName: moduleName.join('.'), + path: + // We'll be putting the generated Main in something like this: + // + // my-project-name/elm-stuff/generated-code/elm-community/elm-test/0.19.1-revisionX/src/Test/Generated/Main.elm + path.join(getGeneratedSrcDir(generatedCodeDir), ...moduleName) + '.elm', + }; } function generateMainModule( @@ -190,39 +144,19 @@ function generateMainModule( moduleName: string, possiblyTests: Array, }> */, - generatedSrc /*: string */, + mainModule /*: { moduleName: string, path: string } */, processes /*: number */ -) /*: string */ { +) /*: void */ { const testFileBody = makeTestFileBody( testModules, makeOptsCode(fuzz, seed, report, testFileGlobs, testFilePaths, processes) ); - // Generate a filename that incorporates the hash of file contents. - // This way, if you run e.g. `elm-test Foo.elm` and then `elm-test Bar.elm` - // and then re-run `elm-test Foo.elm` we still have a cached `Main` for - // `Foo.elm` (assuming none of its necessary imports have changed - and - // why would they?) so we don't have to recompile it. - const salt = Murmur.murmur3(testFileBody); - const moduleName = 'Main' + salt; - const mainPath = path.join(generatedSrc, 'Test', 'Generated'); - const mainFile = path.join(mainPath, moduleName + '.elm'); - - // We'll be putting the generated Main in something like this: - // - // my-project-name/elm-stuff/generated-code/elm-community/elm-test/src/Test/Generated/Main123456.elm - const testFileContents = `module Test.Generated.${moduleName} exposing (main)\n\n${testFileBody}`; - - // Make sure src/Test/Generated/ exists so we can write the file there. - fs.mkdirSync(mainPath, { recursive: true }); - - // Always write the file, in order to update its timestamp. This is important, - // because if we run `elm-make Main123456.elm` and that file's timestamp did - // not change, elm-make will short-circuit and not recompile *anything* - even - // if some of Main's dependencies (such as an individual test file) changed. - fs.writeFileSync(mainFile, testFileContents); - - return mainFile; + const testFileContents = `module ${mainModule.moduleName} exposing (main)\n\n${testFileBody}`; + + fs.mkdirSync(path.dirname(mainModule.path), { recursive: true }); + + fs.writeFileSync(mainModule.path, testFileContents); } function makeTestFileBody( @@ -335,4 +269,9 @@ function makeElmString(string) { .replace(/\r/g, '\\r')}"`; } -module.exports = { prepareCompiledJsFile, generateElmJson, generateMainModule }; +module.exports = { + generateElmJson, + generateMainModule, + getMainModule, + prepareCompiledJsFile, +}; diff --git a/lib/Install.js b/lib/Install.js index b2d75e04..943ef5ca 100644 --- a/lib/Install.js +++ b/lib/Install.js @@ -1,86 +1,71 @@ // @flow -const child_process = require('child_process'); +const spawn = require('cross-spawn'); const fs = require('fs'); const path = require('path'); const rimraf = require('rimraf'); -const Compiler = require('./Compile'); +const ElmJson = require('./ElmJson'); +const Project = require('./Project'); -function install(pathToElmBinary /*: string */, packageName /*: string */) { - var oldSourceDirectories; +void Project; - const dirPath = path.join( - Compiler.getGeneratedCodeDir(process.cwd()), - 'install' - ); +function install( + project /*: typeof Project.Project */, + pathToElmBinary /*: string */, + packageName /*: string */ +) /*: 'SuccessfullyInstalled' | 'AlreadyInstalled' */ { + const installationScratchDir = path.join(project.generatedCodeDir, 'install'); try { // Recreate the directory to remove any artifacts from the last time // someone ran `elm-test install`. We do not delete this directory after // the installation finishes in case the user needs to debug the test run. - if (fs.existsSync(dirPath)) { + if (fs.existsSync(installationScratchDir)) { // We can replace this with `fs.rmdirSync(dir, { recursive: true })` // once Node.js 10 is EOL 2021-04-30 and support for Node.js 10 is dropped. - rimraf.sync(dirPath); + rimraf.sync(installationScratchDir); } - fs.mkdirSync(dirPath, { recursive: true }); - } catch (err) { - console.error( - 'Unable to create temporary directory for elm-test install.', - err + fs.mkdirSync(installationScratchDir, { recursive: true }); + } catch (error) { + throw new Error( + `Unable to create temporary directory for elm-test install: ${error.message}` ); - process.exit(1); } - var elmJson = JSON.parse(fs.readFileSync('elm.json', 'utf8')); - var tmpElmJsonPath = path.join(dirPath, 'elm.json'); - var isPackage; - - switch (elmJson['type']) { - case 'package': - isPackage = true; - break; + const { elmJson } = project; - case 'application': - isPackage = false; - break; - - default: - console.error('Unrecognized elm.json type:', elmJson['type']); - process.exit(1); - } - - // This mirrors the behavior of `elm install` passing a package that is - // already installed. Say it's already installed, then exit 0. if ( - (isPackage && elmJson['test-dependencies'].hasOwnProperty(packageName)) || - (!isPackage && - elmJson['test-dependencies']['direct'].hasOwnProperty(packageName)) + elmJson.type === 'package' + ? elmJson['test-dependencies'].hasOwnProperty(packageName) + : elmJson['test-dependencies'].direct.hasOwnProperty(packageName) ) { - console.log('It is already installed!'); - return; + return 'AlreadyInstalled'; } - oldSourceDirectories = elmJson['source-directories']; - - // Without this, `elm install` will complain about missing source dirs - // in the temp dir. This way we don't have to create them! - elmJson['source-directories'] = ['.']; - - fs.writeFileSync(tmpElmJsonPath, JSON.stringify(elmJson), 'utf8'); - - try { - child_process.execFileSync(pathToElmBinary, ['install', packageName], { - stdio: 'inherit', - cwd: dirPath, - }); - } catch (error) { - process.exit(error.status || 1); + const tmpElmJson = + elmJson.type === 'package' + ? elmJson + : { + ...elmJson, + // Without this, `elm install` will complain about missing source dirs + // in the temp dir. This way we don't have to create them! + 'source-directories': ['.'], + }; + + ElmJson.write(installationScratchDir, tmpElmJson); + + const result = spawn.sync(pathToElmBinary, ['install', packageName], { + stdio: 'inherit', + cwd: installationScratchDir, + }); + + if (result.status !== 0) { + process.exit(result.status); } - var newElmJson = JSON.parse(fs.readFileSync(tmpElmJsonPath, 'utf8')); + const newElmJson = ElmJson.read(installationScratchDir); - if (isPackage) { + if (newElmJson.type === 'package') { Object.keys(newElmJson['dependencies']).forEach(function (key) { if (!elmJson['dependencies'].hasOwnProperty(key)) { // If we didn't have this dep before, move it to test-dependencies. @@ -114,18 +99,18 @@ function install(pathToElmBinary /*: string */, packageName /*: string */) { moveToTestDeps('direct'); moveToTestDeps('indirect'); + + if (elmJson.type === 'application') { + // Restore the old source-directories value. + newElmJson['source-directories'] = elmJson['source-directories']; + } } - // Restore the old source-directories value. - newElmJson['source-directories'] = oldSourceDirectories; + ElmJson.write(project.rootDir, newElmJson); - fs.writeFileSync( - 'elm.json', - JSON.stringify(newElmJson, null, 4) + '\n', - 'utf8' - ); + return 'SuccessfullyInstalled'; } module.exports = { - install: install, + install, }; diff --git a/lib/Parser.js b/lib/Parser.js index f2a63490..b87542e8 100644 --- a/lib/Parser.js +++ b/lib/Parser.js @@ -28,7 +28,7 @@ function extractExposedPossiblyTests( filePath /*: string */, createReadStream /*: ( path: string, - options?: Object + options?: { encoding?: string, ... } ) => stream$Readable & { close(): void } */ ) /*: Promise> */ { return new Promise((resolve, reject) => { @@ -312,16 +312,19 @@ void OnParserTokenResult; function expected( expectedDescription /*: string */, - actual /*: any */ + actual /*: mixed */ ) /*: typeof ParseError */ { return { tag: 'ParseError', - message: `Expected ${expectedDescription} but got: ${JSON.stringify( - actual - )}`, + message: `Expected ${expectedDescription} but got: ${stringify(actual)}`, }; } +function stringify(json /*: mixed */) /*: string */ { + const maybeString = JSON.stringify(json); + return maybeString === undefined ? 'undefined' : maybeString; +} + function backslashError(actual) { return expected( `one of \`${Array.from(backslashableChars).join(' ')}\``, diff --git a/lib/Project.js b/lib/Project.js new file mode 100644 index 00000000..ff37529d --- /dev/null +++ b/lib/Project.js @@ -0,0 +1,117 @@ +// @flow + +const fs = require('fs'); +const path = require('path'); +const ElmJson = require('./ElmJson'); + +// Poor man’s type alias. We can’t use /*:: type Project = ... */ because of: +// https://github.com/prettier/prettier/issues/2597 +const Project /*: { + rootDir: string, + testsDir: string, + generatedCodeDir: string, + testsSourceDirs: Array, + elmJson: typeof ElmJson.ElmJson, +} */ = { + rootDir: '', + testsDir: '', + generatedCodeDir: '', + testsSourceDirs: [], + elmJson: ElmJson.ElmJson, +}; + +function getTestsDir(rootDir /*: string */) /*: string */ { + return path.join(rootDir, 'tests'); +} + +function init( + rootDir /*: string */, + version /*: string */ +) /*: typeof Project */ { + const testsDir = getTestsDir(rootDir); + + // The tests/ directory is not required. You can also co-locate tests with + // their source files. + const shouldAddTestsDirAsSource = fs.existsSync(testsDir); + + const elmJson = ElmJson.read(rootDir); + + const projectSourceDirs = + elmJson.type === 'package' ? ['src'] : elmJson['source-directories']; + + const testsSourceDirs /*: Array */ = projectSourceDirs + .map((src) => path.resolve(rootDir, src)) + .concat(shouldAddTestsDirAsSource ? [testsDir] : []); + + const generatedCodeDir = path.join( + rootDir, + 'elm-stuff', + 'generated-code', + 'elm-community', + 'elm-test', + version + ); + + return { + rootDir, + testsDir, + generatedCodeDir, + testsSourceDirs, + elmJson, + }; +} + +/* We do this validation ourselves to avoid the ../../../../../ in Elm’s error message: + +-- MISSING SOURCE DIRECTORY ------------------------------------------- elm.json + +I need a valid elm.json file, but the "source-directories" field lists the +following directory: + + ../../../../../app + +I cannot find it though. Is it missing? Is there a typo? +*/ +function validateTestsSourceDirs(project /*: typeof Project */) /*: void */ { + for (const dir of project.testsSourceDirs) { + const fullDir = path.resolve(project.rootDir, dir); + let stats; + try { + stats = fs.statSync(fullDir); + } catch (error) { + throw new Error( + validateTestsSourceDirsError( + fullDir, + error.code === 'ENOENT' + ? "It doesn't exist though. Is it missing? Is there a typo?" + : `Failed to read that directory: ${error.message}` + ) + ); + } + if (!stats.isDirectory()) { + throw new Error( + validateTestsSourceDirsError( + fullDir, + `It exists but isn't a directory!` + ) + ); + } + } +} + +function validateTestsSourceDirsError(dir, message) { + return ` +The "source-directories" field in your elm.json lists the following directory: + +${dir} + +${message} + `.trim(); +} + +module.exports = { + Project, + getTestsDir, + init, + validateTestsSourceDirs, +}; diff --git a/lib/Report.js b/lib/Report.js index 819e1159..e8fbc33c 100644 --- a/lib/Report.js +++ b/lib/Report.js @@ -4,17 +4,14 @@ // https://github.com/prettier/prettier/issues/2597 const Report /*: 'console' | 'json' | 'junit' */ = 'console'; -function parse(string /*: string */) /*: Result */ { +function parse(string /*: string */) /*: typeof Report */ { switch (string) { case 'console': case 'json': case 'junit': - return { tag: 'Ok', value: string }; + return string; default: - return { - tag: 'Error', - error: `unknown reporter: ${string}`, - }; + throw new Error(`unknown reporter: ${string}`); } } diff --git a/lib/RunTests.js b/lib/RunTests.js new file mode 100644 index 00000000..9358420d --- /dev/null +++ b/lib/RunTests.js @@ -0,0 +1,273 @@ +// @flow + +const chalk = require('chalk'); +const chokidar = require('chokidar'); +const path = require('path'); +const packageInfo = require('../package.json'); +const Compile = require('./Compile'); +const ElmJson = require('./ElmJson'); +const FindTests = require('./FindTests'); +const Generate = require('./Generate'); +const Project = require('./Project'); +const Report = require('./Report'); +const Supervisor = require('./Supervisor'); + +void Report; + +// 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. +function getPipeFilename(runsExecuted /*: number */) /*: string */ { + return process.platform === 'win32' + ? `\\\\.\\pipe\\elm_test-${process.pid}-${runsExecuted}` + : `/tmp/elm_test-${process.pid}.sock`; +} + +function infoLog( + report /*: typeof Report.Report */, + msg /*: string */ +) /*: void */ { + if (report === 'console') { + console.log(msg); + } +} + +function clearConsole(report /*: typeof Report.Report */) { + if (report === 'console') { + process.stdout.write( + process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H' + ); + } +} + +function diffArrays/*:: */( + from /*: Array */, + to /*: Array */ +) /*: { added: Array, removed: Array } */ { + return { + added: to.filter((item) => !from.includes(item)), + removed: from.filter((item) => !to.includes(item)), + }; +} + +function delay(ms /*: number */) /*: Promise */ { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +const Queue /*: Array<{ + event: 'added' | 'changed' | 'removed', + filePath: string, +}> */ = []; +void Queue; + +function watcherEventMessage(queue /*: typeof Queue */) /*: string */ { + const suffix = '. Rebuilding!'; + + const filePaths = Array.from(new Set(queue.map(({ filePath }) => filePath))); + if (filePaths.length === 1) { + const { event, filePath } = queue[0]; + return `${filePath} ${event}${suffix}`; + } + + const events = Array.from(new Set(queue.map(({ event }) => event))).sort(); + return `${filePaths.length} files ${events.join('/')}${suffix}`; +} + +function runTests( + projectRootDir /*: string */, + pathToElmBinary /*: string */, + testFileGlobs /*: Array */, + processes /*: number */, + { + watch, + report, + seed, + fuzz, + } /*: { + watch: boolean, + report: typeof Report.Report, + seed: number, + fuzz: number, + } */ +) /*: Promise */ { + let watcher = undefined; + let watchedPaths /*: Array */ = []; + let runsExecuted /*: number */ = 0; + let currentRun /*: Promise | void */ = undefined; + let queue /*: typeof Queue */ = []; + + async function run() /*: Promise */ { + try { + // Don’t delay the first run (that’s the only time the queue is empty). + // Otherwise, wait for a little bit to batch events that happened roughly + // at the same time. The chokidar docs also mention that by default, the + // add event will fire when a file first appears on disk, before the + // entire file has been written. They have a `awaitWriteFinish` option for + // that, with a `stabilityThreshold` that is an amount of time in + // milliseconds for a file size to remain constant before emitting its + // event. Elm files aren’t that huge so it should be fine to just wait a + // fixed amount of time here instead. I tried it with a 378 MB file. That + // resulted in first a "added" event and then a "changed" event a little + // later. So the worst thing that can happen is that you get one run with + // half a file immediately followed by another run with the whole file. + // I tested touching 100 files at a time. All of them produced events + // within 60 ms on Windows, and faster on Mac and Linux. So 100 ms sounds + // like a reasonable number – not too short, not too long of a wait. + if (queue.length > 0) { + await delay(100); + // Re-print the message in case the queue has become longer while waiting. + clearConsole(report); + infoLog(report, watcherEventMessage(queue)); + } + + queue = []; + + // All operations down to `Generate.generateElmJson(project)` could + // potentially be avoided depending on what changed (checking what’s + // inside `queue`). But at the time of this writing all of them are fast + // (less than 20 ms, often less than 1 ms) so it’s not worth bothering. + + // Files may be changed, added or removed so always re-create project info + // from disk to stay fresh. + const project = Project.init(projectRootDir, packageInfo.version); + Project.validateTestsSourceDirs(project); + + const testFilePaths = FindTests.resolveGlobs( + testFileGlobs.length === 0 ? [project.testsDir] : testFileGlobs, + project.rootDir + ); + + if (testFilePaths.length === 0) { + throw new Error( + FindTests.noFilesFoundError(project.rootDir, testFileGlobs) + ); + } + + if (watcher !== undefined) { + const diff = diffArrays(watchedPaths, project.testsSourceDirs); + watchedPaths = project.testsSourceDirs; + watcher.add(diff.added); + watcher.unwatch(diff.removed); + } + + runsExecuted++; + const pipeFilename = getPipeFilename(runsExecuted); + const testModules = await FindTests.findTests(testFilePaths, project); + const mainModule = Generate.getMainModule(project.generatedCodeDir); + const dest = path.join(project.generatedCodeDir, 'elmTestOutput.js'); + + Generate.generateElmJson(project); + + Generate.generateMainModule( + fuzz, + seed, + report, + testFileGlobs, + testFilePaths, + testModules, + mainModule, + processes + ); + + await Compile.compile( + project.generatedCodeDir, + mainModule.path, + dest, + pathToElmBinary, + report + ); + + Generate.prepareCompiledJsFile(pipeFilename, dest); + + return await Supervisor.run( + packageInfo.version, + pipeFilename, + report, + processes, + dest, + watch + ); + } catch (err) { + console.error(err.message); + return 1; + } + } + + if (watch) { + clearConsole(report); + infoLog(report, 'Running in watch mode'); + + const onRunFinish = () => { + if (queue.length > 0) { + clearConsole(report); + infoLog(report, watcherEventMessage(queue)); + currentRun = run().then(onRunFinish); + } else { + infoLog(report, chalk.blue('Watching for changes...')); + currentRun = undefined; + } + }; + + // The directories to watch change over time and are added and removed as + // needed in `run`. We should always watch `elm.json` and `tests/`, though + // (see the 'addDir' event below). + const alwaysWatched = [ + ElmJson.getPath(projectRootDir), + Project.getTestsDir(projectRootDir), + ]; + + const rerun = (event) => (absoluteFilePath) => { + if ( + absoluteFilePath.endsWith('.elm') || + alwaysWatched.includes(absoluteFilePath) + ) { + queue.push({ + event, + filePath: path.relative(projectRootDir, absoluteFilePath), + }); + if (currentRun === undefined) { + clearConsole(report); + infoLog(report, watcherEventMessage(queue)); + currentRun = run().then(onRunFinish); + } + } + }; + + watcher = chokidar.watch(alwaysWatched, { + ignoreInitial: true, + ignored: FindTests.ignoredDirsGlobs, + disableGlobbing: true, + }); + + watcher.on('add', rerun('added')); + watcher.on('change', rerun('changed')); + watcher.on('unlink', rerun('removed')); + + // The only time this event is interesting is when the `tests/` directory is + // created after the watcher was started. There’s no need to listen for + // 'unlinkDir' – that makes no difference. + watcher.on('addDir', rerun('added')); + + // It’s unclear when this event occurrs. + watcher.on('error', (error) => console.error('Watcher error:', error)); + + currentRun = run().then(onRunFinish); + + // A promise that never resolves. We’ll watch until killed. + return new Promise(() => {}); + } else { + return run(); + } +} + +module.exports = { + runTests, +}; diff --git a/lib/Runner.js b/lib/Runner.js deleted file mode 100644 index 3a2c8d25..00000000 --- a/lib/Runner.js +++ /dev/null @@ -1,123 +0,0 @@ -// @flow - -const gracefulFs = require('graceful-fs'); -const path = require('path'); -const Parser = require('./Parser'); - -function findTests( - testFilePaths /*: Array */, - sourceDirs /*: Array */, - isPackageProject /*: boolean */ -) /*: Promise }>> */ { - return Promise.all( - testFilePaths.map((filePath) => { - const matchingSourceDirs = sourceDirs.filter((dir) => - filePath.startsWith(`${dir}${path.sep}`) - ); - - // Tests must be in tests/ or in source-directories – otherwise they won’t - // compile. Elm won’t be able to find imports. - switch (matchingSourceDirs.length) { - case 0: - return Promise.reject( - Error(missingSourceDirectoryError(filePath, isPackageProject)) - ); - - case 1: - // Keep going. - break; - - default: - // This shouldn’t be possible for package projects. - return Promise.reject( - new Error( - multipleSourceDirectoriesError(filePath, matchingSourceDirs) - ) - ); - } - - // By finding the module name from the file path we can import it even if - // the file is full of errors. Elm will then report what’s wrong. - const moduleNameParts = path - .relative(matchingSourceDirs[0], filePath) - .replace(/\.elm$/, '') - .split(path.sep); - const moduleName = moduleNameParts.join('.'); - - if (!moduleNameParts.every(Parser.isUpperName)) { - return Promise.reject( - new Error( - badModuleNameError(filePath, matchingSourceDirs[0], moduleName) - ) - ); - } - - return Parser.extractExposedPossiblyTests( - filePath, - // We’re reading files asynchronously in a loop here, so it makes sense - // to use graceful-fs to avoid “too many open files” errors. - gracefulFs.createReadStream - ).then((possiblyTests) => ({ - moduleName, - possiblyTests, - })); - }) - ); -} - -function missingSourceDirectoryError(filePath, isPackageProject) { - return ` -This file: - -${filePath} - -…matches no source directory! Imports won’t work then. - -${ - isPackageProject - ? 'Move it to tests/ or src/ in your project root.' - : 'Move it to tests/ in your project root, or make sure it is covered by "source-directories" in your elm.json.' -} - `.trim(); -} - -function multipleSourceDirectoriesError(filePath, matchingSourceDirs) { - return ` -This file: - -${filePath} - -…matches more than one source directory: - -${matchingSourceDirs.join('\n')} - -Edit "source-directories" in your elm.json and try to make it so no source directory contains another source directory! - `.trim(); -} - -function badModuleNameError(filePath, sourceDir, moduleName) { - return ` -This file: - -${filePath} - -…located in this directory: - -${sourceDir} - -…is problematic. Trying to construct a module name from the parts after the directory gives: - -${moduleName} - -…but module names need to look like for example: - -Main -Http.Helpers - -Make sure that all parts start with an uppercase letter and don’t contain any spaces or anything like that. - `.trim(); -} - -module.exports = { - findTests: findTests, -}; diff --git a/lib/Solve.js b/lib/Solve.js index 78ada5f4..f0d72d3d 100644 --- a/lib/Solve.js +++ b/lib/Solve.js @@ -4,24 +4,29 @@ const spawn = require('cross-spawn'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); +const ElmJson = require('./ElmJson'); +const Project = require('./Project'); + +void Project; function sha1(string) { return crypto.createHash('sha1').update(string).digest('hex'); } function getDependenciesCached( - generatedCodeDir /*: string */, - elmJsonPath /*: string */, - projectElmJson /*: any */ -) /*: { direct: { [string]: string }, indirect: { [string]: string } } */ { + project /*: typeof Project.Project */ +) /*: typeof ElmJson.DirectAndIndirectDependencies */ { const hash = sha1( JSON.stringify({ - dependencies: projectElmJson.dependencies, - 'test-dependencies': projectElmJson['test-dependencies'], + dependencies: project.elmJson.dependencies, + 'test-dependencies': project.elmJson['test-dependencies'], }) ); - const cacheFile = path.join(generatedCodeDir, `dependencies.${hash}.json`); + const cacheFile = path.join( + project.generatedCodeDir, + `dependencies.${hash}.json` + ); try { return JSON.parse(fs.readFileSync(cacheFile, 'utf8')); @@ -33,15 +38,18 @@ function getDependenciesCached( } } - const dependencies = getDependencies(elmJsonPath); + const dependencies = getDependencies(ElmJson.getPath(project.rootDir)); fs.writeFileSync(cacheFile, dependencies); - return JSON.parse(dependencies); + return ElmJson.parseDirectAndIndirectDependencies( + JSON.parse(dependencies), + 'elm-json solve output' + ); } -function getDependencies(elmJsonPath) { - var result = spawn.sync( +function getDependencies(elmJsonPath /*: string */) /*: string */ { + const result = spawn.sync( 'elm-json', [ 'solve', @@ -60,8 +68,7 @@ function getDependencies(elmJsonPath) { ); if (result.status != 0) { - console.error(`Failed to run \`elm-json solve\`:\n${result.stderr}`); - process.exit(1); + throw new Error(`Failed to run \`elm-json solve\`:\n${result.stderr}`); } return result.stdout; diff --git a/lib/Supervisor.js b/lib/Supervisor.js index b1833276..bb1f2a18 100644 --- a/lib/Supervisor.js +++ b/lib/Supervisor.js @@ -6,7 +6,6 @@ const fs = require('fs'); const net = require('net'); const split = require('split'); const XmlBuilder = require('xmlbuilder'); -// $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] const Report = require('./Report'); function run( @@ -16,7 +15,7 @@ function run( processes /*: number */, dest /*: string */, watch /*: boolean */ -) /*: Promise */ { +) /*: Promise */ { return new Promise(function (resolve) { var nextResultToPrint = null; var finishedWorkers = 0; @@ -177,11 +176,7 @@ function run( workers.forEach(function (worker) { worker.kill(); }); - if (watch) { - resolve(); - } else { - process.exit(response.exitCode); - } + resolve(response.exitCode); break; case 'BEGIN': testsToRun = response.testCount; @@ -250,11 +245,11 @@ function run( reportRuntimeException(); pendingException = false; } - resolve(); + resolve(1); } } else if (hasNonZeroExitCode) { reportRuntimeException(); - process.exit(1); + resolve(1); } }); diff --git a/lib/elm-test.js b/lib/elm-test.js index bc908524..9157f2b0 100644 --- a/lib/elm-test.js +++ b/lib/elm-test.js @@ -1,331 +1,22 @@ // @flow -const chalk = require('chalk'); -const chokidar = require('chokidar'); const { program } = require('commander'); const fs = require('fs'); -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 Generate = require('./Generate.js'); -const Install = require('./Install.js'); -const Report = require('./Report.js'); -const Runner = require('./Runner.js'); -const Supervisor = require('./Supervisor.js'); +const Compile = require('./Compile'); +const ElmJson = require('./ElmJson'); +const FindTests = require('./FindTests'); +const Generate = require('./Generate'); +const Install = require('./Install'); +const Project = require('./Project'); +const Report = require('./Report'); +const RunTests = require('./RunTests'); void Report; -function getPathToElmBinary( - compiler /*: string | void */ -) /*: Result */ { - const name = compiler === undefined ? 'elm' : compiler; - try { - 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}`, - }; - } -} - -function resolveGlobs(fileGlobs /*: Array */) /*: Array */ { - const results = - fileGlobs.length > 0 - ? flatMap(fileGlobs, globify) - : globify('test?(s)/**/*.elm'); - return flatMap(results, resolveFilePath); -} - -function flatMap/*:: */( - array /*: Array */, - f /*: (T) => Array */ -) /*: Array */ { - return array.reduce((result, item) => result.concat(f(item)), []); -} - -function globify(globString /*: string */) /*: Array */ { - return glob.sync(globString, { - nocase: true, - ignore: '**/elm-stuff/**', - nodir: false, - absolute: true, - }); -} - -// 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)]; - - // Exclude everything having anything to do with elm-stuff - return candidates.filter( - (candidate) => !candidate.split(path.sep).includes('elm-stuff') - ); -} - -function getGlobsToWatch(elmJson /*: any */) /*: Array */ { - const sourceDirectories = - elmJson.type === 'package' ? ['src'] : elmJson['source-directories']; - return [...sourceDirectories, 'tests'].map((sourceDirectory) => - path.posix.join(sourceDirectory, '**', '*.elm') - ); -} - -function infoLog( - report /*: typeof Report.Report */, - msg /*: string */ -) /*: void */ { - if (report === 'console') { - console.log(msg); - } -} - -function clearConsole() { - process.stdout.write( - process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H' - ); -} - -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 = JSON.parse(fs.readFileSync(elmJsonPath, 'utf8')); - 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}`, - }; - } -} - -function make( - report, - { - pathToElmBinary, - testFilePaths, - projectRootDir, - generatedCodeDir, - hasBeenGivenCustomGlobs, - elmJsonPath, - projectElmJson, - } -) { - Generate.generateElmJson( - projectRootDir, - generatedCodeDir, - hasBeenGivenCustomGlobs, - elmJsonPath, - projectElmJson - ); - - return Compile.compileSources( - testFilePaths, - generatedCodeDir, - pathToElmBinary, - report - ); -} - -function test( - testFileGlobs, - processes, - { - pathToElmBinary, - testFilePaths, - projectRootDir, - generatedCodeDir, - hasBeenGivenCustomGlobs, - elmJsonPath, - projectElmJson, - isPackageProject, - }, - { watch, report, seed, fuzz } -) { - const [generatedSrc, sourceDirs] = Generate.generateElmJson( - projectRootDir, - generatedCodeDir, - hasBeenGivenCustomGlobs, - elmJsonPath, - projectElmJson - ); - - async function run() { - 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...')); - } - - let currentRun = run(); - - if (watch) { - clearConsole(); - infoLog(report, 'Running in watch mode'); - - const globsToWatch = getGlobsToWatch(projectElmJson); - const watcher = chokidar.watch(globsToWatch, { - awaitWriteFinish: { - stabilityThreshold: 500, - }, - ignoreInitial: true, - ignored: /(\/|^)elm-stuff(\/|$)/, - cwd: projectRootDir, - }); - - const eventNameMap = { - add: 'added', - addDir: 'added', - change: 'changed', - unlink: 'removed', - unlinkDir: 'removed', - }; - - watcher.on('all', (event, filePath) => { - const eventName = eventNameMap[event] || event; - clearConsole(); - infoLog(report, '\n' + filePath + ' ' + eventName + '. Rebuilding!'); - - // TODO if a previous run is in progress, wait until it's done. - currentRun = currentRun.then(run); - }); - } -} - -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); - Generate.prepareCompiledJsFile(pipeFilename, dest); - await Supervisor.run( - packageInfo.version, - pipeFilename, - report, - processes, - dest, - watch - ); -} - -function noFilesFoundError(testFileGlobs) { - return testFileGlobs.length === 0 - ? ` -No .elm files found in the tests/ directory. - -To generate some initial tests to get things going: elm-test init - -Alternatively, if your project has tests in a different directory, -try calling elm-test with a glob such as: elm-test "src/**/*Tests.elm" - `.trim() - : ` -No files found matching: - -${testFileGlobs.join('\n')} - -Are the above patterns correct? Maybe try running elm-test with no arguments? - `.trim(); -} - // TODO(https://github.com/rtfeldman/node-test-runner/pull/465): replace this // function with commander's custom error messages once // https://github.com/tj/commander.js/pull/1392 lands and is released. @@ -354,16 +45,86 @@ const parsePositiveInteger = (flag /*: string */) => ( const parseReport = (flag /*: string */) => ( string /*: string */ ) /*: typeof Report.Report */ => { - const result = Report.parse(string); - switch (result.tag) { - case 'Ok': - return result.value; - case 'Error': - console.error(`error: option '${flag}' ${result.error}`); - throw process.exit(1); + try { + return Report.parse(string); + } catch (error) { + console.error(`error: option '${flag}' ${error.message}`); + throw process.exit(1); } }; +function findClosestElmJson(dir /*: string */) /*: string | void */ { + const entry = ElmJson.getPath(dir); + return fs.existsSync(entry) + ? entry + : dir === path.parse(dir).root + ? undefined + : findClosestElmJson(path.dirname(dir)); +} + +function getProjectRootDir(subcommand /*: string */) { + const elmJsonPath = findClosestElmJson(process.cwd()); + if (elmJsonPath === undefined) { + const command = + subcommand === 'tests' ? 'elm-test' : `elm-test ${subcommand}`; + console.error( + `\`${command}\` requires an elm.json up the directory tree, but none could be found! To make one: elm init` + ); + throw process.exit(1); + } + return path.dirname(elmJsonPath); +} + +function getProject(subcommand /*: string */) { + try { + return Project.init(getProjectRootDir(subcommand), packageInfo.version); + } catch (error) { + console.error(error.message); + throw process.exit(1); + } +} + +function getPathToElmBinary(compiler /*: string | void */) { + const name = compiler === undefined ? 'elm' : compiler; + try { + return path.resolve(which.sync(name)); + } catch (_error) { + throw new Error( + compiler === undefined + ? `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.)` + : `The elm executable passed to --compiler must exist and be exectuble. Got: ${compiler}` + ); + } +} + +// Unfortunately commander is very permissive about extra arguments. Therefore, +// we manually check for excessive arguments. +// See: https://github.com/tj/commander.js/issues/1268 +function handleTooManyArgs(action) { + return (...args) => { + if (args.length < 2) { + action(...args); + } else { + // The arguments to Commander actions are: + // expectedCliArg1, expectedCliArg2, expectedCliArgN, Cmd, restCliArgs + const rest = args[args.length - 1]; + if (rest.length > 0) { + const expected = args.length - 2; + const s = expected === 1 ? '' : 's'; + console.error( + `Expected ${expected} argument${s}, but got ${ + expected + rest.length + }.` + ); + process.exit(1); + } else { + action(...args); + } + } + }; +} + const examples = ` elm-test Run tests in the tests/ folder @@ -375,19 +136,6 @@ elm-test "src/**/*Tests.elm" function main() { process.title = 'elm-test'; - const processes = Math.max(1, os.cpus().length); - - const assertElmJsonInCwd = (subcommand /*: string */) => { - if (!fs.existsSync('elm.json')) { - const command = - subcommand === 'tests' ? 'elm-test' : `elm-test ${subcommand}`; - console.error( - `\`${command}\` must be run in the same directory as an existing elm.json file! To make one: elm init` - ); - process.exit(1); - } - }; - program .storeOptionsAsProperties(false) .name('elm-test') @@ -427,106 +175,95 @@ function main() { program .command('init') .description('Create example tests') - .action((cmd) => { - if (cmd.args.length > 0) { - console.error( - `error: init takes no arguments, but got ${ - cmd.args.length - }: ${cmd.args.join(' ')}` - ); - throw process.exit(1); - } - assertElmJsonInCwd('init'); - const options = program.opts(); - const pathToElmBinary = getPathToElmBinary(options.compiler); - switch (pathToElmBinary.tag) { - case 'Ok': - Install.install(pathToElmBinary.value, 'elm-explorations/test'); - fs.mkdirSync('tests', { recursive: true }); + .action( + handleTooManyArgs(() => { + const options = program.opts(); + const pathToElmBinary = getPathToElmBinary(options.compiler); + const project = getProject('init'); + try { + Install.install(project, pathToElmBinary, 'elm-explorations/test'); + fs.mkdirSync(project.testsDir, { recursive: true }); fs.copyFileSync( path.join(__dirname, '..', 'templates', 'tests', 'Example.elm'), - path.join('tests', 'Example.elm') - ); - console.log( - '\nCheck out the documentation for getting started at https://package.elm-lang.org/packages/elm-explorations/test/latest' + path.join(project.testsDir, 'Example.elm') ); - throw process.exit(0); - case 'Error': - console.error(pathToElmBinary.error); + } catch (error) { + console.error(error.message); throw process.exit(1); - } - }); + } + console.log( + '\nCheck out the documentation for getting started at https://package.elm-lang.org/packages/elm-explorations/test/latest' + ); + process.exit(0); + }) + ); program .command('install ') .description( 'Like `elm install package`, except it installs to "test-dependencies" in your elm.json' ) - .action((packageName, cmd) => { - if (cmd.args.length > 1) { - // Unfortunately commander is very permissive about extra arguments. Therefore, - // we manually check for excessive arguments. - // See: https://github.com/tj/commander.js/issues/1268 - console.error( - `error: install takes one single argument, but got ${ - cmd.args.length - }: ${cmd.args.join(' ')}` - ); - throw process.exit(1); - } - assertElmJsonInCwd('install'); - const options = program.opts(); - const pathToElmBinary = getPathToElmBinary(options.compiler); - switch (pathToElmBinary.tag) { - case 'Ok': - Install.install(pathToElmBinary.value, packageName); - throw process.exit(0); - case 'Error': - console.error(pathToElmBinary.error); - throw process.exit(1); - } - }); + .action( + handleTooManyArgs((packageName) => { + const options = program.opts(); + const pathToElmBinary = getPathToElmBinary(options.compiler); + const project = getProject('install'); + try { + const result = Install.install(project, pathToElmBinary, packageName); + // This mirrors the behavior of `elm install` passing a package that is + // already installed. Say it's already installed, then exit 0. + if (result === 'AlreadyInstalled') { + console.log('It is already installed!'); + } + process.exit(0); + } catch (error) { + console.error(error.message); + process.exit(1); + } + }) + ); program .command('make [globs...]') .description('Check files matching the globs for compilation errors') .action((testFileGlobs) => { - assertElmJsonInCwd('make'); const options = program.opts(); - const result = makeAndTestHelper(testFileGlobs, options.compiler); - switch (result.tag) { - case 'Ok': - make(options.report, result.value).then( - () => process.exit(0), - () => process.exit(1) - ); - break; - case 'Error': - console.error(result.error); - throw process.exit(1); - } + const pathToElmBinary = getPathToElmBinary(options.compiler); + const project = getProject('make'); + Generate.generateElmJson(project); + Compile.compileSources( + FindTests.resolveGlobs(testFileGlobs, project.rootDir), + project.generatedCodeDir, + pathToElmBinary, + options.report + ).then( + () => process.exit(0), + // `elm-test make` has never logged errors it seems. + () => process.exit(1) + ); }); program - // Hack: This command is named `tests` so that `elm-test tests` means the same - // as `elm-test tests/` (due defaulting to `tests/` if no arguments given). - .command('tests [globs...]', { hidden: true, isDefault: true }) + // Hack: This command has a name that isn’t likely to exist as a directory + // containing tests. If the command were instead called "tests" then + // commander would interpret the `tests` in `elm-test tests src` as a + // command and only run tests in `src/`, ignoring all files in `tests/`. + .command('__elmTestCommand__ [globs...]', { hidden: true, isDefault: true }) .action((testFileGlobs) => { - assertElmJsonInCwd('tests'); const options = program.opts(); - const result = makeAndTestHelper(testFileGlobs, options.compiler); - switch (result.tag) { - case 'Ok': - if (result.value.testFilePaths.length === 0) { - console.error(noFilesFoundError(testFileGlobs)); - throw process.exit(1); - } - test(testFileGlobs, processes, result.value, options); - break; - case 'Error': - console.error(result.error); - throw process.exit(1); - } + const pathToElmBinary = getPathToElmBinary(options.compiler); + const projectRootDir = getProjectRootDir('tests'); + const processes = Math.max(1, os.cpus().length); + RunTests.runTests( + projectRootDir, + pathToElmBinary, + testFileGlobs, + processes, + options + ).then(process.exit, (error) => { + console.error(error.message); + process.exit(1); + }); }); program.parse(process.argv); diff --git a/package-lock.json b/package-lock.json index 906a2d7a..451d1da3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1837,11 +1837,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "murmur-hash-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/murmur-hash-js/-/murmur-hash-js-1.0.0.tgz", - "integrity": "sha1-UEEEkmnJZjPIZjhpYLL0KJ515bA=" - }, "mustache": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/mustache/-/mustache-3.2.1.tgz", diff --git a/package.json b/package.json index d881773b..b7b2faec 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "elm-json": "^0.2.8", "glob": "^7.1.6", "graceful-fs": "^4.2.4", - "murmur-hash-js": "^1.0.0", "rimraf": "^3.0.2", "split": "^1.0.1", "which": "^2.0.2", diff --git a/templates/after.js b/templates/after.js index bd8da95e..49f29e1b 100644 --- a/templates/after.js +++ b/templates/after.js @@ -1,19 +1,3 @@ -// Make sure necessary things are defined. -if (typeof Elm === 'undefined') { - throw 'test runner config error: Elm is not defined. Make sure you provide a file compiled by Elm!'; -} - -var potentialModuleNames = Object.keys(Elm.Test.Generated); - -if (potentialModuleNames.length !== 1) { - console.error( - 'Multiple potential generated modules to run in the Elm.Test.Generated namespace: ', - potentialModuleNames, - ' - this should never happen!' - ); - process.exit(1); -} - var net = require('net'), client = net.createConnection(pipeFilename); @@ -26,10 +10,8 @@ client.on('error', function (error) { client.setEncoding('utf8'); client.setNoDelay(true); -var testModule = Elm.Test.Generated[potentialModuleNames[0]]; - // Run the Elm app. -var app = testModule.init({ flags: Date.now() }); +var app = Elm.Test.Generated.Main.init({ flags: Date.now() }); client.on('data', function (msg) { app.ports.receive.send(JSON.parse(msg)); diff --git a/tests/ElmJson.js b/tests/ElmJson.js new file mode 100644 index 00000000..a7335524 --- /dev/null +++ b/tests/ElmJson.js @@ -0,0 +1,104 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const ElmJson = require('../lib/ElmJson'); +const { fixturesDir } = require('./util'); + +const invalidElmJsonContainerDir = path.join(fixturesDir, 'invalid-elm-json'); + +const invalidElmJsonDirs = [ + 'application-with-package-style-test-dependencies', + 'dependency-not-string', + 'empty-source-directories', + 'is-folder', + 'is-null', + 'json-syntax-error', + 'null-type', + 'package-with-application-style-dependencies', + 'source-directories-not-array', + 'source-directory-not-string', + 'unknown-type', +]; + +describe('handling invalid elm.json', () => { + it('Should run every directory in invalid-elm-json/', () => { + const filesFound = fs.readdirSync(invalidElmJsonContainerDir).sort(); + assert.deepStrictEqual(filesFound, invalidElmJsonDirs); + }); + + for (const dir of invalidElmJsonDirs) { + it(`Should handle error for: ${dir}`, () => { + const fullPath = path.join(invalidElmJsonContainerDir, dir); + const expected = fs + .readFileSync(path.join(fullPath, 'expected.txt'), 'utf8') + .trim() + .replace('/full/path/to/elm.json', path.join(fullPath, 'elm.json')) + .replace(/\r\n/g, '\n'); + assert.throws(() => ElmJson.read(fullPath), { + message: expected, + }); + }); + } +}); + +// Note: +// - The fields should be in the same order as the input file. +// - The changed fields should be updated. +// - The non-standard fields should be preserved. +// - The file should use 4 spaces of indentation. +const expectedWrittenElmJson = `{ + "nonStandardFieldStart": 1, + "type": "application", + "source-directories": [ + "other/directory" + ], + "elm-version": "0.19.0", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "nonStandardFieldMiddle": [ + 1, + 2, + 3 + ], + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + }, + "nonStandardFieldEnd": { + "a": 1, + "b": 2 + } +} +`; + +describe('Writing an elm.json', () => { + it('Should have a correct output', () => { + const dir = path.join(fixturesDir, 'write-elm-json'); + fs.copyFileSync( + path.join(dir, 'elm.input.json'), + path.join(dir, 'elm.json') + ); + const elmJson = ElmJson.read(dir); + const newElmJson = { + ...elmJson, + 'elm-version': '0.19.0', + 'source-directories': ['other/directory'], + }; + ElmJson.write(dir, newElmJson); + const actual = fs.readFileSync(path.join(dir, 'elm.json'), 'utf8'); + assert.strictEqual(actual, expectedWrittenElmJson); + assert(actual.endsWith('\n'), 'elm.json should end with a newline'); + }); +}); diff --git a/tests/Parser.js b/tests/Parser.js index ad42f1a8..1eee84e8 100644 --- a/tests/Parser.js +++ b/tests/Parser.js @@ -4,17 +4,19 @@ const assert = require('assert'); const stream = require('stream'); const Parser = require('../lib/Parser'); -function testParser(elmCode, expectedExposedNames) { - return Parser.extractExposedPossiblyTests('SomeFile.elm', (_, options) => { - const readable = stream.Readable.from([elmCode], { - ...options, - autoDestroy: true, - }); - readable.close = readable.destroy; - return readable; - }).then((exposed) => { - assert.deepStrictEqual(exposed, expectedExposedNames); - }); +async function testParser(elmCode, expectedExposedNames) { + const exposed = await Parser.extractExposedPossiblyTests( + 'SomeFile.elm', + (_, options) => { + const readable = stream.Readable.from([elmCode], { + ...options, + autoDestroy: true, + }); + readable.close = readable.destroy; + return readable; + } + ); + assert.deepStrictEqual(exposed, expectedExposedNames); } describe('Parser', () => { @@ -185,14 +187,15 @@ One = 1 testParser('module "string" Main exposing (one)', [])); it('treats `effect module` as a critical error', () => - testParser( - 'effect module Example where { subscription = MySub } exposing (..)', - ['should', 'not', 'succeed'] - ).catch((error) => { - assert.strictEqual( - error.message, - 'This file is problematic:\n\nSomeFile.elm\n\nIt starts with `effect module`. Effect modules can only exist inside src/ in elm and elm-explorations packages. They cannot contain tests.' - ); - })); + assert.rejects( + testParser( + 'effect module Example where { subscription = MySub } exposing (..)', + ['should', 'not', 'succeed'] + ), + { + message: + 'This file is problematic:\n\nSomeFile.elm\n\nIt starts with `effect module`. Effect modules can only exist inside src/ in elm and elm-explorations packages. They cannot contain tests.', + } + )); }); }); diff --git a/tests/ci.js b/tests/ci.js index 2311e064..28800f39 100755 --- a/tests/ci.js +++ b/tests/ci.js @@ -75,6 +75,13 @@ function assertTestFailure(runResult) { ); } +function readdir(dir) { + return fs + .readdirSync(dir) + .filter((item) => item.endsWith('.elm')) + .sort(); +} + describe('--help', () => { it('Should print the usage and exit indicating success', () => { const runResult = execElmTest(['--help']); @@ -166,8 +173,7 @@ describe('Testing elm-test on single Elm files', () => { } it(`Should run every file in tests/Passing`, () => { - const filesFound = fs.readdirSync(cwd + '/tests/Passing/'); - filesFound.sort(); + const filesFound = readdir(path.join(cwd, 'tests', 'Passing')); assert.deepStrictEqual(filesFound, passingTestFiles); }); @@ -192,8 +198,7 @@ describe('Testing elm-test on single Elm files', () => { } it(`Should run every file in tests/Failing`, () => { - const filesFound = fs.readdirSync(cwd + '/tests/Failing/'); - filesFound.sort(); + const filesFound = readdir(path.join(cwd, 'tests', 'Failing')); assert.deepStrictEqual(filesFound, failingTestFiles); }); @@ -209,8 +214,7 @@ describe('Testing elm-test on single Elm files', () => { } it(`Should run every file in tests/RuntimeException`, () => { - const filesFound = fs.readdirSync(cwd + '/tests/RuntimeException/'); - filesFound.sort(); + const filesFound = readdir(path.join(cwd, 'tests', 'RuntimeException')); assert.deepStrictEqual(filesFound, erroredTestFiles); }); }); diff --git a/tests/fixtures/elm.json b/tests/fixtures/elm.json index 13bd15a4..5d3b83bd 100644 --- a/tests/fixtures/elm.json +++ b/tests/fixtures/elm.json @@ -1,6 +1,8 @@ { "type": "application", - "source-directories": [], + "source-directories": [ + "src" + ], "elm-version": "0.19.1", "dependencies": { "direct": { diff --git a/tests/fixtures/invalid-elm-json/application-with-package-style-test-dependencies/elm.json b/tests/fixtures/invalid-elm-json/application-with-package-style-test-dependencies/elm.json new file mode 100644 index 00000000..7776be19 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/application-with-package-style-test-dependencies/elm.json @@ -0,0 +1,16 @@ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "elm-explorations/test": "1.2.0 <= v < 2.0.0" + } +} diff --git a/tests/fixtures/invalid-elm-json/application-with-package-style-test-dependencies/expected.txt b/tests/fixtures/invalid-elm-json/application-with-package-style-test-dependencies/expected.txt new file mode 100644 index 00000000..6d942061 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/application-with-package-style-test-dependencies/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected test-dependencies->"direct" to be an object, but got: undefined diff --git a/tests/fixtures/invalid-elm-json/dependency-not-string/elm.json b/tests/fixtures/invalid-elm-json/dependency-not-string/elm.json new file mode 100644 index 00000000..8347124c --- /dev/null +++ b/tests/fixtures/invalid-elm-json/dependency-not-string/elm.json @@ -0,0 +1,23 @@ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": 1.2 + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + } +} diff --git a/tests/fixtures/invalid-elm-json/dependency-not-string/expected.txt b/tests/fixtures/invalid-elm-json/dependency-not-string/expected.txt new file mode 100644 index 00000000..68d8ddea --- /dev/null +++ b/tests/fixtures/invalid-elm-json/dependency-not-string/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected test-dependencies->"direct"->"elm-explorations/test" to be a string, but got: 1.2 diff --git a/tests/fixtures/invalid-elm-json/empty-source-directories/elm.json b/tests/fixtures/invalid-elm-json/empty-source-directories/elm.json new file mode 100644 index 00000000..8b5be192 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/empty-source-directories/elm.json @@ -0,0 +1,22 @@ +{ + "type": "application", + "source-directories": [ + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + } +} diff --git a/tests/fixtures/invalid-elm-json/empty-source-directories/expected.txt b/tests/fixtures/invalid-elm-json/empty-source-directories/expected.txt new file mode 100644 index 00000000..5a707680 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/empty-source-directories/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected "source-directories" to contain at least one item, but it is empty. diff --git a/tests/fixtures/invalid-elm-json/is-folder/elm.json/.gitkeep b/tests/fixtures/invalid-elm-json/is-folder/elm.json/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/invalid-elm-json/is-folder/expected.txt b/tests/fixtures/invalid-elm-json/is-folder/expected.txt new file mode 100644 index 00000000..29fec2ea --- /dev/null +++ b/tests/fixtures/invalid-elm-json/is-folder/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +EISDIR: illegal operation on a directory, read diff --git a/tests/fixtures/invalid-elm-json/is-null/elm.json b/tests/fixtures/invalid-elm-json/is-null/elm.json new file mode 100644 index 00000000..19765bd5 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/is-null/elm.json @@ -0,0 +1 @@ +null diff --git a/tests/fixtures/invalid-elm-json/is-null/expected.txt b/tests/fixtures/invalid-elm-json/is-null/expected.txt new file mode 100644 index 00000000..dc387968 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/is-null/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected the file to be an object, but got: null diff --git a/tests/fixtures/invalid-elm-json/json-syntax-error/elm.json b/tests/fixtures/invalid-elm-json/json-syntax-error/elm.json new file mode 100644 index 00000000..e9904f6d --- /dev/null +++ b/tests/fixtures/invalid-elm-json/json-syntax-error/elm.json @@ -0,0 +1,22 @@ +{ "type" "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + } +} diff --git a/tests/fixtures/invalid-elm-json/json-syntax-error/expected.txt b/tests/fixtures/invalid-elm-json/json-syntax-error/expected.txt new file mode 100644 index 00000000..9985b459 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/json-syntax-error/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Unexpected string in JSON at position 11 diff --git a/tests/fixtures/invalid-elm-json/null-type/elm.json b/tests/fixtures/invalid-elm-json/null-type/elm.json new file mode 100644 index 00000000..e14f0998 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/null-type/elm.json @@ -0,0 +1,23 @@ +{ + "type": null, + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + } +} diff --git a/tests/fixtures/invalid-elm-json/null-type/expected.txt b/tests/fixtures/invalid-elm-json/null-type/expected.txt new file mode 100644 index 00000000..b3a19247 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/null-type/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected "type" to be "application" or "package", but got: null diff --git a/tests/fixtures/invalid-elm-json/package-with-application-style-dependencies/elm.json b/tests/fixtures/invalid-elm-json/package-with-application-style-dependencies/elm.json new file mode 100644 index 00000000..e09be081 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/package-with-application-style-dependencies/elm.json @@ -0,0 +1,20 @@ +{ + "type": "package", + "name": "elm-explorations/node-test-runner-example-package", + "summary": "Example package with tests", + "license": "BSD-3-Clause", + "version": "1.0.0", + "exposed-modules": [ + "Something" + ], + "elm-version": "0.19.0 <= v < 0.20.0", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "elm-explorations/test": "1.2.0 <= v < 2.0.0" + } +} diff --git a/tests/fixtures/invalid-elm-json/package-with-application-style-dependencies/expected.txt b/tests/fixtures/invalid-elm-json/package-with-application-style-dependencies/expected.txt new file mode 100644 index 00000000..48b25e7f --- /dev/null +++ b/tests/fixtures/invalid-elm-json/package-with-application-style-dependencies/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected dependencies->"direct" to be a string, but got: {"elm/core":"1.0.0"} diff --git a/tests/fixtures/invalid-elm-json/source-directories-not-array/elm.json b/tests/fixtures/invalid-elm-json/source-directories-not-array/elm.json new file mode 100644 index 00000000..7c275e3b --- /dev/null +++ b/tests/fixtures/invalid-elm-json/source-directories-not-array/elm.json @@ -0,0 +1,23 @@ +{ + "type": "application", + "source-directories": { + "src": true + }, + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + } +} diff --git a/tests/fixtures/invalid-elm-json/source-directories-not-array/expected.txt b/tests/fixtures/invalid-elm-json/source-directories-not-array/expected.txt new file mode 100644 index 00000000..a375e83e --- /dev/null +++ b/tests/fixtures/invalid-elm-json/source-directories-not-array/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected "source-directories" to be an array, but got: {"src":true} diff --git a/tests/fixtures/invalid-elm-json/source-directory-not-string/elm.json b/tests/fixtures/invalid-elm-json/source-directory-not-string/elm.json new file mode 100644 index 00000000..40dd8f93 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/source-directory-not-string/elm.json @@ -0,0 +1,21 @@ +{ + "type": "application", + "source-directories": ["src", 1, "app"], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + } +} diff --git a/tests/fixtures/invalid-elm-json/source-directory-not-string/expected.txt b/tests/fixtures/invalid-elm-json/source-directory-not-string/expected.txt new file mode 100644 index 00000000..eb087e58 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/source-directory-not-string/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected "source-directories"->1 to be a string, but got: 1 diff --git a/tests/fixtures/invalid-elm-json/unknown-type/elm.json b/tests/fixtures/invalid-elm-json/unknown-type/elm.json new file mode 100644 index 00000000..6797a949 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/unknown-type/elm.json @@ -0,0 +1,23 @@ +{ + "type": "Application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/core": "1.0.0" + }, + "indirect": {} + }, + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + } +} diff --git a/tests/fixtures/invalid-elm-json/unknown-type/expected.txt b/tests/fixtures/invalid-elm-json/unknown-type/expected.txt new file mode 100644 index 00000000..0f500585 --- /dev/null +++ b/tests/fixtures/invalid-elm-json/unknown-type/expected.txt @@ -0,0 +1,3 @@ +/full/path/to/elm.json +Failed to read elm.json: +Expected "type" to be "application" or "package", but got: "Application" diff --git a/tests/fixtures/templates/application/elm.json b/tests/fixtures/templates/application/elm.json index ceb16cf9..97d61ebc 100644 --- a/tests/fixtures/templates/application/elm.json +++ b/tests/fixtures/templates/application/elm.json @@ -1,7 +1,7 @@ { "type": "application", "source-directories": [ - "." + "src" ], "elm-version": "0.19.1", "dependencies": { diff --git a/tests/fixtures/tests/Passing/.gitignore b/tests/fixtures/tests/Passing/.gitignore new file mode 100644 index 00000000..fb307d7b --- /dev/null +++ b/tests/fixtures/tests/Passing/.gitignore @@ -0,0 +1 @@ +Generated.elm diff --git a/tests/fixtures/tests/Passing/Dedup/One.elm b/tests/fixtures/tests/Passing/Dedup/One.elm new file mode 100644 index 00000000..982e36ec --- /dev/null +++ b/tests/fixtures/tests/Passing/Dedup/One.elm @@ -0,0 +1,11 @@ +module Passing.Dedup.One exposing (..) + +import Expect +import Test exposing (..) + + +plainExpectation : Test +plainExpectation = + test "this should pass" <| + \() -> + Expect.equal "success" "success" diff --git a/tests/fixtures/write-elm-json/.gitignore b/tests/fixtures/write-elm-json/.gitignore new file mode 100644 index 00000000..e4e99f51 --- /dev/null +++ b/tests/fixtures/write-elm-json/.gitignore @@ -0,0 +1 @@ +elm.json diff --git a/tests/fixtures/write-elm-json/elm.input.json b/tests/fixtures/write-elm-json/elm.input.json new file mode 100644 index 00000000..d08792c4 --- /dev/null +++ b/tests/fixtures/write-elm-json/elm.input.json @@ -0,0 +1,25 @@ +{ + "nonStandardFieldStart": 1, + "type": "application", + "source-directories": [ "src" ], + "elm-version": "0.19.1", + "dependencies": { + "direct": { "elm/core": "1.0.0" }, + "indirect": {} + }, + "nonStandardFieldMiddle": [1, 2, 3], + "test-dependencies": { + "direct": { + "elm/regex": "1.0.0", + "elm-explorations/test": "1.2.0" + }, + "indirect": { + "elm/html": "1.0.0", + "elm/virtual-dom": "1.0.2" + } + }, + "nonStandardFieldEnd": { + "a": 1, + "b": 2 + } +} diff --git a/tests/flags.js b/tests/flags.js index af5b90ad..9e4130c5 100644 --- a/tests/flags.js +++ b/tests/flags.js @@ -11,7 +11,8 @@ const rimraf = require('rimraf'); const stripAnsi = require('strip-ansi'); const { fixturesDir, spawnOpts, dummyBinPath } = require('./util'); -const elmTestPath = path.join(__dirname, '..', 'bin', 'elm-test'); +const rootDir = path.join(__dirname, '..'); +const elmTestPath = path.join(rootDir, 'bin', 'elm-test'); const scratchDir = path.join(fixturesDir, 'scratch'); const scratchElmJsonPath = path.join(scratchDir, 'elm.json'); @@ -47,6 +48,11 @@ function ensureEmptyDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } +function touch(filePath) { + const now = new Date(); + fs.utimesSync(filePath, now, now); +} + function readJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } @@ -139,8 +145,8 @@ describe('flags', () => { 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); + it('should fail if no elm.json can be found', () => { + const runResult = execElmTest(['install', 'elm/regex'], rootDir); assert.ok(Number.isInteger(runResult.status)); assert.notStrictEqual(runResult.status, 0); }).timeout(60000); @@ -164,7 +170,6 @@ describe('flags', () => { it('should exit with success if package already installed', () => { const runResult = execElmTest(['install', 'elm-explorations/test']); - console.log(runResult); assert.strictEqual(runResult.status, 0); }).timeout(60000); }); @@ -409,40 +414,162 @@ describe('flags', () => { assert.notStrictEqual(runResult.status, 0); }).timeout(5000); - it('Should re-run tests if a test file is touched', (done) => { + it('Should re-run tests when files are changed, added and removed', (done) => { + const addedFile = path.join( + fixturesDir, + 'tests', + 'Passing', + 'Generated.elm' + ); + if (fs.existsSync(addedFile)) { + fs.unlinkSync(addedFile); + } + const child = spawn( elmTestPath, ['--report=json', '--watch', path.join('tests', 'Passing', 'One.elm')], Object.assign({ encoding: 'utf-8', cwd: fixturesDir }, spawnOpts) ); - let hasRetriggered = false; - child.on('close', (code, signal) => { // don't send error when killed after test passed if (code !== null || signal !== 'SIGTERM') { done(new Error('elm-test --watch exited with status code: ' + code)); } }); + + let runsExecuted = 0; const reader = readline.createInterface({ input: child.stdout }); + reader.on('line', (line) => { try { - const json = stripAnsi('' + line); - // skip expected non-json - if (json === 'Watching for changes...') return; - const parsedLine = JSON.parse(json); + const parsedLine = JSON.parse(stripAnsi('' + line)); if (parsedLine.event !== 'runComplete') return; - if (!hasRetriggered) { - const now = new Date(); - fs.utimesSync( - path.join(fixturesDir, 'tests', 'Passing', 'One.elm'), - now, - now - ); - hasRetriggered = true; - } else { + runsExecuted++; + switch (runsExecuted) { + case 1: + // Imagine this adds `import Passing.Generated`… + touch(path.join(fixturesDir, 'tests', 'Passing', 'One.elm')); + break; + case 2: + // … then if Generated.elm is created we should re-run the tests. + // (A really smart implementation cound follow the import graph.) + fs.writeFileSync(addedFile, 'module Generated exposing (a)\na=1'); + break; + case 3: + // Same thing if we remove it again. + fs.unlinkSync(addedFile); + break; + case 4: + // Tests might depend on source files. (Again, a really smart + // implementation would know for sure.) + touch(path.join(fixturesDir, 'src', 'Port1.elm')); + break; + case 5: + // elm.json needs to be watched too. You might add source + // directories or install dependencies. + touch(path.join(fixturesDir, 'elm.json')); + // Another change close after should be batched into the same run. + setTimeout(() => touch(path.join(fixturesDir, 'elm.json')), 100); + break; + case 6: + child.kill(); + done(); + break; + default: + child.kill(); + done( + new Error( + `More runs executed than expected: ${runsExecuted}\n${line}` + ) + ); + } + } catch (e) { + child.kill(); + done(e); + } + }); + }).timeout(60000); + + it('Should re-run tests after init and install', (done) => { + ensureEmptyDir(scratchDir); + + fs.copyFileSync( + path.join(fixturesDir, 'templates', 'application', 'elm.json'), + scratchElmJsonPath + ); + fs.mkdirSync(path.join(scratchDir, 'src')); + + // We start the watcher in a directory with only an elm.json, no tests dir. + const child = spawn( + elmTestPath, + ['--report=json', '--watch'], + Object.assign({ encoding: 'utf-8', cwd: scratchDir }, spawnOpts) + ); + + child.on('close', (code, signal) => { + // don't send error when killed after test passed + if (code !== null || signal !== 'SIGTERM') { + done(new Error('elm-test --watch exited with status code: ' + code)); + } + }); + + let runsExecuted = 0; + + child.stderr.on('data', (data) => { + switch (runsExecuted) { + case 0: { + // We got an error message saying that no tests were found. + // Let’s init the example tests! This should trigger a re-run. + elmTestWithYes(['init'], (code) => { + assert.strictEqual(code, 0); + runsExecuted++; + }); + break; + } + case 2: child.kill(); done(); + break; + default: + child.kill(); + done( + new Error( + `Unexpected stderr test run: ${runsExecuted}\n${data.toString()}` + ) + ); + } + }); + + const reader = readline.createInterface({ input: child.stdout }); + + reader.on('line', (line) => { + try { + const parsedLine = JSON.parse(stripAnsi('' + line)); + if (parsedLine.event !== 'runComplete') return; + runsExecuted++; + switch (runsExecuted) { + case 1: { + // elm/json is in the "indirect" dependencies – let’s move it to "direct". + // This should re-run the tests because we likely did this for a + // reason – some file depends on elm/json now. + elmTestWithYes(['install', 'elm/json'], (code) => { + assert.strictEqual(code, 0); + }); + break; + } + case 2: + // Remove the tests dir again. This should re-run and output + // messages about no tests found but not crash. + rimraf.sync(path.join(scratchDir, 'tests')); + break; + default: + child.kill(); + done( + new Error( + `Unexpected stdout test run: ${runsExecuted}\n${line}` + ) + ); } } catch (e) { child.kill(); @@ -452,6 +579,25 @@ describe('flags', () => { }).timeout(60000); }); + describe('mixed', () => { + it('Should find an elm.json up the directory tree', () => { + const runResult = execElmTest( + ['One.elm'], + path.join(fixturesDir, 'tests', 'Passing') + ); + assert.strictEqual(runResult.status, 0); + }).timeout(60000); + + it('Should deduplicate test files', () => { + // This is nice if two globs accidentally intersect. + const runResult = execElmTest([ + 'tests/Passing/Dedup/*.elm', + 'tests/**/!(Failing)/**/One.elm', + ]); + assert.strictEqual(runResult.status, 0); + }).timeout(60000); + }); + describe('unknown flags', () => { it('Should fail on unknown short flag', () => { const runResult = execElmTest([