From 344a0575d5cdf84b5158d96db4f707018e3bc939 Mon Sep 17 00:00:00 2001 From: Kukhyeon Heo Date: Sat, 4 Apr 2020 03:53:20 +0900 Subject: [PATCH] feat: Add out-of-the-tbox typescript support (#38) * Add doc. * Migrated typescript code from cypress Move through2 to dependencies. Fix * Add typescript. * Add e2e tests for typescript. * Test .tsx file. * Reason why simple_tsify is necessary. * Test typescript options + remove babelify even if it's not the last item * Test tsx file with enzyme. * Fix test failure. * remove enzyme * use arrow function syntax * move files into lib directory * remove duplicate file * remove need to extra test files * update error message * move e2e tests to unit tests * properly nest e2e tests * make it clear where error is coming from Co-authored-by: Chris Breiding --- README.md | 10 +++ index.js | 44 ++++++++++- fs.js => lib/fs.js | 0 lib/simple_tsify.js | 43 ++++++++++ package-lock.json | 56 +++++++++++++ package.json | 8 +- test/e2e/e2e_spec.js | 101 +++++++++++++++--------- test/e2e/output.js | 0 test/fixtures/typescript/component.tsx | 7 ++ test/fixtures/typescript/math.ts | 5 ++ test/fixtures/typescript/math_spec.ts | 18 +++++ test/fixtures/typescript/react_spec.tsx | 14 ++++ test/unit/index_spec.js | 95 +++++++++++++++++++++- 13 files changed, 353 insertions(+), 48 deletions(-) rename fs.js => lib/fs.js (100%) create mode 100644 lib/simple_tsify.js delete mode 100644 test/e2e/output.js create mode 100644 test/fixtures/typescript/component.tsx create mode 100644 test/fixtures/typescript/math.ts create mode 100644 test/fixtures/typescript/math_spec.ts create mode 100644 test/fixtures/typescript/react_spec.tsx diff --git a/README.md b/README.md index 19600d5..69dc496 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,16 @@ browserify({ }) ``` +### typescript + +When the path to the TypeScript package is given, Cypress will automatically transpile `.ts` spec, plugin, support files. Note that this **DOES NOT** check types. + +```javascript +browserify({ + typescript: require.resolve('typescript') +}) +``` + **Default**: `undefined` ## Modifying default options diff --git a/index.js b/index.js index 969ab41..ab2f349 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ const path = require('path') const Promise = require('bluebird') -const fs = require('./fs') +const fs = require('./lib/fs') const cloneDeep = require('lodash.clonedeep') const browserify = require('browserify') @@ -60,7 +60,7 @@ const defaultOptions = { }, } -const getBrowserifyOptions = (entry, userBrowserifyOptions = {}) => { +const getBrowserifyOptions = (entry, userBrowserifyOptions = {}, typescriptPath = null) => { let browserifyOptions = cloneDeep(defaultOptions.browserifyOptions) // allow user to override default options @@ -81,6 +81,36 @@ const getBrowserifyOptions = (entry, userBrowserifyOptions = {}) => { entries: [entry], }) + if (typescriptPath) { + const transform = browserifyOptions.transform + const hasTsifyTransform = transform.some(([name]) => name.includes('tsify')) + const hastsifyPlugin = browserifyOptions.plugin.includes('tsify') + + if (hasTsifyTransform || hastsifyPlugin) { + const type = hasTsifyTransform ? 'transform' : 'plugin' + + throw new Error(`Error running @cypress/browserify-preprocessor: + +It looks like you passed the 'typescript' option and also specified a browserify ${type} for TypeScript. This may cause conflicts. + +Please do one of the following: + +1) Pass in the 'typescript' option and omit the browserify ${type} (Recommmended) +2) Omit the 'typescript' option and continue to use your own browserify ${type} +`) + } + + browserifyOptions.extensions.push('.ts', '.tsx') + // remove babelify setting + browserifyOptions.transform = transform.filter(([name]) => !name.includes('babelify')) + // add typescript compiler + browserifyOptions.transform.push([ + path.join(__dirname, './lib/simple_tsify'), { + typescript: require(typescriptPath), + }, + ]) + } + debug('browserifyOptions: %o', browserifyOptions) return browserifyOptions @@ -127,7 +157,7 @@ const preprocessor = (options = {}) => { debug('input:', filePath) debug('output:', outputPath) - const browserifyOptions = getBrowserifyOptions(filePath, options.browserifyOptions) + const browserifyOptions = getBrowserifyOptions(filePath, options.browserifyOptions, options.typescript) const watchifyOptions = Object.assign({}, defaultOptions.watchifyOptions, options.watchifyOptions) const bundler = browserify(browserifyOptions) @@ -222,4 +252,12 @@ const preprocessor = (options = {}) => { // provide a clone of the default options preprocessor.defaultOptions = JSON.parse(JSON.stringify(defaultOptions)) +if (process.env.__TESTING__) { + preprocessor.reset = () => { + for (let filePath in bundles) { + delete bundles[filePath] + } + } +} + module.exports = preprocessor diff --git a/fs.js b/lib/fs.js similarity index 100% rename from fs.js rename to lib/fs.js diff --git a/lib/simple_tsify.js b/lib/simple_tsify.js new file mode 100644 index 0000000..33d7054 --- /dev/null +++ b/lib/simple_tsify.js @@ -0,0 +1,43 @@ +let through = require('through2') + +const isJson = (code) => { + try { + JSON.parse(code) + } catch (e) { + return false + } + + return true +} + +// tsify doesn't have transpile-only option like ts-node or ts-loader. +// It means it should check types whenever spec file is changed +// and it slows down the test speed a lot. +// We skip this slow type-checking process by using transpileModule() api. +module.exports = function (b, opts) { + const chunks = [] + + return through( + (buf, enc, next) => { + chunks.push(buf.toString()) + next() + }, + function (next) { + const ts = opts.typescript + const text = chunks.join('') + + if (isJson(text)) { + this.push(text) + } else { + this.push(ts.transpileModule(text, { + compilerOptions: { + esModuleInterop: true, + jsx: 'react', + }, + }).outputText) + } + + next() + }, + ) +} diff --git a/package-lock.json b/package-lock.json index 0d1939a..1dd5b47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11202,6 +11202,17 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -11312,6 +11323,35 @@ } } }, + "react": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react/-/react-16.13.1.tgz", + "integrity": "sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "react-dom": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.13.1.tgz", + "integrity": "sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.19.1" + } + }, + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + }, "read-installed": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/read-installed/-/read-installed-4.0.3.tgz", @@ -11692,6 +11732,16 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "scheduler": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", + "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "semantic-release": { "version": "15.13.15", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-15.13.15.tgz", @@ -12910,6 +12960,12 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" }, + "typescript": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz", + "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==", + "dev": true + }, "uglify-js": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz", diff --git a/package.json b/package.json index 0d52f51..5f948f6 100644 --- a/package.json +++ b/package.json @@ -52,10 +52,13 @@ "mocha": "5.2.0", "mockery": "2.1.0", "nsp": "3.2.1", + "react": "16.13.1", + "react-dom": "16.13.1", "semantic-release": "15.13.15", "sinon": "7.2.3", "sinon-chai": "3.3.0", - "snap-shot-it": "7.9.2" + "snap-shot-it": "7.9.2", + "typescript": "3.8.3" }, "dependencies": { "@babel/core": "7.4.5", @@ -74,7 +77,8 @@ "debug": "4.1.1", "fs-extra": "7.0.1", "lodash.clonedeep": "4.5.0", - "watchify": "3.11.1" + "watchify": "3.11.1", + "through2": "^2.0.0" }, "release": { "analyzeCommits": { diff --git a/test/e2e/e2e_spec.js b/test/e2e/e2e_spec.js index 972687b..2145be3 100644 --- a/test/e2e/e2e_spec.js +++ b/test/e2e/e2e_spec.js @@ -2,14 +2,17 @@ const chai = require('chai') const path = require('path') const snapshot = require('snap-shot-it') -const fs = require('../../fs') +process.env.__TESTING__ = true + +const fs = require('../../lib/fs') const preprocessor = require('../../index') /* eslint-disable-next-line no-unused-vars */ const expect = chai.expect -beforeEach(function () { +beforeEach(() => { fs.removeSync(path.join(__dirname, '_test-output')) + preprocessor.reset() }) // do not generate source maps by default @@ -25,60 +28,80 @@ const bundle = (fixtureName, options = DEFAULT_OPTIONS) => { }) } -describe('browserify preprocessor - e2e', function () { - it('correctly preprocesses the file', function () { +describe('browserify preprocessor - e2e', () => { + it('correctly preprocesses the file', () => { return bundle('example_spec.js').then((output) => { snapshot(output) }) }) -}) -describe('imports and exports', () => { - it('handles imports and exports', () => { - return bundle('math_spec.js').then((output) => { - // check that bundled tests work - eval(output) + describe('imports and exports', () => { + it('handles imports and exports', () => { + return bundle('math_spec.js').then((output) => { + // check that bundled tests work + eval(output) + }) }) - }) - it('named ES6', () => { - return bundle('divide_spec.js').then((output) => { - // check that bundled tests work - eval(output) + it('named ES6', () => { + return bundle('divide_spec.js').then((output) => { + // check that bundled tests work + eval(output) + }) }) - }) - it('handles module.exports and import', () => { - return bundle('sub_spec.js').then((output) => { - // check that bundled tests work - eval(output) - snapshot('sub import', output) + it('handles module.exports and import', () => { + return bundle('sub_spec.js').then((output) => { + // check that bundled tests work + eval(output) + snapshot('sub import', output) + }) }) - }) - it('handles module.exports and default import', () => { - return bundle('mul_spec.js').then((output) => { - // check that bundled tests work - eval(output) - // for some reason, this bundle included full resolved path - // to interop require module - // which on CI generates different path. - // so as long as eval works, do not snapshot it + it('handles module.exports and default import', () => { + return bundle('mul_spec.js').then((output) => { + // check that bundled tests work + eval(output) + // for some reason, this bundle included full resolved path + // to interop require module + // which on CI generates different path. + // so as long as eval works, do not snapshot it + }) + }) + + it('handles default string import', () => { + return bundle('dom_spec.js').then((output) => { + // check that bundled tests work + eval(output) + }) }) - }) - it('handles default string import', () => { - return bundle('dom_spec.js').then((output) => { - // check that bundled tests work - eval(output) + it('handles non-top-level require', () => { + return bundle('require_spec.js').then((output) => { + // check that bundled tests work + eval(output) + }) }) }) - it('handles non-top-level require', () => { - return bundle('require_spec.js').then((output) => { - // check that bundled tests work - eval(output) + describe('typescript', () => { + it('handles .ts file when the path is given', () => { + return bundle('typescript/math_spec.ts', { + typescript: require.resolve('typescript'), + }).then((output) => { + // check that bundled tests work + eval(output) + }) + }) + + it('handles .tsx file when the path is given', () => { + return bundle('typescript/react_spec.tsx', { + typescript: require.resolve('typescript'), + }).then((output) => { + // check that bundled tests work + eval(output) + }) }) }) }) diff --git a/test/e2e/output.js b/test/e2e/output.js deleted file mode 100644 index e69de29..0000000 diff --git a/test/fixtures/typescript/component.tsx b/test/fixtures/typescript/component.tsx new file mode 100644 index 0000000..b36133b --- /dev/null +++ b/test/fixtures/typescript/component.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default () => { + return ( +
icon
+ ) +} \ No newline at end of file diff --git a/test/fixtures/typescript/math.ts b/test/fixtures/typescript/math.ts new file mode 100644 index 0000000..0568956 --- /dev/null +++ b/test/fixtures/typescript/math.ts @@ -0,0 +1,5 @@ +export default { + add: (a: number, b: number) => { + return a + b + }, +} diff --git a/test/fixtures/typescript/math_spec.ts b/test/fixtures/typescript/math_spec.ts new file mode 100644 index 0000000..54dd135 --- /dev/null +++ b/test/fixtures/typescript/math_spec.ts @@ -0,0 +1,18 @@ +// math exports default object +// so if we want a property, first we need to grab the default +import math from './math' +const { add } = math + +const x: number = 3 + +context('math.ts', function () { + it('imports function', () => { + expect(add, 'add').to.be.a('function') + }) + it('can add numbers', function () { + expect(add(1, 2)).to.eq(3) + }) + it('test ts-typed variable', function () { + expect(x).to.eq(3) + }) +}) diff --git a/test/fixtures/typescript/react_spec.tsx b/test/fixtures/typescript/react_spec.tsx new file mode 100644 index 0000000..c976152 --- /dev/null +++ b/test/fixtures/typescript/react_spec.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { expect } from 'chai' + +import MyComponent from './component' + +describe('', () => { + it('renders an `.icon-star`', () => { + const component = + + expect(component.type().type).to.equal('div') + expect(component.type().props.className).to.equal('icon-star') + expect(component.type().props.children).to.equal('icon') + }) +}) diff --git a/test/unit/index_spec.js b/test/unit/index_spec.js index 6c23487..f3f07cb 100644 --- a/test/unit/index_spec.js +++ b/test/unit/index_spec.js @@ -23,7 +23,9 @@ const streamApi = { streamApi.on = sandbox.stub().returns(streamApi) -const fs = require('../../fs') +process.env.__TESTING__ = true + +const fs = require('../../lib/fs') const preprocessor = require('../../index') describe('browserify preprocessor', function () { @@ -46,7 +48,6 @@ describe('browserify preprocessor', function () { on: sandbox.stub(), } sandbox.stub(fs, 'createWriteStream').returns(this.createWriteStreamApi) - sandbox.stub(fs, 'ensureDirAsync').resolves() this.options = {} @@ -75,8 +76,8 @@ describe('browserify preprocessor', function () { }) describe('preprocessor function', function () { - afterEach(function () { - this.file.on.withArgs('close').yield() // resets the cached bundles + beforeEach(function () { + preprocessor.reset() }) describe('when it finishes cleanly', function () { @@ -351,5 +352,91 @@ describe('browserify preprocessor', function () { }) }) }) + + describe('typescript support', function () { + beforeEach(function () { + this.options.typescript = require.resolve('typescript') + }) + + it('adds tsify transform', function () { + this.createWriteStreamApi.on.withArgs('finish').yields() + + return this.run().then(() => { + expect(browserify.lastCall.args[0].transform[1][0]).to.include('simple_tsify') + }) + }) + + it('adds to extensions', function () { + this.createWriteStreamApi.on.withArgs('finish').yields() + + return this.run().then(() => { + expect(browserify.lastCall.args[0].extensions).to.eql(['.js', '.jsx', '.coffee', '.ts', '.tsx']) + }) + }) + + it('removes babelify transform', function () { + this.createWriteStreamApi.on.withArgs('finish').yields() + + return this.run().then(() => { + const transforms = browserify.lastCall.args[0].transform + + expect(transforms).to.have.length(2) + expect(transforms[1][0]).not.to.include('babelify') + }) + }) + + it('does not change browserify options without typescript option', function () { + this.createWriteStreamApi.on.withArgs('finish').yields() + + this.options.typescript = undefined + + return this.run().then(() => { + expect(browserify.lastCall.args[0].transform[1][0]).to.include('babelify') + expect(browserify.lastCall.args[0].transform[1][0]).not.to.include('simple_tsify') + expect(browserify.lastCall.args[0].extensions).to.eql(['.js', '.jsx', '.coffee']) + }) + }) + + it('removes babelify transform even if it is not the last item', function () { + this.createWriteStreamApi.on.withArgs('finish').yields() + + const { browserifyOptions } = preprocessor.defaultOptions + + this.options.browserifyOptions = { + ...browserifyOptions, + transform: [ + browserifyOptions.transform[1], + browserifyOptions.transform[0], + ], + } + + return this.run().then(() => { + const transforms = browserify.lastCall.args[0].transform + + expect(transforms).to.have.length(2) + expect(transforms[1][0]).not.to.include('babelify') + }) + }) + + describe('when typescript path and tsify are given together', function () { + it('throws error when it is a plugin', function () { + this.options.browserifyOptions = { + plugin: ['tsify'], + } + + expect(this.run).to.throw('This may cause conflicts') + }) + + it('throws error when it is a transform', function () { + this.options.browserifyOptions = { + transform: [ + ['path/to/tsify', {}], + ], + } + + expect(this.run).to.throw('This may cause conflicts') + }) + }) + }) }) })