import setupCore from '@baserow/modules/core/plugin' import Papa from 'papaparse' import axios from 'axios' import setupClient, { ClientErrorMap, } from '@baserow/modules/core/plugins/clientHandler' import setupDatabasePlugin from '@baserow/modules/database/plugin' import setupBuilderPlugin from '@baserow/modules/builder/plugin' import { bootstrapVueContext } from '@baserow/test/helpers/components' import MockAdapter from 'axios-mock-adapter' import _ from 'lodash' import { MockServer } from '@baserow/test/fixtures/mockServer' import flushPromises from 'flush-promises' import setupHasFeaturePlugin from '@baserow/modules/core/plugins/hasFeature' /** * Uses the real baserow plugins to setup a Vuex store and baserow registry * correctly. */ function _createBaserowStoreAndRegistry(app, vueContext, extraPluginSetupFunc) { const store = new vueContext.vuex.Store({}) setupCore({ store, app }, (name, dep) => { app[`$${name}`] = dep }) setupClient({ store, app }, (key, value) => { app[key] = value }) app.$client = app.client store.$registry = app.$registry store.$client = app.client store.$config = app.$config store.app = app app.$store = store // Nuxt seems to allow both access patterns to get at the store? app.store = store const setupContext = { store, app, } setupDatabasePlugin(setupContext) setupBuilderPlugin(setupContext) setupHasFeaturePlugin(setupContext, (name, dep) => { app[`$${name}`] = dep }) if (extraPluginSetupFunc) { extraPluginSetupFunc(setupContext) } return store } /** * An acceptance testing framework for testing Baserow components and surrounding logic * like stores. * TestApp sets up baserow components, registries and stores so they work out of the * box and can be tested without having to: * - wait 30+ seconds for a Nuxt server to startup and build * - mock out stores, registries or carve arbitrary boundaries in * the tests causing problems when store and component logic is tightly * coupled. * * To use create an instance of this class in your beforeAll * test suite hook and make sure to call testApp.afterEach() in the afterEach hook. * * The following attributes are exposed for use in your tests: * testApp.mockServer : a helper class providing methods to initialize a fake * baserow server with consistent test data. * testApp.mock : a mock axios adapter used to mock out HTTP calls to the server, * also used by testApp.mockServer to actually do the server call * mocking. * testApp.store : a Vuex store populated with Baserow's stores ready for you to * commit, get and dispatch to. * UIHelpers : a collection of methods which know how to perform common actions * on Baserow's components. * */ export class TestApp { constructor(extraPluginSetupFunc = null) { this.mock = new MockAdapter(axios, { onNoMatch: 'throwException' }) // Fix "scrollIntoViewError is not a function error" // as described here: https://github.com/jsdom/jsdom/issues/1695 window.HTMLElement.prototype.scrollIntoView = function () {} // In the future we can extend this stub realtime implementation to perform // useful testing of realtime interaction in the frontend! this._realtime = { registerEvent(e, f) {}, subscribe(e, f) {}, connect(a, b) {}, disconnect() {}, } // Various stub and mock attributes which will be injected into components // mounted using TestApp. const cookieStorage = {} this.cookieStorage = cookieStorage this._app = { $realtime: this._realtime, $cookies: { set(name, value) { cookieStorage[name] = value }, get(name) { return cookieStorage[name] }, }, $config: { PUBLIC_WEB_FRONTEND_URL: 'https://localhost/', PRIVATE: 'http://backend:8000', BASEROW_USE_PG_FULLTEXT_SEARCH: 'true', }, i18n: { t: (key) => key, tc: (key) => key, }, $i18n: { getBrowserLocale: () => 'en', }, $router: { resolve({ name, params }) { return new URL(`https://${name}`) }, replace({ name, params }) { return new URL(`https://${name}`) }, }, $route: { params: {}, matched: [], }, $featureFlagIsEnabled: (flag) => true, $hasPermission: () => true, } this._app.$clientErrorMap = new ClientErrorMap(this._app) this._vueContext = bootstrapVueContext() this.store = _createBaserowStoreAndRegistry( this._app, this._vueContext, extraPluginSetupFunc ) this.store.$cookies = this._app.$cookies this._initialCleanStoreState = _.cloneDeep(this.store.state) Papa.arrayToString = (array) => { return Papa.unparse([array]) } Papa.stringToArray = (str) => { return Papa.parse(str).data[0] } this._app.$papa = Papa this.mockServer = new MockServer(this.mock, this.store) this.failTestOnErrorResponse = true this._app.client.interceptors.response.use( (response) => { return response }, (error) => { if (this.failTestOnErrorResponse) { fail(error) } return Promise.reject(error) } ) this._wrappers = [] } dontFailOnErrorResponses() { this.failTestOnErrorResponse = false } failOnErrorResponses() { this.failTestOnErrorResponse = true } /** * Cleans up after a test run performed by TestApp. Make sure you call this * in your test suits afterEach method! */ async afterEach() { // Destroy all constructed components so when we replace the state no reactivity // starts running in existing components. this._wrappers.forEach((w) => w.destroy()) this._wrappers = [] // Flushing promises should be done before the mock reset to avoid raising // unwanted exceptions await flushPromises() this.mock.reset() this.failOnErrorResponses() this._vueContext.teardownVueContext() this.store.replaceState(_.cloneDeep(this._initialCleanStoreState)) } /** * Creates a fully rendered version of the provided Component and calls * asyncData on the component at the correct time with the provided params. */ async mount(Component, { asyncDataParams = {}, ...kwargs }) { // Sometimes baserow directly appends to the documents body, ensure that we // are mounting into the document so we can correctly inspect the modals that // are placed there. const rootDiv = document.createElement('div') document.body.appendChild(rootDiv) if (Object.prototype.hasOwnProperty.call(Component, 'asyncData')) { const data = await Component.asyncData({ store: this.store, params: asyncDataParams, error: fail, app: this._app, }) Component.data = function () { return data } } const wrapper = this._vueContext.vueTestUtils.mount(Component, { localVue: this._vueContext.vue, mocks: this._app, attachTo: rootDiv, ...kwargs, }) // The vm property doesn't alway exist. See https://vue-test-utils.vuejs.org/api/wrapper/#properties if (wrapper.vm) { await this.callFetchOnChildren(wrapper.vm) } this._wrappers.push(wrapper) return wrapper } async callFetchOnChildren(c) { if (c.$options.fetch) { const fetch = c.$options.fetch if (typeof fetch !== 'function') { throw new TypeError('fetch should be a function') } await c.$options.fetch.bind(c)() } for (const child of c.$children) { await this.callFetchOnChildren(child) } } getApp() { return this._app } getStore() { return this._app.store } getRegistry() { return this._app.$registry } setRouteToBe(name) { this._app.$route.matched = [{ name }] } } /** * Various helper functions which interact with baserow components. Lean towards * putting and sharing any test code which relies on specific details of how baserow * components are structured and styled in here This way there is a single place * to fix when changes are made to the components instead of 30 different test cases. */ export const UIHelpers = { async performSearch(tableComponent, searchTerm) { await tableComponent.get('i.header__search-icon').trigger('click') const searchBox = tableComponent.get( 'input[placeholder*="viewSearchContext.searchInRows"]' ) await searchBox.setValue(searchTerm) await searchBox.trigger('submit') await flushPromises() }, async startEditForCellContaining(tableComponent, htmlInsideCellToSearchFor) { const targetCell = tableComponent .findAll('.grid-view__cell') .filter((w) => w.html().includes(htmlInsideCellToSearchFor)) .at(0) await targetCell.trigger('click') const activeCell = tableComponent.get('.grid-view__cell.active') // Double click to start editing cell await activeCell.trigger('click') await activeCell.trigger('click') return activeCell.find('input') }, getSidebarItemNames(sidebarComponent) { return sidebarComponent .findAll('.sidebar__nav .tree__action') .wrappers.map((t) => t.text()) }, getDisabledSidebarItemNames(sidebarComponent) { return sidebarComponent .findAll('.sidebar__nav .tree__action--disabled') .wrappers.map((t) => t.text()) }, async selectSidebarItem(sidebarComponent, itemName) { const allNames = [] for (const wrapper of sidebarComponent.findAll( '.sidebar__nav .tree__action' ).wrappers) { allNames.push(wrapper.text()) if (wrapper.text() === itemName) { await wrapper.trigger('click') return } } throw new Error( `Did not find ${itemName} in the Sidebar to click, only found ${allNames}` ) }, }