From 601ac9cc73357aa5cca138a59212d768ab0c6145 Mon Sep 17 00:00:00 2001 From: Pascal Barth Date: Fri, 3 Nov 2023 12:49:01 +0100 Subject: [PATCH] BGDIINF_SB-3154 : add e2e test to cover crosshair URL param Handles correctly removal of crosshair param from the URL (removes the crosshair entirely) Changing a bit how the store sync router handles interaction with the store, to make sure we wait for the dispatch to have occurred (hence the async...await instead of previous then(...)) Adding a Cypress utility (and necessary code in the app) so that Cypress can now wait for the sync between the URL and the store be finished. Adding another Cypress utility function that changes a URL param on the fly, without reloading the app. For that I needed to expose to Cypress the VueRouter instance and its history. --- src/router/index.js | 11 ++- .../storeSync/CrossHairParamConfig.class.js | 4 + .../storeSync/storeSync.routerPlugin.js | 48 +++++++---- src/store/modules/position.store.js | 23 ++++-- tests/e2e-cypress/integration/crosshair.cy.js | 82 +++++++++++++++++++ tests/e2e-cypress/support/commands.js | 59 +++++++++++++ 6 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 tests/e2e-cypress/integration/crosshair.cy.js diff --git a/src/router/index.js b/src/router/index.js index 764e0bf4c..a3f9847da 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,3 +1,4 @@ +import { IS_TESTING_WITH_CYPRESS } from '@/config' import appLoadingManagementRouterPlugin from '@/router/appLoadingManagement.routerPlugin' import legacyPermalinkManagementRouterPlugin from '@/router/legacyPermalinkManagement.routerPlugin' import storeSyncRouterPlugin from '@/router/storeSync/storeSync.routerPlugin' @@ -7,6 +8,8 @@ import LoadingView from '@/views/LoadingView.vue' import MapView from '@/views/MapView.vue' import { createRouter, createWebHashHistory } from 'vue-router' +const history = createWebHashHistory() + /** * The Vue Router for this app, see [Vue Router's doc on how to use * it]{@link https://router.vuejs.org/guide/} @@ -14,7 +17,7 @@ import { createRouter, createWebHashHistory } from 'vue-router' * @type {Router} */ const router = createRouter({ - history: createWebHashHistory(), + history, routes: [ { path: '/', @@ -42,4 +45,10 @@ appLoadingManagementRouterPlugin(router, store) legacyPermalinkManagementRouterPlugin(router, store) storeSyncRouterPlugin(router, store) +// exposing the router to Cypress, so that we may change URL param on the fly (without app reload), +// and this way test app reaction to URL changes +if (IS_TESTING_WITH_CYPRESS) { + window.vueRouterHistory = history + window.vueRouter = router +} export default router diff --git a/src/router/storeSync/CrossHairParamConfig.class.js b/src/router/storeSync/CrossHairParamConfig.class.js index 00d3cad09..2ba743330 100644 --- a/src/router/storeSync/CrossHairParamConfig.class.js +++ b/src/router/storeSync/CrossHairParamConfig.class.js @@ -4,6 +4,10 @@ import { round } from '@/utils/numberUtils' function dispatchCrossHairFromUrlIntoStore(store, urlParamValue) { const promisesForAllDispatch = [] + if (typeof urlParamValue !== 'string') { + promisesForAllDispatch.push(store.dispatch('setCrossHair', { crossHair: null })) + } + const parts = urlParamValue.split(',') if (parts.length === 1) { promisesForAllDispatch.push(store.dispatch('setCrossHair', { crossHair: urlParamValue })) diff --git a/src/router/storeSync/storeSync.routerPlugin.js b/src/router/storeSync/storeSync.routerPlugin.js index 3b8f82345..f45c54abd 100644 --- a/src/router/storeSync/storeSync.routerPlugin.js +++ b/src/router/storeSync/storeSync.routerPlugin.js @@ -1,5 +1,9 @@ +import { IS_TESTING_WITH_CYPRESS } from '@/config' import storeSyncConfig from '@/router/storeSync/storeSync.config' import log from '@/utils/logging' +import axios from 'axios' + +export const FAKE_URL_CALLED_AFTER_ROUTE_CHANGE = '/tell-cypress-route-has-changed' const watchedMutations = [ ...new Set( @@ -97,25 +101,25 @@ function urlQueryWatcher(store, to) { routeChangeIsTriggeredByThisModule = false return undefined } + const pendingStoreDispatch = [] let requireQueryUpdate = false - const newQuery = Object.assign({}, to.query) - // if the route change is not made by this module we need to check if a store change is needed + const newQuery = { ...to.query } + // if this module did not trigger the route change, we need to check if a store change is needed storeSyncConfig.forEach((paramConfig) => { const queryValue = paramConfig.readValueFromQuery(to.query) const storeValue = paramConfig.readValueFromStore(store) - const setValueInStore = (paramConfig, store, value) => { - // preventing store.subscribe above to change what is in the URL while dispatching this change - // if we don't ignore this next mutation, all other param than the one treated here could go back + const setValueInStore = async (paramConfig, store, value) => { + // Preventing store.subscribe above to change what is in the URL, while dispatching this change. + // If we don't ignore this next mutation, all other param than the one treated here could go back // to default/store value even though they could be defined differently in the URL. pendingMutationTriggeredByThisModule.push(paramConfig.mutationsToWatch) - paramConfig.populateStoreWithQueryValue(store, value).then(() => { - // removing mutation name from the pending ones - pendingMutationTriggeredByThisModule.splice( - pendingMutationTriggeredByThisModule.indexOf(paramConfig.mutationsToWatch), - 1 - ) - }) + await paramConfig.populateStoreWithQueryValue(store, value) + // removing mutation name from the pending ones + pendingMutationTriggeredByThisModule.splice( + pendingMutationTriggeredByThisModule.indexOf(paramConfig.mutationsToWatch), + 1 + ) } if (queryValue && queryValue !== storeValue) { @@ -126,7 +130,7 @@ function urlQueryWatcher(store, to) { 'to store with value', queryValue ) - setValueInStore(paramConfig, store, queryValue) + pendingStoreDispatch.push(setValueInStore(paramConfig, store, queryValue)) } else if (!queryValue && storeValue) { if (paramConfig.keepInUrlWhenDefault) { // if we don't have a query value but a store value update the url query with it @@ -145,16 +149,26 @@ function urlQueryWatcher(store, to) { paramConfig.urlParamName, 'has been removed from the URL, setting it to falsy value in the store' ) - setValueInStore( - paramConfig, - store, - paramConfig.valueType === Boolean ? false : null + pendingStoreDispatch.push( + setValueInStore( + paramConfig, + store, + paramConfig.valueType === Boolean ? false : null + ) ) delete newQuery[paramConfig.urlParamName] } requireQueryUpdate = true } }) + // Fake call to a URL so that Cypress can wait for route changes without waiting for arbitrary length of time + if (IS_TESTING_WITH_CYPRESS) { + Promise.all(pendingStoreDispatch).then(() => { + axios({ + url: FAKE_URL_CALLED_AFTER_ROUTE_CHANGE, + }) + }) + } if (requireQueryUpdate) { log.debug('Update URL query to', newQuery) // NOTE: this rewrite of query currently don't work when navigating manually got the `/#/` diff --git a/src/store/modules/position.store.js b/src/store/modules/position.store.js index 99f38c9be..66cd8283d 100644 --- a/src/store/modules/position.store.js +++ b/src/store/modules/position.store.js @@ -229,19 +229,24 @@ const actions = { }, increaseZoom: ({ dispatch, state }) => dispatch('setZoom', Number(state.zoom) + 1), decreaseZoom: ({ dispatch, state }) => dispatch('setZoom', Number(state.zoom) - 1), - /** @param {CrossHairs | String | null} crossHair */ + /** + * @param {CrossHairs | String | null} crossHair + * @param {Number[] | null} crossHairPosition + */ setCrossHair: ({ commit, state }, { crossHair, crossHairPosition }) => { if (crossHair === null) { - commit('setCrossHair', crossHair) + commit('setCrossHair', null) + commit('setCrossHairPosition', null) } else if (crossHair in CrossHairs) { commit('setCrossHair', CrossHairs[crossHair]) - } - // if a position is defined as param we use it - if (crossHairPosition) { - commit('setCrossHairPosition', crossHairPosition) - } else { - // if no position was given, we use the current center of the map as crosshair position - commit('setCrossHairPosition', state.center) + + // if a position is defined as param we use it + if (crossHairPosition) { + commit('setCrossHairPosition', crossHairPosition) + } else { + // if no position was given, we use the current center of the map as crosshair position + commit('setCrossHairPosition', state.center) + } } }, /** diff --git a/tests/e2e-cypress/integration/crosshair.cy.js b/tests/e2e-cypress/integration/crosshair.cy.js new file mode 100644 index 000000000..d9448c2a5 --- /dev/null +++ b/tests/e2e-cypress/integration/crosshair.cy.js @@ -0,0 +1,82 @@ +/// + +import { DEFAULT_PROJECTION } from '@/config' +import { CrossHairs } from '@/store/modules/position.store' + +describe('Testing the crosshair URL param', () => { + context('At app startup', () => { + it('does not add the crosshair by default', () => { + cy.goToMapView() + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.be.null + expect(positionStore.crossHairPosition).to.be.null + }) + }) + it('adds the crosshair at the center of the map if only the crosshair param is given', () => { + cy.goToMapView({ + crosshair: CrossHairs.point, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.point) + expect(positionStore.crossHairPosition).to.eql(positionStore.center) + }) + }) + it('sets the crosshair at the given coordinate if provided in the URL (and not at map center)', () => { + const crossHairPosition = DEFAULT_PROJECTION.bounds.center.map((value) => value + 1000) + cy.goToMapView({ + crosshair: `${CrossHairs.bowl},${crossHairPosition.join(',')}`, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.bowl) + expect(positionStore.crossHairPosition).to.eql(crossHairPosition) + }) + }) + }) + context('Changes of URL param value while the app has been loaded', () => { + it('Changes the crosshair types correctly if changed after app load', () => { + cy.goToMapView({ + crosshair: CrossHairs.point, + }) + cy.readStoreValue('state.position.crossHair').should('eq', CrossHairs.point) + cy.changeUrlParam('crosshair', CrossHairs.marker) + cy.readStoreValue('state.position.crossHair').should('eq', CrossHairs.marker) + }) + it('Changes the crosshair position if set after app reload', () => { + cy.goToMapView({ + crosshair: CrossHairs.cross, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.cross) + expect(positionStore.crossHairPosition).to.eql(positionStore.center) + }) + + const newCrossHairPosition = DEFAULT_PROJECTION.bounds.center.map( + (value) => value - 12345 + ) + cy.changeUrlParam( + 'crosshair', + `${CrossHairs.cross},${newCrossHairPosition.join(',')}`, + // a change of the crosshair param with position triggers two dispatches: setCrossHair and setCrossHairPosition + 2 + ) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.cross) + expect(positionStore.crossHairPosition).to.eql(newCrossHairPosition) + }) + }) + it('removes the crosshair if the URL param is removed (or set to null)', () => { + cy.goToMapView({ + crosshair: CrossHairs.circle, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.circle) + expect(positionStore.crossHairPosition).to.eql(positionStore.center) + }) + cy.changeUrlParam('crosshair', null, 2) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.be.null + expect(positionStore.crossHairPosition).to.be.null + }) + }) + }) +}) diff --git a/tests/e2e-cypress/support/commands.js b/tests/e2e-cypress/support/commands.js index affdb1460..14fda93ed 100644 --- a/tests/e2e-cypress/support/commands.js +++ b/tests/e2e-cypress/support/commands.js @@ -1,4 +1,5 @@ import { BREAKPOINT_TABLET } from '@/config' +import { FAKE_URL_CALLED_AFTER_ROUTE_CHANGE } from '@/router/storeSync/storeSync.routerPlugin' import { randomIntBetween } from '@/utils/numberUtils' import 'cypress-real-events' import 'cypress-wait-until' @@ -10,6 +11,12 @@ import { MapBrowserEvent } from 'ol' // https://on.cypress.io/custom-commands // *********************************************** +const addVueRouterIntercept = () => { + cy.intercept(FAKE_URL_CALLED_AFTER_ROUTE_CHANGE, { + statusCode: 200, + }).as('routeChange') +} + const addLayerTileFixture = () => { // catching WMTS type URLs in web mercator and lv95 cy.intercept(/1.0.0\/.*\/.*\/.*\/(21781|2056|3857|4326)\/\d+\/\d+\/\d+.jpe?g/, { @@ -86,6 +93,7 @@ const addGeoJsonIntercept = () => { export function getDefaultFixturesAndIntercepts() { return { + addVueRouterIntercept, addLayerTileFixture, addLayerFixtureAndIntercept, addTopicFixtureAndIntercept, @@ -193,6 +201,57 @@ Cypress.Commands.add( } ) +/** + * Changes a URL parameter without reloading the app. + * + * Help when you want to change a value in the URL but don't want the whole app be reloaded from + * scratch in the process. + * + * @param {string} urlParamName URL param name (present or not in the URL, will be added or + * overwritten) + * @param {string} urlParamValue The new URL param value we want to have + * @param {number} amountOfExpectedStoreDispatches The number of dispatches this change in the URL + * is going to trigger. This function will then wait for this amount of dispatch in the store + * before letting the test go further + */ +Cypress.Commands.add( + 'changeUrlParam', + (urlParamName, urlParamValue, amountOfExpectedStoreDispatches = 1) => { + cy.window() + .its('vueRouterHistory') + .then((vueRouterHistory) => { + // the router location will everything behind the hash sign, meaning /map?param1=...¶m2=... + const queryPart = vueRouterHistory.location.split('?')[1] + const query = new URLSearchParams(queryPart) + if (urlParamValue) { + query.set(urlParamName, urlParamValue) + } else { + query.delete(urlParamName) + } + + // We have to do the toString by hand, as if we use the standard toString all param value + // will be encoded. And so comas will be URL encoded instead of left untouched, meaning layers, camera and + // other params that use the coma to split values will not work. + const unencodedQuery = Array.from(query.entries()) + .map(([key, value]) => `${key}=${value}`) + .reduce((param1, param2) => `${param1}${param2}&`, '?') + // removing the trailing & resulting of the reduction above + .slice(0, -1) + // regenerating the complete router location + const newLocation = `${vueRouterHistory.location.split('?')[0]}${unencodedQuery}` + cy.log('router location changed from', vueRouterHistory.location, 'to', newLocation) + cy.window() + .its('vueRouter') + .then((vueRouter) => { + vueRouter.push(newLocation) + for (let i = 0; i < amountOfExpectedStoreDispatches; i++) { + cy.wait('@routeChange') + } + }) + }) + } +) + /** * Click on language command *