Wrote end-to-end cypress tests
This commit is contained in:
6
client/cypress/.eslintrc.json
Normal file
6
client/cypress/.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": ["plugin:cypress/recommended"],
|
||||
"rules": {
|
||||
"no-unused-expressions": 0 // chai assertions trigger this rule
|
||||
}
|
||||
}
|
||||
21
client/cypress/integration/authentication.spec.js
Normal file
21
client/cypress/integration/authentication.spec.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Authentication', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('creates guest account if user has no auth token', () => {
|
||||
cy.window()
|
||||
.its('localStorage.authToken')
|
||||
.should('be.undefined');
|
||||
|
||||
cy.window()
|
||||
.its('localStorage.authToken')
|
||||
.should('be.a', 'string')
|
||||
.and('not.be.empty');
|
||||
|
||||
cy.get(testid`list-issue`).should('have.length', 7);
|
||||
});
|
||||
});
|
||||
35
client/cypress/integration/issueCreate.spec.js
Normal file
35
client/cypress/integration/issueCreate.spec.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue create', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/settings?modal-issue-create=true');
|
||||
});
|
||||
|
||||
it('validates form and creates issue successfully', () => {
|
||||
cy.get(testid`modal:issue-create`).within(() => {
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.get(testid`form-field:title`).should('contain', 'This field is required');
|
||||
|
||||
cy.selectOption('type', 'Story');
|
||||
cy.get('input[name="title"]').type('TEST_TITLE');
|
||||
cy.get('.ql-editor').type('TEST_DESCRIPTION');
|
||||
cy.selectOption('reporterId', 'Yoda');
|
||||
cy.selectOption('userIds', 'Gaben', 'Yoda');
|
||||
cy.selectOption('priority', 'High');
|
||||
|
||||
cy.get('button[type="submit"]').click();
|
||||
});
|
||||
|
||||
cy.get(testid`modal:issue-create`).should('not.exist');
|
||||
cy.contains('Issue has been successfully created.').should('exist');
|
||||
cy.location('pathname').should('equal', '/project/board');
|
||||
cy.location('search').should('be.empty');
|
||||
|
||||
cy.contains(testid`list-issue`, 'TEST_TITLE')
|
||||
.should('have.descendants', testid`avatar:Gaben`)
|
||||
.and('have.descendants', testid`avatar:Yoda`)
|
||||
.and('have.descendants', testid`icon:story`);
|
||||
});
|
||||
});
|
||||
168
client/cypress/integration/issueDetails.spec.js
Normal file
168
client/cypress/integration/issueDetails.spec.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue details', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
getListIssue().click(); // open issue details modal
|
||||
});
|
||||
|
||||
it('updates type, status, assignees, reporter, priority successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.selectOption('type', 'Story');
|
||||
cy.selectOption('status', 'Done');
|
||||
cy.selectOption('assignees', 'Gaben', 'Yoda');
|
||||
cy.selectOption('reporter', 'Yoda');
|
||||
cy.selectOption('priority', 'Medium');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.selectShouldContain('type', 'Story');
|
||||
cy.selectShouldContain('status', 'Done');
|
||||
cy.selectShouldContain('assignees', 'Gaben', 'Yoda');
|
||||
cy.selectShouldContain('reporter', 'Yoda');
|
||||
cy.selectShouldContain('priority', 'Medium');
|
||||
});
|
||||
|
||||
getListIssue()
|
||||
.should('have.descendants', testid`avatar:Gaben`)
|
||||
.and('have.descendants', testid`avatar:Yoda`)
|
||||
.and('have.descendants', testid`icon:story`);
|
||||
});
|
||||
});
|
||||
|
||||
it('updates title, description successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get('textarea[placeholder="Short summary"]')
|
||||
.clear()
|
||||
.type('TEST_TITLE')
|
||||
.blur();
|
||||
|
||||
cy.contains('Add a description...')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('.ql-editor').type('TEST_DESCRIPTION');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get('textarea[placeholder="Short summary"]').should('have.value', 'TEST_TITLE');
|
||||
cy.get('.ql-editor').should('contain', 'TEST_DESCRIPTION');
|
||||
});
|
||||
|
||||
cy.get(testid`list-issue`).should('contain', 'TEST_TITLE');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates estimate, time tracking successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
getNumberInputAtIndex(0).debounced('type', '10');
|
||||
cy.contains('10h estimated').click(); // open tracking modal
|
||||
});
|
||||
|
||||
cy.get(testid`modal:tracking`).within(() => {
|
||||
cy.contains('No time logged').should('exist');
|
||||
getNumberInputAtIndex(0).debounced('type', 1);
|
||||
|
||||
cy.get('div[width="10"]').should('exist'); // tracking bar
|
||||
getNumberInputAtIndex(1).debounced('type', 2);
|
||||
|
||||
cy.contains('button', 'Done')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
getNumberInputAtIndex(0).should('have.value', '10');
|
||||
cy.contains('1h logged').should('exist');
|
||||
cy.contains('2h remaining').should('exist');
|
||||
cy.get('div[width*="33.3333"]').should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes an issue successfully', () => {
|
||||
getIssueDetailsModal()
|
||||
.find(`button ${testid`icon:trash`}`)
|
||||
.click();
|
||||
|
||||
cy.get(testid`modal:confirm`)
|
||||
.contains('button', 'Delete issue')
|
||||
.click();
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueDetailsModal().should('not.exist');
|
||||
getListIssue().should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a comment successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.contains('Add a comment...')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('textarea[placeholder="Add a comment..."]').type('TEST_COMMENT');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.contains('Add a comment...').should('exist');
|
||||
cy.get(testid`issue-comment`).should('contain', 'TEST_COMMENT');
|
||||
});
|
||||
});
|
||||
|
||||
it('edits a comment successfully', () => {
|
||||
getIssueDetailsModal().within(() => {
|
||||
cy.get(testid`issue-comment`)
|
||||
.first()
|
||||
.contains('Edit')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get('textarea[placeholder="Add a comment..."]')
|
||||
.should('have.value', 'Comment body')
|
||||
.clear()
|
||||
.type('TEST_COMMENT_EDITED');
|
||||
|
||||
cy.contains('button', 'Save')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
cy.get(testid`issue-comment`)
|
||||
.first()
|
||||
.should('contain', 'Edit')
|
||||
.and('contain', 'TEST_COMMENT_EDITED');
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a comment successfully', () => {
|
||||
getIssueDetailsModal()
|
||||
.find(testid`issue-comment`)
|
||||
.first()
|
||||
.contains('Delete')
|
||||
.click();
|
||||
|
||||
cy.get(testid`modal:confirm`)
|
||||
.contains('button', 'Delete comment')
|
||||
.click()
|
||||
.should('not.exist');
|
||||
|
||||
getIssueDetailsModal()
|
||||
.find(testid`issue-comment`)
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
const getIssueDetailsModal = () => cy.get(testid`modal:issue-details`);
|
||||
const getListIssue = () => cy.contains(testid`list-issue`, 'Issue title 1');
|
||||
const getNumberInputAtIndex = index => cy.get('input[placeholder="Number"]').eq(index);
|
||||
});
|
||||
35
client/cypress/integration/issueFilters.spec.js
Normal file
35
client/cypress/integration/issueFilters.spec.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue filters', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
});
|
||||
|
||||
it('filters issues', () => {
|
||||
getSearchInput().debounced('type', 'Issue title 1');
|
||||
assertIssuesCount(1);
|
||||
getSearchInput().debounced('clear');
|
||||
assertIssuesCount(3);
|
||||
|
||||
getUserAvatar().click();
|
||||
assertIssuesCount(2);
|
||||
getUserAvatar().click();
|
||||
assertIssuesCount(3);
|
||||
|
||||
getMyOnlyButton().click();
|
||||
assertIssuesCount(2);
|
||||
getMyOnlyButton().click();
|
||||
assertIssuesCount(3);
|
||||
|
||||
getRecentButton().click();
|
||||
assertIssuesCount(3);
|
||||
});
|
||||
|
||||
const getSearchInput = () => cy.get(testid`board-filters`).find('input');
|
||||
const getUserAvatar = () => cy.get(testid`board-filters`).find(testid`avatar:Gaben`);
|
||||
const getMyOnlyButton = () => cy.get(testid`board-filters`).contains('Only My Issues');
|
||||
const getRecentButton = () => cy.get(testid`board-filters`).contains('Recently Updated');
|
||||
const assertIssuesCount = count => cy.get(testid`list-issue`).should('have.length', count);
|
||||
});
|
||||
50
client/cypress/integration/issueSearch.spec.js
Normal file
50
client/cypress/integration/issueSearch.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issue search', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board?modal-issue-search=true');
|
||||
});
|
||||
|
||||
it('displays recent issues if search input is empty', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.contains('Recent Issues').should('exist');
|
||||
getIssues().should('have.length', 3);
|
||||
|
||||
cy.get('input').debounced('type', 'anything');
|
||||
cy.contains('Recent Issues').should('not.exist');
|
||||
|
||||
cy.get('input').debounced('clear');
|
||||
cy.contains('Recent Issues').should('exist');
|
||||
getIssues().should('have.length', 3);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays matching issues successfully', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.get('input').debounced('type', 'Issue');
|
||||
getIssues().should('have.length', 3);
|
||||
|
||||
cy.get('input').debounced('type', ' description');
|
||||
getIssues().should('have.length', 2);
|
||||
|
||||
cy.get('input').debounced('type', ' 3');
|
||||
getIssues().should('have.length', 1);
|
||||
|
||||
cy.contains('Matching Issues').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays message if no results were found', () => {
|
||||
getIssueSearchModal().within(() => {
|
||||
cy.get('input').debounced('type', 'gibberish');
|
||||
|
||||
getIssues().should('not.exist');
|
||||
cy.contains("We couldn't find anything matching your search").should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
const getIssueSearchModal = () => cy.get(testid`modal:issue-search`);
|
||||
const getIssues = () => cy.get('a[href*="/project/board/issues/"]');
|
||||
});
|
||||
48
client/cypress/integration/issuesDragDrop.spec.js
Normal file
48
client/cypress/integration/issuesDragDrop.spec.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { KeyCodes } from 'shared/constants/keyCodes';
|
||||
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Issues drag & drop', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/board');
|
||||
});
|
||||
|
||||
it('moves issue between different lists', () => {
|
||||
cy.get(testid`board-list:backlog`).should('contain', firstIssueTitle);
|
||||
cy.get(testid`board-list:selected`).should('not.contain', firstIssueTitle);
|
||||
moveFirstIssue(KeyCodes.ARROW_RIGHT);
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
cy.get(testid`board-list:backlog`).should('not.contain', firstIssueTitle);
|
||||
cy.get(testid`board-list:selected`).should('contain', firstIssueTitle);
|
||||
});
|
||||
});
|
||||
|
||||
it('moves issue within a single list', () => {
|
||||
getIssueAtIndex(0).should('contain', firstIssueTitle);
|
||||
getIssueAtIndex(1).should('contain', secondIssueTitle);
|
||||
moveFirstIssue(KeyCodes.ARROW_DOWN);
|
||||
|
||||
cy.assertReloadAssert(() => {
|
||||
getIssueAtIndex(0).should('contain', secondIssueTitle);
|
||||
getIssueAtIndex(1).should('contain', firstIssueTitle);
|
||||
});
|
||||
});
|
||||
|
||||
const firstIssueTitle = 'Issue title 1';
|
||||
const secondIssueTitle = 'Issue title 2';
|
||||
|
||||
const getIssueAtIndex = index => cy.get(testid`list-issue`).eq(index);
|
||||
|
||||
const moveFirstIssue = directionKeyCode => {
|
||||
cy.waitForXHR('PUT', '/issues/**', () => {
|
||||
getIssueAtIndex(0)
|
||||
.focus()
|
||||
.trigger('keydown', { keyCode: KeyCodes.SPACE })
|
||||
.trigger('keydown', { keyCode: directionKeyCode, force: true })
|
||||
.trigger('keydown', { keyCode: KeyCodes.SPACE, force: true });
|
||||
});
|
||||
};
|
||||
});
|
||||
34
client/cypress/integration/projectSettings.spec.js
Normal file
34
client/cypress/integration/projectSettings.spec.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { testid } from '../support/utils';
|
||||
|
||||
describe('Project settings', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.createTestAccount();
|
||||
cy.visit('/project/settings');
|
||||
});
|
||||
|
||||
it('should display current values in form', () => {
|
||||
cy.get('input[name="name"]').should('have.value', 'Project name');
|
||||
cy.get('input[name="url"]').should('have.value', 'https://www.testurl.com');
|
||||
cy.get('.ql-editor').should('contain', 'Project description');
|
||||
cy.selectShouldContain('category', 'Software');
|
||||
});
|
||||
|
||||
it('validates form and updates project successfully', () => {
|
||||
cy.get('input[name="name"]').clear();
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.get(testid`form-field:name`).should('contain', 'This field is required');
|
||||
|
||||
cy.get('input[name="name"]').type('TEST_NAME');
|
||||
cy.get(testid`form-field:name`).should('not.contain', 'This field is required');
|
||||
|
||||
cy.selectOption('category', 'Business');
|
||||
cy.get('button[type="submit"]').click();
|
||||
cy.contains('Changes have been saved successfully.').should('exist');
|
||||
|
||||
cy.reload();
|
||||
|
||||
cy.get('input[name="name"]').should('have.value', 'TEST_NAME');
|
||||
cy.selectShouldContain('category', 'Business');
|
||||
});
|
||||
});
|
||||
22
client/cypress/plugins/index.js
Normal file
22
client/cypress/plugins/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/* eslint-disable global-require */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
const webpack = require('@cypress/webpack-preprocessor');
|
||||
const webpackOptions = require('../../webpack.config.js');
|
||||
|
||||
module.exports = on => {
|
||||
on('file:preprocessor', webpack({ webpackOptions }));
|
||||
};
|
||||
75
client/cypress/support/commands.js
Normal file
75
client/cypress/support/commands.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import '@4tw/cypress-drag-drop';
|
||||
|
||||
import { objectToQueryString } from 'shared/utils/url';
|
||||
import { getStoredAuthToken, storeAuthToken } from 'shared/utils/authToken';
|
||||
|
||||
import { testid } from './utils';
|
||||
|
||||
Cypress.Commands.add('selectOption', (selectName, ...optionLabels) => {
|
||||
optionLabels.forEach(optionLabel => {
|
||||
cy.get(testid`select:${selectName}`).click('bottomRight');
|
||||
cy.get(testid`select-option:${optionLabel}`).click();
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('selectShouldContain', (selectName, ...optionLabels) => {
|
||||
optionLabels.forEach(optionLabel => {
|
||||
cy.get(testid`select:${selectName}`).should('contain', optionLabel);
|
||||
});
|
||||
});
|
||||
|
||||
// We don't want to waste time when running tests on cypress waiting for debounced
|
||||
// inputs. We can use tick() to speed up time and trigger onChange immediately.
|
||||
Cypress.Commands.add('debounced', { prevSubject: true }, (input, action, value) => {
|
||||
cy.clock();
|
||||
cy.wrap(input)[action](value);
|
||||
cy.tick(1000);
|
||||
});
|
||||
|
||||
// Sometimes cypress fails to properly wait for api requests to finish which results
|
||||
// in flaky tests, and in those cases we need to explicitly tell it to wait
|
||||
// https://docs.cypress.io/guides/guides/network-requests.html#Flake
|
||||
Cypress.Commands.add('waitForXHR', (method, url, funcThatTriggersXHR) => {
|
||||
const alias = method + url;
|
||||
cy.server();
|
||||
cy.route(method, url).as(alias);
|
||||
funcThatTriggersXHR();
|
||||
cy.wait(`@${alias}`);
|
||||
});
|
||||
|
||||
// We're using optimistic updates (not waiting for API response before updating
|
||||
// the local data and UI) in a lot of places in the app. That's why we want to assert
|
||||
// both the immediate local UI change in the first assert, and if the change was
|
||||
// successfully persisted by the API in the second assert after page reload
|
||||
Cypress.Commands.add('assertReloadAssert', assertFunc => {
|
||||
assertFunc();
|
||||
cy.reload();
|
||||
assertFunc();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('apiRequest', (method, url, variables = {}, options = {}) => {
|
||||
cy.request({
|
||||
method,
|
||||
url: `${Cypress.env('apiBaseUrl')}${url}`,
|
||||
qs: method === 'GET' ? objectToQueryString(variables) : undefined,
|
||||
body: method !== 'GET' ? variables : undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: getStoredAuthToken() ? `Bearer ${getStoredAuthToken()}` : undefined,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('resetDatabase', () => {
|
||||
cy.apiRequest('DELETE', '/test/reset-database');
|
||||
});
|
||||
|
||||
Cypress.Commands.add('createTestAccount', () => {
|
||||
cy.apiRequest('POST', '/test/create-account').then(response => {
|
||||
storeAuthToken(response.body.authToken);
|
||||
});
|
||||
});
|
||||
16
client/cypress/support/index.js
Normal file
16
client/cypress/support/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// ***********************************************************
|
||||
// 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';
|
||||
4
client/cypress/support/utils.js
Normal file
4
client/cypress/support/utils.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const testid = (strings, ...values) => {
|
||||
const id = strings.map((str, index) => str + (values[index] || '')).join('');
|
||||
return `[data-testid="${id}"]`;
|
||||
};
|
||||
Reference in New Issue
Block a user