diff --git a/webapp/cypress.json b/webapp/cypress.json index 85b21d154..7447aa6d7 100644 --- a/webapp/cypress.json +++ b/webapp/cypress.json @@ -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 diff --git a/webapp/cypress/config.json b/webapp/cypress/config.json index 028c42ec7..b27299000 100644 --- a/webapp/cypress/config.json +++ b/webapp/cypress/config.json @@ -1,11 +1,13 @@ { - "serverRoot": "http://localhost:8088", - "port": 8088, - "dbtype": "sqlite3", - "dbconfig": "file::memory:?cache=shared", - "useSSL": false, - "webpath": "../pack", - "filespath": "../../files", - "telemetry": false, - "webhook_update": [] + "serverRoot": "http://localhost:8088", + "port": 8088, + "dbtype": "sqlite3", + "dbconfig": "file::memory:?cache=shared", + "useSSL": false, + "webpath": "../pack", + "filespath": "../../files", + "telemetry": false, + "webhook_update": [], + "session_expire_time": 2592000, + "session_refresh_time": 18000 } diff --git a/webapp/cypress/global.d.ts b/webapp/cypress/global.d.ts new file mode 100644 index 000000000..293611b2c --- /dev/null +++ b/webapp/cypress/global.d.ts @@ -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 + apiChangePassword: (userId: string, oldPassword: string, newPassword: string) => Chainable + apiInitServer: () => Chainable + } +} diff --git a/webapp/cypress/integration/createBoard.js b/webapp/cypress/integration/createBoard.js deleted file mode 100644 index 2cbbc4ddd..000000000 --- a/webapp/cypress/integration/createBoard.js +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/* eslint-disable max-nested-callbacks */ - -/// - -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'); - }); -}); diff --git a/webapp/cypress/integration/createBoard.ts b/webapp/cypress/integration/createBoard.ts new file mode 100644 index 000000000..d7f8bed78 --- /dev/null +++ b/webapp/cypress/integration/createBoard.ts @@ -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') + }) +}) diff --git a/webapp/cypress/integration/homepage.js b/webapp/cypress/integration/homepage.js deleted file mode 100644 index 25a8e006f..000000000 --- a/webapp/cypress/integration/homepage.js +++ /dev/null @@ -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'); - }); -}); diff --git a/webapp/cypress/integration/loginActions.ts b/webapp/cypress/integration/loginActions.ts new file mode 100644 index 000000000..24943de44 --- /dev/null +++ b/webapp/cypress/integration/loginActions.ts @@ -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)) + } +}) diff --git a/webapp/cypress/support/commands.js b/webapp/cypress/support/commands.js deleted file mode 100644 index ca4d256f3..000000000 --- a/webapp/cypress/support/commands.js +++ /dev/null @@ -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) => { ... }) diff --git a/webapp/cypress/support/commands.ts b/webapp/cypress/support/commands.ts new file mode 100644 index 000000000..454426edd --- /dev/null +++ b/webapp/cypress/support/commands.ts @@ -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, + }) +}) diff --git a/webapp/cypress/support/index.js b/webapp/cypress/support/index.js deleted file mode 100644 index d68db96df..000000000 --- a/webapp/cypress/support/index.js +++ /dev/null @@ -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') diff --git a/webapp/cypress/support/index.ts b/webapp/cypress/support/index.ts new file mode 100644 index 000000000..da5414ead --- /dev/null +++ b/webapp/cypress/support/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import './commands' diff --git a/webapp/cypress/tsconfig.json b/webapp/cypress/tsconfig.json index 3569258ce..7ae64dab0 100644 --- a/webapp/cypress/tsconfig.json +++ b/webapp/cypress/tsconfig.json @@ -2,9 +2,11 @@ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": true, - "types": ["cypress"] + "types": [ + "cypress" + ] }, "include": [ "**/*.ts" - ] + ] } diff --git a/webapp/package.json b/webapp/package.json index f2b5210fb..b7150ebc6 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -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", diff --git a/webapp/src/pages/boardPage.tsx b/webapp/src/pages/boardPage.tsx index f83fdcc66..2a068b7f9 100644 --- a/webapp/src/pages/boardPage.tsx +++ b/webapp/src/pages/boardPage.tsx @@ -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) } - wsClient.unsubscribeToWorkspace(match.params.workspaceId || '0') + 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') diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index 74deefe23..aa65defca 100644 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -1,27 +1,28 @@ { - "compilerOptions": { - "jsx": "react", - "target": "es2019", - "module": "commonjs", - "esModuleInterop": true, - "noImplicitAny": true, - "strict": true, - "strictNullChecks": true, - "forceConsistentCasingInFileNames": true, - "sourceMap": true, - "allowJs": true, - "resolveJsonModule": true, - "incremental": false, - "outDir": "./dist", - "moduleResolution": "node" - }, - "include": [ - "." - ], - "exclude": [ + "compilerOptions": { + "jsx": "react", + "target": "es2019", + "module": "commonjs", + "esModuleInterop": true, + "noImplicitAny": true, + "strict": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "allowJs": true, + "resolveJsonModule": true, + "incremental": false, + "baseUrl": "src", + "outDir": "./dist", + "moduleResolution": "node" + }, + "include": [ + "." + ], + "exclude": [ ".git", - "**/node_modules/*", - "dist", - "pack" + "**/node_modules/*", + "dist", + "pack" ] }