1
0
mirror of https://github.com/mattermost/focalboard.git synced 2024-11-27 08:31:20 +02:00

[GH-42] Cypress tests for login actions (#1679)

* Testing API added to server:
 - registered only if `enableTestingAPI` is set to `true` in the config file
 - has only one route `test/reset`
 - reset clears the tables in db for blocks, users, sessions
 - functions `DeleteAllBlocks` and `DeleteAllUsers` added to `Store` interface
 - new functions implemented for `SQLStore`

* Cypress tests (initial version) for login actions added:

  - redirect to login page,
  - register user,
  - test for loading home page deleted,
  - allow js in `tsconfig.json` for cypress tests.

* Cypress tests for login actions:

  - check that main page with workspace is visible after registration,
  - initial version of test for login of register user.

* Cypress tests for login actions:

  - function for checking that workspace is available added,
  - functions for login and logout added,
  - test for password change added,
  - session parameters added to server config for cypress testing.

* Switch Cypress tests to typescript.

* Use ids for inputs instead of placeholder text.

* Use cypress request for login without loading login page.

* Cypress custom command for login added.

* Cypress tests fixed:
 - new cypress commands for server reset, register/login user
 - single test for "create and delete board/card"
 - fixes for `BoardPage` component useEffect callbacks
 - npm script `runserver-test` doesn't use single user mode

* Deletion of all blocks changed:
 - also deletes blocks from history
 - public function renamed to DeleteAllBlocksPermanently
 - code for mocks and public methods generated

* Server tests for files fixed on windows.

* Cypress tests for the registration of second user via invite link added.

* Added `baseUrl` in main `tsconfig.json` (required by cypress configuration).

* Cypress test fixed. Comments as well as log messages added.

* Log a message if testing API is enabled.

* Single cypress test for register/login actions.

* Revert changes to server.

* More convenient cypress commands:
  - all API calls made as separate commands
  - declarations for commands moved to separate global.d.ts file
  - utility functions moved after test actions in 'Login actions' test
This commit is contained in:
kamre 2021-11-22 18:59:01 +03:00 committed by GitHub
parent e0657dad9a
commit a498149a76
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 378 additions and 225 deletions

View File

@ -1,6 +1,15 @@
{
"chromeWebSecurity": false,
"baseUrl": "http://localhost:8088",
"testFiles": [
"**/login*.ts",
"**/create*.ts"
],
"env": {
"username": "test-user",
"password": "test-password",
"email": "test@mail.com"
},
"video": false,
"viewportWidth": 1600,
"viewportHeight": 1200

View File

@ -7,5 +7,7 @@
"webpath": "../pack",
"filespath": "../../files",
"telemetry": false,
"webhook_update": []
"webhook_update": [],
"session_expire_time": 2592000,
"session_refresh_time": 18000
}

19
webapp/cypress/global.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare namespace Cypress {
type LoginData = {
username: string
password: string
}
type UserData = LoginData & {
email: string
}
interface Chainable {
apiRegisterUser: (data: UserData, token?: string, failOnError?: boolean) => Chainable
apiLoginUser: (data: LoginData) => Chainable
apiGetMe: () => Chainable<string>
apiChangePassword: (userId: string, oldPassword: string, newPassword: string) => Chainable
apiInitServer: () => Chainable
}
}

View File

@ -1,134 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-nested-callbacks */
/// <reference types="Cypress" />
describe('Create and delete board / card', () => {
const timestamp = new Date().toLocaleString();
const boardTitle = `Test Board (${timestamp})`;
const cardTitle = `Test Card (${timestamp})`;
beforeEach(() => {
localStorage.setItem('focalboardSessionId', 'TESTTOKEN');
localStorage.setItem('language', 'en');
cy.expect(localStorage.getItem('focalboardSessionId')).to.eq('TESTTOKEN');
localStorage.setItem('welcomePageViewed', 'true');
});
it('Can create and delete a board and card', () => {
cy.visit('/');
cy.contains('+ Add board').click({force: true});
cy.contains('Empty board').click({force: true});
cy.get('.BoardComponent').should('exist');
});
it('Can set the board title', () => {
// Board title
cy.get('.Editable.title').
type(boardTitle).
type('{enter}').
should('have.value', boardTitle);
});
it('Can hide and show the sidebar with active board', async () => {
// Hide and show the sidebar option available on mobile devices
cy.viewport(767, 1024);
cy.get('.sidebarSwitcher').click();
cy.get('.Sidebar .heading').should('not.exist');
cy.get('.Sidebar .show-button').click();
cy.get('.Sidebar .heading').should('exist');
cy.viewport(1024, 1024);
cy.get('.sidebarSwitcher').should('not.exist');
});
it('Can rename the board view', () => {
// Rename board view
const boardViewTitle = `Test board (${timestamp})`;
cy.get('.ViewHeader>.Editable[title=\'Board view\']').should('exist');
cy.get('.ViewHeader>.Editable').
clear().
type(boardViewTitle).
type('{esc}');
cy.get(`.ViewHeader .Editable[title='${boardViewTitle}']`).should('exist');
});
it('Can create a card', () => {
// Create card
cy.get('.ViewHeader').contains('New').click();
cy.get('.CardDetail').should('exist');
});
it('Can set the card title', () => {
// Card title
cy.get('.CardDetail .EditableArea.title').
type(cardTitle).
type('{enter}').
should('have.value', cardTitle);
// Close card
cy.get('.Dialog.dialog-back .wrapper').click({force: true});
});
it('Can create a card by clicking on the + button', () => {
// Create a card by clicking on the + button
cy.get('.KanbanColumnHeader :nth-child(5) > .CompassIcon ').click();
cy.get('.CardDetail').should('exist');
// Close card
cy.get('.Dialog.dialog-back .wrapper').click({force: true});
});
it('Can create a table view', () => {
// Create table view
// cy.intercept('POST', '/api/v1/blocks').as('insertBlocks');
cy.get('.ViewHeader').get('.DropdownIcon').first().parent().click();
cy.get('.ViewHeader').contains('Add view').click();
cy.get('.ViewHeader').contains('Add view').click();
cy.get('.ViewHeader').contains('Add view').parent().contains('Table').click();
// cy.wait('@insertBlocks');
// Wait for round-trip to complete and DOM to update
cy.get('.ViewHeader .Editable[title=\'Table view\']').should('exist');
// Card should exist in table
cy.get(`.TableRow [value='${cardTitle}']`).should('exist');
});
it('Can rename the table view', () => {
// Rename table view
const tableViewTitle = `Test table (${timestamp})`;
cy.get('.ViewHeader .Editable[title=\'Table view\']').
clear().
type(tableViewTitle).
type('{esc}');
cy.get(`.ViewHeader .Editable[title='${tableViewTitle}']`).should('exist');
});
it('Can sort the table', () => {
// Sort
cy.get('.ViewHeader').contains('Sort').click();
cy.get('.ViewHeader').contains('Sort').parent().contains('Name').click();
});
it('Can delete the board', () => {
// Delete board
cy.get('.Sidebar .octo-sidebar-list').
contains(boardTitle).first().
next().
find('.Button.IconButton').
click({force: true});
cy.contains('Delete board').click({force: true});
cy.get('.DeleteBoardDialog button.danger').click({force: true});
// Board should not exist
cy.contains(boardTitle).should('not.exist');
});
});

View File

@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
describe('Create and delete board / card', () => {
const timestamp = new Date().toLocaleString()
const boardTitle = `Test Board (${timestamp})`
const cardTitle = `Test Card (${timestamp})`
beforeEach(() => {
cy.apiInitServer()
localStorage.setItem('welcomePageViewed', 'true')
})
it('Can create and delete a board and a card', () => {
cy.visit('/')
// Create new empty board
cy.log('**Create new empty board**')
cy.contains('+ Add board').click({force: true})
cy.contains('Empty board').click({force: true})
cy.get('.BoardComponent').should('exist')
// Change board title
cy.log('**Change board title**')
cy.get('.Editable.title').
type(boardTitle).
type('{enter}').
should('have.value', boardTitle)
// Hide and show the sidebar
cy.log('**Hide and show the sidebar**')
cy.get('.sidebarSwitcher').click()
cy.get('.Sidebar .heading').should('not.exist')
cy.get('.Sidebar .show-button').click()
cy.get('.Sidebar .heading').should('exist')
// Rename board view
cy.log('**Rename board view**')
const boardViewTitle = `Test board (${timestamp})`
cy.get(".ViewHeader>.Editable[title='Board view']").should('exist')
cy.get('.ViewHeader>.Editable').
clear().
type(boardViewTitle).
type('{esc}')
cy.get(`.ViewHeader .Editable[title='${boardViewTitle}']`).should('exist')
// Create card
cy.log('**Create card**')
cy.get('.ViewHeader').contains('New').click()
cy.get('.CardDetail').should('exist')
// Change card title
cy.log('**Change card title**')
cy.get('.CardDetail .EditableArea.title').
type(cardTitle).
type('{enter}').
should('have.value', cardTitle)
// Close card dialog
cy.log('**Close card dialog**')
cy.get('.Dialog.dialog-back .wrapper').click({force: true})
// Create a card by clicking on the + button
cy.log('**Create a card by clicking on the + button**')
cy.get('.KanbanColumnHeader .Button .AddIcon').click()
cy.get('.CardDetail').should('exist')
cy.get('.Dialog.dialog-back .wrapper').click({force: true})
// Create table view
cy.log('**Create table view**')
cy.get('.ViewHeader').get('.DropdownIcon').first().parent().click()
cy.get('.ViewHeader').contains('Add view').click()
cy.get('.ViewHeader').contains('Add view').click()
cy.get('.ViewHeader').
contains('Add view').
parent().
contains('Table').
click()
cy.get(".ViewHeader .Editable[title='Table view']").should('exist')
cy.get(`.TableRow [value='${cardTitle}']`).should('exist')
// Rename table view
cy.log('**Rename table view**')
const tableViewTitle = `Test table (${timestamp})`
cy.get(".ViewHeader .Editable[title='Table view']").
clear().
type(tableViewTitle).
type('{esc}')
cy.get(`.ViewHeader .Editable[title='${tableViewTitle}']`).should('exist')
// Sort the table
cy.log('**Sort the table**')
cy.get('.ViewHeader').contains('Sort').click()
cy.get('.ViewHeader').
contains('Sort').
parent().
contains('Name').
click()
// Delete board
cy.log('**Delete board**')
cy.get('.Sidebar .octo-sidebar-list').
contains(boardTitle).
first().
next().
find('.Button.IconButton').
click({force: true})
cy.contains('Delete board').click({force: true})
cy.get('.DeleteBoardDialog button.danger').click({force: true})
cy.contains(boardTitle).should('not.exist')
})
})

View File

@ -1,9 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
describe('Load homepage', () => {
it('Can load homepage', () => {
cy.visit('/');
cy.get('div#focalboard-app').should('exist');
});
});

View File

@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
describe('Login actions', () => {
const username = Cypress.env('username')
const email = Cypress.env('email')
const password = Cypress.env('password')
it('Can perform login/register actions', () => {
// Redirects to login page
cy.log('**Redirects to login page**')
cy.visit('/')
cy.location('pathname').should('eq', '/login')
cy.get('.LoginPage').contains('Log in')
cy.get('#login-username').should('exist')
cy.get('#login-password').should('exist')
cy.get('button').contains('Log in')
cy.get('a').contains('create an account')
// Can register a user
cy.log('**Can register a user**')
cy.visit('/login')
cy.get('a').contains('create an account').click()
cy.location('pathname').should('eq', '/register')
cy.get('.RegisterPage').contains('Sign up')
cy.get('#login-email').type(email)
cy.get('#login-username').type(username)
cy.get('#login-password').type(password)
cy.get('button').contains('Register').click()
workspaceIsAvailable()
// Can log out user
cy.log('**Can log out user**')
cy.get('.SidebarUserMenu').click()
cy.get('.menu-name').contains('Log out').click()
cy.location('pathname').should('eq', '/login')
// User should not be logged in automatically
cy.log('**User should not be logged in automatically**')
cy.visit('/')
cy.location('pathname').should('eq', '/login')
// Can log in registered user
cy.log('**Can log in registered user**')
loginUser(password)
// Can change password
cy.log('**Can change password**')
const newPassword = 'new_password'
cy.get('.SidebarUserMenu').click()
cy.get('.menu-name').contains('Change password').click()
cy.location('pathname').should('eq', '/change_password')
cy.get('.ChangePasswordPage').contains('Change Password')
cy.get('#login-oldpassword').type(password)
cy.get('#login-newpassword').type(newPassword)
cy.get('button').contains('Change password').click()
cy.get('.succeeded').click()
workspaceIsAvailable()
// Can log in user with new password
cy.log('**Can log in user with new password**')
loginUser(newPassword).then(() => resetPassword(newPassword))
// Can't register second user without invite link
cy.log('**Can\'t register second user without invite link**')
cy.visit('/register')
cy.get('#login-email').type(email)
cy.get('#login-username').type(username)
cy.get('#login-password').type(password)
cy.get('button').contains('Register').click()
cy.get('.error').contains('Invalid registration link').should('exist')
// Can register second user using invite link
cy.log('**Can register second user using invite link**')
// Copy invite link
cy.log('**Copy invite link**')
loginUser(password)
cy.get('.Sidebar .SidebarUserMenu').click()
cy.get('.menu-name').contains('Invite users').click()
cy.get('.Button').contains('Copy link').click()
cy.get('.Button').contains('Copied').should('exist')
cy.get('a.shareUrl').invoke('attr', 'href').then((inviteLink) => {
// Log out existing user
cy.log('**Log out existing user**')
cy.get('.Sidebar .SidebarUserMenu').click()
cy.get('.menu-name').contains('Log out').click()
// Register a new user
cy.log('**Register new user**')
cy.visit(inviteLink as string)
cy.get('#login-email').type('new-user@mail.com')
cy.get('#login-username').type('new-user')
cy.get('#login-password').type('new-password')
cy.get('button').contains('Register').click()
workspaceIsAvailable()
})
})
const workspaceIsAvailable = () => {
cy.location('pathname').should('eq', '/')
cy.get('.Workspace').should('exist')
return cy.get('.Sidebar').should('exist')
}
const loginUser = (withPassword: string) => {
cy.visit('/login')
cy.get('#login-username').type(username)
cy.get('#login-password').type(withPassword)
cy.get('button').contains('Log in').click()
return workspaceIsAvailable()
}
const resetPassword = (oldPassword: string) => {
cy.apiGetMe().then((userId) => cy.apiChangePassword(userId, oldPassword, password))
}
})

View File

@ -1,25 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
Cypress.Commands.add('apiRegisterUser', (data: Cypress.UserData, token?: string, failOnError?: boolean) => {
return cy.request({
method: 'POST',
url: '/api/v1/register',
body: {
...data,
token,
},
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
failOnStatusCode: failOnError,
})
})
Cypress.Commands.add('apiLoginUser', (data: Cypress.LoginData) => {
return cy.request({
method: 'POST',
url: '/api/v1/login',
body: {
...data,
type: 'normal',
},
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
}).then((response) => {
expect(response.body).to.have.property('token')
localStorage.setItem('focalboardSessionId', response.body.token)
})
})
Cypress.Commands.add('apiInitServer', () => {
const data: Cypress.UserData = {
username: Cypress.env('username'),
password: Cypress.env('password'),
email: Cypress.env('email'),
}
return cy.apiRegisterUser(data, '', false).apiLoginUser(data)
})
const headers = () => ({
headers: {
'X-Requested-With': 'XMLHttpRequest',
Authorization: `Bearer ${localStorage.getItem('focalboardSessionId')}`,
},
})
Cypress.Commands.add('apiGetMe', () => {
return cy.request({
method: 'GET',
url: '/api/v1/users/me',
...headers(),
}).then((response) => response.body.id)
})
Cypress.Commands.add('apiChangePassword', (userId: string, oldPassword: string, newPassword: string) => {
const body = {oldPassword, newPassword}
return cy.request({
method: 'POST',
url: `/api/v1/users/${encodeURIComponent(userId)}/changepassword`,
...headers(),
body,
})
})

View File

@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js 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')

View File

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import './commands'

View File

@ -2,7 +2,9 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["cypress"]
"types": [
"cypress"
]
},
"include": [
"**/*.ts"

View File

@ -13,7 +13,7 @@
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache && stylelint --fix **/*.scss",
"fix:scss": "prettier --write './src/**/*.scss'",
"i18n-extract": "formatjs extract ../mattermost-plugin/webapp/src/*/*/*.ts? src/*/*/*.ts? src/*/*.ts? src/*.ts? --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json",
"runserver-test": "cd cypress && cross-env FOCALBOARD_SINGLE_USER_TOKEN=TESTTOKEN ../../bin/focalboard-server -single-user",
"runserver-test": "cd cypress && \"../../bin/focalboard-server\"",
"cypress:ci": "start-server-and-test runserver-test http://localhost:8088 cypress:run",
"cypress:run": "cypress run",
"cypress:run:chrome": "cypress run --browser chrome",

View File

@ -187,9 +187,11 @@ const BoardPage = (props: Props): JSX.Element => {
dispatch(loadAction(match.params.boardId))
let subscribedToWorkspace = false
if (wsClient.state === 'open') {
wsClient.authenticate(match.params.workspaceId || '0', token)
wsClient.subscribeToWorkspace(match.params.workspaceId || '0')
subscribedToWorkspace = true
}
const incrementalUpdate = (_: WSClient, blocks: Block[]) => {
@ -211,6 +213,7 @@ const BoardPage = (props: Props): JSX.Element => {
const newToken = localStorage.getItem('focalboardSessionId') || ''
wsClient.authenticate(match.params.workspaceId || '0', newToken)
wsClient.subscribeToWorkspace(match.params.workspaceId || '0')
subscribedToWorkspace = true
}
if (timeout) {
@ -220,6 +223,7 @@ const BoardPage = (props: Props): JSX.Element => {
if (newState === 'close') {
timeout = setTimeout(() => {
setWebsocketClosed(true)
subscribedToWorkspace = false
}, websocketTimeoutForBanner)
} else {
setWebsocketClosed(false)
@ -233,12 +237,14 @@ const BoardPage = (props: Props): JSX.Element => {
if (timeout) {
clearTimeout(timeout)
}
if (subscribedToWorkspace) {
wsClient.unsubscribeToWorkspace(match.params.workspaceId || '0')
}
wsClient.removeOnChange(incrementalUpdate)
wsClient.removeOnReconnect(() => dispatch(loadAction(match.params.boardId)))
wsClient.removeOnStateChange(updateWebsocketState)
}
}, [match.params.workspaceId, props.readonly])
}, [match.params.workspaceId, props.readonly, match.params.boardId])
useHotkeys('ctrl+z,cmd+z', () => {
Utils.log('Undo')

View File

@ -12,6 +12,7 @@
"allowJs": true,
"resolveJsonModule": true,
"incremental": false,
"baseUrl": "src",
"outDir": "./dist",
"moduleResolution": "node"
},