diff --git a/cli.js b/cli.js index d292428e45..3c3ce8156f 100755 --- a/cli.js +++ b/cli.js @@ -1,3 +1,3 @@ #!/usr/bin/env node -require('./dist/cli') +require('./dist/cli').processArgv() diff --git a/docs/user/config.md b/docs/user/config.md index 92a8633696..42e781b828 100644 --- a/docs/user/config.md +++ b/docs/user/config.md @@ -89,3 +89,19 @@ All options have default values which should fit most of the projects. - [**`diagnostics`**: Diagnostics related configuration.](config/diagnostics) - [**`babelConfig`**: Babel(Jest) related configuration.](config/babelConfig) - [**`stringifyContentPathRegex`**: Configure which file(s) will become a module returning its content.](config/stringifyContentPathRegex) + +### Upgrading + +You can use the `config:migrate` tool of TSJest CLI if you're coming from an older version to help you migrate your Jest configuration. + +
+If you're using `jest.config.json`: +```sh +node ./node_modules/.bin/ts-jest config:migrate jest.config.js +``` +
+If you're using `jest` config property of `package.json`: +```sh +node ./node_modules/.bin/ts-jest config:migrate package.json +``` +
diff --git a/src/cli/cli.spec.ts b/src/cli/cli.spec.ts new file mode 100644 index 0000000000..3847fd2816 --- /dev/null +++ b/src/cli/cli.spec.ts @@ -0,0 +1,284 @@ +import { testing } from 'bs-logger' +import * as _fs from 'fs' +import { normalize, resolve } from 'path' + +import { mocked } from '../__helpers__/mocks' +import { rootLogger as _rootLogger } from '../util/logger' + +import { processArgv } from '.' + +// === helpers ================================================================ +jest.mock('../util/logger') +jest.mock('fs') +const fs = mocked(_fs) +const rootLogger = _rootLogger as testing.LoggerMock + +const mockWriteStream = () => { + return { + written: [] as string[], + write(text: string) { + this.written.push(text) + }, + clear() { + this.written = [] + }, + } +} + +const mockObject = (obj: T, newProps: M): T & M & { mockRestore: () => T } => { + const backup: any = Object.create(null) + + Object.keys(newProps).forEach(key => { + const desc = (backup[key] = Object.getOwnPropertyDescriptor(obj, key)) + const newDesc: any = { ...desc } + if (newDesc.get) { + newDesc.get = () => (newProps as any)[key] + } else { + newDesc.value = (newProps as any)[key] + } + Object.defineProperty(obj, key, newDesc) + }) + if ((obj as any).mockRestore) backup.mockRestore = Object.getOwnPropertyDescriptor(obj, 'mockRestore') + return Object.defineProperty(obj, 'mockRestore', { + value() { + Object.keys(backup).forEach(key => { + Object.defineProperty(obj, key, backup[key]) + }) + return obj + }, + configurable: true, + }) +} + +let lastExitCode: number | undefined + +const runCli = async ( + ...args: any[] +): Promise<{ stdout: string; stderr: string; exitCode: number | undefined; log: string }> => { + mockedProcess.stderr.clear() + mockedProcess.stdout.clear() + rootLogger.target.clear() + mockedProcess.argv.splice(2, mockedProcess.argv.length - 2, ...args) + lastExitCode = undefined + await processArgv() + return { + exitCode: lastExitCode, + stdout: mockedProcess.stdout.written.join('\n'), + stderr: mockedProcess.stderr.written.join('\n'), + log: rootLogger.target.lines.join('\n'), + } +} + +let mockedProcess: any +const FAKE_CWD = normalize('/foo/bar') +const FAKE_PKG = normalize(`${FAKE_CWD}/package.json`) +fs.existsSync.mockImplementation(f => f === FAKE_PKG) +fs.readFileSync.mockImplementation(f => { + if (f === FAKE_PKG) return JSON.stringify({ name: 'mock', version: '0.0.0-mock.0' }) + throw new Error('ENOENT') +}) + +// === test =================================================================== + +beforeEach(() => { + // jest.resetModules() + lastExitCode = undefined + mockedProcess = mockObject(process, { + cwd: () => FAKE_CWD, + argv: ['node', resolve(__dirname, '..', '..', 'cli.js')], + stderr: mockWriteStream(), + stdout: mockWriteStream(), + exit: (exitCode = 0) => { + lastExitCode = exitCode + }, + }) + fs.writeFileSync.mockClear() + fs.existsSync.mockClear() + fs.readFileSync.mockClear() + rootLogger.target.clear() +}) +afterEach(() => { + mockedProcess.mockRestore() + mockedProcess = undefined +}) + +describe('cli', async () => { + it('should output usage', async () => { + expect.assertions(2) + await expect(runCli()).resolves.toMatchInlineSnapshot(` +Object { + "exitCode": 0, + "log": "", + "stderr": "", + "stdout": " +Usage: + ts-jest command [options] [...args] + +Commands: + config:init Creates initial Jest configuration + config:migrate Migrates a given Jest configuration + help [command] Show this help, or help about a command + +Example: + ts-jest help config:migrate +", +} +`) + await expect(runCli('hello:motto')).resolves.toMatchInlineSnapshot(` +Object { + "exitCode": 0, + "log": "", + "stderr": "", + "stdout": " +Usage: + ts-jest command [options] [...args] + +Commands: + config:init Creates initial Jest configuration + config:migrate Migrates a given Jest configuration + help [command] Show this help, or help about a command + +Example: + ts-jest help config:migrate +", +} +`) + }) +}) + +describe('config', async () => { + // briefly tested, see header comment in `config/init.ts` + describe('init', async () => { + const noOption = ['config:init'] + const fullOptions = [ + ...noOption, + '--babel', + '--tsconfig', + 'tsconfig.test.json', + '--jsdom', + '--no-jest-preset', + '--allow-js', + ] + it('should create a jest.config.json (without options)', async () => { + expect.assertions(2) + const res = await runCli(...noOption) + expect(res).toEqual({ + exitCode: 0, + log: '', + stderr: ` +Jest configuration written to "${normalize('/foo/bar/jest.config.js')}". +`, + stdout: '', + }) + expect(fs.writeFileSync.mock.calls).toEqual([ + [ + normalize('/foo/bar/jest.config.js'), + `module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +};`, + ], + ]) + }) + it('should create a jest.config.foo.json (with all options set)', async () => { + expect.assertions(2) + const res = await runCli(...fullOptions, 'jest.config.foo.js') + expect(res).toEqual({ + exitCode: 0, + log: '', + stderr: ` +Jest configuration written to "${normalize('/foo/bar/jest.config.foo.js')}". +`, + stdout: '', + }) + expect(fs.writeFileSync.mock.calls).toEqual([ + [ + normalize('/foo/bar/jest.config.foo.js'), + `const tsJest = require('ts-jest').createJestPreset({ allowJs: true }); + +module.exports = { + ...tsJest, + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.test.json', + babelConfig: true, + }, + }, +};`, + ], + ]) + }) + it('should update package.json (without options)', async () => { + expect.assertions(2) + const res = await runCli(...noOption, 'package.json') + expect(res).toEqual({ + exitCode: 0, + log: '', + stderr: ` +Jest configuration written to "${normalize('/foo/bar/package.json')}". +`, + stdout: '', + }) + expect(fs.writeFileSync.mock.calls).toEqual([ + [ + normalize('/foo/bar/package.json'), + `{ + "name": "mock", + "version": "0.0.0-mock.0", + "jest": { + "preset": "ts-jest", + "testEnvironment": "node" + } +}`, + ], + ]) + }) + it('should update package.json (with all options set)', async () => { + expect.assertions(2) + const res = await runCli(...fullOptions, 'package.json') + expect(res).toEqual({ + exitCode: 0, + log: `[level:20] creating jest presets handling JavaScript files +`, + stderr: ` +Jest configuration written to "${normalize('/foo/bar/package.json')}". +`, + stdout: '', + }) + expect(fs.writeFileSync.mock.calls).toEqual([ + [ + normalize('/foo/bar/package.json'), + `{ + "name": "mock", + "version": "0.0.0-mock.0", + "jest": { + "transform": { + "^.+\\\\.[tj]sx?$": "ts-jest" + }, + "testMatch": [ + "**/__tests__/**/*.js?(x)", + "**/?(*.)+(spec|test).js?(x)", + "**/__tests__/**/*.ts?(x)", + "**/?(*.)+(spec|test).ts?(x)" + ], + "moduleFileExtensions": [ + "js", + "json", + "jsx", + "node", + "ts", + "tsx" + ], + "globals": { + "ts-jest": { + "tsconfig": "tsconfig.test.json", + "babelConfig": true + } + } + } +}`, + ], + ]) + }) + }) +}) diff --git a/src/cli/config/init.ts b/src/cli/config/init.ts new file mode 100644 index 0000000000..e0318a5d46 --- /dev/null +++ b/src/cli/config/init.ts @@ -0,0 +1,116 @@ +/** + * This has been written quickly. While trying to improve I realised it'd be better to have it in Jest... + * ...and I saw a merged PR with `jest --init` tool! + * TODO: see what's the best path for this + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { stringify as stringifyJson5 } from 'json5' +import { basename, join } from 'path' +import { Arguments } from 'yargs' + +import { CliCommand } from '..' +import { createJestPreset } from '../../config/create-jest-preset' + +export const run: CliCommand = async (args: Arguments /* , logger: Logger */) => { + const file = args._[0] || 'jest.config.js' + const filePath = join(process.cwd(), file) + const name = basename(file) + const isPackage = name === 'package.json' + const exists = existsSync(filePath) + const pkgFile = isPackage ? filePath : join(process.cwd(), 'package.json') + const hasPackage = isPackage || existsSync(pkgFile) + // read config + const { allowJs = false, jestPreset = true, tsconfig: askedTsconfig, babel = false, force, jsdom } = args + const tsconfig = askedTsconfig === 'tsconfig.json' ? undefined : askedTsconfig + const hasPresetVar = allowJs || !jestPreset + // read package + const pkgJson = hasPackage ? JSON.parse(readFileSync(pkgFile, 'utf8')) : {} + + if (isPackage && !exists) { + throw new Error(`File ${file} does not exists.`) + } else if (!isPackage && exists && !force) { + throw new Error(`Configuration file ${file} already exists.`) + } + if (!isPackage && !name.endsWith('.js')) { + throw new TypeError(`Configuration file ${file} must be a .js file or the package.json.`) + } + if (hasPackage && pkgJson.jest) { + if (force && !isPackage) { + delete pkgJson.jest + writeFileSync(pkgFile, JSON.stringify(pkgJson, undefined, ' ')) + } else if (!force) { + throw new Error(`A Jest configuration is already set in ${pkgFile}.`) + } + } + + // build configuration + let body: string + + if (isPackage) { + // package.json config + const base: any = hasPresetVar ? createJestPreset({ allowJs }) : { preset: 'ts-jest' } + if (!jsdom) base.testEnvironment = 'node' + if (tsconfig || babel) { + const tsJestConf: any = {} + base.globals = { 'ts-jest': tsJestConf } + if (tsconfig) tsJestConf.tsconfig = tsconfig + if (babel) tsJestConf.babelConfig = true + } + body = JSON.stringify({ ...pkgJson, jest: base }, undefined, ' ') + } else { + // js config + const content = [] + if (hasPresetVar) { + content.push(`const tsJest = require('ts-jest').createJestPreset({ allowJs: ${allowJs} });`, '') + } + content.push('module.exports = {') + if (hasPresetVar) { + content.push(` ...tsJest,`) + } else { + content.push(` preset: 'ts-jest',`) + } + if (!jsdom) content.push(` testEnvironment: 'node',`) + + if (tsconfig || babel) { + content.push(` globals: {`) + content.push(` 'ts-jest': {`) + if (tsconfig) content.push(` tsconfig: ${stringifyJson5(tsconfig)},`) + if (babel) content.push(` babelConfig: true,`) + content.push(` },`) + content.push(` },`) + } + + content.push('};') + + // join all together + body = content.join('\n') + } + + writeFileSync(filePath, body) + + process.stderr.write(` +Jest configuration written to "${filePath}". +`) +} + +export const help: CliCommand = async () => { + process.stdout.write(` +Usage: + ts-jest config:init [options] [] + +Arguments: + Can be a js or json Jest config file. If it is a + package.json file, the configuration will be read from + the "jest" property. + Defaul: jest.config.js + +Options: + --force Dicard any existing Jest config + --allow-js TSJest will be used to process JS files as well + --no-jest-preset Disable the use of Jest presets + --tsconfig Path to the tsconfig.json file + --babel Call BabelJest after TSJest + --jsdom Use jsdom as test environment instead of node +`) +} diff --git a/src/cli/help.ts b/src/cli/help.ts index af892ce3ab..de0d5465d7 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -6,8 +6,9 @@ Usage: ts-jest command [options] [...args] Commands: - help [command] Show this help, or help about a command + config:init Creates initial Jest configuration config:migrate Migrates a given Jest configuration + help [command] Show this help, or help about a command Example: ts-jest help config:migrate diff --git a/src/cli/index.ts b/src/cli/index.ts index 6c51aec9b8..a1f44f53a5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,38 +4,41 @@ import yargsParser from 'yargs-parser' import { rootLogger } from '../util/logger' -const VALID_COMMANDS = ['help', 'config:migrate'] - -// tslint:disable-next-line:prefer-const -let [, , ...args] = process.argv +const VALID_COMMANDS = ['help', 'config:migrate', 'config:init'] const logger = rootLogger.child({ [LogContexts.namespace]: 'cli', [LogContexts.application]: 'ts-jest' }) -const parsedArgv = yargsParser(args, { - boolean: ['dryRun', 'jestPreset', 'allowJs', 'diff'], - count: ['verbose'], - alias: { verbose: ['v'] }, - default: { dryRun: false, jestPreset: true, allowJs: false, verbose: 0, diff: false }, -}) -let command = parsedArgv._.shift() as string -const isHelp = command === 'help' -if (isHelp) command = parsedArgv._.shift() as string +export type CliCommand = (argv: Arguments, logger: Logger) => Promise -if (!VALID_COMMANDS.includes(command)) command = 'help' +async function cli(args: string[]): Promise { + const parsedArgv = yargsParser(args, { + boolean: ['dry-run', 'jest-preset', 'allow-js', 'diff', 'babel', 'force', 'jsdom'], + string: ['tsconfig'], + count: ['verbose'], + alias: { verbose: ['v'] }, + default: { jestPreset: true, verbose: 0 }, + }) -export type CliCommand = (argv: Arguments, logger: Logger) => Promise + let command = parsedArgv._.shift() as string + const isHelp = command === 'help' + if (isHelp) command = parsedArgv._.shift() as string + + if (!VALID_COMMANDS.includes(command)) command = 'help' + + // tslint:disable-next-line:no-var-requires + const { run, help }: { run: CliCommand; help: CliCommand } = require(`./${command.replace(/:/g, '/')}`) -// tslint:disable-next-line:no-var-requires -const { run, help }: { run: CliCommand; help: CliCommand } = require(`./${command.replace(/:/g, '/')}`) + const cmd = isHelp && command !== 'help' ? help : run -const cmd = isHelp && command !== 'help' ? help : run + return cmd(parsedArgv, logger) +} -cmd(parsedArgv, logger).then( - () => { +export async function processArgv(): Promise { + try { + await cli(process.argv.slice(2)) process.exit(0) - }, - (err: Error) => { + } catch (err) { logger.fatal(err.message) process.exit(1) - }, -) + } +}