From 6e92cf3994e8b360e80cd4f3bf248588fbaf1ece Mon Sep 17 00:00:00 2001 From: Gleb Bahmutov Date: Wed, 31 Jan 2018 09:09:53 -0500 Subject: [PATCH] feat: Vuex support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * working on Vuex example for bahmutov/cypress-vue-unit-test#6 * fix: Vuex integration and add Counter example tests (bahmutov/cypress-vue-unit-test#13) * fix: vuex integration (bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6) The inner Vue instance within Vuex Store must be refeshed by `resetStoreVM` to restore reactivity of the store state. This doesn’t fix stale mapped getters within components. That’s a separate WIP issue. * fix: stale mapped getter `evenOrOdd` (bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6) Current mapped vuex getters within components become stale. To have a working Counter example, the computed mapped `evenOrOdd` is accessed directly via `$store`. This will be changed back once the stale mapped getter issue (WIP) is fixed. * feat: added example tests for Counter component Added tests to cover all the features within the component. Fixes bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6 * minor: following better practices * Full Vuex support with enhanced Counter.vue example and test spec (bahmutov/cypress-vue-unit-test#15) * fix: vuex integration (bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6) The inner Vue instance within Vuex Store must be refeshed by `resetStoreVM` to restore reactivity of the store state. This doesn’t fix stale mapped getters within components. That’s a separate WIP issue. * fix: stale mapped getter `evenOrOdd` (bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6) Current mapped vuex getters within components become stale. To have a working Counter example, the computed mapped `evenOrOdd` is accessed directly via `$store`. This will be changed back once the stale mapped getter issue (WIP) is fixed. * feat: added example tests for Counter component Added tests to cover all the features within the component. Fixes bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6 * minor: following better practices * fix: refactor Vue initialization in app frame Refactors how the Vue instance within the component app frame is initialized, and fully fixes Vue integration. - fixes lingering issues after pull request bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#13 - fully fixes bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6 - fixes amirrustam/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#1 - README docs have been updated to reflect refactoring - 'vue' option has been deprecated (with warning to user) as it's no longer necessary - spread operator support has been added for upcoming Vuex example * feat: enhanced Vuex Counter example and test spec To thoroughly demonstrate Vuex support after the refactoring in commit amirrustam/cypress-vue-unit-test@e8773383, Counter.vue now: - Utilizes all Vuex mapping functions. - Can properly use computed mapped getters in its template. This was an issue after bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#13. - Set count state via an input field to demonstrate mapped mutations. Two tests have been added to the spec for this new input field. Close bahmutov/cypress-vue-unit-testbahmutov/cypress-vue-unit-test#6 * minor: remove redundant declaration --- README.md | 23 +++- components/.babelrc | 5 + components/Counter.vue | 31 +++++ components/store.js | 60 +++++++++ cypress/integration/counter-vuex-spec.js | 70 ++++++++++ package.json | 6 +- src/index.js | 164 +++++++++++++---------- 7 files changed, 281 insertions(+), 78 deletions(-) create mode 100644 components/.babelrc create mode 100644 components/Counter.vue create mode 100644 components/store.js create mode 100644 cypress/integration/counter-vuex-spec.js diff --git a/README.md b/README.md index e2421b78acd4..8a35c40c56ff 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,11 @@ See examples below for details. See [cypress/integration/options-spec.js](cypress/integration/options-spec.js) for examples of options. -* `vue` - path or URL to the Vue library to load. By default, will -try to load `../node_modules/vue/dist/vue.js`, but you can pass your -own path or URL. +* `mountId` - specify root Vue app mount element ID. Defaults to `app`. ```js const options = { - vue: 'https://unpkg.com/vue' + mountId: 'rootApp' // div#rootApp } beforeEach(mountVue(/* my Vue code */, options)) ``` @@ -103,12 +101,25 @@ beforeEach(mountVue(/* my Vue code */, options)) place to load additional libraries, polyfills and styles. ```js -const vue = '../node_modules/vue/dist/vue.js' +const polyfill = '../node_modules/mypolyfill/dist/polyfill.js' const options = { - html: `
` + html: `
` +} +beforeEach(mountVue(/* my Vue code */, options)) +``` + +* `vue` **[DEPRECATED]** - path or URL to the Vue library to load. By default, will +try to load `../node_modules/vue/dist/vue.js`, but you can pass your +own path or URL. + +```js +const options = { + vue: 'https://unpkg.com/vue' } beforeEach(mountVue(/* my Vue code */, options)) ``` +> #### Deprecation Warning +> `vue` option has been deprecated. `node_modules/vue/dist/vue` is always used. diff --git a/components/.babelrc b/components/.babelrc new file mode 100644 index 000000000000..02e7f242c05b --- /dev/null +++ b/components/.babelrc @@ -0,0 +1,5 @@ +{ + "plugins": [ + "transform-object-rest-spread" + ] +} \ No newline at end of file diff --git a/components/Counter.vue b/components/Counter.vue new file mode 100644 index 000000000000..c8b6ae244358 --- /dev/null +++ b/components/Counter.vue @@ -0,0 +1,31 @@ + + + diff --git a/components/store.js b/components/store.js new file mode 100644 index 000000000000..468106b5b82f --- /dev/null +++ b/components/store.js @@ -0,0 +1,60 @@ +import Vue from 'vue' +import Vuex from 'vuex' +Vue.use(Vuex) + +// root state object. +// each Vuex instance is just a single state tree. +const state = { + count: 0 +} + +// mutations are operations that actually mutates the state. +// each mutation handler gets the entire state tree as the +// first argument, followed by additional payload arguments. +// mutations must be synchronous and can be recorded by plugins +// for debugging purposes. +const mutations = { + set (state, value) { + state.count = value + }, + increment (state) { + state.count++ + }, + decrement (state) { + state.count-- + } +} + +// actions are functions that cause side effects and can involve +// asynchronous operations. +const actions = { + increment: ({ commit }) => commit('increment'), + decrement: ({ commit }) => commit('decrement'), + incrementIfOdd ({ commit, state }) { + if ((state.count + 1) % 2 === 0) { + commit('increment') + } + }, + incrementAsync ({ commit }) { + return new Promise((resolve, reject) => { + setTimeout(() => { + commit('increment') + resolve() + }, 1000) + }) + } +} + +// getters are functions +const getters = { + evenOrOdd: state => state.count % 2 === 0 ? 'even' : 'odd' +} + +// A Vuex instance is created by combining the state, mutations, actions, +// and getters. +export default new Vuex.Store({ + state, + getters, + actions, + mutations +}) diff --git a/cypress/integration/counter-vuex-spec.js b/cypress/integration/counter-vuex-spec.js new file mode 100644 index 000000000000..76926c14e6ca --- /dev/null +++ b/cypress/integration/counter-vuex-spec.js @@ -0,0 +1,70 @@ +// testing Vuex component +// https://github.com/vuejs/vuex/tree/dev/examples/counter +import Counter from '../../components/Counter.vue' +import store from '../../components/store' +import Vuex from 'vuex' +import mountVue from '../..' + +/* eslint-env mocha */ +describe('Vuex Counter', () => { + + // configure component + const extensions = { + plugins: [Vuex], + components: { + Counter + } + } + + // define component template + const template = '' + + // define count get and set helpers + const getCount = () => Cypress.vue.$store.state.count + const setCount = value => Cypress.vue.$store.commit('set', value) + + // initialize a fresh Vue app before each test + beforeEach(mountVue({template, store}, {extensions})) + + it('starts with zero', () => { + cy.contains('0 times') + }) + + it('increments the counter on click of "+"', () => { + cy.contains('button', '+').click() + cy.contains('1 times') + }) + + it('decrements the counter on click of "-"', () => { + cy.contains('button', '-').click() + cy.contains('0 times') + }) + + it('increments the counter if count is odd', () => { + setCount(3) // start with an odd number + cy.contains('odd') + cy.contains('button', 'Increment if odd').as('btn').click() + cy.contains('even') + cy.get('@btn').click() + cy.contains('even') + }) + + it('asynchronously increments counter', () => { + const count = getCount() + // increment mutation is delayed by 1 second + // Cypress waits 4 seconds by default + cy.contains('button', 'Increment async').click() + cy.contains(`${count + 1} times`) + }) + + it('count is zero when input is cleared', () => { + cy.get('input').type(`{selectall}{backspace}`) + cy.contains('0 times') + }), + + it('set count via input field', () => { + const count = 42 + cy.get('input').type(`{selectall}{backspace}${count}`) + cy.contains(`${count} times`) + }) +}) diff --git a/package.json b/package.json index 719a654b9909..d8bd7f565969 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "generateNotes": "github-post-release" }, "devDependencies": { + "babel-plugin-transform-object-rest-spread": "6.26.0", "axios": "0.17.1", "ban-sensitive-files": "1.9.2", "css-loader": "0.28.7", @@ -87,8 +88,8 @@ "semantic-action": "1.1.0", "simple-commit-message": "3.3.2", "standard": "10.0.3", - "vue": "2.5.13", "vue-loader": "13.6.1", + "vuex": "3.0.1" "vue-router": "3.0.1", "vue-template-compiler": "2.5.13" }, @@ -101,6 +102,7 @@ }, "dependencies": { "@cypress/webpack-preprocessor": "1.1.2", - "common-tags": "1.6.0" + "common-tags": "1.6.0", + "vue": "2.5.13" } } diff --git a/src/index.js b/src/index.js index 240b9d0daf05..16f97e80e174 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,17 @@ +const Vue = require('vue/dist/vue') const { stripIndent } = require('common-tags') +// mountVue options +const defaultOptions = ['html', 'vue', 'base', 'mountId', 'extensions'] + +// default mount point element ID for root Vue instance +const defaultMountId = 'app' + +const parentDocument = window.parent.document +const projectName = Cypress.config('projectName') +const appIframeId = `Your App: '${projectName}'` +const appIframe = parentDocument.getElementById(appIframeId) + // having weak reference to styles prevents garbage collection // and "losing" styles when the next test starts const stylesCache = new Map() @@ -23,10 +35,6 @@ const copyStyles = component => { return } - const parentDocument = window.parent.document - const projectName = Cypress.config('projectName') - const appIframeId = `Your App: '${projectName}'` - const appIframe = parentDocument.getElementById(appIframeId) const head = appIframe.contentDocument.querySelector('head') styles.forEach(style => { head.appendChild(style) @@ -42,52 +50,20 @@ const deleteCachedConstructors = component => { Cypress._.values(component.components).forEach(deleteConstructor) } -const getVuePath = options => - options.vue || options.vuePath || '../node_modules/vue/dist/vue.js' - const getPageHTML = options => { - if (options.html) { - return options.html - } - const vue = getVuePath(options) - - // note: add "base" tag to force loading static assets - // from the server, not from the "spec" file URL - if (options.base) { - if (vue.startsWith('.')) { - console.error( - 'You are using base tag %s and relative Vue path %s', - options.base, - vue - ) - console.error('the relative path might NOT work') - console.error( - 'maybe pass Vue url using "https://unpkg.com/vue/dist/vue.js"' - ) - } - return stripIndent` - - - - - -
- - - - ` - } - - const vueHtml = stripIndent` + return ( + options.html || + stripIndent` - + + ${options.base ? `` : ''} + -
- +
` - return vueHtml + ) } const registerGlobalComponents = (Vue, options) => { @@ -130,13 +106,40 @@ const installMixins = (Vue, options) => { } } -const isOptionName = name => - ['vue', 'html', 'vuePath', 'base', 'extensions'].includes(name) +const isOptionName = name => defaultOptions.includes(name) const isOptions = object => Object.keys(object).every(isOptionName) 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 + function setXMLHttpRequest (w) { // by grabbing the XMLHttpRequest from app's iframe // and putting it here - in the test iframe @@ -162,35 +165,56 @@ const mountVue = (component, optionsOrProps = {}) => () => { props = optionsOrProps } - const vueHtml = getPageHTML(options) - const document = cy.state('document') - document.write(vueHtml) - document.close() + // 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.`) + } + + // insert base app template + const doc = appIframe.contentDocument + doc.write(getPageHTML(options)) + doc.close() + + // get root Vue mount element + const mountId = options.mountId || defaultMountId + const el = doc.getElementById(mountId) + + // 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) + } + + // setup Vue instance + 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(el) + copyStyles(Cmp) + } else { + Cypress.vue = new Vue(component).$mount(el) + copyStyles(component) + } - // TODO: do not log out "its(Vue)" command - // but it currently does not support it return cy .window({ log: false }) .then(setXMLHttpRequest) .then(setAlert) - .its('Vue') - .then(Vue => { - installFilters(Vue, options) - installMixins(Vue, options) - installPlugins(Vue, options) - registerGlobalComponents(Vue, options) - deleteCachedConstructors(component) - - if (isConstructor(component)) { - const Cmp = Vue.extend(component) - Cypress.vue = new Cmp(props).$mount('#app') - copyStyles(Cmp) - } else { - Cypress.vue = new Vue(component).$mount('#app') - copyStyles(component) - } - - return Cypress.vue + .then(() => { + return cy.wrap(Cypress.vue) }) }