diff --git a/.npmignore b/.npmignore index 08862636ec..7cfff8b0a7 100644 --- a/.npmignore +++ b/.npmignore @@ -1,8 +1,6 @@ -# TypeScript source code -src - # Tests -tests +e2e +test # Developement scripts scripts diff --git a/e2e/__cases__/hoisting/Hello.spec.ts b/e2e/__cases__/hoisting/Hello.spec.ts deleted file mode 100644 index 9117c317e2..0000000000 --- a/e2e/__cases__/hoisting/Hello.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Hello } from './Hello'; - -jest.mock('./Hello', () => ({ Hello() {} })); - -describe('Hello Class', () => { - // tslint:disable-next-line:variable-name - const OriginalClass = require.requireActual('./Hello').Hello; - it('should create a new mocked Hello', () => { - const hello = new Hello('foo'); - expect(hello.msg).toBeUndefined(); - expect(hello).not.toBeInstanceOf(OriginalClass); - expect(hello).toHaveProperty('mock'); - }); -}); diff --git a/e2e/__cases__/hoisting/Hello.ts b/e2e/__cases__/hoisting/Hello.ts deleted file mode 100644 index a6f54b1959..0000000000 --- a/e2e/__cases__/hoisting/Hello.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class Hello { - constructor(readonly msg: string) {} -} diff --git a/e2e/__cases__/hoisting/hello.spec.ts b/e2e/__cases__/hoisting/hello.spec.ts new file mode 100644 index 0000000000..cc6ceb88b5 --- /dev/null +++ b/e2e/__cases__/hoisting/hello.spec.ts @@ -0,0 +1,13 @@ +import hello from './hello'; + +jest.mock('./hello'); + +describe('hello', () => { + const original = require.requireActual('./hello').default; + it('should have been mocked', () => { + const msg = hello(); + expect(hello).not.toBe(original); + expect(msg).toBeUndefined(); + expect(hello).toHaveProperty('mock'); + }); +}); diff --git a/e2e/__cases__/hoisting/hello.ts b/e2e/__cases__/hoisting/hello.ts new file mode 100644 index 0000000000..bffa1c5a4a --- /dev/null +++ b/e2e/__cases__/hoisting/hello.ts @@ -0,0 +1,3 @@ +export default function() { + return 'hi!'; +} diff --git a/e2e/__helpers__/test-case.ts b/e2e/__helpers__/test-case.ts index 9fccc02a70..f898834998 100644 --- a/e2e/__helpers__/test-case.ts +++ b/e2e/__helpers__/test-case.ts @@ -4,42 +4,37 @@ import { join } from 'path'; import * as Paths from '../../scripts/paths'; import * as fs from 'fs-extra'; -const jestDescribe = describe; -const jestIt = it; -const jestExpect = expect; - const TEMPLATE_EXCLUDED_ITEMS = ['node_modules', 'package-lock.json']; type RunWithTemplatesIterator = ( runtTest: () => TestRunResult, - templateName: string, + context: RunWithTemplateIteratorContext, ) => void; -interface RunWithTemplatesOptions { - iterator?: RunWithTemplatesIterator; - logUnlessStatus?: number; -} -interface WithTemplatesIteratorOptions { - describe?: string | false; - it?: string; - expect?: RunWithTemplatesIterator; + +interface RunWithTemplateIteratorContext { + templateName: string; + describeLabel: string; + itLabel: string; + testLabel: string; } -export function withTemplatesIterator({ - describe = 'with template "__TEMPLATE_NAME__"', - it = 'should run as expected', - expect = runTest => { - jestExpect(runTest()).toMatchSnapshot(); - }, -}: WithTemplatesIteratorOptions = {}): RunWithTemplatesIterator { - return (runTest, name) => { - const interpolate = (msg: string) => msg.replace('__TEMPLATE_NAME__', name); - if (describe) { - jestDescribe(interpolate(describe), () => { - jestIt(interpolate(it), () => expect(runTest, name)); - }); - } else { - jestIt(interpolate(it), () => expect(runTest, name)); +function createIteratorContext( + templateName: string, + expectedStatus?: number, +): RunWithTemplateIteratorContext { + const actionForExpectedStatus = (status?: number): string => { + if (status == null) { + return 'run'; } + return status === 0 ? 'pass' : 'fail'; + }; + return { + templateName, + describeLabel: `with template "${templateName}"`, + itLabel: `should ${actionForExpectedStatus(expectedStatus)}`, + testLabel: `should ${actionForExpectedStatus( + expectedStatus, + )} using template "${templateName}"`, }; } @@ -94,13 +89,15 @@ class TestCaseRunDescriptor { runWithTemplates( templates: T[], - { iterator, logUnlessStatus }: RunWithTemplatesOptions = {}, + expectedStatus?: number, + iterator?: RunWithTemplatesIterator, ): TestRunResultsMap { if (templates.length < 1) { throw new RangeError( `There must be at least one template to run the test case with.`, ); } + if (!templates.every((t, i) => templates.indexOf(t, i + 1) === -1)) { throw new Error( `Each template must be unique. Given ${templates.join(', ')}`, @@ -113,12 +110,12 @@ class TestCaseRunDescriptor { template, }); const runTest = () => { - const out = desc.run(logUnlessStatus); + const out = desc.run(expectedStatus); map[template] = { ...out }; return out; }; if (iterator) { - iterator(runTest, template); + iterator(runTest, createIteratorContext(template, expectedStatus)); } else { runTest(); } @@ -151,7 +148,7 @@ export type TestRunResultsMap = { [key in T]: TestRunResult }; -export default function configureTestCase( +export function configureTestCase( name: string, options: RunTestOptions = {}, ): TestCaseRunDescriptor { @@ -246,17 +243,19 @@ function stripAnsiColors(stringToStrip: string): string { function prepareTest(name: string, template: string): string { const sourceDir = join(Paths.e2eSourceDir, name); - // working directory is in the temp directory, different for each tempalte name + // working directory is in the temp directory, different for each template name const caseDir = join(Paths.e2eWorkDir, template, name); const templateDir = join(Paths.e2eWorkTemplatesDir, template); - // ensure directory exists + // recreate the directory + fs.removeSync(caseDir); fs.mkdirpSync(caseDir); - // link the node_modules dir if the template has const tmplModulesDir = join(templateDir, 'node_modules'); const caseModulesDir = join(caseDir, 'node_modules'); - if (!fs.existsSync(caseModulesDir) && fs.existsSync(tmplModulesDir)) { + + // link the node_modules dir if the template has one + if (fs.existsSync(tmplModulesDir)) { fs.symlinkSync(tmplModulesDir, caseModulesDir); } diff --git a/e2e/__templates__/with-babel-7/jest.config.js b/e2e/__templates__/with-babel-7/jest.config.js index 00b443043b..24745fcf8c 100644 --- a/e2e/__templates__/with-babel-7/jest.config.js +++ b/e2e/__templates__/with-babel-7/jest.config.js @@ -5,5 +5,5 @@ module.exports = { testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$', moduleFileExtensions: ['ts', 'js'], testEnvironment: 'node', - globals: { 'ts-jest': { tsConfig: {} } }, + globals: { 'ts-jest': { tsConfig: {}, useBabelJest: true } }, }; diff --git a/e2e/__tests__/__snapshots__/hoisting.test.ts.snap b/e2e/__tests__/__snapshots__/hoisting.test.ts.snap new file mode 100644 index 0000000000..0c30a8c019 --- /dev/null +++ b/e2e/__tests__/__snapshots__/hoisting.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Hoisting test with template "default" should pass 1`] = ` +jest exit code: 0 + ===[ STDOUT ]=================================================================== + + ===[ STDERR ]=================================================================== + PASS ./hello.spec.ts + hello + √ should have been mocked + + Test Suites: 1 passed, 1 total + Tests: 1 passed, 1 total + Snapshots: 0 total + Time: XXs + Ran all test suites. + ================================================================================ +`; + +exports[`Hoisting test with template "with-babel-6" should pass 1`] = ` +jest exit code: 0 + ===[ STDOUT ]=================================================================== + + ===[ STDERR ]=================================================================== + PASS ./hello.spec.ts + hello + √ should have been mocked + + Test Suites: 1 passed, 1 total + Tests: 1 passed, 1 total + Snapshots: 0 total + Time: XXs + Ran all test suites. + ================================================================================ +`; + +exports[`Hoisting test with template "with-babel-7" should pass 1`] = ` +jest exit code: 0 + ===[ STDOUT ]=================================================================== + + ===[ STDERR ]=================================================================== + PASS ./hello.spec.ts + hello + √ should have been mocked + + Test Suites: 1 passed, 1 total + Tests: 1 passed, 1 total + Snapshots: 0 total + Time: XXs + Ran all test suites. + ================================================================================ +`; + +exports[`Hoisting test with template "with-jest-22" should pass 1`] = ` +jest exit code: 0 + ===[ STDOUT ]=================================================================== + + ===[ STDERR ]=================================================================== + PASS ./hello.spec.ts + hello + √ should have been mocked + + Test Suites: 1 passed, 1 total + Tests: 1 passed, 1 total + Snapshots: 0 total + Time: XXs + Ran all test suites. + ================================================================================ +`; diff --git a/e2e/__tests__/__snapshots__/simple.spec.ts.snap b/e2e/__tests__/__snapshots__/simple.test.ts.snap similarity index 100% rename from e2e/__tests__/__snapshots__/simple.spec.ts.snap rename to e2e/__tests__/__snapshots__/simple.test.ts.snap diff --git a/e2e/__tests__/__snapshots__/source-map.spec.ts.snap b/e2e/__tests__/__snapshots__/source-map.test.ts.snap similarity index 100% rename from e2e/__tests__/__snapshots__/source-map.spec.ts.snap rename to e2e/__tests__/__snapshots__/source-map.test.ts.snap diff --git a/e2e/__tests__/hoisting.test.ts b/e2e/__tests__/hoisting.test.ts new file mode 100644 index 0000000000..c5c63631e4 --- /dev/null +++ b/e2e/__tests__/hoisting.test.ts @@ -0,0 +1,20 @@ +import { configureTestCase } from '../__helpers__/test-case'; +import { allPackageSets } from '../__helpers__/templates'; + +describe('Hoisting test', () => { + const testCase = configureTestCase('hoisting', { args: ['--no-cache'] }); + + testCase.runWithTemplates( + allPackageSets, + 0, + (runTest, { describeLabel, itLabel }) => { + describe(describeLabel, () => { + it(itLabel, () => { + const result = runTest(); + expect(result.status).toBe(0); + expect(result).toMatchSnapshot(); + }); + }); + }, + ); +}); diff --git a/e2e/__tests__/simple.spec.ts b/e2e/__tests__/simple.spec.ts deleted file mode 100644 index b3aeffa2f3..0000000000 --- a/e2e/__tests__/simple.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import configureTestCase, { - withTemplatesIterator, -} from '../__helpers__/test-case'; -import { allPackageSets } from '../__helpers__/templates'; - -describe('Simple test', () => { - const testCase = configureTestCase('simple', { args: ['--no-cache'] }); - - testCase.runWithTemplates(allPackageSets, { - logUnlessStatus: 0, - iterator: withTemplatesIterator({ it: 'should pass' }), - }); -}); diff --git a/e2e/__tests__/simple.test.ts b/e2e/__tests__/simple.test.ts new file mode 100644 index 0000000000..9045f74550 --- /dev/null +++ b/e2e/__tests__/simple.test.ts @@ -0,0 +1,20 @@ +import { configureTestCase } from '../__helpers__/test-case'; +import { allPackageSets } from '../__helpers__/templates'; + +describe('Simple test', () => { + const testCase = configureTestCase('simple', { args: ['--no-cache'] }); + + testCase.runWithTemplates( + allPackageSets, + 0, + (runTest, { describeLabel, itLabel }) => { + describe(describeLabel, () => { + it(itLabel, () => { + const result = runTest(); + expect(result.status).toBe(0); + expect(result).toMatchSnapshot(); + }); + }); + }, + ); +}); diff --git a/e2e/__tests__/source-map.spec.ts b/e2e/__tests__/source-map.spec.ts deleted file mode 100644 index 1c10b51b0a..0000000000 --- a/e2e/__tests__/source-map.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import configureTestCase, { - withTemplatesIterator, -} from '../__helpers__/test-case'; -import { allPackageSets } from '../__helpers__/templates'; - -describe('Source maps', () => { - describe('console.log()', () => { - const testCase = configureTestCase('source-maps', { args: ['--no-cache'] }); - - testCase.runWithTemplates(allPackageSets, { - iterator: withTemplatesIterator({ - it: 'should pass reporting correct line number', - }), - logUnlessStatus: 0, - }); - }); - - describe('throw new Error()', () => { - const testCase = configureTestCase('source-maps', { - args: ['--no-cache'], - env: { __FORCE_FAIL: '1' }, - }); - - testCase.runWithTemplates(allPackageSets, { - iterator: withTemplatesIterator({ - it: 'should fail reporting correct line number', - }), - logUnlessStatus: 1, - }); - }); -}); diff --git a/e2e/__tests__/source-map.test.ts b/e2e/__tests__/source-map.test.ts new file mode 100644 index 0000000000..8180dad8b7 --- /dev/null +++ b/e2e/__tests__/source-map.test.ts @@ -0,0 +1,43 @@ +import { configureTestCase } from '../__helpers__/test-case'; +import { allPackageSets } from '../__helpers__/templates'; + +describe('Source maps', () => { + describe('console.log()', () => { + const testCase = configureTestCase('source-maps', { args: ['--no-cache'] }); + + testCase.runWithTemplates( + allPackageSets, + 0, + (runTest, { describeLabel }) => { + describe(describeLabel, () => { + it('should pass reporting correct line number', () => { + const result = runTest(); + expect(result.status).toBe(0); + expect(result).toMatchSnapshot(); + }); + }); + }, + ); + }); + + describe('throw new Error()', () => { + const testCase = configureTestCase('source-maps', { + args: ['--no-cache'], + env: { __FORCE_FAIL: '1' }, + }); + + testCase.runWithTemplates( + allPackageSets, + 1, + (runTest, { describeLabel }) => { + describe(describeLabel, () => { + it('should fail reporting correct line number', () => { + const result = runTest(); + expect(result.status).toBe(1); + expect(result).toMatchSnapshot(); + }); + }); + }, + ); + }); +}); diff --git a/e2e/jest.config.js b/e2e/jest.config.js new file mode 100644 index 0000000000..fd9fcb11bf --- /dev/null +++ b/e2e/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + transform: { + '\\.ts$': '/../dist/index.js', + }, + testRegex: '/__tests__/.+\\.test\\.ts$', + collectCoverageFrom: ['/../src/**/*.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + testEnvironment: 'node', + snapshotSerializers: ['/__serializers__/test-run-result.ts'], +}; diff --git a/jest.config.js b/jest.config.js index a4375e4dc0..e2371dd51d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,10 @@ module.exports = { + rootDir: './test', transform: { - '\\.ts$': '/dist/index.js', + '\\.ts$': '/../dist/index.js', }, - testRegex: '/e2e/__tests__/.+\\.spec\\.ts$', - collectCoverageFrom: ['src/**/*.ts'], + testRegex: '/.+\\.spec\\.ts$', + collectCoverageFrom: ['/../src/**/*.ts'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], testEnvironment: 'node', - snapshotSerializers: ['/e2e/__serializers__/test-run-result.ts'], }; diff --git a/package.json b/package.json index 2abfded890..b9a4191f1c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "build:watch": "tsc -p tsconfig.build.json -w", "clean": "node scripts/clean.js", "pretest": "npm run lint", - "test": "node scripts/e2e.js", + "test": "npm run test:e2e && npm run test:unit", + "test:e2e": "node scripts/e2e.js", + "test:unit": "jest", "lint": "tslint --project .", "doc": "doctoc .", "prepare": "npm run build", diff --git a/scripts/e2e.js b/scripts/e2e.js index 0d1d1f269d..1e60e1a5c9 100755 --- a/scripts/e2e.js +++ b/scripts/e2e.js @@ -1,8 +1,6 @@ #!/usr/bin/env node 'use strict'; -process.env.NODE_ENV = 'test'; -process.env.PUBLIC_URL = ''; const jest = require('jest'); const { sync: spawnSync } = require('cross-spawn'); const fs = require('fs-extra'); @@ -53,13 +51,6 @@ function setupE2e() { // ensure directory exists before copying over fs.mkdirpSync(Paths.e2eWorkTemplatesDir); - // create the template packages from which node_modules will be originally copied from - log('copying templates to the work directory'); - fs.copySync( - path.join(Paths.e2eTemplatesDir), - path.join(Paths.e2eWorkTemplatesDir) - ); - // link locally so we could find it easily if (!fs.existsSync(Paths.e2eWotkDirLink)) { fs.symlinkSync(Paths.e2eWorkDir, Paths.e2eWotkDirLink, 'dir'); @@ -71,17 +62,30 @@ function setupE2e() { // install with `npm ci` in each template, this is the fastest but needs a package lock file, // that is why we end with the npm install of our bundle - getDirectories(Paths.e2eWorkTemplatesDir).forEach(name => { - log('checking temlate ', name); + getDirectories(Paths.e2eTemplatesDir).forEach(name => { + log('checking template ', name); + const sourceDir = path.join(Paths.e2eTemplatesDir, name); const dir = path.join(Paths.e2eWorkTemplatesDir, name); const nodeModulesDir = path.join(dir, 'node_modules'); - const pkgLockFile = path.join( - Paths.e2eTemplatesDir, - name, - 'package-lock.json' - ); + const pkgLockFile = path.join(sourceDir, 'package-lock.json'); const e2eFile = path.join(nodeModulesDir, '.ts-jest-e2e.json'); + // remove all files expect node_modules + if (fs.existsSync(dir)) { + log(` [template: ${name}]`, 'removing old files'); + fs.readdirSync(dir).forEach(file => { + if (file !== 'node_modules') { + fs.unlinkSync(path.join(dir, file)); + } + }); + } else { + fs.mkdirpSync(dir); + } + + // copy files from template + log(` [template: ${name}]`, 'copying files from template source'); + fs.copySync(sourceDir, dir); + // no package-lock.json => this template doesn't provide any package-set if (!fs.existsSync(pkgLockFile)) { log(` [template: ${name}]`, 'not a package-set template, nothing to do'); @@ -135,5 +139,9 @@ function setupE2e() { setupE2e(); log('templates are ready, running tests', '\n\n'); -const argv = process.argv.slice(2); -jest.run(argv); + +jest.run([ + '--config', + path.resolve(__dirname, '..', 'e2e', 'jest.config.js'), + ...process.argv.slice(2), +]); diff --git a/src/index.ts b/src/index.ts index 31f4611ed3..18f4dceb7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ // tslint:disable:member-ordering import { TsJestGlobalOptions, TsJestConfig } from './types'; -import TsProgram from './ts-program.simple'; +import TsProgram from './ts-program'; import Memoize from './memoize'; import { normalizeDiagnosticTypes } from './utils/diagnostics'; @@ -36,7 +36,7 @@ class TsJestTransformer implements jest.Transformer { process( source: string, - path: jest.Path, + filePath: jest.Path, jestConfig: jest.ProjectConfig, transformOptions?: jest.TransformOptions, ): jest.TransformedSource | string { @@ -49,13 +49,13 @@ class TsJestTransformer implements jest.Transformer { !!transformOptions && transformOptions.instrument; // transpile TS code (source maps are included) - result = program.transpileModule(path, source, instrument); + result = program.transpileModule(filePath, source, instrument); // calling babel-jest transformer if (config.useBabelJest) { result = this.babelJest.process( result, - path, + filePath, jestConfig, transformOptions, ); diff --git a/src/transformers/README.md b/src/transformers/README.md new file mode 100644 index 0000000000..e91c0a18a6 --- /dev/null +++ b/src/transformers/README.md @@ -0,0 +1,32 @@ +# Transformer + +See https://dev.doctorevidence.com/how-to-write-a-typescript-transform-plugin-fc5308fdd943 + +## Boilerplate + +```ts +import { + TransformationContext, + SourceFile, + Visitor, + visitEachChild, + Transformer, + visitNode, +} from 'typescript'; +import TsProgram from '../ts-program'; + +export default function(prog: TsProgram) { + function createVisitor(ctx: TransformationContext, sf: SourceFile) { + const visitor: Visitor = node => { + // here we can check each node and potentially return + // new nodes if we want to leave the node as is, and + // continue searching through child nodes: + return visitEachChild(node, visitor, ctx); + }; + return visitor; + } + return (ctx: TransformationContext): Transformer => { + return (sf: SourceFile) => visitNode(sf, createVisitor(ctx, sf)); + }; +}; +``` diff --git a/src/transformers/hoisting.ts b/src/transformers/hoisting.ts index cb1f9126c0..df38a7a6c0 100644 --- a/src/transformers/hoisting.ts +++ b/src/transformers/hoisting.ts @@ -1,131 +1,57 @@ -// import { -// TransformationContext, -// EmitHint, -// Node, -// JsxEmit, -// SyntaxKind, -// SourceFile, -// JsxSelfClosingElement, -// JsxClosingElement, -// JsxOpeningElement, -// Bundle, -// isPropertyAccessExpression, -// isPropertyAssignment, -// PropertyAccessExpression, -// Expression, -// createElementAccess, -// setTextRange, -// PropertyAssignment, -// isIdentifier, -// updatePropertyAssignment, -// Identifier, -// } from 'typescript'; -// import { chainBundle, getOriginalNodeId, getNodeId } from './utilis'; - -// export default function hoistingTransformer( -// context: TransformationContext, -// ): (x: SourceFile | Bundle) => SourceFile | Bundle { -// const compilerOptions = context.getCompilerOptions(); - -// // enable emit notification only if using --jsx preserve or react-native -// let previousOnEmitNode: (hint: EmitHint, node: Node, emitCallback: (hint: EmitHint, node: Node) => void) => void; -// let noSubstitution: boolean[]; - -// if (compilerOptions.jsx === JsxEmit.Preserve || compilerOptions.jsx === JsxEmit.ReactNative) { -// previousOnEmitNode = context.onEmitNode; -// context.onEmitNode = onEmitNode; -// context.enableEmitNotification(SyntaxKind.JsxOpeningElement); -// context.enableEmitNotification(SyntaxKind.JsxClosingElement); -// context.enableEmitNotification(SyntaxKind.JsxSelfClosingElement); -// noSubstitution = []; -// } - -// const previousOnSubstituteNode = context.onSubstituteNode; -// context.onSubstituteNode = onSubstituteNode; -// context.enableSubstitution(SyntaxKind.PropertyAccessExpression); -// context.enableSubstitution(SyntaxKind.PropertyAssignment); -// return chainBundle(transformSourceFile); - -// function transformSourceFile(node: SourceFile) { -// return node; -// } - -// /** -// * Called by the printer just before a node is printed. -// * -// * @param hint A hint as to the intended usage of the node. -// * @param node The node to emit. -// * @param emitCallback A callback used to emit the node. -// */ -// function onEmitNode(hint: EmitHint, node: Node, emitCallback: (emitContext: EmitHint, node: Node) => void) { -// switch (node.kind) { -// case SyntaxKind.JsxOpeningElement: -// case SyntaxKind.JsxClosingElement: -// case SyntaxKind.JsxSelfClosingElement: -// const tagName = (node as JsxOpeningElement | JsxClosingElement | JsxSelfClosingElement).tagName; -// noSubstitution[getOriginalNodeId(tagName)] = true; -// break; -// } - -// previousOnEmitNode(hint, node, emitCallback); -// } - -// /** -// * Hooks node substitutions. -// * -// * @param hint A hint as to the intended usage of the node. -// * @param node The node to substitute. -// */ -// function onSubstituteNode(hint: EmitHint, node: Node) { -// if (getNodeId(node) && noSubstitution && noSubstitution[getNodeId(node)]) { -// return previousOnSubstituteNode(hint, node); -// } - -// node = previousOnSubstituteNode(hint, node); -// if (isPropertyAccessExpression(node)) { -// return substitutePropertyAccessExpression(node); -// } else if (isPropertyAssignment(node)) { -// return substitutePropertyAssignment(node); -// } -// return node; -// } - -// /** -// * Substitutes a PropertyAccessExpression whose name is a reserved word. -// * -// * @param node A PropertyAccessExpression -// */ -// function substitutePropertyAccessExpression(node: PropertyAccessExpression): Expression { -// const literalName = trySubstituteReservedName(node.name); -// if (literalName) { -// return setTextRange(createElementAccess(node.expression, literalName), node); -// } -// return node; -// } - -// /** -// * Substitutes a PropertyAssignment whose name is a reserved word. -// * -// * @param node A PropertyAssignment -// */ -// function substitutePropertyAssignment(node: PropertyAssignment): PropertyAssignment { -// const literalName = isIdentifier(node.name) && trySubstituteReservedName(node.name); -// if (literalName) { -// return updatePropertyAssignment(node, literalName, node.initializer); -// } -// return node; -// } - -// /** -// * If an identifier name is a reserved word, returns a string literal for the name. -// * -// * @param name An Identifier -// */ -// function trySubstituteReservedName(name: Identifier) { -// const token = name.originalKeywordKind || (nodeIsSynthesized(name) ? stringToToken(idText(name)) : undefined); -// if (token !== undefined && token >= SyntaxKind.FirstReservedWord && token <= SyntaxKind.LastReservedWord) { -// return setTextRange(createLiteral(name), name); -// } -// return undefined; -// } -// } +// tslint:disable:curly +import { + Node, + ExpressionStatement, + isExpressionStatement, + isCallExpression, + isPropertyAccessExpression, + isIdentifier, + TransformationContext, + SourceFile, + Visitor, + visitEachChild, + Transformer, + visitNode, + isSourceFile, + NodeArray, + Statement, + createNodeArray, +} from 'typescript'; +import TsProgram from '../ts-program'; + +function isJestMockCallExpression(node: Node): node is ExpressionStatement { + return ( + isExpressionStatement(node) && + isCallExpression(node.expression) && + isPropertyAccessExpression(node.expression.expression) && + isIdentifier(node.expression.expression.expression) && + node.expression.expression.expression.text === 'jest' && + isIdentifier(node.expression.expression.name) && + node.expression.expression.name.text === 'mock' + ); +} + +export default function(prog: TsProgram) { + function createVisitor(ctx: TransformationContext, sf: SourceFile) { + const hoisted: Statement[] = []; + + const visitor: Visitor = node => { + const resultNode = visitEachChild(node, visitor, ctx); + if (isSourceFile(resultNode)) { + resultNode.statements = createNodeArray([ + ...hoisted, + ...resultNode.statements, + ]); + } else if (isJestMockCallExpression(resultNode)) { + hoisted.push(resultNode); + return; + } + return resultNode; + }; + return visitor; + } + + return (ctx: TransformationContext): Transformer => { + return (sf: SourceFile) => visitNode(sf, createVisitor(ctx, sf)); + }; +} diff --git a/src/transformers/manager.ts b/src/transformers/manager.ts new file mode 100644 index 0000000000..e62383b35f --- /dev/null +++ b/src/transformers/manager.ts @@ -0,0 +1,71 @@ +import { SourceFile, Node } from 'typescript'; +import Replacement from './replacement'; + +// see tslint: https://github.com/palantir/tslint/blob/master/src/language/rule/rule.ts + +export default class TransformationManager { + protected _replacements: Replacement[]; + + constructor(protected _sourceFile: SourceFile) { + this._replacements = []; + } + + get sourceFile(): SourceFile { + return this._sourceFile; + } + + applyAndFlush(): SourceFile { + if (this._replacements.length === 0) { + return this._sourceFile; + } + + const replacements = this._replacements; + // sort + replacements.sort( + (a, b) => (b.end !== a.end ? b.end - a.end : b.start - a.start), + ); + // compute new text + const oldText = this.sourceFile.text; + const newText = replacements.reduce((text, r) => r.apply(text), oldText); + // apply + this._sourceFile = this._sourceFile.update(newText, { + newLength: newText.length, + span: { start: 0, length: oldText.length }, + }); + // flush + this._replacements = []; + + return this._sourceFile; + } + + replaceNode(node: Node, text: string) { + return this._push( + this._replaceFromTo(node.getStart(this.sourceFile), node.getEnd(), text), + ); + } + + replaceFromTo(start: number, end: number, text: string) { + return this._push(new Replacement(start, end - start, text)); + } + + deleteText(start: number, length: number) { + return this._push(new Replacement(start, length, '')); + } + + deleteFromTo(start: number, end: number) { + return this._push(new Replacement(start, end - start, '')); + } + + appendText(start: number, text: string) { + return this._push(new Replacement(start, 0, text)); + } + + protected _push(...replacements: Replacement[]): this { + this._replacements.push(...replacements); + return this; + } + + protected _replaceFromTo(start: number, end: number, text: string) { + return new Replacement(start, end - start, text); + } +} diff --git a/src/transformers/replacement.ts b/src/transformers/replacement.ts new file mode 100644 index 0000000000..c8331f887d --- /dev/null +++ b/src/transformers/replacement.ts @@ -0,0 +1,21 @@ +// see tslint: https://github.com/palantir/tslint/blob/master/src/language/rule/rule.ts + +export default class Replacement { + constructor( + readonly start: number, + readonly length: number, + readonly text: string, + ) {} + + get end() { + return this.start + this.length; + } + + apply(content: string) { + return ( + content.substring(0, this.start) + + this.text + + content.substring(this.start + this.length) + ); + } +} diff --git a/src/ts-program.incremental.ts b/src/ts-program.incremental.ts deleted file mode 100644 index b1f48828f6..0000000000 --- a/src/ts-program.incremental.ts +++ /dev/null @@ -1,321 +0,0 @@ -// tslint:disable:member-ordering -import { TsJestConfig, DiagnosticTypes } from './types'; -import { - FormatDiagnosticsHost, - sys, - findConfigFile, - CompilerOptions, - Diagnostic, - flattenDiagnosticMessageText, - readConfigFile, - parseJsonConfigFileContent, - ModuleKind, - CustomTransformers, - Program, - ParsedCommandLine, - ParseConfigHost, - createCompilerHost, - createProgram, - CompilerHost, -} from 'typescript'; -import { sep, resolve, dirname, basename } from 'path'; -import { existsSync, readFileSync } from 'fs'; -import Memoize from './memoize'; -import fileExtension from './utils/file-extension'; -// import { fixupCompilerOptions } from './utils/ts-internals'; - -export const compilerOptionsOverrides: Readonly = { - // ts-jest - module: ModuleKind.CommonJS, - esModuleInterop: true, - inlineSources: undefined, - sourceMap: false, - inlineSourceMap: true, - - // see https://github.com/Microsoft/TypeScript/blob/master/src/services/transpile.ts - isolatedModules: true, - // transpileModule does not write anything to disk so there is no need to verify that - // there are no conflicts between input and output paths. - suppressOutputPathCheck: true, - // Filename can be non-ts file. - allowNonTsExtensions: true, - // We are not returning a sourceFile for lib file when asked by the program, - // so pass --noLib to avoid reporting a file not found error. - noLib: true, - // Clear out other settings that would not be used in transpiling this module - // lib: undefined, - // types: undefined, - noEmit: undefined, - noEmitOnError: undefined, - // paths: undefined, - // rootDirs: undefined, - declaration: undefined, - declarationDir: undefined, - out: undefined, - outFile: undefined, - // We are not doing a full typecheck, we are not resolving the whole context, - // so pass --noResolve to avoid reporting missing file errors. - noResolve: true, -}; - -export default class TsProgram { - // a cache of all transpiled files - protected _inputSource = new Map(); - protected _transpiledSource = new Map(); - protected _transpiledMap = new Map(); - protected _transpiledDiagnostics = new Map(); - - constructor(readonly rootDir: string, readonly tsJestConfig: TsJestConfig) {} - - @Memoize() - get formatHost(): FormatDiagnosticsHost { - return { - getCanonicalFileName: path => path, - getCurrentDirectory: () => this.rootDir, - getNewLine: () => sys.newLine, - }; - } - - @Memoize() - get fileNameNormalizer() { - // const { rootDir } = this; - // return (path: string): string => resolve(rootDir, path); - return (path: string): string => path; - } - - @Memoize() - get compilerHost(): CompilerHost { - const { fileNameNormalizer, overriddenCompilerOptions } = this; - const options = { ...overriddenCompilerOptions }; - return { - ...createCompilerHost(options, true), - // overrides - // useCaseSensitiveFileNames: () => false, - // getCanonicalFileName: fileName => fileName, - writeFile: (name, text) => { - const key = fileNameNormalizer(name); - if (fileExtension(name) === 'map') { - this._transpiledMap.set(key, text); - } else { - this._transpiledSource.set(key, text); - } - }, - // getSourceFile: (fileName) => { - // const key = fileNameNormalizer(fileName); - // const content = this._inputSource.get(key); - // // if (content == null) { - // // throw new Error( - // // `[ts-jest] Trying to get a source file content outside of Jest (file: ${fileName}).`, - // // ); - // // } - // return createSourceFile(fileName, content || '', options.target!,); - // }, - fileExists: fileName => - this._inputSource.has(fileNameNormalizer(fileName)), - readFile: fileName => { - const content = this._inputSource.get(fileNameNormalizer(fileName)); - if (content == null) { - throw new Error( - `[ts-jest] Trying to get the content of a file outside of Jest (file: ${fileName}).`, - ); - } - return content; - }, - - // NOTE: below are the ones used in TypeScript's transpileModule() - // getDefaultLibFileName: () => 'lib.d.ts', - // getCurrentDirectory: () => this.rootDir, - // getNewLine: () => newLine, - // directoryExists: () => true, - // getDirectories: () => [], - }; - } - - @Memoize() - get configFile(): string | null { - const given = this.tsJestConfig.inputOptions.tsConfig; - let resolved: string | undefined; - if (typeof given === 'string') { - // we got a path to a custom (or not) tsconfig - resolved = given.replace('', `${this.rootDir}${sep}`); - resolved = resolve(this.rootDir, resolved); - if (!existsSync(resolved)) { - resolved = undefined; - } - } else if (typeof given === 'undefined') { - // we got undefined, go look for the default file - resolved = findConfigFile(this.rootDir, sys.fileExists, 'tsconfig.json'); - } else { - // what we got was compiler options - return null; - } - // could we find one? - if (!resolved) { - throw new Error( - `Could not find a TS config file (given: "${given}", root: "${ - this.rootDir - }")`, - ); - } - return resolved; - } - - @Memoize() - get program(): Program { - const { - parsedConfig: { fileNames }, - overriddenCompilerOptions: options, - } = this; - const compilerOptions = { ...options }; - - const host = this.compilerHost; - return createProgram(fileNames, compilerOptions, host); - } - - @Memoize() - get originalCompilerOptions() { - return { ...this.parsedConfig.options } as CompilerOptions; - } - - @Memoize() - get overriddenCompilerOptions() { - return { - ...this.originalCompilerOptions, - ...compilerOptionsOverrides, - } as CompilerOptions; - } - - @Memoize() - get parsedConfig(): ParsedCommandLine { - const { configFile } = this; - const { config, error } = configFile - ? readConfigFile(configFile, sys.readFile) - : { - config: { compilerOptions: this.tsJestConfig.inputOptions.tsConfig }, - error: undefined, - }; - if (error) throw error; // tslint:disable-line:curly - - const parseConfigHost: ParseConfigHost = { - fileExists: existsSync, - readDirectory: sys.readDirectory, - readFile: file => readFileSync(file, 'utf8'), - useCaseSensitiveFileNames: true, - }; - - const result = parseJsonConfigFileContent( - config, - parseConfigHost, - configFile ? dirname(configFile) : this.rootDir, - undefined, - configFile ? basename(configFile) : undefined, - ); - - // will throw if at least one error - this.reportDiagnostic(...result.errors); - - return result; - } - - // transpileModule( - // path: string, - // content: string, - // instrument: boolean = false, - // ): string { - // const options: TranspileOptions = { - // fileName: path, - // reportDiagnostics: false, // TODO: make this an option - // transformers: this.transformers, - // compilerOptions: { ...this.overriddenCompilerOptions }, - // }; - // const { diagnostics, outputText } = transpileModule(content, options); - // // TODO: handle diagnostics - // this.reportDiagnostic(...diagnostics); - - // // outputText will contain inline sourmaps - // return outputText; - // } - - transpileModule( - path: string, - content: string, - instrument: boolean = false, - ): string { - const { - program, - tsJestConfig: { diagnostics: diagnosticTypes }, - fileNameNormalizer, - } = this; - const diagnostics: Diagnostic[] = []; - - // register the source content - const fileKey = fileNameNormalizer(path); - this._inputSource.set(fileKey, content); - - // get the source file - const sourceFile = this.compilerHost.getSourceFile( - path, - this.overriddenCompilerOptions.target!, - ); - - // diagnostics - if (diagnosticTypes.includes(DiagnosticTypes.global)) { - diagnostics.push(...program.getGlobalDiagnostics()); - } - if (diagnosticTypes.includes(DiagnosticTypes.options)) { - diagnostics.push(...program.getOptionsDiagnostics()); - } - if (diagnosticTypes.includes(DiagnosticTypes.syntactic)) { - diagnostics.push(...program.getSyntacticDiagnostics(sourceFile)); - } - if (diagnosticTypes.includes(DiagnosticTypes.semantic)) { - diagnostics.push(...program.getSemanticDiagnostics(sourceFile)); - } - - // finally triger the compilation - program.emit( - /*targetSourceFile*/ sourceFile, - /*writeFile*/ undefined, - /*cancellationToken*/ undefined, - /*emitOnlyDtsFiles*/ undefined, - this.transformers, - ); - - // get the generated source - const transpiledSource = this._transpiledSource.get(fileKey); - if (transpiledSource == null) { - throw new Error(`[ts-jest] Output generation failed (file: ${path}).`); - } - - // source maps are inlined - return transpiledSource; - } - - get transformers(): CustomTransformers { - return { - // before: [() => this.beforeTransformer], - }; - } - - // @Memoize() - // get beforeTransformer(): Transformer { - // return (fileNode: SourceFile): SourceFile => { - // if (fileNode.isDeclarationFile) return fileNode; - // const nodeTransformer = (node: Node): Node => { - // return node; - // }; - // fileNode.getChildAt(0). - // }; - // } - - reportDiagnostic(...diagnostics: Diagnostic[]) { - const diagnostic = diagnostics[0]; - if (!diagnostic) return; // tslint:disable-line:curly - - const message = flattenDiagnosticMessageText( - diagnostic.messageText, - this.formatHost.getNewLine(), - ); - throw new Error(`${diagnostic.code}: ${message}`); - } -} diff --git a/src/ts-program.simple.ts b/src/ts-program.ts similarity index 85% rename from src/ts-program.simple.ts rename to src/ts-program.ts index a38c242173..ccb790eff2 100644 --- a/src/ts-program.simple.ts +++ b/src/ts-program.ts @@ -1,5 +1,5 @@ // tslint:disable:member-ordering -import { TsJestConfig, DiagnosticTypes } from './types'; +import { TsJestConfig } from './types'; import { FormatDiagnosticsHost, sys, @@ -10,21 +10,18 @@ import { readConfigFile, parseJsonConfigFileContent, ModuleKind, - CustomTransformers, - Program, ParsedCommandLine, ParseConfigHost, - createCompilerHost, - createProgram, - CompilerHost, TranspileOptions, transpileModule, + CustomTransformers, + TransformerFactory, + SourceFile, } from 'typescript'; import { sep, resolve, dirname, basename } from 'path'; import { existsSync, readFileSync } from 'fs'; import Memoize from './memoize'; -import fileExtension from './utils/file-extension'; -import { fixupCompilerOptions } from './utils/ts-internals'; +import hoisting from './transformers/hoisting'; export const compilerOptionsOverrides: Readonly = { // ts-jest @@ -128,16 +125,38 @@ export default class TsProgram { return result; } + @Memoize() + get transformers(): CustomTransformers { + // https://dev.doctorevidence.com/how-to-write-a-typescript-transform-plugin-fc5308fdd943 + const before: Array> = []; + const after: Array> = []; + + // FIXME: somehow babel doesn't do the hoisting + // no babel-jest, we need to handle the hoisting + // if (!this.tsJestConfig.useBabelJest) { + before.push(hoisting(this)); + // } + + return { + before, + after, + }; + } + transpileModule( path: string, content: string, instrument: boolean = false, + extraCompilerOptions?: CompilerOptions, ): string { const options: TranspileOptions = { fileName: path, reportDiagnostics: false, transformers: this.transformers, - compilerOptions: { ...this.overriddenCompilerOptions }, + compilerOptions: { + ...this.overriddenCompilerOptions, + ...extraCompilerOptions, + }, }; const { diagnostics, outputText } = transpileModule(content, options); @@ -147,23 +166,6 @@ export default class TsProgram { return outputText; } - get transformers(): CustomTransformers { - return { - // before: [() => this.beforeTransformer], - }; - } - - // @Memoize() - // get beforeTransformer(): Transformer { - // return (fileNode: SourceFile): SourceFile => { - // if (fileNode.isDeclarationFile) return fileNode; - // const nodeTransformer = (node: Node): Node => { - // return node; - // }; - // fileNode.getChildAt(0). - // }; - // } - reportDiagnostic(...diagnostics: Diagnostic[]) { const diagnostic = diagnostics[0]; if (!diagnostic) return; // tslint:disable-line:curly diff --git a/src/types.ts b/src/types.ts index a82566d471..c9af6f5123 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import * as _babelJest from 'babel-jest'; -import { CompilerOptions } from 'typescript'; +import { CompilerOptions, Node } from 'typescript'; +import TransformationManager from './transformers/manager'; export type TBabelJest = typeof _babelJest; diff --git a/src/utils/values.ts b/src/utils/values.ts deleted file mode 100644 index 8048ba319e..0000000000 --- a/src/utils/values.ts +++ /dev/null @@ -1,7 +0,0 @@ -// to avoid dependencies we have our own Object.values() -export default function values(obj: Record): T[] { - return Object.keys(obj).reduce( - (array, key) => [...array, obj[key]], - [] as T[], - ); -} diff --git a/test/__snapshots__/ts-program.spec.ts.snap b/test/__snapshots__/ts-program.spec.ts.snap new file mode 100644 index 0000000000..25a81b90c7 --- /dev/null +++ b/test/__snapshots__/ts-program.spec.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`hoisting should hoist jest.mock() 1`] = ` +"\\"use strict\\"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { \\"default\\": mod }; +}; +Object.defineProperty(exports, \\"__esModule\\", { value: true }); +jest.mock('./upper', function () { return function (s) { return s.toUpperCase(); }; }); +jest.mock('./lower', function () { return function (s) { return s.toLowerCase(); }; }); +var upper_1 = __importDefault(require(\\"./upper\\")); +var lower_1 = __importDefault(require(\\"./lower\\")); +describe('hello', function () { + test('my test', function () { + expect(upper_1.default('hello')).toBe('HELLO'); + expect(lower_1.default('HELLO')).toBe('hello'); + }); +}); +" +`; diff --git a/test/ts-program.spec.ts b/test/ts-program.spec.ts new file mode 100644 index 0000000000..ef4c7dce97 --- /dev/null +++ b/test/ts-program.spec.ts @@ -0,0 +1,33 @@ +import TsProgram from '../src/ts-program'; +import { resolve } from 'path'; + +const path = '/dummy/path/to/file.ts'; +const content = ` +import upper from './upper'; +import lower from './lower'; + +jest.mock('./upper', () => (s) => s.toUpperCase()); + +describe('hello', () => { + test('my test', () => { + expect(upper('hello')).toBe('HELLO'); + expect(lower('HELLO')).toBe('hello'); + jest.mock('./lower', () => (s) => s.toLowerCase()); + }); +}); +`; + +describe('hoisting', () => { + const prog = new TsProgram(resolve(__dirname, '..'), { + useBabelJest: false, + inputOptions: {}, + diagnostics: [], + }); + + it('should hoist jest.mock()', () => { + const result = prog.transpileModule(path, content, undefined, { + inlineSourceMap: false, + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index a1df697392..1954b7c79b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "sourceMap": false, + "sourceMap": true, + "inlineSourceMap": false, "removeComments": true, "outDir": "dist", "rootDir": "src",