diff --git a/package.json b/package.json index 1c9c816..55099c3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ ], "license": "MIT", "main": "dist/index.js", + "types": "dist", "publishConfig": { "registry": "http://registry.npmjs.org/" }, diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 963ebd7..0000000 --- a/src/index.js +++ /dev/null @@ -1,195 +0,0 @@ -/// - -const Vue = require('vue').default -const { stripIndent } = require('common-tags') - -// mountVue options -const defaultOptions = ['vue', 'extensions', 'style', 'stylesheets'] - -function checkMountModeEnabled() { - // @ts-ignore - if (Cypress.spec.specType !== 'component') { - throw new Error( - `In order to use mount or unmount functions please place the spec in component folder`, - ) - } -} - -const deleteConstructor = (comp) => delete comp._Ctor - -const deleteCachedConstructors = (component) => { - if (!component.components) { - return - } - Cypress._.values(component.components).forEach(deleteConstructor) -} - -const registerGlobalComponents = (Vue, options) => { - const globalComponents = Cypress._.get(options, 'extensions.components') - if (Cypress._.isPlainObject(globalComponents)) { - Cypress._.forEach(globalComponents, (component, id) => { - Vue.component(id, component) - }) - } -} - -const installFilters = (Vue, options) => { - const filters = Cypress._.get(options, 'extensions.filters') - if (Cypress._.isPlainObject(filters)) { - Object.keys(filters).forEach((name) => { - Vue.filter(name, filters[name]) - }) - } -} - -const installPlugins = (Vue, options) => { - const plugins = - Cypress._.get(options, 'extensions.use') || - Cypress._.get(options, 'extensions.plugins') - if (Cypress._.isArray(plugins)) { - plugins.forEach((plugin) => { - if (Array.isArray(plugin)) { - const [aPlugin, options] = plugin - Vue.use(aPlugin, options) - } else { - Vue.use(plugin) - } - }) - } -} - -const installMixins = (Vue, options) => { - const mixins = - Cypress._.get(options, 'extensions.mixin') || - Cypress._.get(options, 'extensions.mixins') - if (Cypress._.isArray(mixins)) { - mixins.forEach((mixin) => { - Vue.mixin(mixin) - }) - } -} - -const isConstructor = (object) => object && object._compiled - -const hasStore = ({ store }) => store && store._vm - -const forEachValue = (obj, fn) => - Object.keys(obj).forEach((key) => fn(obj[key], key)) - -const resetStoreVM = (Vue, { store }) => { - // bind store public getters - store.getters = {} - const wrappedGetters = store._wrappedGetters - const computed = {} - forEachValue(wrappedGetters, (fn, key) => { - // use computed to leverage its lazy-caching mechanism - computed[key] = () => fn(store) - Object.defineProperty(store.getters, key, { - get: () => store._vm[key], - enumerable: true, // for local getters - }) - }) - - store._watcherVM = new Vue() - store._vm = new Vue({ - data: { - $$state: store._vm._data.$$state, - }, - computed, - }) - return store -} - -const mountVue = (component, optionsOrProps = {}) => { - checkMountModeEnabled() - - const options = Cypress._.pick(optionsOrProps, defaultOptions) - const props = Cypress._.omit(optionsOrProps, defaultOptions) - - // display deprecation warnings - if (options.vue) { - console.warn(stripIndent` - [DEPRECATION]: 'vue' option has been deprecated. - 'node_modules/vue/dis/vue' is always used. - Please remove it from your 'mountVue' options.`) - } - - // set global Vue instance: - // 1. convenience for debugging in DevTools - // 2. some libraries might check for this global - // appIframe.contentWindow.Vue = Vue - - // refresh inner Vue instance of Vuex store - if (hasStore(component)) { - component.store = resetStoreVM(Vue, component) - } - - // render function components should be market to be properly initialized - // https://github.com/bahmutov/cypress-vue-unit-test/issues/313 - if ( - Cypress._.isPlainObject(component) && - Cypress._.isFunction(component.render) - ) { - component._compiled = true - } - - return cy.window({ log: false }).then((win) => { - win.Vue = Vue - - const document = cy.state('document') - let el = document.getElementById('cypress-jsdom') - - // If the target div doesn't exist, create it - if (!el) { - const div = document.createElement('div') - div.id = 'cypress-jsdom' - document.body.appendChild(div) - el = div - } - - if (typeof options.stylesheets === 'string') { - options.stylesheets = [options.stylesheets] - } - if (Array.isArray(options.stylesheets)) { - console.log('adding stylesheets') - options.stylesheets.forEach((href) => { - const link = document.createElement('link') - link.type = 'text/css' - link.rel = 'stylesheet' - link.href = href - el.append(link) - }) - } - - if (options.style) { - const style = document.createElement('style') - style.appendChild(document.createTextNode(options.style)) - el.append(style) - } - - const componentNode = document.createElement('div') - el.append(componentNode) - - // setup Vue instance - installFilters(Vue, options) - installMixins(Vue, options) - installPlugins(Vue, options) - registerGlobalComponents(Vue, options) - deleteCachedConstructors(component) - - // create root Vue component - // and make it accessible via Cypress.vue - if (isConstructor(component)) { - const Cmp = Vue.extend(component) - Cypress.vue = new Cmp(props).$mount(componentNode) - } else { - Cypress.vue = new Vue(component).$mount(componentNode) - } - }) -} - -// the double function allows mounting a component quickly -// beforeEach(mountVue(component, options)) -const mountCallback = (...args) => () => mountVue(...args) - -module.exports = { mount: mountVue, mountCallback } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7bdad12 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,418 @@ +/// + +import Vue from 'vue' +const { stripIndent } = require('common-tags') + +// mountVue options +const defaultOptions: (keyof MountOptions)[] = [ + 'vue', + 'extensions', + 'style', + 'stylesheets', +] + +function checkMountModeEnabled() { + // @ts-ignore + if (Cypress.spec.specType !== 'component') { + throw new Error( + `In order to use mount or unmount functions please place the spec in component folder`, + ) + } +} + +const deleteConstructor = (comp) => delete comp._Ctor + +const deleteCachedConstructors = (component) => { + if (!component.components) { + return + } + Cypress._.values(component.components).forEach(deleteConstructor) +} + +const registerGlobalComponents = (Vue, options) => { + const globalComponents = Cypress._.get(options, 'extensions.components') + if (Cypress._.isPlainObject(globalComponents)) { + Cypress._.forEach(globalComponents, (component, id) => { + Vue.component(id, component) + }) + } +} + +const installFilters = (Vue, options) => { + const filters: VueFilters | undefined = Cypress._.get( + options, + 'extensions.filters', + ) + if (Cypress._.isPlainObject(filters)) { + Object.keys(filters).forEach((name) => { + Vue.filter(name, filters[name]) + }) + } +} + +const installPlugins = (Vue, options) => { + const plugins: VuePlugins = + Cypress._.get(options, 'extensions.use') || + Cypress._.get(options, 'extensions.plugins') + + if (Cypress._.isArray(plugins)) { + plugins.forEach((plugin) => { + if (Array.isArray(plugin)) { + const [aPlugin, options] = plugin + Vue.use(aPlugin, options) + } else { + Vue.use(plugin) + } + }) + } +} + +const installMixins = (Vue, options) => { + const mixins = + Cypress._.get(options, 'extensions.mixin') || + Cypress._.get(options, 'extensions.mixins') + if (Cypress._.isArray(mixins)) { + mixins.forEach((mixin) => { + Vue.mixin(mixin) + }) + } +} + +const isConstructor = (object) => object && object._compiled + +// @ts-ignore +const hasStore = ({ store }: { store: object }) => store && store._vm + +const forEachValue = (obj: object, fn: Function) => + Object.keys(obj).forEach((key) => fn(obj[key], key)) + +const resetStoreVM = (Vue, { store }) => { + // bind store public getters + store.getters = {} + const wrappedGetters = store._wrappedGetters + const computed = {} + forEachValue(wrappedGetters, (fn, key) => { + // use computed to leverage its lazy-caching mechanism + computed[key] = () => fn(store) + Object.defineProperty(store.getters, key, { + get: () => store._vm[key], + enumerable: true, // for local getters + }) + }) + + store._watcherVM = new Vue() + store._vm = new Vue({ + data: { + $$state: store._vm._data.$$state, + }, + computed, + }) + return store +} + +/** + * Type for component passed to "mount" + * + * @interface VueComponent + * @example + * import Hello from './Hello.vue' + * ^^^^^ this type + * mount(Hello) + */ +type VueComponent = Vue.ComponentOptions + +/** + * Options to pass to the component when creating it, like + * props. + * + * @interface ComponentOptions + */ +interface ComponentOptions {} + +// local placeholder types +type VueLocalComponents = object + +type VueFilters = { + [key: string]: Function +} + +type VueMixin = unknown +type VueMixins = VueMixin | VueMixin[] + +/** + * A Vue plugin to register, + * or a plugin + its options pair inside an array + */ +type VuePlugin = unknown | [unknown, unknown] +/** + * A single Vue plugin or a list of plugins to register + */ +type VuePlugins = VuePlugin | VuePlugin[] + +/** + * Additional Vue services to register while mounting the component, like + * local components, plugins, etc. + * + * @interface MountOptionsExtensions + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + */ +interface MountOptionsExtensions { + /** + * Extra local components + * + * @memberof MountOptionsExtensions + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + * @example + * import Hello from './Hello.vue' + * // imagine Hello needs AppComponent + * // that it uses in its template like + * // during testing we can replace it with a mock component + * const appComponent = ... + * const components = { + * 'app-component': appComponent + * }, + * mount(Hello, { extensions: { components }}) + */ + components?: VueLocalComponents + + /** + * Optional Vue filters to install while mounting the component + * + * @memberof MountOptionsExtensions + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + * @example + * const filters = { + * reverse: (s) => s.split('').reverse().join(''), + * } + * mount(Hello, { extensions: { filters }}) + */ + filters?: VueFilters + + /** + * Optional Vue mixin(s) to install when mounting the component + * + * @memberof MountOptionsExtensions + * @alias mixins + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + */ + mixin?: VueMixins + + /** + * Optional Vue mixin(s) to install when mounting the component + * + * @memberof MountOptionsExtensions + * @alias mixin + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + */ + mixins?: VueMixins + + /** + * A single plugin or multiple plugins. + * + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + * @alias plugins + * @memberof MountOptionsExtensions + */ + use?: VuePlugins + + /** + * A single plugin or multiple plugins. + * + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + * @alias use + * @memberof MountOptionsExtensions + */ + plugins?: VuePlugins +} + +/** + * Options controlling how the component is going to be mounted, + * including global Vue plugins and extensions. + * + * @interface MountOptions + */ +interface MountOptions { + /** + * Vue instance to use. + * + * @deprecated + * @memberof MountOptions + */ + vue: unknown + + /** + * CSS style string to inject when mounting the component + * + * @memberof MountOptions + * @example + * const style = ` + * .todo.done { + * text-decoration: line-through; + * color: gray; + * }` + * mount(Todo, { style }) + */ + style: string + + /** + * Stylesheet(s) urls to inject as `` elements when + * mounting the component + * + * @memberof MountOptions + * @example + * const template = '...' + * const stylesheets = '/node_modules/tailwindcss/dist/tailwind.min.css' + * mount({ template }, { stylesheets }) + * + * @example + * const template = '...' + * const stylesheets = ['https://cdn.../lib.css', 'https://lib2.css'] + * mount({ template }, { stylesheets }) + */ + stylesheets: string | string[] + + /** + * Extra Vue plugins, mixins, local components to register while + * mounting this component + * + * @memberof MountOptions + * @see https://github.com/bahmutov/cypress-vue-unit-test#examples + */ + extensions: MountOptionsExtensions +} + +/** + * Utility type for union of options passed to "mount(..., options)" + */ +type MountOptionsArgument = Partial + +/** + * Mounts a Vue component inside Cypress browser. + * @param {object} component imported from Vue file + * @example + * import Greeting from './Greeting.vue' + * import { mount } from 'cypress-vue-unit-test' + * it('works', () => { + * // pass props, additional extensions, etc + * mount(Greeting, { ... }) + * // use any Cypress command to test the component + * cy.get('#greeting').should('be.visible') + * }) + */ +export const mount = ( + component: VueComponent, + optionsOrProps: MountOptionsArgument = {}, +) => { + checkMountModeEnabled() + + const options: Partial = Cypress._.pick( + optionsOrProps, + defaultOptions, + ) + const props: Partial = Cypress._.omit( + optionsOrProps, + defaultOptions, + ) + + // display deprecation warnings + if (options.vue) { + console.warn(stripIndent` + [DEPRECATION]: 'vue' option has been deprecated. + 'node_modules/vue/dis/vue' is always used. + Please remove it from your 'mountVue' options.`) + } + + // set global Vue instance: + // 1. convenience for debugging in DevTools + // 2. some libraries might check for this global + // appIframe.contentWindow.Vue = Vue + + // refresh inner Vue instance of Vuex store + // @ts-ignore + if (hasStore(component)) { + // @ts-ignore + component.store = resetStoreVM(Vue, component) + } + + // render function components should be market to be properly initialized + // https://github.com/bahmutov/cypress-vue-unit-test/issues/313 + if ( + Cypress._.isPlainObject(component) && + Cypress._.isFunction(component.render) + ) { + // @ts-ignore + component._compiled = true + } + + return cy + .window({ + log: false, + }) + .then((win) => { + // @ts-ignore + win.Vue = Vue + + // @ts-ignore + const document: Document = cy.state('document') + let el = document.getElementById('cypress-jsdom') + + // If the target div doesn't exist, create it + if (!el) { + const div = document.createElement('div') + div.id = 'cypress-jsdom' + document.body.appendChild(div) + el = div + } + + if (typeof options.stylesheets === 'string') { + options.stylesheets = [options.stylesheets] + } + if (Array.isArray(options.stylesheets)) { + options.stylesheets.forEach((href) => { + const link = document.createElement('link') + link.type = 'text/css' + link.rel = 'stylesheet' + link.href = href + el.append(link) + }) + } + + if (options.style) { + const style = document.createElement('style') + style.appendChild(document.createTextNode(options.style)) + el.append(style) + } + + const componentNode = document.createElement('div') + el.append(componentNode) + + // setup Vue instance + installFilters(Vue, options) + installMixins(Vue, options) + installPlugins(Vue, options) + registerGlobalComponents(Vue, options) + deleteCachedConstructors(component) + + // create root Vue component + // and make it accessible via Cypress.vue + if (isConstructor(component)) { + const Cmp = Vue.extend(component) + // @ts-ignore + Cypress.vue = new Cmp(props).$mount(componentNode) + } else { + // @ts-ignore + Cypress.vue = new Vue(component).$mount(componentNode) + } + }) +} + +/** + * Helper function for mounting a component quickly in test hooks. + * @example + * import {mountCallback} from 'cypress-vue-unit-test' + * beforeEach(mountVue(component, options)) + */ +export const mountCallback = ( + component: VueComponent, + options?: MountOptionsArgument, +) => () => mount(component, options) diff --git a/src/preprocessor/webpack.js b/src/preprocessor/webpack.js index 04e986e..4f09c16 100644 --- a/src/preprocessor/webpack.js +++ b/src/preprocessor/webpack.js @@ -1,5 +1,5 @@ -const webpack = require('webpack') -const util = require('util') +import webpack from 'webpack' +import util from 'util' // Cypress webpack bundler adaptor // https://github.com/cypress-io/cypress-webpack-preprocessor @@ -54,7 +54,7 @@ function compileTemplate(options = {}) { /** * Warning: modifies the input object * @param {Cypress.ConfigOptions} config - * @param {import('webpack/lib/Compiler').WebpackOptions} options + * @param {WebpackOptions} options */ function insertBabelLoader(config, options) { const skipCodeCoverage = config && config.env && config.env.coverage === false diff --git a/tsconfig.json b/tsconfig.json index 19d647e..edf0d1e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "allowJs": true /* Allow javascript files to be compiled. */, // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ - // "declaration": true, /* Generates corresponding '.d.ts' file. */ + "declaration": true /* Generates corresponding '.d.ts' file. */, // "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "dist" /* Redirect output structure to the directory. */, @@ -23,7 +23,8 @@ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ /* Strict Type-Checking Options */ - "strict": true /* Enable all strict type-checking options. */, + "strict": false /* Enable all strict type-checking options. */, + "noImplicitAny": false, /* Module Resolution Options */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */