Skip to content

Commit

Permalink
BGDIINF_SB-3154 : add e2e test to cover crosshair URL param
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pakb committed Nov 3, 2023
1 parent 083feb0 commit 601ac9c
Show file tree
Hide file tree
Showing 6 changed files with 200 additions and 27 deletions.
11 changes: 10 additions & 1 deletion src/router/index.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -7,14 +8,16 @@ 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/}
*
* @type {Router}
*/
const router = createRouter({
history: createWebHashHistory(),
history,
routes: [
{
path: '/',
Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions src/router/storeSync/CrossHairParamConfig.class.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down
48 changes: 31 additions & 17 deletions src/router/storeSync/storeSync.routerPlugin.js
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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 `/#/`
Expand Down
23 changes: 14 additions & 9 deletions src/store/modules/position.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
},
/**
Expand Down
82 changes: 82 additions & 0 deletions tests/e2e-cypress/integration/crosshair.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/// <reference types="cypress" />

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
})
})
})
})
59 changes: 59 additions & 0 deletions tests/e2e-cypress/support/commands.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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/, {
Expand Down Expand Up @@ -86,6 +93,7 @@ const addGeoJsonIntercept = () => {

export function getDefaultFixturesAndIntercepts() {
return {
addVueRouterIntercept,
addLayerTileFixture,
addLayerFixtureAndIntercept,
addTopicFixtureAndIntercept,
Expand Down Expand Up @@ -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=...&param2=...
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
*
Expand Down

0 comments on commit 601ac9c

Please sign in to comment.