diff --git a/e2e/cypress/tests/integration/accessibility/accessibility_sidebar_spec.js b/e2e/cypress/tests/integration/accessibility/accessibility_sidebar_spec.ts similarity index 99% rename from e2e/cypress/tests/integration/accessibility/accessibility_sidebar_spec.js rename to e2e/cypress/tests/integration/accessibility/accessibility_sidebar_spec.ts index 5b235df63955..f344182310c3 100644 --- a/e2e/cypress/tests/integration/accessibility/accessibility_sidebar_spec.js +++ b/e2e/cypress/tests/integration/accessibility/accessibility_sidebar_spec.ts @@ -51,7 +51,7 @@ describe('Verify Accessibility Support in Channel Sidebar Navigation', () => { beforeEach(() => { // # Login as test user and visit the off-topic channel cy.apiLogin(testUser); - cy.apiSaveSidebarSettingPreference(); + (cy as any).apiSaveSidebarSettingPreference(); cy.visit(offTopicUrl); cy.get('#postListContent').should('be.visible'); }); diff --git a/e2e/cypress/tests/plugins/external_request.js b/e2e/cypress/tests/plugins/external_request.ts similarity index 69% rename from e2e/cypress/tests/plugins/external_request.js rename to e2e/cypress/tests/plugins/external_request.ts index 4f4f91d88b4a..6a9aefc9985e 100644 --- a/e2e/cypress/tests/plugins/external_request.js +++ b/e2e/cypress/tests/plugins/external_request.ts @@ -1,11 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -const axios = require('axios'); +import axios, {AxiosError, Method} from 'axios'; -const timeouts = require('../fixtures/timeouts'); +import * as timeouts from '../fixtures/timeouts'; -module.exports = async ({baseUrl, user, method = 'get', path, data = {}}) => { +export interface ExternalRequestUser{ + username: string; + password: string; +} +interface ExternalRequestArg { + baseUrl: string; + user: ExternalRequestUser; + method: Method; + path: string; + data: any; +} +type ExternalRequestResult = { status: number; statusText: string; data: any; isError?: boolean } | { data: { id: string; isTimeout: boolean }; status?: undefined; statusText?: undefined; isError?: undefined }; +export default async function externalRequest(arg: ExternalRequestArg): Promise { + const {baseUrl, user, method = 'get', path, data = {}} = arg; const loginUrl = `${baseUrl}/api/v4/users/login`; // First we need to login with our external user to get cookies/tokens @@ -20,7 +33,7 @@ module.exports = async ({baseUrl, user, method = 'get', path, data = {}}) => { }); const setCookie = response.headers['set-cookie']; - setCookie.forEach((cookie) => { + (setCookie as any).forEach((cookie: string) => { const nameAndValue = cookie.split(';')[0]; cookieString += nameAndValue + ';'; }); @@ -50,9 +63,9 @@ module.exports = async ({baseUrl, user, method = 'get', path, data = {}}) => { // If we have a response for the error, pull out the relevant parts return getErrorResponse(error); } -}; +} -function getErrorResponse(error) { +function getErrorResponse(error: AxiosError) { if (error.response) { return { status: error.response.status, diff --git a/e2e/cypress/tests/plugins/index.js b/e2e/cypress/tests/plugins/index.js index 18bc9925ada6..a8b26b7172ea 100644 --- a/e2e/cypress/tests/plugins/index.js +++ b/e2e/cypress/tests/plugins/index.js @@ -10,7 +10,7 @@ const { dbGetUserSession, dbUpdateUserSession, } = require('./db_request'); -const externalRequest = require('./external_request'); +const externalRequest = require('./external_request').default; const {fileExist, writeToFile} = require('./file_util'); const getPdfContent = require('./get_pdf_content'); const getRecentEmail = require('./get_recent_email'); diff --git a/e2e/cypress/tests/support/api/channel.d.ts b/e2e/cypress/tests/support/api/channel.d.ts index d5b5cd32e3a3..2f2049af5328 100644 --- a/e2e/cypress/tests/support/api/channel.d.ts +++ b/e2e/cypress/tests/support/api/channel.d.ts @@ -42,7 +42,7 @@ declare namespace Cypress { type?: string, purpose?: string, header?: string - ): Chainable; + ): Chainable<{channel: Channel}>; /** * Create a new direct message channel between two users. diff --git a/e2e/cypress/tests/support/api/user.d.ts b/e2e/cypress/tests/support/api/user.d.ts index 532af8ff3625..f5284aa25d8f 100644 --- a/e2e/cypress/tests/support/api/user.d.ts +++ b/e2e/cypress/tests/support/api/user.d.ts @@ -205,7 +205,7 @@ declare namespace Cypress { * @example * cy.apiCreateUser(options); */ - apiCreateUser(options: Record): Chainable; + apiCreateUser(options: Record): Chainable<{user: UserProfile}>; /** * Create a new guest user with an options to set name prefix and be able to bypass tutorial steps. diff --git a/e2e/cypress/tests/support/api_commands.js b/e2e/cypress/tests/support/api_commands.js deleted file mode 100644 index 5df56ac2d57f..000000000000 --- a/e2e/cypress/tests/support/api_commands.js +++ /dev/null @@ -1,417 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {getAdminAccount} from './env'; - -// ***************************************************************************** -// Read more: -// - https://on.cypress.io/custom-commands on writing Cypress commands -// - https://api.mattermost.com/ for Mattermost API reference -// ***************************************************************************** - -// ***************************************************************************** -// Commands -// https://api.mattermost.com/#tag/commands -// ***************************************************************************** - -/** - * Creates a command directly via API - * This API assume that the user is logged in and has required permission to create a command - * @param {Object} command - command to be created - */ -Cypress.Commands.add('apiCreateCommand', (command = {}) => { - const options = { - url: '/api/v4/commands', - headers: {'X-Requested-With': 'XMLHttpRequest'}, - method: 'POST', - body: command, - }; - - return cy.request(options).then((response) => { - expect(response.status).to.equal(201); - return cy.wrap({data: response.body, status: response.status}); - }); -}); - -// ***************************************************************************** -// Email -// ***************************************************************************** - -/** - * Test SMTP setup - */ -Cypress.Commands.add('apiEmailTest', () => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: '/api/v4/email/test', - method: 'POST', - }).then((response) => { - expect(response.status, 'SMTP not setup at sysadmin config').to.equal(200); - return cy.wrap(response); - }); -}); - -// ***************************************************************************** -// Posts -// https://api.mattermost.com/#tag/posts -// ***************************************************************************** - -/** - * Creates a post directly via API - * This API assume that the user is logged in and has cookie to access - * @param {String} channelId - Where to post - * @param {String} message - What to post - * @param {String} rootId - Parent post ID. Set to "" to avoid nesting - * @param {Object} props - Post props - * @param {String} token - Optional token to use for auth. If not provided - posts as current user - */ -Cypress.Commands.add('apiCreatePost', (channelId, message, rootId, props, token = '', failOnStatusCode = true) => { - const headers = {'X-Requested-With': 'XMLHttpRequest'}; - if (token !== '') { - headers.Authorization = `Bearer ${token}`; - } - return cy.request({ - headers, - failOnStatusCode, - url: '/api/v4/posts', - method: 'POST', - body: { - channel_id: channelId, - root_id: rootId, - message, - props, - }, - }); -}); - -/** - * Deletes a post directly via API - * @param {String} postId - Post ID - * @param {Object} [user] - the user trying to invoke the API - */ -Cypress.Commands.add('apiDeletePost', (postId, user = getAdminAccount()) => { - return cy.externalRequest({ - user, - method: 'delete', - path: `posts/${postId}`, - }).then((response) => { - // * Validate that request was successful - expect(response.status).to.equal(200); - return cy.wrap({status: response.status}); - }); -}); - -/** - * Creates a post directly via API - * This API assume that the user is logged in as admin - * @param {String} userDd - user for whom to create the token - */ -Cypress.Commands.add('apiCreateToken', (userId) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/users/${userId}/tokens`, - method: 'POST', - body: { - description: 'some text', - }, - }).then((response) => { - // * Validate that request was denied - expect(response.status).to.equal(200); - return cy.wrap({token: response.body.token}); - }); -}); - -/** - * Unpins pinned posts of given postID directly via API - * This API assume that the user is logged in and has cookie to access - * @param {Post} postId - Post ID of the pinned post to unpin - */ -Cypress.Commands.add('apiUnpinPosts', (postId) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: '/api/v4/posts/' + postId + '/unpin', - method: 'POST', - }); -}); - -// ***************************************************************************** -// Webhooks -// https://api.mattermost.com/#tag/webhooks -// ***************************************************************************** - -Cypress.Commands.add('apiCreateWebhook', (hook = {}, isIncoming = true) => { - const hookUrl = isIncoming ? '/api/v4/hooks/incoming' : '/api/v4/hooks/outgoing'; - const options = { - url: hookUrl, - headers: {'X-Requested-With': 'XMLHttpRequest'}, - method: 'POST', - body: hook, - }; - - return cy.request(options).then((response) => { - const data = response.body; - return cy.wrap({...data, url: isIncoming ? `${Cypress.config().baseUrl}/hooks/${data.id}` : ''}); - }); -}); - -/** - * Gets a team on the system - * * @param {String} teamId - The team ID to get - * All parameter required - */ - -Cypress.Commands.add('apiGetTeam', (teamId) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `api/v4/teams/${teamId}`, - method: 'GET', - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -}); - -/** - * Remove a User from a Channel directly via API - * @param {String} channelId - The channel ID - * @param {String} userId - The user ID - * All parameter required - */ -Cypress.Commands.add('removeUserFromChannel', (channelId, userId) => { - //Remove a User from a Channel - const baseUrl = Cypress.config('baseUrl'); - const admin = getAdminAccount(); - - cy.externalRequest({user: admin, method: 'delete', baseUrl, path: `channels/${channelId}/members/${userId}`}); -}); - -/** - * Remove a User from a Team directly via API - * @param {String} teamID - The team ID - * @param {String} userId - The user ID - * All parameter required - */ -Cypress.Commands.add('removeUserFromTeam', (teamId, userId) => { - //Remove a User from a Channel - const baseUrl = Cypress.config('baseUrl'); - const admin = getAdminAccount(); - - cy.externalRequest({user: admin, method: 'delete', baseUrl, path: `teams/${teamId}/members/${userId}`}); -}); - -/** - * Get LDAP Group Sync Job Status - * - */ -Cypress.Commands.add('apiGetLDAPSync', () => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: '/api/v4/jobs/type/ldap_sync?page=0&per_page=50', - method: 'GET', - timeout: 60000, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -}); - -// ***************************************************************************** -// Groups -// https://api.mattermost.com/#tag/groups -// ***************************************************************************** - -/** - * Get all groups via the API - * - * @param {Integer} page - The desired page of the paginated list - * @param {Integer} perPage - The number of groups per page - * - */ -Cypress.Commands.add('apiGetGroups', (page = 0, perPage = 100) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/groups?page=${page}&per_page=${perPage}`, - method: 'GET', - timeout: 60000, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -}); - -/** - * Patch a group directly via API - * - * @param {String} name - The new name for the group - * @param {Object} patch - * {Boolean} allow_reference - Whether to allow reference (group mention) or not - true/false - * {String} name - Name for the group, used for group mentions - * {String} display_name - Display name for the group - * {String} description - Description for the group - * - */ -Cypress.Commands.add('apiPatchGroup', (groupID, patch) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/groups/${groupID}/patch`, - method: 'PUT', - timeout: 60000, - body: patch, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -}); - -/** - * Get all LDAP groups via API - * @param {Integer} page - The page to select - * @param {Integer} perPage - The number of groups per page - */ -Cypress.Commands.add('apiGetLDAPGroups', (page = 0, perPage = 100) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/ldap/groups?page=${page}&per_page=${perPage}`, - method: 'GET', - timeout: 60000, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -}); - -/** - * Add a link for LDAP group via API - * @param {String} remoteId - remote ID of the group - */ -Cypress.Commands.add('apiAddLDAPGroupLink', (remoteId) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/ldap/groups/${remoteId}/link`, - method: 'POST', - timeout: 60000, - }).then((response) => { - return cy.wrap(response); - }); -}); - -/** - * Retrieve the list of groups associated with a given team via API - * @param {String} teamId - Team GUID - */ -Cypress.Commands.add('apiGetTeamGroups', (teamId) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/teams/${teamId}/groups`, - method: 'GET', - timeout: 60000, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -}); - -/** - * Delete a link from a team to a group via API - * @param {String} groupId - Group GUID - * @param {String} teamId - Team GUID - */ -Cypress.Commands.add('apiDeleteLinkFromTeamToGroup', (groupId, teamId) => { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/groups/${groupId}/teams/${teamId}/link`, - method: 'DELETE', - timeout: 60000, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -}); - -Cypress.Commands.add('apiLinkGroup', (groupID) => { - return linkUnlinkGroup(groupID, 'POST'); -}); - -Cypress.Commands.add('apiUnlinkGroup', (groupID) => { - return linkUnlinkGroup(groupID, 'DELETE'); -}); - -function linkUnlinkGroup(groupID, httpMethod) { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/ldap/groups/${groupID}/link`, - method: httpMethod, - timeout: 60000, - }).then((response) => { - expect(response.status).to.be.oneOf([200, 201, 204]); - return cy.wrap(response); - }); -} - -Cypress.Commands.add('apiGetGroupTeams', (groupID) => { - return getGroupSyncables(groupID, 'team'); -}); - -Cypress.Commands.add('apiGetGroupTeam', (groupID, teamID) => { - return getGroupSyncable(groupID, 'team', teamID); -}); - -Cypress.Commands.add('apiGetGroupChannels', (groupID) => { - return getGroupSyncables(groupID, 'channel'); -}); - -Cypress.Commands.add('apiGetGroupChannel', (groupID, channelID) => { - return getGroupSyncable(groupID, 'channel', channelID); -}); - -function getGroupSyncable(groupID, syncableType, syncableID) { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/groups/${groupID}/${syncableType}s/${syncableID}`, - method: 'GET', - timeout: 60000, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -} - -function getGroupSyncables(groupID, syncableType) { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/groups/${groupID}/${syncableType}s?page=0&per_page=100`, - method: 'GET', - timeout: 60000, - }).then((response) => { - expect(response.status).to.equal(200); - return cy.wrap(response); - }); -} - -Cypress.Commands.add('apiUnlinkGroupTeam', (groupID, teamID) => { - return linkUnlinkGroupSyncable(groupID, teamID, 'team', 'DELETE'); -}); - -Cypress.Commands.add('apiLinkGroupTeam', (groupID, teamID) => { - return linkUnlinkGroupSyncable(groupID, teamID, 'team', 'POST'); -}); - -Cypress.Commands.add('apiUnlinkGroupChannel', (groupID, channelID) => { - return linkUnlinkGroupSyncable(groupID, channelID, 'channel', 'DELETE'); -}); - -Cypress.Commands.add('apiLinkGroupChannel', (groupID, channelID) => { - return linkUnlinkGroupSyncable(groupID, channelID, 'channel', 'POST'); -}); - -function linkUnlinkGroupSyncable(groupID, syncableID, syncableType, httpMethod) { - return cy.request({ - headers: {'X-Requested-With': 'XMLHttpRequest'}, - url: `/api/v4/groups/${groupID}/${syncableType}s/${syncableID}/link`, - method: httpMethod, - body: {auto_add: true}, - }).then((response) => { - expect(response.status).to.be.oneOf([200, 201, 204]); - return cy.wrap(response); - }); -} diff --git a/e2e/cypress/tests/support/api_commands.ts b/e2e/cypress/tests/support/api_commands.ts new file mode 100644 index 000000000000..5e8eedf8bc40 --- /dev/null +++ b/e2e/cypress/tests/support/api_commands.ts @@ -0,0 +1,516 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ChainableT, ResponseT} from 'tests/types'; + +import {getAdminAccount, User} from './env'; + +// ***************************************************************************** +// Read more: +// - https://on.cypress.io/custom-commands on writing Cypress commands +// - https://api.mattermost.com/ for Mattermost API reference +// ***************************************************************************** + +// ***************************************************************************** +// Commands +// https://api.mattermost.com/#tag/commands +// ***************************************************************************** + +type CypressResponseAny = Cypress.Response +function apiCreateCommand(command: Record = {}): Cypress.Chainable<{data: CypressResponseAny['body']; status: CypressResponseAny['status']}> { + const options = { + url: '/api/v4/commands', + headers: {'X-Requested-With': 'XMLHttpRequest'}, + method: 'POST', + body: command, + }; + + return cy.request(options).then((response) => { + expect(response.status).to.equal(201); + return cy.wrap({data: response.body, status: response.status}); + }); +} + +Cypress.Commands.add('apiCreateCommand', apiCreateCommand); + +// ***************************************************************************** +// Email +// ***************************************************************************** +function apiEmailTest(): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: '/api/v4/email/test', + method: 'POST', + }).then((response) => { + expect(response.status, 'SMTP not setup at sysadmin config').to.equal(200); + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiEmailTest', apiEmailTest); + +// ***************************************************************************** +// Posts +// https://api.mattermost.com/#tag/posts +// ***************************************************************************** + +function apiCreatePost(channelId: string, message: string, rootId: string, props: Record, token = '', failOnStatusCode = true): ResponseT { + const headers: Record = {'X-Requested-With': 'XMLHttpRequest'}; + if (token !== '') { + headers.Authorization = `Bearer ${token}`; + } + return cy.request({ + headers, + failOnStatusCode, + url: '/api/v4/posts', + method: 'POST', + body: { + channel_id: channelId, + root_id: rootId, + message, + props, + }, + }); +} + +Cypress.Commands.add('apiCreatePost', apiCreatePost); + +function apiDeletePost(postId: string, user: User = getAdminAccount()): Cypress.Chainable<{status: number}> { + return cy.externalRequest({ + user, + method: 'delete', + path: `posts/${postId}`, + }).then((response) => { + // * Validate that request was successful + expect(response.status).to.equal(200); + return cy.wrap({status: response.status}); + }); +} +Cypress.Commands.add('apiDeletePost', apiDeletePost); + +function apiCreateToken(userId: string): Cypress.Chainable<{token: string}> { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/users/${userId}/tokens`, + method: 'POST', + body: { + description: 'some text', + }, + }).then((response) => { + // * Validate that request was successful + expect(response.status).to.equal(200); + return cy.wrap({token: response.body.token}); + }); +} +Cypress.Commands.add('apiCreateToken', apiCreateToken); + +/** + * Unpins pinned posts of given postID directly via API + * This API assume that the user is logged in and has cookie to access + */ +function apiUnpinPosts(postId: string): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: '/api/v4/posts/' + postId + '/unpin', + method: 'POST', + }); +} +Cypress.Commands.add('apiUnpinPosts', apiUnpinPosts); + +// ***************************************************************************** +// Webhooks +// https://api.mattermost.com/#tag/webhooks +// ***************************************************************************** + +function apiCreateWebhook(hook: Record = {}, isIncoming = true): ChainableT<{data: CypressResponseAny['body']; url: string}> { + const hookUrl = isIncoming ? '/api/v4/hooks/incoming' : '/api/v4/hooks/outgoing'; + const options = { + url: hookUrl, + headers: {'X-Requested-With': 'XMLHttpRequest'}, + method: 'POST', + body: hook, + }; + + return cy.request(options).then((response) => { + const data = response.body; + return cy.wrap(Promise.resolve({...data, url: isIncoming ? `${Cypress.config().baseUrl}/hooks/${data.id}` : ''})); + }); +} + +Cypress.Commands.add('apiCreateWebhook', apiCreateWebhook); + +function apiGetTeam(teamId: string): ChainableT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `api/v4/teams/${teamId}`, + method: 'GET', + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiGetTeam', apiGetTeam); + +function removeUserFromChannel(channelId: string, userId: string): ReturnType { + const admin = getAdminAccount(); + + return cy.externalRequest({user: admin, method: 'delete', path: `channels/${channelId}/members/${userId}`}); +} +Cypress.Commands.add('removeUserFromChannel', removeUserFromChannel); + +function removeUserFromTeam(teamId: string, userId: string): ReturnType { + const admin = getAdminAccount(); + + return cy.externalRequest({user: admin, method: 'delete', path: `teams/${teamId}/members/${userId}`}); +} +Cypress.Commands.add('removeUserFromTeam', removeUserFromTeam); + +interface LDAPSyncResponse { + status: number; + body: Array<{status: string; last_activity_at: number}>; +} + +function apiGetLDAPSync(): Cypress.Chainable { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: '/api/v4/jobs/type/ldap_sync?page=0&per_page=50', + method: 'GET', + timeout: 60000, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiGetLDAPSync', apiGetLDAPSync); + +// ***************************************************************************** +// Groups +// https://api.mattermost.com/#tag/groups +// ***************************************************************************** +function apiGetGroups(page = 0, perPage = 100): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/groups?page=${page}&per_page=${perPage}`, + method: 'GET', + timeout: 60000, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiGetGroups', apiGetGroups); + +function apiPatchGroup(groupID: string, patch: Record): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/groups/${groupID}/patch`, + method: 'PUT', + timeout: 60000, + body: patch, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiPatchGroup', apiPatchGroup); + +function apiGetLDAPGroups(page = 0, perPage = 100): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/ldap/groups?page=${page}&per_page=${perPage}`, + method: 'GET', + timeout: 60000, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} + +Cypress.Commands.add('apiGetLDAPGroups', apiGetLDAPGroups); + +function apiAddLDAPGroupLink(remoteId: string) { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/ldap/groups/${remoteId}/link`, + method: 'POST', + timeout: 60000, + }).then((response) => { + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiAddLDAPGroupLink', apiAddLDAPGroupLink); + +function apiGetTeamGroups(teamId: string) { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/teams/${teamId}/groups`, + method: 'GET', + timeout: 60000, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiGetTeamGroups', apiGetTeamGroups); + +function apiDeleteLinkFromTeamToGroup(groupId: string, teamId: string): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/groups/${groupId}/teams/${teamId}/link`, + method: 'DELETE', + timeout: 60000, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} +Cypress.Commands.add('apiDeleteLinkFromTeamToGroup', apiDeleteLinkFromTeamToGroup); + +function apiLinkGroup(groupID: string): ResponseT { + return linkUnlinkGroup(groupID, 'POST'); +} +Cypress.Commands.add('apiLinkGroup', apiLinkGroup); + +function apiUnlinkGroup(groupID: string): ResponseT { + return linkUnlinkGroup(groupID, 'DELETE'); +} +Cypress.Commands.add('apiUnlinkGroup', apiUnlinkGroup); + +function linkUnlinkGroup(groupID: string, httpMethod: string): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/ldap/groups/${groupID}/link`, + method: httpMethod, + timeout: 60000, + }).then((response) => { + expect(response.status).to.be.oneOf([200, 201, 204]); + return cy.wrap(response); + }); +} + +function apiGetGroupTeams(groupID: string): ResponseT { + return getGroupSyncables(groupID, 'team'); +} +Cypress.Commands.add('apiGetGroupTeams', apiGetGroupTeams); + +function apiGetGroupTeam(groupID: string, teamID: string): ResponseT { + return getGroupSyncable(groupID, 'team', teamID); +} +Cypress.Commands.add('apiGetGroupTeam', apiGetGroupTeam); + +function apiGetGroupChannels(groupID: string): ResponseT { + return getGroupSyncables(groupID, 'channel'); +} +Cypress.Commands.add('apiGetGroupChannels', apiGetGroupChannels); + +function apiGetGroupChannel(groupID: string, channelID: string): ResponseT { + return getGroupSyncable(groupID, 'channel', channelID); +} +Cypress.Commands.add('apiGetGroupChannel', apiGetGroupChannel); + +function getGroupSyncable(groupID: string, syncableType: string, syncableID: string): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/groups/${groupID}/${syncableType}s/${syncableID}`, + method: 'GET', + timeout: 60000, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} + +function getGroupSyncables(groupID: string, syncableType: string): ResponseT { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/groups/${groupID}/${syncableType}s?page=0&per_page=100`, + method: 'GET', + timeout: 60000, + }).then((response) => { + expect(response.status).to.equal(200); + return cy.wrap(response); + }); +} + +function apiUnlinkGroupTeam(groupID: string, teamID: string): ResponseT { + return linkUnlinkGroupSyncable(groupID, teamID, 'team', 'DELETE'); +} +Cypress.Commands.add('apiUnlinkGroupTeam', apiUnlinkGroupTeam); + +function apiLinkGroupTeam(groupID: string, teamID: string): ResponseT { + return linkUnlinkGroupSyncable(groupID, teamID, 'team', 'POST'); +} +Cypress.Commands.add('apiLinkGroupTeam', apiLinkGroupTeam); + +function apiUnlinkGroupChannel(groupID: string, channelID: string): ResponseT { + return linkUnlinkGroupSyncable(groupID, channelID, 'channel', 'DELETE'); +} +Cypress.Commands.add('apiUnlinkGroupChannel', apiUnlinkGroupChannel); + +function apiLinkGroupChannel(groupID: string, channelID: string): ResponseT { + return linkUnlinkGroupSyncable(groupID, channelID, 'channel', 'POST'); +} +Cypress.Commands.add('apiLinkGroupChannel', apiLinkGroupChannel); + +function linkUnlinkGroupSyncable(groupID: string, syncableID: string, syncableType: string, httpMethod: string) { + return cy.request({ + headers: {'X-Requested-With': 'XMLHttpRequest'}, + url: `/api/v4/groups/${groupID}/${syncableType}s/${syncableID}/link`, + method: httpMethod, + body: {auto_add: true}, + }).then((response) => { + expect(response.status).to.be.oneOf([200, 201, 204]); + return cy.wrap(response); + }); +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + + /** + * Get LDAP Group Sync Job Status + * + * @example + * cy.apiGetLDAPSync().then((response) => { + */ + apiGetLDAPSync: typeof apiGetLDAPSync; + + /** + * Test SMTP setup + */ + apiEmailTest: typeof apiEmailTest; + + /** + * Creates a post directly via API + * This API assume that the user is logged in and has cookie to access + * @param {String} channelId - Where to post + * @param {String} message - What to post + * @param {String} rootId - Parent post ID. Set to "" to avoid nesting + * @param {Object} props - Post props + * @param {String} token - Optional token to use for auth. If not provided - posts as current user + */ + apiCreatePost: typeof apiCreatePost; + + /** + * Deletes a post directly via API + * @param {String} postId - Post ID + * @param {Object} [user] - the user trying to invoke the API + */ + apiDeletePost: typeof apiDeletePost; + + /** + * Creates a post directly via API + * This API assume that the user is logged in as admin + * @param {String} userId - user for whom to create the token + */ + apiCreateToken: typeof apiCreateToken; + + /** + * Unpins pinned posts of given postID directly via API + * This API assume that the user is logged in and has cookie to access + */ + apiUnpinPosts: typeof apiUnpinPosts; + + /** + * Creates a command directly via API + * This API assume that the user is logged in and has required permission to create a command + * @param {Object} command - command to be created + */ + apiCreateCommand: typeof apiCreateCommand; + + apiCreateWebhook: typeof apiCreateWebhook; + + /** + * Gets a team on the system + * * @param {String} teamId - The team ID to get + * All parameter required + */ + apiGetTeam: typeof apiGetTeam; + + /** + * Remove a User from a Channel directly via API + * @param {String} channelId - The channel ID + * @param {String} userId - The user ID + * All parameter required + */ + removeUserFromChannel: typeof removeUserFromChannel; + + /** + * Remove a User from a Team directly via API + * @param {String} teamID - The team ID + * @param {String} userId - The user ID + * All parameter required + */ + removeUserFromTeam: typeof removeUserFromTeam; + + /** + * Get all groups via the API + * + * @param {Integer} page - The desired page of the paginated list + * @param {Integer} perPage - The number of groups per page + * + */ + apiGetGroups: typeof apiGetGroups; + + /** + * Patch a group directly via API + * + * @param {String} name - The new name for the group + * @param {Object} patch + * {Boolean} allow_reference - Whether to allow reference (group mention) or not - true/false + * {String} name - Name for the group, used for group mentions + * {String} display_name - Display name for the group + * {String} description - Description for the group + * + */ + apiPatchGroup: typeof apiPatchGroup; + + /** + * Get all LDAP groups via API + * @param {Integer} page - The page to select + * @param {Integer} perPage - The number of groups per page + */ + apiGetLDAPGroups: typeof apiGetLDAPGroups; + + /** + * Add a link for LDAP group via API + * @param {String} remoteId - remote ID of the group + */ + apiAddLDAPGroupLink: typeof apiAddLDAPGroupLink; + + /** + * Retrieve the list of groups associated with a given team via API + * @param {String} teamId - Team GUID + */ + apiGetTeamGroups: typeof apiGetTeamGroups; + + /** + * Delete a link from a team to a group via API + * @param {String} groupId - Group GUID + * @param {String} teamId - Team GUID + */ + apiDeleteLinkFromTeamToGroup: typeof apiDeleteLinkFromTeamToGroup; + + apiLinkGroup: typeof apiLinkGroup; + + apiUnlinkGroup: typeof apiUnlinkGroup; + + apiLinkGroupTeam: typeof apiLinkGroupTeam; + + apiUnlinkGroupTeam: typeof apiUnlinkGroupTeam; + + apiUnlinkGroupChannel: typeof apiUnlinkGroupChannel; + + apiLinkGroupChannel: typeof apiLinkGroupChannel; + + apiGetGroupTeams: typeof apiGetGroupTeams; + + apiGetGroupTeam: typeof apiGetGroupTeam; + + apiGetGroupChannels: typeof apiGetGroupChannels; + + apiGetGroupChannel: typeof apiGetGroupChannel; + } + } +} diff --git a/e2e/cypress/tests/support/env.js b/e2e/cypress/tests/support/env.ts similarity index 83% rename from e2e/cypress/tests/support/env.js rename to e2e/cypress/tests/support/env.ts index 8447ee9c1182..2880c8f84886 100644 --- a/e2e/cypress/tests/support/env.js +++ b/e2e/cypress/tests/support/env.ts @@ -1,6 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export interface User { + username: string; + password: string; + email: string; +} + export function getAdminAccount() { return { username: Cypress.env('adminUsername'), diff --git a/e2e/cypress/tests/support/index.d.ts b/e2e/cypress/tests/support/index.d.ts index 5aa5e039b82d..cd098fc8812b 100644 --- a/e2e/cypress/tests/support/index.d.ts +++ b/e2e/cypress/tests/support/index.d.ts @@ -31,4 +31,7 @@ declare namespace Cypress { type UserStatus = import('@mattermost/types/users').UserStatus; type UserCustomStatus = import('@mattermost/types/users').UserCustomStatus; type UserAccessToken = import('@mattermost/types/users').UserAccessToken; + interface Chainable { + tab: (options?: {shift?: boolean}) => Chainable; + } } diff --git a/e2e/cypress/tests/support/task_commands.d.ts b/e2e/cypress/tests/support/task_commands.d.ts deleted file mode 100644 index 600de9770a07..000000000000 --- a/e2e/cypress/tests/support/task_commands.d.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/// - -// *************************************************************** -// Each command should be properly documented using JSDoc. -// See https://jsdoc.app/index.html for reference. -// Basic requirements for documentation are the following: -// - Meaningful description -// - Specific link to https://api.mattermost.com -// - Each parameter with `@params` -// - Return value with `@returns` -// - Example usage with `@example` -// *************************************************************** - -declare namespace Cypress { - interface Chainable { - - /** - * externalRequest is a task which is wrapped as command with post-verification - * that the external request is successfully completed - * @param {Object} options - * @param {} options.user - a user initiating external request - * @param {String} options.method - an HTTP method (e.g. get, post, etc) - * @param {String} options.path - API path that is relative to Cypress.config().baseUrl - * @param {Object} options.data - payload - * @param {Boolean} options.failOnStatusCode - whether to fail on status code, default is true - * - * @example - * cy.externalRequest({user: sysadmin, method: 'POST', path: 'config', data}); - */ - externalRequest(options?: { - user: Pick; - method: string; - path: string; - data?: Record; - failOnStatusCode?: boolean; - }): Chainable; - - /** - * Adds a given reaction to a specific post from a user - * @param {Object} reactToMessageObject - Information on person and post to which a reaction needs to be added - * @param {Object} reactToMessageObject.sender - a user object who will post a message - * @param {string} reactToMessageObject.postId - post on which reaction is intended - * @param {string} reactToMessageObject.reaction - emoji text eg. smile - * @returns {Response} response: Cypress-chainable response - * - * @example - * cy.reactToMessageAs({sender:user2, postId:"ABC123", reaction: 'smile'}); - */ - reactToMessageAs({sender, postId, reaction}: {sender: Record; postId: string; reaction: string}): Chainable; - - /** - * Verify that the webhook server is accessible, and then sets up base URLs and credential. - * - * @example - * cy.requireWebhookServer(); - */ - requireWebhookServer(): Chainable; - } -} diff --git a/e2e/cypress/tests/support/task_commands.js b/e2e/cypress/tests/support/task_commands.ts similarity index 58% rename from e2e/cypress/tests/support/task_commands.js rename to e2e/cypress/tests/support/task_commands.ts index be050eaa9bc7..6dfdfac31271 100644 --- a/e2e/cypress/tests/support/task_commands.js +++ b/e2e/cypress/tests/support/task_commands.ts @@ -1,5 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {AxiosResponse} from 'axios'; + +import {ChainableT} from '../types'; /** * postMessageAs is a task which is wrapped as command with post-verification @@ -8,16 +11,37 @@ * @param {String} message - message in a post * @param {Object} channelId - where a post will be posted */ -Cypress.Commands.add('postMessageAs', ({sender, message, channelId, rootId, createAt}) => { + +interface PostMessageResp { + id: string; + status: number; + data: any; +} + +interface PostMessageArg { + sender: { + username: string; + password: string; + }; + message: string; + channelId: string; + rootId?: string; + createAt?: number; +} + +function postMessageAs(arg: PostMessageArg): ChainableT { + const {sender, message, channelId, rootId, createAt} = arg; const baseUrl = Cypress.config('baseUrl'); - return cy.task('postMessageAs', {sender, message, channelId, rootId, createAt, baseUrl}).then(({status, data}) => { + return cy.task('postMessageAs', {sender, message, channelId, rootId, createAt, baseUrl}).then((response: AxiosResponse<{id: string}>) => { + const {status, data} = response; expect(status).to.equal(201); // # Return the data so it can be interacted in a test return cy.wrap({id: data.id, status, data}); }); -}); +} +Cypress.Commands.add('postMessageAs', postMessageAs); /** * @param {string} [numberOfMessages = 30] - Number of messages @@ -25,13 +49,16 @@ Cypress.Commands.add('postMessageAs', ({sender, message, channelId, rootId, crea * @param {String} message - message in a post * @param {Object} channelId - where a post will be posted */ -Cypress.Commands.add('postListOfMessages', ({numberOfMessages = 30, ...rest}) => { + +function postListOfMessages({numberOfMessages = 30, ...rest}): ChainableT { const baseUrl = Cypress.config('baseUrl'); - return cy. + return (cy as any). task('postListOfMessages', {numberOfMessages, baseUrl, ...rest}, {timeout: numberOfMessages * 200}). each((message) => expect(message.status).to.equal(201)); -}); +} + +Cypress.Commands.add('postListOfMessages', postListOfMessages); /** * reactToMessageAs is a task wrapped as command with post-verification @@ -57,7 +84,8 @@ Cypress.Commands.add('reactToMessageAs', ({sender, postId, reaction}) => { * @param {String} url - incoming webhook URL * @param {Object} data - payload on incoming webhook */ -Cypress.Commands.add('postIncomingWebhook', ({url, data, waitFor}) => { + +function postIncomingWebhook({url, data, waitFor}): ChainableT { cy.task('postIncomingWebhook', {url, data}).its('status').should('be.equal', 200); if (!waitFor) { @@ -78,12 +106,23 @@ Cypress.Commands.add('postIncomingWebhook', ({url, data, waitFor}) => { return false; } })); -}); +} -Cypress.Commands.add('externalRequest', ({user, method, path, data, failOnStatusCode = true}) => { +Cypress.Commands.add('postIncomingWebhook', postIncomingWebhook); + +interface ExternalRequestArg { + user: { + }; + method: string; + path: string; + data?: T; + failOnStatusCode?: boolean; +} +function externalRequest(arg: ExternalRequestArg): ChainableT, 'data' | 'status'>> { + const {user, method, path, data, failOnStatusCode = true} = arg; const baseUrl = Cypress.config('baseUrl'); - return cy.task('externalRequest', {baseUrl, user, method, path, data}).then((response) => { + return cy.task('externalRequest', {baseUrl, user, method, path, data}).then((response: Pick, 'data' | 'status'>) => { // Temporarily ignore error related to Cloud const cloudErrorId = [ 'ent.cloud.request_error', @@ -96,7 +135,8 @@ Cypress.Commands.add('externalRequest', ({user, method, path, data, failOnStatus return cy.wrap(response); }); -}); +} +Cypress.Commands.add('externalRequest', externalRequest); /** * postMessageAs is a task which is wrapped as command with post-verification @@ -104,7 +144,8 @@ Cypress.Commands.add('externalRequest', ({user, method, path, data, failOnStatus * @param {String} message - message in a post * @param {Object} channelId - where a post will be posted */ -Cypress.Commands.add('postBotMessage', ({token, message, props, channelId, rootId, createAt, failOnStatus = true}) => { + +function postBotMessage({token, message, props, channelId, rootId, createAt, failOnStatus = true}): ChainableT { const baseUrl = Cypress.config('baseUrl'); return cy.task('postBotMessage', {token, message, props, channelId, rootId, createAt, baseUrl}).then(({status, data}) => { @@ -115,7 +156,9 @@ Cypress.Commands.add('postBotMessage', ({token, message, props, channelId, rootI // # Return the data so it can be interacted in a test return cy.wrap({id: data.id, status, data}); }); -}); +} + +Cypress.Commands.add('postBotMessage', postBotMessage); /** * urlHealthCheck is a task wrapped as command that checks whether @@ -126,7 +169,8 @@ Cypress.Commands.add('postBotMessage', ({token, message, props, channelId, rootI * @param {String} method - a request using a specific method * @param {String} httpStatus - expected HTTP status */ -Cypress.Commands.add('urlHealthCheck', ({name, url, helperMessage, method, httpStatus}) => { + +function urlHealthCheck({name, url, helperMessage, method, httpStatus}): ChainableT { Cypress.log({name, message: `Checking URL health at ${url}`}); return cy.task('urlHealthCheck', {url, method}).then(({data, errorCode, status, success}) => { @@ -144,7 +188,9 @@ Cypress.Commands.add('urlHealthCheck', ({name, url, helperMessage, method, httpS return cy.wrap({data, status}); }); -}); +} + +Cypress.Commands.add('urlHealthCheck', urlHealthCheck); Cypress.Commands.add('requireWebhookServer', () => { const baseUrl = Cypress.config('baseUrl'); @@ -177,3 +223,63 @@ __Tips:__ }); Cypress.Commands.overwrite('log', (subject, message) => cy.task('log', message)); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + + /** + * externalRequest is a task which is wrapped as command with post-verification + * that the external request is successfully completed + * @param {Object} options + * @param {} options.user - a user initiating external request + * @param {String} options.method - an HTTP method (e.g. get, post, etc) + * @param {String} options.path - API path that is relative to Cypress.config().baseUrl + * @param {Object} options.data - payload + * @param {Boolean} options.failOnStatusCode - whether to fail on status code, default is true + * + * @example + * cy.externalRequest({user: sysadmin, method: 'POST', path: 'config', data}); + */ + externalRequest(options?: { + user: Pick; + method: string; + path: string; + data?: Record; + failOnStatusCode?: boolean; + }): Chainable; + + /** + * Adds a given reaction to a specific post from a user + * @param {Object} reactToMessageObject - Information on person and post to which a reaction needs to be added + * @param {Object} reactToMessageObject.sender - a user object who will post a message + * @param {string} reactToMessageObject.postId - post on which reaction is intended + * @param {string} reactToMessageObject.reaction - emoji text eg. smile + * @returns {Response} response: Cypress-chainable response + * + * @example + * cy.reactToMessageAs({sender:user2, postId:"ABC123", reaction: 'smile'}); + */ + reactToMessageAs({sender, postId, reaction}: {sender: Record; postId: string; reaction: string}): Chainable; + + /** + * Verify that the webhook server is accessible, and then sets up base URLs and credential. + * + * @example + * cy.requireWebhookServer(); + */ + requireWebhookServer(): Chainable; + + postMessageAs: typeof postMessageAs; + + postListOfMessages: typeof postListOfMessages; + + postIncomingWebhook: typeof postIncomingWebhook; + + postBotMessage: typeof postBotMessage; + + urlHealthCheck: typeof urlHealthCheck; + } + } +} diff --git a/e2e/cypress/tests/support/ui/post.d.ts b/e2e/cypress/tests/support/ui/post.d.ts deleted file mode 100644 index 13b10b85c08a..000000000000 --- a/e2e/cypress/tests/support/ui/post.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/// - -// *************************************************************** -// Each command should be properly documented using JSDoc. -// See https://jsdoc.app/index.html for reference. -// Basic requirements for documentation are the following: -// - Meaningful description -// - Each parameter with `@params` -// - Return value with `@returns` -// - Example usage with `@example` -// Custom command should follow naming convention of having `ui` prefix, e.g. `uiGetPostHeader`. -// *************************************************************** - -declare namespace Cypress { - interface Chainable { - - /** - * Get post textbox - * - * @param {bool} option.exist - Set to false to check whether element should not exist. Otherwise, true (default) to check visibility. - * - * @example - * cy.uiGetPostTextBox(); - */ - uiGetPostTextBox(option: {exist: boolean}): Chainable; - - /** - * Get reply textbox - * - * @param {bool} option.exist - Set to false to check whether element should not exist. Otherwise, true (default) to check visibility. - * - * @example - * cy.uiGetReplyTextBox(); - */ - uiGetReplyTextBox(option: {exist: boolean}): Chainable; - - /** - * Get post profile image of a given post ID or the last post if post ID is not given - * - * @param {string} - postId (optional) - * - * @example - * cy.uiGetPostProfileImage(); - */ - uiGetPostProfileImage(postId: string): Chainable; - - /** - * Get post header of a given post ID or the last post if post ID is not given - * - * @param {string} - postId (optional) - * - * @example - * cy.uiGetPostHeader(); - */ - uiGetPostHeader(postId: string): Chainable; - - /** - * Get post body of a given post ID or the last post if post ID is not given - * - * @param {string} - postId (optional) - * - * @example - * cy.uiGetPostBody(); - */ - uiGetPostBody(postId: string): Chainable; - - /** - * Get post thread footer of a given post ID or the last post if post ID is not given - * - * @param {string} - postId (optional) - * - * @example - * cy.uiGetPostThreadFooter(); - */ - uiGetPostThreadFooter(postId: string): Chainable; - - /** - * Get post embed container of a given post ID or the last post if post ID is not given - * - * @param {string} - postId (optional) - * - * @example - * cy.uiGetPostEmbedContainer(); - */ - uiGetPostEmbedContainer(postId: string): Chainable; - } -} - -export function verifySavedPost(postId, message): void; -export function verifyUnsavedPost(postId): void; diff --git a/e2e/cypress/tests/support/ui/post.js b/e2e/cypress/tests/support/ui/post.ts similarity index 57% rename from e2e/cypress/tests/support/ui/post.js rename to e2e/cypress/tests/support/ui/post.ts index 371cf5850de5..47ef8781c056 100644 --- a/e2e/cypress/tests/support/ui/post.js +++ b/e2e/cypress/tests/support/ui/post.ts @@ -1,57 +1,67 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -Cypress.Commands.add('uiGetPostTextBox', (option = {exist: true}) => { +import {ChainableT} from '../../types'; + +function uiGetPostTextBox(option = {exist: true}): ChainableT { if (option.exist) { return cy.get('#post_textbox').should('be.visible'); } return cy.get('#post_textbox').should('not.exist'); -}); +} +Cypress.Commands.add('uiGetPostTextBox', uiGetPostTextBox); -Cypress.Commands.add('uiGetReplyTextBox', (option = {exist: true}) => { +function uiGetReplyTextBox(option = {exist: true}): ChainableT { if (option.exist) { return cy.get('#reply_textbox').should('be.visible'); } return cy.get('#reply_textbox').should('not.exist'); -}); +} +Cypress.Commands.add('uiGetReplyTextBox', uiGetReplyTextBox); -Cypress.Commands.add('uiGetPostProfileImage', (postId) => { +function uiGetPostProfileImage(postId: string): ChainableT { return getPost(postId).within(() => { return cy.get('.post__img').should('be.visible'); }); -}); +} +Cypress.Commands.add('uiGetPostProfileImage', uiGetPostProfileImage); -Cypress.Commands.add('uiGetPostHeader', (postId) => { +function uiGetPostHeader(postId: string): ChainableT { return getPost(postId).within(() => { return cy.get('.post__header').should('be.visible'); }); -}); +} +Cypress.Commands.add('uiGetPostHeader', uiGetPostHeader); -Cypress.Commands.add('uiGetPostBody', (postId) => { +function uiGetPostBody(postId: string): ChainableT { return getPost(postId).within(() => { - return cy.get('.post__body').scrollIntoView().should('be.visible'); + return cy.get('.post__body').should('be.visible'); }); -}); +} +Cypress.Commands.add('uiGetPostBody', uiGetPostBody); -Cypress.Commands.add('uiGetPostThreadFooter', (postId) => { +function uiGetPostThreadFooter(postId: string): ChainableT { return getPost(postId).find('.ThreadFooter'); -}); +} +Cypress.Commands.add('uiGetPostThreadFooter', uiGetPostThreadFooter); -Cypress.Commands.add('uiGetPostEmbedContainer', (postId) => { +function uiGetPostEmbedContainer(postId: string): ChainableT { return cy.uiGetPostBody(postId). find('.file-preview__button'). should('be.visible'); -}); +} +Cypress.Commands.add('uiGetPostEmbedContainer', uiGetPostEmbedContainer); -function getPost(postId) { +function getPost(postId: string): ChainableT { if (postId) { return cy.get(`#post_${postId}`).should('be.visible'); } return cy.getLastPost(); } +Cypress.Commands.add('getPost', getPost); export function verifySavedPost(postId, message) { // * Check that the center save icon has been updated correctly @@ -160,3 +170,83 @@ export function verifyUnsavedPost(postId) { // # Close the RHS cy.get('#searchResultsCloseButton').should('be.visible').click(); } + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + + /** + * Get post profile image of a given post ID or the last post if post ID is not given + * + * @param {string} - postId (optional) + * + * @example + * cy.uiGetPostProfileImage(); + */ + uiGetPostProfileImage: typeof uiGetPostProfileImage; + + /** + * Get post header of a given post ID or the last post if post ID is not given + * + * @param {string} - postId (optional) + * + * @example + * cy.uiGetPostHeader(); + */ + uiGetPostHeader: typeof uiGetPostHeader; + + /** + * Get post body of a given post ID or the last post if post ID is not given + * + * @param {string} - postId (optional) + * + * @example + * cy.uiGetPostBody(); + */ + uiGetPostBody: typeof uiGetPostBody; + + /** + * Get post thread footer of a given post ID or the last post if post ID is not given + * + * @param {string} - postId (optional) + * + * @example + * cy.uiGetPostThreadFooter(); + */ + uiGetPostThreadFooter: typeof uiGetPostThreadFooter; + + /** + * Get post embed container of a given post ID or the last post if post ID is not given + * + * @param {string} - postId (optional) + * + * @example + * cy.uiGetPostEmbedContainer(); + */ + uiGetPostEmbedContainer: typeof uiGetPostEmbedContainer; + + /** + * Get post textbox + * + * @param {bool} option.exist - Set to false to check whether element should not exist. Otherwise, true (default) to check visibility. + * + * @example + * cy.uiGetPostTextBox(); + */ + uiGetPostTextBox: typeof uiGetPostTextBox; + + /** + * Get reply textbox + * + * @param {bool} option.exist - Set to false to check whether element should not exist. Otherwise, true (default) to check visibility. + * + * @example + * cy.uiGetReplyTextBox(); + */ + uiGetReplyTextBox: typeof uiGetReplyTextBox; + + getPost: typeof getPost; + } + } +} diff --git a/e2e/cypress/tests/support/ui/sidebar_left.d.ts b/e2e/cypress/tests/support/ui/sidebar_left.d.ts deleted file mode 100644 index c95d522f2878..000000000000 --- a/e2e/cypress/tests/support/ui/sidebar_left.d.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/// - -// *************************************************************** -// Each command should be properly documented using JSDoc. -// See https://jsdoc.app/index.html for reference. -// Basic requirements for documentation are the following: -// - Meaningful description -// - Each parameter with `@params` -// - Return value with `@returns` -// - Example usage with `@example` -// Custom command should follow naming convention of having `ui` prefix, e.g. `uiCheckLicenseExists`. -// *************************************************************** - -declare namespace Cypress { - interface Chainable { - - /** - * Get LHS - * - * @example - * cy.uiGetLHS(); - */ - uiGetLHS(): Chainable; - - /** - * Get LHS header - * - * @example - * cy.uiGetLHSHeader().click(); - */ - uiGetLHSHeader(): Chainable; - - /** - * Open team menu - * - * @param {string} item - ex. 'Invite People', 'Team Settings', etc. - * - * @example - * cy.uiOpenTeamMenu(); - */ - uiOpenTeamMenu(item: string): Chainable; - - /** - * Get LHS add channel button - * - * @example - * cy.uiGetLHSAddChannelButton().click(); - */ - uiGetLHSAddChannelButton(): Chainable; - - /** - * Get LHS team menu - * - * @example - * cy.uiGetLHSTeamMenu().should('not.exist); - */ - uiGetLHSTeamMenu(): Chainable; - - /** - * Get LHS section - * @param {string} section - section such as UNREADS, CHANNELS, FAVORITES, DIRECT MESSAGES and other custom category - * - * @example - * cy.uiGetLhsSection('CHANNELS'); - */ - uiGetLhsSection(section: string): Chainable; - - /** - * Open menu to browse or create channel - * @param {string} item - dropdown menu. If set, it will do click action. - * - * @example - * cy.uiBrowseOrCreateChannel('Browse Channels'); - */ - uiBrowseOrCreateChannel(item: string): Chainable; - - /** - * Get "+" button to write a direct message - * @example - * cy.uiAddDirectMessage(); - */ - uiAddDirectMessage(): Chainable; - - /** - * Get find channels button - * @example - * cy.uiGetFindChannels(); - */ - uiGetFindChannels(): Chainable; - - /** - * Open find channels - * @example - * cy.uiOpenFindChannels(); - */ - uiOpenFindChannels(): Chainable; - - /** - * Open menu of a channel in the sidebar - * @param {string} channelName - name of channel, ex. 'town-square' - * - * @example - * cy.uiGetChannelSidebarMenu('town-square'); - */ - uiGetChannelSidebarMenu(channelName: string): Chainable; - - /** - * Click sidebar item by channel or thread name - * @param {string} name - channel name for channels, and threads for Global Threads - * - * @example - * cy.uiClickSidebarItem('town-square'); - */ - uiClickSidebarItem(name: string): Chainable; - - /** - * Get sidebar item by channel or thread name - * @param {string} name - channel name for channels, and threads for Global Threads - * - * @example - * cy.uiGetSidebarItem('town-square').find('.badge').should('be.visible'); - */ - uiGetSidebarItem(name: string): Chainable; - } -} diff --git a/e2e/cypress/tests/support/ui/sidebar_left.js b/e2e/cypress/tests/support/ui/sidebar_left.js deleted file mode 100644 index 37b99a87c1dd..000000000000 --- a/e2e/cypress/tests/support/ui/sidebar_left.js +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -Cypress.Commands.add('uiGetLHS', () => { - return cy.get('#SidebarContainer').should('be.visible'); -}); - -Cypress.Commands.add('uiGetLHSHeader', () => { - return cy.uiGetLHS(). - find('.SidebarHeaderMenuWrapper'). - should('be.visible'); -}); - -Cypress.Commands.add('uiOpenTeamMenu', (item = '') => { - // # Click on LHS header - cy.uiGetLHSHeader().click(); - - if (!item) { - // # Return the menu if no item is passed - return cy.uiGetLHSTeamMenu(); - } - - // # Click on a particular item - return cy.uiGetLHSTeamMenu(). - findByText(item). - scrollIntoView(). - should('be.visible'). - click(); -}); - -Cypress.Commands.add('uiGetLHSAddChannelButton', () => { - return cy.uiGetLHS(). - findByRole('button', {name: 'Add Channel Dropdown'}); -}); - -Cypress.Commands.add('uiGetLHSTeamMenu', () => { - return cy.uiGetLHS().find('#sidebarDropdownMenu'); -}); - -Cypress.Commands.add('uiOpenSystemConsoleMenu', (item = '') => { - // # Click on LHS header button - cy.uiGetSystemConsoleButton().click(); - - if (!item) { - // # Return the menu if no item is passed - return cy.uiGetSystemConsoleMenu(); - } - - // # Click on a particular item - return cy.uiGetSystemConsoleMenu(). - findByText(item). - scrollIntoView(). - should('be.visible'). - click(); -}); - -Cypress.Commands.add('uiGetSystemConsoleButton', () => { - return cy.get('.admin-sidebar'). - findByRole('button', {name: 'Menu Icon'}); -}); - -Cypress.Commands.add('uiGetSystemConsoleMenu', () => { - return cy.get('.admin-sidebar'). - find('.dropdown-menu'). - should('be.visible'); -}); - -Cypress.Commands.add('uiGetLhsSection', (section) => { - if (section === 'UNREADS') { - return cy.findByText(section). - parent(). - parent(). - parent(); - } - - return cy.findAllByRole('button', {name: section}). - first(). - parent(). - parent(). - parent(); -}); - -Cypress.Commands.add('uiBrowseOrCreateChannel', (item) => { - cy.findByRole('button', {name: 'Add Channel Dropdown'}). - should('be.visible'). - click(); - cy.get('.dropdown-menu').should('be.visible'); - - if (item) { - cy.findByRole('menuitem', {name: item}); - } -}); - -Cypress.Commands.add('uiAddDirectMessage', () => { - return cy.findByRole('button', {name: 'Write a direct message'}); -}); - -Cypress.Commands.add('uiGetFindChannels', () => { - return cy.get('#lhsNavigator').findByRole('button', {name: 'Find Channels'}); -}); - -Cypress.Commands.add('uiOpenFindChannels', () => { - cy.uiGetFindChannels().click(); -}); - -Cypress.Commands.add('uiGetSidebarThreadsButton', () => { - cy.get('#sidebar-threads-button').should('be.visible'); -}); - -Cypress.Commands.add('uiGetSidebarInsightsButton', () => { - cy.get('#sidebar-insights-button').should('be.visible'); -}); - -Cypress.Commands.add('uiGetChannelSidebarMenu', (channelName) => { - cy.get(`#sidebarItem_${channelName}`). - find('.SidebarMenu_menuButton'). - click({force: true}); - - return cy.get('.dropdown-menu').should('be.visible'); -}); - -Cypress.Commands.add('uiClickSidebarItem', (name) => { - cy.uiGetSidebarItem(name).click(); - - if (name === 'threads') { - cy.get('body').then((body) => { - if (body.find('#genericModalLabel').length > 0) { - cy.uiCloseModal('A new way to view and follow threads'); - } - }); - cy.findByRole('heading', {name: 'Followed threads'}); - } else if (name === 'insights') { - cy.get('#insightsFilterDropdown').should('be.visible').should('contain.text', 'Team insights'); - } else { - cy.findAllByTestId('postView').should('be.visible'); - } -}); - -Cypress.Commands.add('uiGetSidebarItem', (channelName) => { - return cy.get(`#sidebarItem_${channelName}`); -}); diff --git a/e2e/cypress/tests/support/ui/sidebar_left.ts b/e2e/cypress/tests/support/ui/sidebar_left.ts new file mode 100644 index 000000000000..951d408b56a2 --- /dev/null +++ b/e2e/cypress/tests/support/ui/sidebar_left.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {ChainableT} from '../../types'; + +Cypress.Commands.add('uiGetLHS', () => { + return cy.get('#SidebarContainer').should('be.visible'); +}); + +Cypress.Commands.add('uiGetLHSHeader', () => { + return cy.uiGetLHS(). + find('.SidebarHeaderMenuWrapper'). + should('be.visible'); +}); + +Cypress.Commands.add('uiOpenTeamMenu', (item = '') => { + // # Click on LHS header + cy.uiGetLHSHeader().click(); + + if (!item) { + // # Return the menu if no item is passed + return cy.uiGetLHSTeamMenu(); + } + + // # Click on a particular item + return cy.uiGetLHSTeamMenu(). + findByText(item). + scrollIntoView(). + should('be.visible'). + click(); +}); + +Cypress.Commands.add('uiGetLHSAddChannelButton', () => { + return cy.uiGetLHS(). + findByRole('button', {name: 'Add Channel Dropdown'}); +}); + +Cypress.Commands.add('uiGetLHSTeamMenu', () => { + return cy.uiGetLHS().find('#sidebarDropdownMenu'); +}); + +function uiOpenSystemConsoleMenu(item = ''): ChainableT { + // # Click on LHS header button + cy.uiGetSystemConsoleButton().click(); + + if (!item) { + // # Return the menu if no item is passed + return cy.uiGetSystemConsoleMenu(); + } + + // # Click on a particular item + return cy.uiGetSystemConsoleMenu(). + findByText(item). + scrollIntoView(). + should('be.visible'). + click(); +} + +Cypress.Commands.add('uiOpenSystemConsoleMenu', uiOpenSystemConsoleMenu); + +function uiGetSystemConsoleButton(): ChainableT { + return cy.get('.admin-sidebar'). + findByRole('button', {name: 'Menu Icon'}); +} + +Cypress.Commands.add('uiGetSystemConsoleButton', uiGetSystemConsoleButton); + +function uiGetSystemConsoleMenu(): ChainableT { + return cy.get('.admin-sidebar'). + find('.dropdown-menu'). + should('be.visible'); +} + +Cypress.Commands.add('uiGetSystemConsoleMenu', uiGetSystemConsoleMenu); + +Cypress.Commands.add('uiGetLhsSection', (section) => { + if (section === 'UNREADS') { + return cy.findByText(section). + parent(). + parent(). + parent(); + } + + return cy.findAllByRole('button', {name: section}). + first(). + parent(). + parent(). + parent(); +}); + +Cypress.Commands.add('uiBrowseOrCreateChannel', (item) => { + cy.findByRole('button', {name: 'Add Channel Dropdown'}). + should('be.visible'). + click(); + cy.get('.dropdown-menu').should('be.visible'); + + if (item) { + cy.findByRole('menuitem', {name: item}); + } +}); + +Cypress.Commands.add('uiAddDirectMessage', () => { + return cy.findByRole('button', {name: 'Write a direct message'}); +}); + +Cypress.Commands.add('uiGetFindChannels', () => { + return cy.get('#lhsNavigator').findByRole('button', {name: 'Find Channels'}); +}); + +Cypress.Commands.add('uiOpenFindChannels', () => { + cy.uiGetFindChannels().click(); +}); + +function uiGetSidebarThreadsButton(): ChainableT { + return cy.get('#sidebar-threads-button').should('be.visible'); +} +Cypress.Commands.add('uiGetSidebarThreadsButton', uiGetSidebarThreadsButton); + +function uiGetSidebarInsightsButton(): ChainableT { + return cy.get('#sidebar-insights-button').should('be.visible'); +} +Cypress.Commands.add('uiGetSidebarInsightsButton', uiGetSidebarInsightsButton); + +Cypress.Commands.add('uiGetChannelSidebarMenu', (channelName) => { + cy.get(`#sidebarItem_${channelName}`). + find('.SidebarMenu_menuButton'). + click({force: true}); + + return cy.get('.dropdown-menu').should('be.visible'); +}); + +Cypress.Commands.add('uiClickSidebarItem', (name) => { + cy.uiGetSidebarItem(name).click(); + + if (name === 'threads') { + cy.get('body').then((body) => { + if (body.find('#genericModalLabel').length > 0) { + cy.uiCloseModal('A new way to view and follow threads'); + } + }); + cy.findByRole('heading', {name: 'Followed threads'}); + } else { + cy.findAllByTestId('postView').should('be.visible'); + } +}); + +Cypress.Commands.add('uiGetSidebarItem', (channelName) => { + return cy.get(`#sidebarItem_${channelName}`); +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + + /** + * Get LHS + * + * @example + * cy.uiGetLHS(); + */ + uiGetLHS(): Chainable; + + /** + * Get LHS header + * + * @example + * cy.uiGetLHSHeader().click(); + */ + uiGetLHSHeader(): Chainable; + + /** + * Open team menu + * + * @param {string} item - ex. 'Invite People', 'Team Settings', etc. + * + * @example + * cy.uiOpenTeamMenu(); + */ + uiOpenTeamMenu(item: string): Chainable; + + /** + * Get LHS add channel button + * + * @example + * cy.uiGetLHSAddChannelButton().click(); + */ + uiGetLHSAddChannelButton(): Chainable; + + /** + * Get LHS team menu + * + * @example + * cy.uiGetLHSTeamMenu().should('not.exist); + */ + uiGetLHSTeamMenu(): Chainable; + + /** + * Get LHS section + * @param {string} section - section such as UNREADS, CHANNELS, FAVORITES, DIRECT MESSAGES and other custom category + * + * @example + * cy.uiGetLhsSection('CHANNELS'); + */ + uiGetLhsSection(section: string): Chainable; + + /** + * Open menu to browse or create channel + * @param {string} item - dropdown menu. If set, it will do click action. + * + * @example + * cy.uiBrowseOrCreateChannel('Browse Channels'); + */ + uiBrowseOrCreateChannel(item: string): Chainable; + + /** + * Get "+" button to write a direct message + * @example + * cy.uiAddDirectMessage(); + */ + uiAddDirectMessage(): Chainable; + + /** + * Get find channels button + * @example + * cy.uiGetFindChannels(); + */ + uiGetFindChannels(): Chainable; + + /** + * Open find channels + * @example + * cy.uiOpenFindChannels(); + */ + uiOpenFindChannels(): Chainable; + + /** + * Open menu of a channel in the sidebar + * @param {string} channelName - name of channel, ex. 'town-square' + * + * @example + * cy.uiGetChannelSidebarMenu('town-square'); + */ + uiGetChannelSidebarMenu(channelName: string): Chainable; + + /** + * Click sidebar item by channel or thread name + * @param {string} name - channel name for channels, and threads for Global Threads + * + * @example + * cy.uiClickSidebarItem('town-square'); + */ + uiClickSidebarItem(name: string): Chainable; + + /** + * Get sidebar item by channel or thread name + * @param {string} name - channel name for channels, and threads for Global Threads + * + * @example + * cy.uiGetSidebarItem('town-square').find('.badge').should('be.visible'); + */ + uiGetSidebarItem(name: string): Chainable; + + uiOpenSystemConsoleMenu: typeof uiOpenSystemConsoleMenu; + + uiGetSystemConsoleButton: typeof uiGetSystemConsoleButton; + + uiGetSystemConsoleMenu: typeof uiGetSystemConsoleMenu; + + uiGetSidebarThreadsButton: typeof uiGetSidebarThreadsButton; + + uiGetSidebarInsightsButton: typeof uiGetSidebarInsightsButton; + } + } +} diff --git a/e2e/cypress/tests/support/ui_commands.d.ts b/e2e/cypress/tests/support/ui_commands.d.ts deleted file mode 100644 index 579ccf8269e3..000000000000 --- a/e2e/cypress/tests/support/ui_commands.d.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/// - -// *************************************************************** -// Each command should be properly documented using JSDoc. -// See https://jsdoc.app/index.html for reference. -// Basic requirements for documentation are the following: -// - Meaningful description -// - Specific link to https://api.mattermost.com -// - Each parameter with `@params` -// - Return value with `@returns` -// - Example usage with `@example` -// Custom command should follow naming convention of having `ui` prefix, e.g. `uiWaitUntilMessagePostedIncludes`. -// *************************************************************** - -declare namespace Cypress { - interface Chainable { - - /** - * Wait for a message to get posted as the last post. - * @param {string} message - message to check if includes in the last post - * @returns {boolean} returns true if found or fail a test if not. - * - * @example - * const message = 'message'; - * cy.postMessage(message); - * cy.uiWaitUntilMessagePostedIncludes(message); - */ - uiWaitUntilMessagePostedIncludes(message: string): boolean; - - /** - * Get nth post from the post list - * @param {number} index - an identifier of a post - * - zero (0) : oldest post - * - positive number : from old to latest post - * - negative number : from new to oldest post - * @returns {Response} response: Cypress-chainable response - * - * @example - * cy.uiGetNthPost(-1); - */ - uiGetNthPost(index: number): Chainable; - - /** - * Post message via center textbox by directly injected in the textbox - * @param {string} message - message to be posted - * @returns void - * - * @example - * cy.uiPostMessageQuickly('Hello world') - */ - uiPostMessageQuickly(message: string): void; - - /** - * Clicks on a visible emoji in the emoji picker. - * For emojis further down the page, search for that emoji in search bar and then use this command to click on it. - * @param {string} emojiName - The name of emoji to click. For emojis with multiple names concat with ','. eg. slightly_frowning_face - * @returns void - * - * @example - * cy.uiClickSystemEmoji('slightly_frowning_face'); - * cy.uiClickSystemEmoji('star-struck,grinning_face_with_star_eyes'); - */ - clickEmojiInEmojiPicker(emojiName: string): void; - } -} diff --git a/e2e/cypress/tests/support/ui_commands.js b/e2e/cypress/tests/support/ui_commands.js deleted file mode 100644 index 1bdc4d5cbadf..000000000000 --- a/e2e/cypress/tests/support/ui_commands.js +++ /dev/null @@ -1,570 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import localforage from 'localforage'; - -import * as TIMEOUTS from '../fixtures/timeouts'; -import {isMac} from '../utils'; - -// *********************************************************** -// Read more: https://on.cypress.io/custom-commands -// *********************************************************** - -Cypress.Commands.add('logout', () => { - cy.get('#logout').click({force: true}); -}); - -Cypress.Commands.add('getCurrentUserId', () => { - return cy.wrap(new Promise((resolve) => { - cy.getCookie('MMUSERID').then((cookie) => { - resolve(cookie.value); - }); - })); -}); - -// *********************************************************** -// Key Press -// *********************************************************** - -// Type Cmd or Ctrl depending on OS -Cypress.Commands.add('typeCmdOrCtrl', () => { - typeCmdOrCtrlInt('#post_textbox'); -}); - -Cypress.Commands.add('typeCmdOrCtrlForEdit', () => { - typeCmdOrCtrlInt('#edit_textbox'); -}); - -function typeCmdOrCtrlInt(textboxSelector) { - let cmdOrCtrl; - if (isMac()) { - cmdOrCtrl = '{cmd}'; - } else { - cmdOrCtrl = '{ctrl}'; - } - - cy.get(textboxSelector).type(cmdOrCtrl, {release: false}); -} - -Cypress.Commands.add('cmdOrCtrlShortcut', {prevSubject: true}, (subject, text) => { - const cmdOrCtrl = isMac() ? '{cmd}' : '{ctrl}'; - return cy.get(subject).type(`${cmdOrCtrl}${text}`); -}); - -// *********************************************************** -// Post -// *********************************************************** - -Cypress.Commands.add('postMessage', (message) => { - cy.get('#postListContent').should('be.visible'); - postMessageAndWait('#post_textbox', message); -}); - -Cypress.Commands.add('postMessageReplyInRHS', (message) => { - cy.get('#sidebar-right').should('be.visible'); - postMessageAndWait('#reply_textbox', message, true); -}); - -Cypress.Commands.add('uiPostMessageQuickly', (message) => { - cy.uiGetPostTextBox().should('be.visible').clear(). - invoke('val', message).wait(TIMEOUTS.HALF_SEC).type(' {backspace}{enter}'); - cy.waitUntil(() => { - return cy.uiGetPostTextBox().then((el) => { - return el[0].textContent === ''; - }); - }); -}); - -function postMessageAndWait(textboxSelector, message, isComment = false) { - // Add explicit wait to let the page load freely since `cy.get` seemed to block - // some operation which caused to prolong complete page loading. - cy.wait(TIMEOUTS.HALF_SEC); - cy.get(textboxSelector, {timeout: TIMEOUTS.HALF_MIN}).should('be.visible'); - - // # Type then wait for a while for the draft to be saved (async) into the local storage - cy.get(textboxSelector).clear().type(message).wait(TIMEOUTS.ONE_SEC); - - // If posting a comment, wait for comment draft from localforage before hitting enter - if (isComment) { - waitForCommentDraft(message); - } - - cy.get(textboxSelector).should('have.value', message).type('{enter}').wait(TIMEOUTS.HALF_SEC); - - cy.get(textboxSelector).invoke('val').then((value) => { - if (value.length > 0 && value === message) { - cy.get(textboxSelector).type('{enter}').wait(TIMEOUTS.HALF_SEC); - } - }); - cy.waitUntil(() => { - return cy.get(textboxSelector).then((el) => { - return el[0].textContent === ''; - }); - }); -} - -// Wait until comment message is saved as draft from the localforage -function waitForCommentDraft(message) { - const draftPrefix = 'comment_draft_'; - - cy.waitUntil(async () => { - // Get all keys from localforage - const keys = await localforage.keys(); - - // Get all draft comments matching the predefined prefix - const draftPromises = keys. - filter((key) => key.includes(draftPrefix)). - map((key) => localforage.getItem(key)); - const draftItems = await Promise.all(draftPromises); - - // Get the exact draft comment - const commentDraft = draftItems.filter((item) => { - const draft = JSON.parse(item); - - if (draft && draft.value && draft.value.message) { - return draft.value.message === message; - } - - return false; - }); - - return Boolean(commentDraft); - }); -} - -function waitUntilPermanentPost() { - // Add explicit wait to let the page load freely since `cy.get` seemed to block - // some operation which caused to prolong complete page loading. - cy.wait(TIMEOUTS.HALF_SEC); - cy.get('#postListContent', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); - cy.waitUntil(() => cy.findAllByTestId('postView').last().then((el) => !(el[0].id.includes(':')))); -} - -Cypress.Commands.add('getLastPost', () => { - waitUntilPermanentPost(); - - return cy.findAllByTestId('postView').last(); -}); - -Cypress.Commands.add('getLastPostId', () => { - waitUntilPermanentPost(); - - cy.findAllByTestId('postView').last().should('have.attr', 'id').and('not.include', ':'). - invoke('replace', 'post_', ''); -}); - -/** - * @see `cy.uiWaitUntilMessagePostedIncludes` at ./ui_commands.d.ts - */ -Cypress.Commands.add('uiWaitUntilMessagePostedIncludes', (message) => { - const checkFn = () => { - return cy.getLastPost().then((el) => { - const postedMessageEl = el.find('.post-message__text')[0]; - return Boolean(postedMessageEl && postedMessageEl.textContent.includes(message)); - }); - }; - - // Wait for 5 seconds with 500ms check interval - const options = { - timeout: TIMEOUTS.FIVE_SEC, - interval: TIMEOUTS.HALF_SEC, - errorMsg: `Expected "${message}" to be in the last message posted but not found.`, - }; - - return cy.waitUntil(checkFn, options); -}); - -Cypress.Commands.add('getLastPostIdRHS', () => { - waitUntilPermanentPost(); - - cy.get('#rhsContainer .post-right-comments-container > div').last().should('have.attr', 'id').and('not.include', ':'). - invoke('replace', 'rhsPost_', ''); -}); - -/** -* Get post ID based on index of post list -* @param {Integer} index -* zero (0) : oldest post -* positive number : from old to latest post -* negative number : from new to oldest post -*/ -Cypress.Commands.add('getNthPostId', (index = 0) => { - waitUntilPermanentPost(); - - cy.findAllByTestId('postView').eq(index).should('have.attr', 'id').and('not.include', ':'). - invoke('replace', 'post_', ''); -}); - -Cypress.Commands.add('uiGetNthPost', (index) => { - waitUntilPermanentPost(); - - return cy.findAllByTestId('postView').eq(index); -}); - -/** - * Post message from a file instantly post a message in a textbox - * instead of typing into it which takes longer period of time. - * @param {String} file - includes path and filename relative to tests/fixtures - * @param {String} target - either #post_textbox or #reply_textbox - */ -Cypress.Commands.add('postMessageFromFile', (file, target = '#post_textbox') => { - cy.fixture(file, 'utf-8').then((text) => { - cy.get(target).clear().invoke('val', text).wait(TIMEOUTS.HALF_SEC).type(' {backspace}{enter}').should('have.text', ''); - }); -}); - -/** - * Compares HTML content of a last post against the given file - * instead of typing into it which takes longer period of time. - * @param {String} file - includes path and filename relative to tests/fixtures - */ -Cypress.Commands.add('compareLastPostHTMLContentFromFile', (file, timeout = TIMEOUTS.TEN_SEC) => { - // * Verify that HTML Content is correct - cy.getLastPostId().then((postId) => { - const postMessageTextId = `#postMessageText_${postId}`; - - cy.fixture(file, 'utf-8').then((expectedHtml) => { - cy.get(postMessageTextId, {timeout}).should('have.html', expectedHtml.replace(/\n$/, '')); - }); - }); -}); - -// *********************************************************** -// DM -// *********************************************************** - -/** - * Go to a DM channel with a given user - * @param {User} user - the user that should get the message - */ -Cypress.Commands.add('uiGotoDirectMessageWithUser', (user) => { - // # Open a new direct message with firstDMUser - cy.uiAddDirectMessage().click().wait(TIMEOUTS.ONE_SEC); - cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); - - // # Type username - cy.findByRole('textbox', {name: 'Search for people'}). - typeWithForce(user.username). - wait(TIMEOUTS.ONE_SEC); - - // * Expect user count in the list to be 1 - cy.get('#multiSelectList'). - should('be.visible'). - children(). - should('have.length', 1); - - // # Select first user in the list - cy.get('body'). - type('{downArrow}'). - type('{enter}'); - - // # Click on "Go" in the group message's dialog to begin the conversation - cy.get('#saveItems').click(); - - // * Expect the channel title to be the user's username - // In the channel header, it seems there is a space after the username, justifying the use of contains.text instead of have.text - cy.get('#channelHeaderTitle').should('be.visible').and('contain.text', user.username); -}); - -/** - * Sends a DM to a given user - * @param {User} user - the user that should get the message - * @param {String} message - the message to send - */ -Cypress.Commands.add('sendDirectMessageToUser', (user, message) => { - cy.uiGotoDirectMessageWithUser(user); - - // # Type message and send it to the user - cy.postMessage(message); -}); - -/** - * Sends a GM to a given user list - * @param {User[]} users - the users that should get the message - * @param {String} message - the message to send - */ -Cypress.Commands.add('sendDirectMessageToUsers', (users, message) => { - // # Open a new direct message - cy.uiAddDirectMessage().click(); - - users.forEach((user) => { - // # Type username - cy.get('#selectItems input').should('be.enabled').typeWithForce(`@${user.username}`); - - // * Expect user count in the list to be 1 - cy.get('#multiSelectList'). - should('be.visible'). - children(). - should('have.length', 1); - - // # Select first user in the list - cy.get('body'). - type('{downArrow}'). - type('{enter}'); - }); - - // # Click on "Go" in the group message's dialog to begin the conversation - cy.get('#saveItems').click(); - - // * Expect the channel title to be the user's username - // In the channel header, it seems there is a space after the username, justifying the use of contains.text instead of have.text - users.forEach((user) => { - cy.get('#channelHeaderTitle').should('be.visible').and('contain.text', user.username); - }); - - // # Type message and send it to the user - cy.postMessage(message); -}); - -// *********************************************************** -// Post header -// *********************************************************** - -function clickPostHeaderItem(postId, location, item) { - let idPrefix; - switch (location) { - case 'CENTER': - idPrefix = 'post'; - break; - case 'RHS_ROOT': - case 'RHS_COMMENT': - idPrefix = 'rhsPost'; - break; - case 'SEARCH': - idPrefix = 'searchResult'; - break; - - default: - idPrefix = 'post'; - } - - if (postId) { - cy.get(`#${idPrefix}_${postId}`).trigger('mouseover', {force: true}); - cy.wait(TIMEOUTS.HALF_SEC).get(`#${location}_${item}_${postId}`).click({force: true}); - } else { - cy.getLastPostId().then((lastPostId) => { - cy.get(`#${idPrefix}_${lastPostId}`).trigger('mouseover', {force: true}); - cy.wait(TIMEOUTS.HALF_SEC).get(`#${location}_${item}_${lastPostId}`).click({force: true}); - }); - } -} - -/** - * Click post time - * @param {String} postId - Post ID - * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH' - */ -Cypress.Commands.add('clickPostTime', (postId, location = 'CENTER') => { - clickPostHeaderItem(postId, location, 'time'); -}); - -/** - * Click save icon by post ID or to most recent post (if post ID is not provided) - * @param {String} postId - Post ID - * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH' - */ -Cypress.Commands.add('clickPostSaveIcon', (postId, location = 'CENTER') => { - clickPostHeaderItem(postId, location, 'flagIcon'); -}); - -/** - * Click dot menu by post ID or to most recent post (if post ID is not provided) - * @param {String} postId - Post ID - * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH' - */ -Cypress.Commands.add('clickPostDotMenu', (postId, location = 'CENTER') => { - clickPostHeaderItem(postId, location, 'button'); -}); - -/** - * Click post reaction icon - * @param {String} postId - Post ID - * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT' - */ -Cypress.Commands.add('clickPostReactionIcon', (postId, location = 'CENTER') => { - clickPostHeaderItem(postId, location, 'reaction'); -}); - -/** - * Click comment icon by post ID or to most recent post (if post ID is not provided) - * This open up the RHS - * @param {String} postId - Post ID - * @param {String} location - as 'CENTER', 'SEARCH' - */ -Cypress.Commands.add('clickPostCommentIcon', (postId, location = 'CENTER') => { - clickPostHeaderItem(postId, location, 'commentIcon'); -}); - -// *********************************************************** -// Teams -// *********************************************************** - -Cypress.Commands.add('createNewTeam', (teamName, teamURL) => { - cy.visit('/create_team'); - cy.get('#teamNameInput').type(teamName).type('{enter}'); - cy.get('#teamURLInput').type(teamURL).type('{enter}'); - cy.visit(`/${teamURL}`); -}); - -Cypress.Commands.add('getCurrentTeamURL', (siteURL) => { - let path; - - // siteURL can be provided for cases where subpath is being tested - if (siteURL) { - path = window.location.href.substring(siteURL.length); - } else { - path = window.location.pathname; - } - - const result = path.split('/', 2); - return `/${(result[0] ? result[0] : result[1])}`; // sometimes the first element is emply if path starts with '/' -}); - -Cypress.Commands.add('leaveTeam', () => { - // # Open team menu and click "Leave Team" - cy.uiOpenTeamMenu('Leave Team'); - - // * Check that the "leave team modal" opened up - cy.get('#leaveTeamModal').should('be.visible'); - - // # click on yes - cy.get('#leaveTeamYes').click(); - - // * Check that the "leave team modal" closed - cy.get('#leaveTeamModal').should('not.exist'); -}); - -// *********************************************************** -// Text Box -// *********************************************************** - -Cypress.Commands.add('clearPostTextbox', (channelName = 'town-square') => { - cy.get(`#sidebarItem_${channelName}`).click({force: true}); - cy.uiGetPostTextBox().clear(); -}); - -// *********************************************************** -// Min Setting View -// ************************************************************ - -// Checking min setting view for display -Cypress.Commands.add('minDisplaySettings', () => { - cy.get('#themeTitle').should('be.visible', 'contain', 'Theme'); - cy.get('#themeEdit').should('be.visible', 'contain', 'Edit'); - - cy.get('#clockTitle').should('be.visible', 'contain', 'Clock Display'); - cy.get('#clockEdit').should('be.visible', 'contain', 'Edit'); - - cy.get('#name_formatTitle').should('be.visible', 'contain', 'Teammate Name Display'); - cy.get('#name_formatEdit').should('be.visible', 'contain', 'Edit'); - - cy.get('#collapseTitle').should('be.visible', 'contain', 'Default appearance of image previews'); - cy.get('#collapseEdit').should('be.visible', 'contain', 'Edit'); - - cy.get('#message_displayTitle').scrollIntoView().should('be.visible', 'contain', 'Message Display'); - cy.get('#message_displayEdit').should('be.visible', 'contain', 'Edit'); - - cy.get('#languagesTitle').scrollIntoView().should('be.visible', 'contain', 'Language'); - cy.get('#languagesEdit').should('be.visible', 'contain', 'Edit'); -}); - -// *********************************************************** -// Change User Status -// ************************************************************ - -// Need to be in main channel view -// 0 = Online -// 1 = Away -// 2 = Do Not Disturb -// 3 = Offline -Cypress.Commands.add('userStatus', (statusInt) => { - cy.get('.status-wrapper.status-selector').click(); - cy.get('.MenuItem').eq(statusInt).click(); -}); - -// *********************************************************** -// Channel -// ************************************************************ - -Cypress.Commands.add('getCurrentChannelId', () => { - return cy.get('#channel-header', {timeout: TIMEOUTS.HALF_MIN}).invoke('attr', 'data-channelid'); -}); - -/** - * Update channel header - * @param {String} text - Text to set the header to - */ -Cypress.Commands.add('updateChannelHeader', (text) => { - cy.get('#channelHeaderDropdownIcon'). - should('be.visible'). - click(); - cy.get('.Menu__content'). - should('be.visible'). - find('#channelEditHeader'). - click(); - cy.get('#edit_textbox'). - clear(). - type(text). - type('{enter}'). - wait(TIMEOUTS.HALF_SEC); -}); - -/** - * Navigate to system console-PluginManagement from account settings - */ -Cypress.Commands.add('checkRunLDAPSync', () => { - cy.apiGetLDAPSync().then((response) => { - var jobs = response.body; - var currentTime = new Date(); - - // # Run LDAP Sync if no job exists (or) last status is an error (or) last run time is more than 1 day old - if (jobs.length === 0 || jobs[0].status === 'error' || ((currentTime - (new Date(jobs[0].last_activity_at))) > 8640000)) { - // # Go to system admin LDAP page and run the group sync - cy.visit('/admin_console/authentication/ldap'); - - // # Click on AD/LDAP Synchronize Now button and verify if succesful - cy.findByText('AD/LDAP Test').click(); - cy.findByText('AD/LDAP Test Successful').should('be.visible'); - - // # Click on AD/LDAP Synchronize Now button - cy.findByText('AD/LDAP Synchronize Now').click().wait(TIMEOUTS.ONE_SEC); - - // * Get the First row - cy.findByTestId('jobTable'). - find('tbody > tr'). - eq(0). - as('firstRow'); - - // * Wait until first row updates to say Success - cy.waitUntil(() => { - return cy.get('@firstRow').then((el) => { - return el.find('.status-icon-success').length > 0; - }); - } - , { - timeout: TIMEOUTS.FIVE_MIN, - interval: TIMEOUTS.TWO_SEC, - errorMsg: 'AD/LDAP Sync Job did not finish', - }); - } - }); -}); - -/** - * Clicks on a visible emoji in the emoji picker. - * For emojis further down the page, search for that emoji in search bar and then use this command to click on it. - * @param {String} emojiName - The name of emoji to click. For emojis with multiple names concat with ',' - * @returns null - */ -Cypress.Commands.add('clickEmojiInEmojiPicker', (emojiName) => { - cy.get('#emojiPicker').should('exist').and('be.visible').within(() => { - // # Mouse over the emoji to get it selected - cy.findAllByTestId(emojiName).eq(0).trigger('mouseover', {force: true}); - - // * Verify that preview shows the emoji selected - cy.findAllByTestId('emoji_picker_preview').eq(0).should('exist').and('be.visible').contains(emojiName, {matchCase: false}); - - // # Click on the emoji - cy.findAllByTestId(emojiName).eq(0).click({force: true}); - }); -}); diff --git a/e2e/cypress/tests/support/ui_commands.ts b/e2e/cypress/tests/support/ui_commands.ts new file mode 100644 index 000000000000..8e73c26f499a --- /dev/null +++ b/e2e/cypress/tests/support/ui_commands.ts @@ -0,0 +1,758 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import localforage from 'localforage'; + +import * as TIMEOUTS from '../fixtures/timeouts'; +import {isMac} from '../utils'; + +import {ChainableT} from '../types'; + +// *********************************************************** +// Read more: https://on.cypress.io/custom-commands +// *********************************************************** + +function logout(): ChainableT { + return cy.get('#logout').click({force: true}); +} +Cypress.Commands.add('logout', logout); + +function getCurrentUserId(): ChainableT> { + return cy.wrap(new Promise((resolve) => { + cy.getCookie('MMUSERID').then((cookie) => { + resolve(cookie.value); + }); + })); +} +Cypress.Commands.add('getCurrentUserId', getCurrentUserId); + +// *********************************************************** +// Key Press +// *********************************************************** + +// Type Cmd or Ctrl depending on OS +function typeCmdOrCtrl(): ChainableT { + return typeCmdOrCtrlInt('#post_textbox'); +} +Cypress.Commands.add('typeCmdOrCtrl', typeCmdOrCtrl); + +function typeCmdOrCtrlForEdit(): ChainableT { + return typeCmdOrCtrlInt('#edit_textbox'); +} +Cypress.Commands.add('typeCmdOrCtrlForEdit', typeCmdOrCtrlForEdit); + +function typeCmdOrCtrlInt(textboxSelector: string) { + let cmdOrCtrl: string; + if (isMac()) { + cmdOrCtrl = '{cmd}'; + } else { + cmdOrCtrl = '{ctrl}'; + } + + return cy.get(textboxSelector).type(cmdOrCtrl, {release: false}); +} + +function cmdOrCtrlShortcut(subject: string, text: string): ChainableT { + const cmdOrCtrl = isMac() ? '{cmd}' : '{ctrl}'; + return cy.get(subject).type(`${cmdOrCtrl}${text}`); +} +Cypress.Commands.add('cmdOrCtrlShortcut', {prevSubject: true}, cmdOrCtrlShortcut); + +// *********************************************************** +// Post +// *********************************************************** + +function postMessage(message: string): ChainableT { + cy.get('#postListContent').should('be.visible'); + return postMessageAndWait('#post_textbox', message); +} +Cypress.Commands.add('postMessage', postMessage); + +function postMessageReplyInRHS(message: string): ChainableT { + cy.get('#sidebar-right').should('be.visible'); + return postMessageAndWait('#reply_textbox', message, true); +} +Cypress.Commands.add('postMessageReplyInRHS', postMessageReplyInRHS); + +Cypress.Commands.add('uiPostMessageQuickly', (message) => { + cy.uiGetPostTextBox().should('be.visible').clear(). + invoke('val', message).wait(TIMEOUTS.HALF_SEC).type(' {backspace}{enter}'); + cy.waitUntil(() => { + return cy.uiGetPostTextBox().then((el) => { + return el[0].textContent === ''; + }); + }); +}); + +function postMessageAndWait(textboxSelector: string, message: string, isComment = false) { + // Add explicit wait to let the page load freely since `cy.get` seemed to block + // some operation which caused to prolong complete page loading. + cy.wait(TIMEOUTS.HALF_SEC); + cy.get(textboxSelector, {timeout: TIMEOUTS.HALF_MIN}).should('be.visible'); + + // # Type then wait for a while for the draft to be saved (async) into the local storage + cy.get(textboxSelector).clear().type(message).wait(TIMEOUTS.ONE_SEC); + + // If posting a comment, wait for comment draft from localforage before hitting enter + if (isComment) { + waitForCommentDraft(message); + } + + cy.get(textboxSelector).should('have.value', message).type('{enter}').wait(TIMEOUTS.HALF_SEC); + + cy.get(textboxSelector).invoke('val').then((value: string) => { + if (value.length > 0 && value === message) { + cy.get(textboxSelector).type('{enter}').wait(TIMEOUTS.HALF_SEC); + } + }); + return cy.waitUntil(() => { + return cy.get(textboxSelector).then((el) => { + return el[0].textContent === ''; + }); + }); +} + +interface Draft { + value?: { + message?: string; + }; +} + +// Wait until comment message is saved as draft from the localforage +function waitForCommentDraft(message: string) { + const draftPrefix = 'comment_draft_'; + + cy.waitUntil(async () => { + // Get all keys from localforage + const keys = await localforage.keys(); + + // Get all draft comments matching the predefined prefix + const draftPromises = keys. + filter((key) => key.includes(draftPrefix)). + map((key) => localforage.getItem(key)); + const draftItems = await Promise.all(draftPromises) as string[]; + + // Get the exact draft comment + const commentDraft = draftItems.filter((item) => { + const draft: Draft = JSON.parse(item); + + if (draft && draft.value && draft.value.message) { + return draft.value.message === message; + } + + return false; + }); + + return Boolean(commentDraft); + }); +} + +function waitUntilPermanentPost() { + // Add explicit wait to let the page load freely since `cy.get` seemed to block + // some operation which caused to prolong complete page loading. + cy.wait(TIMEOUTS.HALF_SEC); + cy.get('#postListContent', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); + return cy.waitUntil(() => cy.findAllByTestId('postView').last().then((el) => !(el[0].id.includes(':')))); +} + +function getLastPost(): ChainableT { + waitUntilPermanentPost(); + + return cy.findAllByTestId('postView').last(); +} +Cypress.Commands.add('getLastPost', getLastPost); + +function getLastPostId(): ChainableT { + waitUntilPermanentPost(); + + return cy.findAllByTestId('postView').last().should('have.attr', 'id').and('not.include', ':'). + invoke('replace', 'post_', ''); +} +Cypress.Commands.add('getLastPostId', getLastPostId); + +function uiWaitUntilMessagePostedIncludes(message: string): ChainableT { + const checkFn = () => { + return cy.getLastPost().then((el) => { + const postedMessageEl = el.find('.post-message__text')[0]; + return Boolean(postedMessageEl && postedMessageEl.textContent.includes(message)); + }); + }; + + // Wait for 5 seconds with 500ms check interval + const options = { + timeout: TIMEOUTS.FIVE_SEC, + interval: TIMEOUTS.HALF_SEC, + errorMsg: `Expected "${message}" to be in the last message posted but not found.`, + }; + + return cy.waitUntil(checkFn, options); +} +Cypress.Commands.add('uiWaitUntilMessagePostedIncludes', uiWaitUntilMessagePostedIncludes); + +function getLastPostIdRHS(): ChainableT { + waitUntilPermanentPost(); + + return cy.get('#rhsContainer .post-right-comments-container > div').last().should('have.attr', 'id').and('not.include', ':'). + invoke('replace', 'rhsPost_', ''); +} +Cypress.Commands.add('getLastPostIdRHS', getLastPostIdRHS); + +function getNthPostId(index = 0): ChainableT { + waitUntilPermanentPost(); + + return cy.findAllByTestId('postView').eq(index).should('have.attr', 'id').and('not.include', ':'). + invoke('replace', 'post_', ''); +} +Cypress.Commands.add('getNthPostId', getNthPostId); + +function uiGetNthPost(index: number): ChainableT { + waitUntilPermanentPost(); + + return cy.findAllByTestId('postView').eq(index); +} +Cypress.Commands.add('uiGetNthPost', uiGetNthPost); + +function postMessageFromFile(file: string, target = '#post_textbox'): ChainableT { + return cy.fixture(file, 'utf-8').then((text) => { + return cy.get(target).clear().invoke('val', text).wait(TIMEOUTS.HALF_SEC).type(' {backspace}{enter}').should('have.text', ''); + }); +} +Cypress.Commands.add('postMessageFromFile', postMessageFromFile); + +function compareLastPostHTMLContentFromFile(file: string, timeout = TIMEOUTS.TEN_SEC): ChainableT { + // * Verify that HTML Content is correct + return cy.getLastPostId().then((postId) => { + const postMessageTextId = `#postMessageText_${postId}`; + + return cy.fixture(file, 'utf-8').then((expectedHtml) => { + cy.get(postMessageTextId, {timeout}).should('have.html', expectedHtml.replace(/\n$/, '')); + }); + }); +} +Cypress.Commands.add('compareLastPostHTMLContentFromFile', compareLastPostHTMLContentFromFile); + +// *********************************************************** +// DM +// *********************************************************** + +export interface User { + username: string; +} + +function uiGotoDirectMessageWithUser(user: User) { + // # Open a new direct message with firstDMUser + cy.uiAddDirectMessage().click().wait(TIMEOUTS.ONE_SEC); + cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC); + + // # Type username + cy.findByRole('textbox', {name: 'Search for people'}).click({force: true}). + type(user.username, {force: true}).wait(TIMEOUTS.ONE_SEC); + + // * Expect user count in the list to be 1 + cy.get('#multiSelectList'). + should('be.visible'). + children(). + should('have.length', 1); + + // # Select first user in the list + cy.get('body'). + type('{downArrow}'). + type('{enter}'); + + // # Click on "Go" in the group message's dialog to begin the conversation + cy.get('#saveItems').click(); + + // * Expect the channel title to be the user's username + // In the channel header, it seems there is a space after the username, justifying the use of contains.text instead of have.text + cy.get('#channelHeaderTitle').should('be.visible').and('contain.text', user.username); +} +Cypress.Commands.add('uiGotoDirectMessageWithUser', uiGotoDirectMessageWithUser); + +function sendDirectMessageToUser(user: User, message: string) { + cy.uiGotoDirectMessageWithUser(user); + + // # Type message and send it to the user + cy.postMessage(message); +} +Cypress.Commands.add('sendDirectMessageToUser', sendDirectMessageToUser); + +function sendDirectMessageToUsers(users: User[], message: string) { + // # Open a new direct message + cy.uiAddDirectMessage().click(); + + users.forEach((user: User) => { + // # Type username + cy.get('#selectItems input').should('be.enabled').type(`@${user.username}`, {force: true}); + + // * Expect user count in the list to be 1 + cy.get('#multiSelectList'). + should('be.visible'). + children(). + should('have.length', 1); + + // # Select first user in the list + cy.get('body'). + type('{downArrow}'). + type('{enter}'); + }); + + // # Click on "Go" in the group message's dialog to begin the conversation + cy.get('#saveItems').click(); + + // * Expect the channel title to be the user's username + // In the channel header, it seems there is a space after the username, justifying the use of contains.text instead of have.text + users.forEach((user) => { + cy.get('#channelHeaderTitle').should('be.visible').and('contain.text', user.username); + }); + + // # Type message and send it to the user + cy.postMessage(message); +} +Cypress.Commands.add('sendDirectMessageToUsers', sendDirectMessageToUsers); + +// *********************************************************** +// Post header +// *********************************************************** + +function clickPostHeaderItem(postId: string, location: string, item: string) { + let idPrefix: string; + switch (location) { + case 'CENTER': + idPrefix = 'post'; + break; + case 'RHS_ROOT': + case 'RHS_COMMENT': + idPrefix = 'rhsPost'; + break; + case 'SEARCH': + idPrefix = 'searchResult'; + break; + + default: + idPrefix = 'post'; + } + + if (postId) { + cy.get(`#${idPrefix}_${postId}`).trigger('mouseover', {force: true}); + cy.wait(TIMEOUTS.HALF_SEC).get(`#${location}_${item}_${postId}`).click({force: true}); + } else { + cy.getLastPostId().then((lastPostId) => { + cy.get(`#${idPrefix}_${lastPostId}`).trigger('mouseover', {force: true}); + cy.wait(TIMEOUTS.HALF_SEC).get(`#${location}_${item}_${lastPostId}`).click({force: true}); + }); + } +} + +function clickPostTime(postId: string, location = 'CENTER') { + clickPostHeaderItem(postId, location, 'time'); +} +Cypress.Commands.add('clickPostTime', clickPostTime); + +function clickPostSaveIcon(postId: string, location = 'CENTER') { + clickPostHeaderItem(postId, location, 'flagIcon'); +} +Cypress.Commands.add('clickPostSaveIcon', clickPostSaveIcon); + +function clickPostDotMenu(postId: string, location = 'CENTER') { + clickPostHeaderItem(postId, location, 'button'); +} +Cypress.Commands.add('clickPostDotMenu', clickPostDotMenu); + +function clickPostReactionIcon(postId: string, location = 'CENTER') { + clickPostHeaderItem(postId, location, 'reaction'); +} +Cypress.Commands.add('clickPostReactionIcon', clickPostReactionIcon); + +function clickPostCommentIcon(postId: string, location = 'CENTER') { + clickPostHeaderItem(postId, location, 'commentIcon'); +} +Cypress.Commands.add('clickPostCommentIcon', clickPostCommentIcon); + +// *********************************************************** +// Teams +// *********************************************************** + +function createNewTeam(teamName: string, teamURL: string) { + cy.visit('/create_team'); + cy.get('#teamNameInput').type(teamName).type('{enter}'); + cy.get('#teamURLInput').type(teamURL).type('{enter}'); + cy.visit(`/${teamURL}`); +} +Cypress.Commands.add('createNewTeam', createNewTeam); + +function getCurrentTeamURL(siteURL: string): ChainableT { + let path: string; + + // siteURL can be provided for cases where subpath is being tested + if (siteURL) { + path = window.location.href.substring(siteURL.length); + } else { + path = window.location.pathname; + } + + const result = path.split('/', 2); + return cy.wrap(`/${(result[0] ? result[0] : result[1])}`); // sometimes the first element is empty if path starts with '/' +} +Cypress.Commands.add('getCurrentTeamURL', getCurrentTeamURL); + +function leaveTeam() { + // # Open team menu and click "Leave Team" + cy.uiOpenTeamMenu('Leave Team'); + + // * Check that the "leave team modal" opened up + cy.get('#leaveTeamModal').should('be.visible'); + + // # click on yes + cy.get('#leaveTeamYes').click(); + + // * Check that the "leave team modal" closed + cy.get('#leaveTeamModal').should('not.exist'); +} +Cypress.Commands.add('leaveTeam', leaveTeam); + +// *********************************************************** +// Text Box +// *********************************************************** + +function clearPostTextbox(channelName = 'town-square') { + cy.get(`#sidebarItem_${channelName}`).click({force: true}); + cy.uiGetPostTextBox().clear(); +} +Cypress.Commands.add('clearPostTextbox', clearPostTextbox); + +// *********************************************************** +// Min Setting View +// ************************************************************ + +function minDisplaySettings() { + cy.get('#themeTitle').should('be.visible', 'contain', 'Theme'); + cy.get('#themeEdit').should('be.visible', 'contain', 'Edit'); + + cy.get('#clockTitle').should('be.visible', 'contain', 'Clock Display'); + cy.get('#clockEdit').should('be.visible', 'contain', 'Edit'); + + cy.get('#name_formatTitle').should('be.visible', 'contain', 'Teammate Name Display'); + cy.get('#name_formatEdit').should('be.visible', 'contain', 'Edit'); + + cy.get('#collapseTitle').should('be.visible', 'contain', 'Default appearance of image previews'); + cy.get('#collapseEdit').should('be.visible', 'contain', 'Edit'); + + cy.get('#message_displayTitle').scrollIntoView().should('be.visible', 'contain', 'Message Display'); + cy.get('#message_displayEdit').should('be.visible', 'contain', 'Edit'); + + cy.get('#languagesTitle').scrollIntoView().should('be.visible', 'contain', 'Language'); + cy.get('#languagesEdit').should('be.visible', 'contain', 'Edit'); +} +Cypress.Commands.add('minDisplaySettings', minDisplaySettings); + +// *********************************************************** +// Change User Status +// ************************************************************ + +function userStatus(statusInt: number) { + cy.get('.status-wrapper.status-selector').click(); + cy.get('.MenuItem').eq(statusInt).click(); +} +Cypress.Commands.add('userStatus', userStatus); + +// *********************************************************** +// Channel +// ************************************************************ + +function getCurrentChannelId(): ChainableT { + return cy.get('#channel-header', {timeout: TIMEOUTS.HALF_MIN}).invoke('attr', 'data-channelid'); +} +Cypress.Commands.add('getCurrentChannelId', getCurrentChannelId); + +function updateChannelHeader(text: string) { + cy.get('#channelHeaderDropdownIcon'). + should('be.visible'). + click(); + cy.get('.Menu__content'). + should('be.visible'). + find('#channelEditHeader'). + click(); + cy.get('#edit_textbox'). + clear(). + type(text). + type('{enter}'). + wait(TIMEOUTS.HALF_SEC); +} + +Cypress.Commands.add('updateChannelHeader', updateChannelHeader); + +function checkRunLDAPSync(): ChainableT { + return cy.apiGetLDAPSync().then((response) => { + const jobs = response.body; + const currentTime = new Date(); + + // # Run LDAP Sync if no job exists (or) last status is an error (or) last run time is more than 1 day old + if (jobs.length === 0 || jobs[0].status === 'error' || ((currentTime.getTime() - (new Date(jobs[0].last_activity_at)).getTime()) > 8640000)) { + // # Go to system admin LDAP page and run the group sync + cy.visit('/admin_console/authentication/ldap'); + + // # Click on AD/LDAP Synchronize Now button and verify if succesful + cy.findByText('AD/LDAP Test').click(); + cy.findByText('AD/LDAP Test Successful').should('be.visible'); + + // # Click on AD/LDAP Synchronize Now button + cy.findByText('AD/LDAP Synchronize Now').click().wait(TIMEOUTS.ONE_SEC); + + // * Get the First row + cy.findByTestId('jobTable'). + find('tbody > tr'). + eq(0). + as('firstRow'); + + // * Wait until first row updates to say Success + cy.waitUntil(() => { + return cy.get('@firstRow').then((el) => { + return el.find('.status-icon-success').length > 0; + }); + } + , { + timeout: TIMEOUTS.FIVE_MIN, + interval: TIMEOUTS.TWO_SEC, + errorMsg: 'AD/LDAP Sync Job did not finish', + }); + } + }); +} +Cypress.Commands.add('checkRunLDAPSync', checkRunLDAPSync); + +function clickEmojiInEmojiPicker(emojiName: string) { + cy.get('#emojiPicker').should('exist').and('be.visible').within(() => { + // # Mouse over the emoji to get it selected + cy.findAllByTestId(emojiName).eq(0).trigger('mouseover', {force: true}); + + // * Verify that preview shows the emoji selected + cy.findAllByTestId('emoji_picker_preview').eq(0).should('exist').and('be.visible').contains(emojiName, {matchCase: false}); + + // # Click on the emoji + cy.findAllByTestId(emojiName).eq(0).click({force: true}); + }); +} +Cypress.Commands.add('clickEmojiInEmojiPicker', clickEmojiInEmojiPicker); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + + /** + * log out user + * + * @example + * cy.logout(); + */ + logout: typeof logout; + + /** + * Wait for a message to get posted as the last post. + * @returns {string} returns true if found or fail a test if not. + * + * @example + * cy.getCurrentUserId().then((id) => { + */ + getCurrentUserId: typeof getCurrentUserId; + + /** + * Types `{cmd}` mac / `{ctrl}` windows into post textbox + */ + typeCmdOrCtrl: typeof typeCmdOrCtrl; + + /** + * Types `{cmd}` mac / `{ctrl}` windows into edit post textbox + */ + typeCmdOrCtrlForEdit: typeof typeCmdOrCtrlForEdit; + + cmdOrCtrlShortcut: typeof cmdOrCtrlShortcut; + + postMessage: typeof postMessage; + + postMessageReplyInRHS: typeof postMessageReplyInRHS; + + /** + * Wait for a message to get posted as the last post. + * @param {string} message - message to check if includes in the last post + * @returns {boolean} returns true if found or fail a test if not. + * + * @example + * const message = 'message'; + * cy.postMessage(message); + * cy.uiWaitUntilMessagePostedIncludes(message); + */ + uiWaitUntilMessagePostedIncludes: typeof uiWaitUntilMessagePostedIncludes; + + /** + * Get nth post from the post list + * @param {number} index - an identifier of a post + * - zero (0) : oldest post + * - positive number : from old to latest post + * - negative number : from new to oldest post + * @returns {JQuery} response: Cypress-chainable JQuery + * + * @example + * cy.uiGetNthPost(-1); + */ + uiGetNthPost: typeof uiGetNthPost; + + /** + * Post message via center textbox by directly injected in the textbox + * @param {string} message - message to be posted + * @returns void + * + * @example + * cy.uiPostMessageQuickly('Hello world') + */ + uiPostMessageQuickly(message: string): void; + + /** + * Clicks on a visible emoji in the emoji picker. + * For emojis further down the page, search for that emoji in search bar and then use this command to click on it. + * @param {string} emojiName - The name of emoji to click. For emojis with multiple names concat with ','. eg. slightly_frowning_face + * @returns void + * + * @example + * cy.uiClickSystemEmoji('slightly_frowning_face'); + * cy.uiClickSystemEmoji('star-struck,grinning_face_with_star_eyes'); + */ + clickEmojiInEmojiPicker(emojiName: string): ChainableT; + + /** + * Get nth post from the post list + * @returns {JQuery} response: Cypress-chainable JQuery + * + * @example + * cy.getLastPost().then((el: Element) => {; + */ + getLastPost: typeof getLastPost; + + /** + * Get nth post from the post list + * @returns {string} response: Cypress-chainable string + * + * @example + * cy.getLastPostId().then((postId) => { + */ + getLastPostId: typeof getLastPostId; + + getLastPostIdRHS: typeof getLastPostIdRHS; + + /** + * Get post ID based on index of post list + * zero (0) : oldest post + * positive number : from old to latest post + * negative number : from new to oldest post + */ + getNthPostId: typeof getNthPostId; + + /** + * Post message from a file instantly post a message in a textbox + * instead of typing into it which takes longer period of time. + */ + postMessageFromFile: typeof postMessageFromFile; + + /** + * Compares HTML content of a last post against the given file + * instead of typing into it which takes longer period of time. + */ + compareLastPostHTMLContentFromFile: typeof compareLastPostHTMLContentFromFile; + + /** + * Go to a DM channel with a given user + * @param {User} user - the user that should get the message + * @example + * const user = {username: 'bob'}; + * cy.uiGotoDirectMessageWithUser(user); + */ + uiGotoDirectMessageWithUser(user: User): ChainableT; + + /** + * Sends a DM to a given user + * @param {User} user - the user that should get the message + * @param {String} message - the message to send + */ + sendDirectMessageToUser: typeof sendDirectMessageToUser; + + /** + * Sends a GM to a given user list + * @param {User[]} users - the users that should get the message + * @param {String} message - the message to send + */ + sendDirectMessageToUsers(users: User[], message: string): ChainableT; + + /** + * Click post time + * @param {String} postId - Post ID + * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH' + */ + clickPostTime(postId: string, location: string): ChainableT; + + /** + * Click save icon by post ID or to most recent post (if post ID is not provided) + * @param {String} postId - Post ID + * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH' + */ + clickPostSaveIcon(postId: string, location: string): ChainableT; + + /** + * Click dot menu by post ID or to most recent post (if post ID is not provided) + * @param {String} postId - Post ID + * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH' + */ + clickPostDotMenu(postId: string, location: string): ChainableT; + + /** + * Click post reaction icon + * @param {String} postId - Post ID + * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT' + */ + clickPostReactionIcon(postId: string, location: string): ChainableT; + + /** + * Click comment icon by post ID or to most recent post (if post ID is not provided) + * This open up the RHS + * @param {String} postId - Post ID + * @param {String} location - as 'CENTER', 'SEARCH' + */ + clickPostCommentIcon(postId: string, location: string): ChainableT; + + createNewTeam(teamName: string, teamURL: string): ChainableT; + + getCurrentTeamURL: typeof getCurrentTeamURL; + + leaveTeam(): ChainableT; + + clearPostTextbox(channelName: string): ChainableT; + + /** + * Checking min setting view for display + */ + minDisplaySettings(): ChainableT; + + /** + * Set the user's status + * Need to be in main channel view for this to work + * 0 = Online + * 1 = Away + * 2 = Do Not Disturb + * 3 = Offline + */ + userStatus(statusInt: number): ChainableT; + + getCurrentChannelId: typeof getCurrentChannelId; + + /** + * Update channel header + * @param {String} text - Text to set the header to + */ + updateChannelHeader(text: string): ChainableT; + + /** + * Navigate to system console-PluginManagement from account settings + */ + checkRunLDAPSync: typeof checkRunLDAPSync; + } + } +}