Set up cypress component testing

This commit is contained in:
Ramon Wenger 2023-02-16 16:49:36 +01:00
parent 5576c21cb9
commit 8368050683
19 changed files with 447 additions and 275 deletions

View File

@ -60,7 +60,7 @@ module.exports = {
}, },
compilerOptions: { compilerOptions: {
compatConfig: { compatConfig: {
MODE: 2, MODE: 3,
}, },
}, },
}, },

View File

@ -4,6 +4,7 @@ import { resolve } from 'path';
export default defineConfig({ export default defineConfig({
chromeWebSecurity: false, chromeWebSecurity: false,
e2e: { e2e: {
baseUrl: 'http://localhost:8080', baseUrl: 'http://localhost:8080',
specPattern: 'cypress/e2e/frontend/**/*.{cy,spec}.{js,ts}', specPattern: 'cypress/e2e/frontend/**/*.{cy,spec}.{js,ts}',
@ -16,14 +17,29 @@ export default defineConfig({
}); });
}, },
}, },
videoUploadOnPasses: false, videoUploadOnPasses: false,
reporter: 'junit', reporter: 'junit',
reporterOptions: { reporterOptions: {
mochaFile: 'cypress/test-reports/frontend/cypress-results-[hash].xml', mochaFile: 'cypress/test-reports/frontend/cypress-results-[hash].xml',
toConsole: true, toConsole: true,
}, },
projectId: 'msk-fe', projectId: 'msk-fe',
retries: { retries: {
runMode: 3, runMode: 3,
}, },
component: {
devServer: {
framework: 'vue',
bundler: 'webpack',
webpackConfig: async () => {
const webpackConfig = await require('./build/webpack.dev.conf');
return webpackConfig;
},
},
},
}); });

View File

@ -0,0 +1,18 @@
import PageFormInput from '@/components/page-form/PageFormInput.vue';
describe('<PageFormInput />', () => {
it('renders', () => {
// see: https://test-utils.vuejs.org/guide/
const inputSpy = cy.spy().as('inputSpy');
cy.mount(PageFormInput, {
props: {
label: 'Hi',
value: 'Some',
onInput: inputSpy,
},
});
cy.get('.page-form-input__label').should('contain.text', 'Hi');
cy.getByDataCy('page-form-input-hi').should('have.value', 'Some').type('A');
cy.get('@inputSpy').should('have.been.calledWith', 'SomeA');
});
});

View File

@ -0,0 +1,23 @@
import SubmissionForm from '@/components/content-blocks/assignment/SubmissionForm.vue';
describe('SubmissionForm', () => {
it('renders', () => {
cy.mount(SubmissionForm, {
props: {
userInput: {
final: false,
text: 'userInput',
},
saved: true,
placeholder: 'Placeholder',
action: 'Feedback teilen',
reopen: () => { },
document: '',
sharedMsg: 'Shared Message',
onTurnIn: () => { },
onSaveInput: () => { },
onReopen: () => { },
},
});
});
});

View File

@ -0,0 +1,18 @@
import SubmissionInput from '@/components/content-blocks/assignment/SubmissionInput.vue';
describe('SubmissionInput', () => {
it('renders', () => {
cy.mount(SubmissionInput, {
props: {
inputText: undefined,
saved: true,
readonly: false,
placeholder: 'Placeholder',
},
});
cy.getByDataCy('submission-textarea')
.should('have.attr', 'placeholder', 'Placeholder')
.type('Hallo Velo')
.should('have.value', 'Hallo Velo');
});
});

View File

@ -27,124 +27,17 @@
// //
import { makeExecutableSchema } from '@graphql-tools/schema'; import { makeExecutableSchema } from '@graphql-tools/schema';
declare global {
namespace Cypress {
interface Chainable {
/**
* Login via API call to the GraphQL endpoint, without calling the frontend. Faster than the other login.
* @param username
* @param password
* @example
* cy.apolloLogin('ross.geller', 'test')
*/
apolloLogin(username: string, password: string): Chainable<any>;
/**
* Selects an element based on the `data-cy=xxx` attribute
* @param selector - The value of the data-cy attribute to select
* @example
* cy.getByDataCy('my-new-button')
*/
getByDataCy(selector: string): Chainable<Element>;
selectClass(schoolClass: string): void;
login(username: string, password: string, visitLogin?: boolean): void;
fakeLogin(username: string, password: string): void;
isSubmissionReadOnly(myText: string): void;
openSidebar(): void;
setup(): void;
}
}
}
// installed a fork of the original package, because of this issue:
// https://github.com/tgriesser/cypress-graphql-mock/issues/23
// todo: once above issue is fixed, go back to the original repo -> npm install cypress-graphql-mock
// import 'cypress-graphql-mock';
import mocks from '../fixtures/mocks';
import { addMocksToSchema } from '@graphql-tools/mock';
import { graphql, GraphQLError } from 'graphql';
Cypress.Commands.add('apolloLogin', (username, password) => {
const payload = {
operationName: 'BetaLogin',
variables: {
input: {
usernameInput: username,
passwordInput: password,
},
},
query:
'mutation BetaLogin($input: BetaLoginInput!) {\n betaLogin(input: $input) {\n success\n __typename\n }\n}\n',
};
cy.request({
method: 'POST',
url: '/api/graphql-public/',
body: payload,
});
});
// todo: replace with apollo call
Cypress.Commands.add('login', (username, password, visitLogin = false) => {
if (visitLogin) {
cy.visit('/beta-login');
}
if (username !== '') {
cy.get('[data-cy=email-input]').type(username);
}
if (password !== '') {
cy.get('[data-cy=password-input]').type(password);
}
cy.get('[data-cy=login-button]').click();
});
Cypress.Commands.add('getByDataCy', (selector: string) => {
return cy.get(`[data-cy=${selector}]`);
});
Cypress.Commands.add('selectClass', (schoolClass) => {
cy.getByDataCy('user-widget-avatar').click();
cy.getByDataCy('class-selection').click();
cy.getByDataCy('class-selection-entry').contains(schoolClass).click();
});
Cypress.Commands.add('fakeLogin', () => {
cy.log('Logging in (fake)');
cy.setCookie('loginStatus', 'true');
});
Cypress.Commands.add('isSubmissionReadOnly', (myText) => {
cy.get('.submission-form__textarea--readonly').as('textarea');
cy.get('@textarea').invoke('val').should('contain', myText);
cy.get('@textarea').should('have.attr', 'readonly');
cy.getByDataCy('submission-form-submit').should('not.exist');
});
Cypress.Commands.add('openSidebar', () => {
cy.getByDataCy('user-widget-avatar').click();
});
Cypress.Commands.add('setup', () => {
cy.fakeLogin('nino.teacher', 'test');
cy.viewport('macbook-15');
cy.mockGraphql();
});
const typenameResolver = { const typenameResolver = {
__resolveType(obj, context, info) { __resolveType(obj, context, info) {
return obj.__typename; return obj.__typename;
}, },
}; };
Cypress.Commands.add('mockGraphql', (options?: any) => { const getByDataCy = (selector: string) => {
return cy.get(`[data-cy=${selector}]`);
};
const mockGraphql = (options?: any) => {
cy.task('getSchema').then((schemaString: string) => { cy.task('getSchema').then((schemaString: string) => {
const resolverMap = { const resolverMap = {
DeleteSnapshotResult: typenameResolver, DeleteSnapshotResult: typenameResolver,
@ -214,12 +107,129 @@ Cypress.Commands.add('mockGraphql', (options?: any) => {
}, },
}).as('mockGraphqlOps'); }).as('mockGraphqlOps');
}); });
};
const mockGraphqlOps = (options) => {
cy.get('@mockGraphqlOps').invoke('setOperations' as any, options);
};
declare global {
namespace Cypress {
interface Chainable {
/**
* Login via API call to the GraphQL endpoint, without calling the frontend. Faster than the other login.
* @param username
* @param password
* @example
* cy.apolloLogin('ross.geller', 'test')
*/
apolloLogin(username: string, password: string): Chainable<any>;
/**
* Selects an element based on the `data-cy=xxx` attribute
* @param selector - The value of the data-cy attribute to select
* @example
* cy.getByDataCy('my-new-button')
*/
// getByDataCy(selector: string): Chainable<Element>;
getByDataCy: typeof getByDataCy;
selectClass(schoolClass: string): void;
login(username: string, password: string, visitLogin?: boolean): void;
fakeLogin(username: string, password: string): void;
isSubmissionReadOnly(myText: string): void;
openSidebar(): void;
setup(): void;
mockGraphql: typeof mockGraphql;
mockGraphqlOps: typeof mockGraphqlOps;
}
}
}
// installed a fork of the original package, because of this issue:
// https://github.com/tgriesser/cypress-graphql-mock/issues/23
// todo: once above issue is fixed, go back to the original repo -> npm install cypress-graphql-mock
// import 'cypress-graphql-mock';
import mocks from '../fixtures/mocks';
import { addMocksToSchema } from '@graphql-tools/mock';
import { graphql, GraphQLError } from 'graphql';
Cypress.Commands.add('apolloLogin', (username, password) => {
const payload = {
operationName: 'BetaLogin',
variables: {
input: {
usernameInput: username,
passwordInput: password,
},
},
query:
'mutation BetaLogin($input: BetaLoginInput!) {\n betaLogin(input: $input) {\n success\n __typename\n }\n}\n',
};
cy.request({
method: 'POST',
url: '/api/graphql-public/',
body: payload,
});
}); });
Cypress.Commands.add('mockGraphqlOps', (options) => { // todo: replace with apollo call
cy.get('@mockGraphqlOps').invoke('setOperations' as any, options); Cypress.Commands.add('login', (username, password, visitLogin = false) => {
if (visitLogin) {
cy.visit('/beta-login');
}
if (username !== '') {
cy.get('[data-cy=email-input]').type(username);
}
if (password !== '') {
cy.get('[data-cy=password-input]').type(password);
}
cy.get('[data-cy=login-button]').click();
}); });
Cypress.Commands.add('getByDataCy', getByDataCy);
Cypress.Commands.add('selectClass', (schoolClass) => {
cy.getByDataCy('user-widget-avatar').click();
cy.getByDataCy('class-selection').click();
cy.getByDataCy('class-selection-entry').contains(schoolClass).click();
});
Cypress.Commands.add('fakeLogin', () => {
cy.log('Logging in (fake)');
cy.setCookie('loginStatus', 'true');
});
Cypress.Commands.add('isSubmissionReadOnly', (myText) => {
cy.get('.submission-form__textarea--readonly').as('textarea');
cy.get('@textarea').invoke('val').should('contain', myText);
cy.get('@textarea').should('have.attr', 'readonly');
cy.getByDataCy('submission-form-submit').should('not.exist');
});
Cypress.Commands.add('openSidebar', () => {
cy.getByDataCy('user-widget-avatar').click();
});
Cypress.Commands.add('setup', () => {
cy.fakeLogin('nino.teacher', 'test');
cy.viewport('macbook-15');
cy.mockGraphql();
});
Cypress.Commands.add('mockGraphql', mockGraphql);
Cypress.Commands.add('mockGraphqlOps', mockGraphqlOps);
const getRootValue = (allOperations: any, operationName: string, variables: any) => { const getRootValue = (allOperations: any, operationName: string, variables: any) => {
const operation = allOperations[operationName]; const operation = allOperations[operationName];
if (typeof operation === 'function') { if (typeof operation === 'function') {

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View File

@ -0,0 +1,40 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from 'cypress/vue';
import '@/main.js';
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
// Example use:
// cy.mount(MyComponent)

View File

@ -87,7 +87,7 @@
"vee-validate": "^4.5.10", "vee-validate": "^4.5.10",
"vue": "3.2.30", "vue": "3.2.30",
"vue-loader": "^16.8.3", "vue-loader": "^16.8.3",
"vue-matomo": "^4.1.0", "vue-matomo": "^4.2.0",
"vue-router": "^4.0.14", "vue-router": "^4.0.14",
"vue-scrollto": "^2.20.0", "vue-scrollto": "^2.20.0",
"vue-style-loader": "^3.0.1", "vue-style-loader": "^3.0.1",

View File

@ -110,7 +110,7 @@
"vee-validate": "^4.5.10", "vee-validate": "^4.5.10",
"vue": "3.2.30", "vue": "3.2.30",
"vue-loader": "^16.8.3", "vue-loader": "^16.8.3",
"vue-matomo": "^4.1.0", "vue-matomo": "^4.2.0",
"vue-router": "^4.0.14", "vue-router": "^4.0.14",
"vue-scrollto": "^2.20.0", "vue-scrollto": "^2.20.0",
"vue-style-loader": "^3.0.1", "vue-style-loader": "^3.0.1",

View File

@ -110,7 +110,6 @@ export default {
</script> </script>
<style lang="scss"> <style lang="scss">
@import '~styles/main.scss';
@import '~styles/helpers'; @import '~styles/helpers';
body { body {

View File

@ -7,7 +7,7 @@ import log from 'loglevel';
import { router } from '@/router'; import { router } from '@/router';
export default function (uri, networkErrorCallback) { export default function(uri, networkErrorCallback) {
const httpLink = createHttpLink({ const httpLink = createHttpLink({
// uri: process.env.NODE_ENV !== 'production' ? 'http://localhost:8000/api/graphql/' : '/api/graphql/', // uri: process.env.NODE_ENV !== 'production' ? 'http://localhost:8000/api/graphql/' : '/api/graphql/',
uri, uri,

View File

@ -1,162 +1,15 @@
import 'core-js/stable'; import 'core-js/stable';
import { createApp, configureCompat, h, provide } from 'vue'; import setupApp from './setup/setupApp';
import VueVimeoPlayer from 'vue-vimeo-player'; import registerPlugins from './setup/plugins';
import apolloClientFactory from './graphql/client'; import setupRouteGuards from './setup/router';
import App from './App.vue'; import registerDirectives from './setup/directives';
import { postLoginRedirectUrlKey, router } from './router'; import '@/styles/main.scss';
import { store } from '@/store';
import VueScrollTo from 'vue-scrollto';
import autoGrow from '@/directives/auto-grow';
import clickOutside from '@/directives/click-outside';
import ME_QUERY from '@/graphql/gql/queries/meQuery.gql';
import VueModal from '@/plugins/modal';
import VueRemoveEdges from '@/plugins/edges';
import VueMatomo from 'vue-matomo';
import { createApolloProvider } from '@vue/apollo-option';
import { joiningClass, loginRequired, unauthorizedAccess } from '@/router/guards';
import flavorPlugin from '@/plugins/flavor';
import { DefaultApolloClient } from '@vue/apollo-composable';
const publicApolloClient = apolloClientFactory('/api/graphql-public/', null); const app = setupApp();
const privateApolloClient = apolloClientFactory('/api/graphql/', networkErrorCallback);
const apolloProvider = createApolloProvider({ registerPlugins(app);
clients: { setupRouteGuards();
publicClient: publicApolloClient,
},
defaultClient: privateApolloClient,
});
configureCompat({ registerDirectives(app);
MODE: 3,
});
const app = createApp({
setup() {
provide(DefaultApolloClient, privateApolloClient);
},
render: () => h(App),
});
app.use(store);
app.use(VueModal);
app.use(VueRemoveEdges);
app.use(VueVimeoPlayer);
app.use(apolloProvider);
app.use(router);
// VueScrollTo.setDefaults({
// duration: 500,
// easing: 'ease-out',
// offset: -50,
// });
app.directive('scroll-to', VueScrollTo);
app.use(flavorPlugin);
if (process.env.MATOMO_HOST) {
app.use(VueMatomo, {
host: process.env.MATOMO_HOST,
siteId: process.env.MATOMO_SITE_ID,
router: router,
});
}
app.directive('click-outside', clickOutside);
app.directive('auto-grow', autoGrow);
/* guards */
function redirectUsersWithoutValidLicense() {
return privateApolloClient
.query({
query: ME_QUERY,
})
.then(({ data }) => data.me.expiryDate == null);
}
function redirectStudentsWithoutClass() {
return privateApolloClient
.query({
query: ME_QUERY,
})
.then(({ data }) => data.me.schoolClasses.length === 0 && !data.me.isTeacher);
}
function redirectUsersToOnboarding() {
return privateApolloClient
.query({
query: ME_QUERY,
})
.then(({ data }) => !data.me.onboardingVisited);
}
function networkErrorCallback(statusCode) {
if (statusCode === 402) {
router.push({ name: 'licenseActivation' });
}
}
router.beforeEach(async (to, from, next) => {
// todo: make logger work outside vue app
// const logger = inject('vuejs3-logger');
// logger.$log.debug('navigation guard called', to, from);
if (to.path === '/logout') {
await publicApolloClient.resetStore();
if (process.env.LOGOUT_REDIRECT_URL) {
location.replace(`https://sso.hep-verlag.ch/logout?return_to=${process.env.LOGOUT_REDIRECT_URL}`);
next(false);
return;
} else {
next({ name: 'hello' });
return;
}
}
if (unauthorizedAccess(to)) {
//logger.$log.debug('unauthorized', to);
const postLoginRedirectionUrl = to.path;
const redirectUrl = `/hello/`;
if (window.localStorage) {
localStorage.setItem(postLoginRedirectUrlKey, postLoginRedirectionUrl);
}
// logger.$log.debug('redirecting to hello', to);
next(redirectUrl);
return;
}
if (to.name && to.name !== 'licenseActivation' && loginRequired(to) && (await redirectUsersWithoutValidLicense())) {
// logger.$log.debug('redirecting to licenseActivation', to, null);
console.log('redirecting to licenseActivation', to, null);
next({ name: 'licenseActivation' });
return;
}
if (!joiningClass(to) && loginRequired(to) && (await redirectStudentsWithoutClass())) {
//logger.$log.debug('redirecting to join-class', to);
//logger.$log.debug('await redirectStudentsWithoutClass()', await redirectStudentsWithoutClass());
next({ name: 'join-class' });
return;
}
if (
to.name &&
to.name.indexOf('onboarding') === -1 &&
!joiningClass(to) &&
loginRequired(to) &&
(await redirectUsersToOnboarding())
) {
//logger.$log.debug('redirecting to onboarding-start', to);
next({ name: 'onboarding-start' });
return;
}
//logger.$log.debug('End of Guard reached', to);
next();
});
app.mount('#app'); app.mount('#app');

View File

@ -0,0 +1,21 @@
import apolloClientFactory from '@/graphql/client';
import { useRouter } from 'vue-router';
const router = useRouter();
function networkErrorCallback(statusCode: number) {
if (statusCode === 402) {
router.push({ name: 'licenseActivation' });
}
}
const createApolloClients = () => {
return {
publicApolloClient: apolloClientFactory('/api/graphql-public/', null),
privateApolloClient: apolloClientFactory('/api/graphql/', networkErrorCallback),
};
};
const apolloClients = createApolloClients();
export default apolloClients;

View File

@ -0,0 +1,8 @@
import autoGrow from '@/directives/auto-grow';
import clickOutside from '@/directives/click-outside';
const registerDirectives = (app: any) => {
app.directive('click-outside', clickOutside);
app.directive('auto-grow', autoGrow);
};
export default registerDirectives;

View File

@ -0,0 +1,36 @@
import { router } from '@/router';
import VueVimeoPlayer from 'vue-vimeo-player';
import { store } from '@/store';
import VueModal from '@/plugins/modal';
import VueRemoveEdges from '@/plugins/edges';
import { createApolloProvider } from '@vue/apollo-option';
import apolloClients from './apollo';
import flavorPlugin from '@/plugins/flavor';
import VueMatomo from 'vue-matomo';
const { publicApolloClient, privateApolloClient } = apolloClients;
const apolloProvider = createApolloProvider({
clients: {
publicClient: publicApolloClient,
},
defaultClient: privateApolloClient,
});
const registerPlugins = (app: any) => {
app.use(store);
app.use(VueModal);
app.use(VueRemoveEdges);
app.use(VueVimeoPlayer);
app.use(apolloProvider);
app.use(router);
app.use(flavorPlugin);
if (process.env.MATOMO_HOST) {
app.use(VueMatomo, {
host: process.env.MATOMO_HOST,
siteId: process.env.MATOMO_SITE_ID,
router: router,
});
}
};
export default registerPlugins;

View File

@ -0,0 +1,90 @@
import apolloClients from './apollo';
const { publicApolloClient, privateApolloClient } = apolloClients;
import ME_QUERY from '@/graphql/gql/queries/meQuery.gql';
import { joiningClass, loginRequired, unauthorizedAccess } from '@/router/guards';
import { postLoginRedirectUrlKey, router } from '@/router';
async function redirectUsersWithoutValidLicense() {
const { data } = await privateApolloClient.query({
query: ME_QUERY,
});
return data.me.expiryDate == null;
}
async function redirectStudentsWithoutClass() {
const { data } = await privateApolloClient.query({
query: ME_QUERY,
});
return data.me.schoolClasses.length === 0 && !data.me.isTeacher;
}
async function redirectUsersToOnboarding() {
const { data } = await privateApolloClient.query({
query: ME_QUERY,
});
return !data.me.onboardingVisited;
}
const setupRouteGuards = () => {
router.beforeEach(async (to, _, next) => {
// todo: make logger work outside vue app
// const logger = inject('vuejs3-logger');
// logger.$log.debug('navigation guard called', to, from);
if (to.path === '/logout') {
await publicApolloClient.resetStore();
if (process.env.LOGOUT_REDIRECT_URL) {
location.replace(`https://sso.hep-verlag.ch/logout?return_to=${process.env.LOGOUT_REDIRECT_URL}`);
next(false);
return;
} else {
next({ name: 'hello' });
return;
}
}
if (unauthorizedAccess(to)) {
//logger.$log.debug('unauthorized', to);
const postLoginRedirectionUrl = to.path;
const redirectUrl = `/hello/`;
if (window.localStorage) {
localStorage.setItem(postLoginRedirectUrlKey, postLoginRedirectionUrl);
}
// logger.$log.debug('redirecting to hello', to);
next(redirectUrl);
return;
}
if (to.name && to.name !== 'licenseActivation' && loginRequired(to) && (await redirectUsersWithoutValidLicense())) {
// logger.$log.debug('redirecting to licenseActivation', to, null);
console.log('redirecting to licenseActivation', to, null);
next({ name: 'licenseActivation' });
return;
}
if (!joiningClass(to) && loginRequired(to) && (await redirectStudentsWithoutClass())) {
//logger.$log.debug('redirecting to join-class', to);
//logger.$log.debug('await redirectStudentsWithoutClass()', await redirectStudentsWithoutClass());
next({ name: 'join-class' });
return;
}
if (
to.name &&
(to.name as string).indexOf('onboarding') === -1 &&
!joiningClass(to) &&
loginRequired(to) &&
(await redirectUsersToOnboarding())
) {
//logger.$log.debug('redirecting to onboarding-start', to);
next({ name: 'onboarding-start' });
return;
}
//logger.$log.debug('End of Guard reached', to);
next();
});
};
export default setupRouteGuards;

View File

@ -0,0 +1,18 @@
import { createApp, h, provide } from 'vue';
import App from '@/App.vue';
import apolloClients from './apollo';
import { DefaultApolloClient } from '@vue/apollo-composable';
const { privateApolloClient } = apolloClients;
const setupApp = () => {
const app = createApp({
setup() {
provide(DefaultApolloClient, privateApolloClient);
},
render: () => h(App),
});
return app;
};
export default setupApp;

View File

@ -10,3 +10,13 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>; const component: DefineComponent<{}, {}, any>;
export default component; export default component;
} }
// ugly hack to make types for those two packages
declare module 'vue-vimeo-player' {
const plugin: any;
export default plugin;
}
declare module 'vue-matomo' {
const plugin: any;
export default plugin;
}