CLD-7841 Optimize E2E testing costs (#27883)
* Utilize free runners for E2E tests, report back to PR on run completion * Increase test stability on smaller instances * Merge worker reports * merge start+prepare steps * upgrade keycloak * increase test timeouts * Add video recording to report artifacts Co-authored-by: Saturnino Abril <5334504+saturninoabril@users.noreply.github.com> --------- Co-authored-by: Saturnino Abril <5334504+saturninoabril@users.noreply.github.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
5f19d8513b
Коммит
948f8941f0
@@ -188,6 +188,10 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
cy.get(`#CENTER_flagIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'save message');
|
||||
cy.focused().tab();
|
||||
|
||||
// * Verify focus is on the actions button
|
||||
cy.get(`#CENTER_actions_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'actions');
|
||||
cy.focused().tab();
|
||||
|
||||
// * Verify focus is on the comment button
|
||||
cy.get(`#CENTER_commentIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'reply');
|
||||
cy.focused().tab();
|
||||
@@ -234,6 +238,10 @@ describe('Verify Accessibility Support in Post', () => {
|
||||
cy.get(`#RHS_COMMENT_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'more');
|
||||
cy.focused().tab({shift: true});
|
||||
|
||||
// * Verify focus is on the actions button
|
||||
cy.get(`#RHS_COMMENT_actions_button_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'actions');
|
||||
cy.focused().tab({shift: true});
|
||||
|
||||
// * Verify focus is on the save icon
|
||||
cy.get(`#RHS_COMMENT_flagIcon_${postId}`).should('be.focused').and('have.attr', 'aria-label', 'save message');
|
||||
cy.focused().tab({shift: true});
|
||||
@@ -333,8 +341,9 @@ function verifyPostLabel(elementId, username, labelSuffix) {
|
||||
// * Verify reader reads out the post correctly
|
||||
cy.get('@lastPost').then((el) => {
|
||||
// # Get the post time
|
||||
cy.wrap(el).find('time.post__time').invoke('text').then((time) => {
|
||||
const expectedLabel = `At ${time} ${Cypress.dayjs().format('dddd, MMMM D')}, ${username} ${labelSuffix}`;
|
||||
cy.wrap(el).find('time.post__time').invoke('attr', 'datetime').then((time) => {
|
||||
const parsedTime = Cypress.dayjs(time);
|
||||
const expectedLabel = `At ${parsedTime.format('h:mm A dddd, MMMM D')}, ${username} ${labelSuffix}`;
|
||||
cy.wrap(el).should('have.attr', 'aria-label', expectedLabel);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,9 @@ describe('Archived channels', () => {
|
||||
|
||||
it('MM-T1721 Archive channel posts menu should have copy link and reply options', () => {
|
||||
// # Click to add a channel description
|
||||
cy.findByRoleExtended('button', {name: 'Set header'}).should('be.visible').click();
|
||||
// Note that it is invisible until the mouse hovers it, which is unfeasible in Cypress
|
||||
// https://docs.cypress.io/api/commands/hover
|
||||
cy.findByText('Add a channel header').click({force: true});
|
||||
|
||||
// # Add channel header for system message
|
||||
const header = 'this is a header!';
|
||||
|
||||
@@ -35,6 +35,9 @@ describe('Bot display name', () => {
|
||||
});
|
||||
cy.visit(`/${team.name}/channels/off-topic`);
|
||||
cy.wait('@resources');
|
||||
|
||||
// # Wait for the page to fully load before continuing
|
||||
cy.get('#sidebar-header-container').should('be.visible').and('have.text', team.display_name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,8 @@ describe('MM-T4063 Custom status expiry', () => {
|
||||
|
||||
const waitingTime = 30; //minutes
|
||||
let expiresAt = dayjs();
|
||||
let expiresAtAcceptableValues = [''];
|
||||
let expiresAtRegexp: RegExp;
|
||||
const expiryTimeFormat = 'h:mm A';
|
||||
it('MM-T4063_1 should open status dropdown', () => {
|
||||
// # Click on the sidebar header to open status dropdown
|
||||
@@ -77,7 +79,12 @@ describe('MM-T4063 Custom status expiry', () => {
|
||||
cy.get('#custom_status_modal').should('not.exist');
|
||||
|
||||
// # Setting the time at which the custom status should be expired
|
||||
// # Note that we need to be flexible around accepted values, as this calculation and the server-side one may differ slightly
|
||||
expiresAt = dayjs().add(waitingTime, 'minute');
|
||||
expiresAtAcceptableValues = [-1, 0, 1].map((el) =>
|
||||
expiresAt.add(el, 'minute').format(expiryTimeFormat),
|
||||
);
|
||||
expiresAtRegexp = new RegExp(`(${expiresAtAcceptableValues.join('|')})`);
|
||||
|
||||
// * Status should be set and the emoji should be visible in the sidebar header
|
||||
cy.uiGetProfileHeader().
|
||||
@@ -97,7 +104,7 @@ describe('MM-T4063 Custom status expiry', () => {
|
||||
cy.get('.status-dropdown-menu .custom_status__row span.emoticon').invoke('attr', 'data-emoticon').should('contain', customStatus.emoji);
|
||||
|
||||
// * Correct clear time should be displayed in the status dropdown
|
||||
cy.get('.status-dropdown-menu .custom_status__expiry time').should('have.text', expiresAt.format(expiryTimeFormat));
|
||||
cy.get('.status-dropdown-menu .custom_status__expiry time').invoke('text').should('match', expiresAtRegexp);
|
||||
});
|
||||
|
||||
it('MM-T4063_6 custom status should be cleared after duration of set custom status', () => {
|
||||
@@ -116,7 +123,7 @@ describe('MM-T4063 Custom status expiry', () => {
|
||||
|
||||
// * Should show expiry time of status when current status is selected
|
||||
cy.get('#custom_status_modal .statusSuggestion__content').contains('span', customStatus.text).click();
|
||||
cy.get('#custom_status_modal .expiry-value').should('have.text', expiresAt.format(expiryTimeFormat));
|
||||
cy.get('#custom_status_modal .expiry-value').invoke('text').should('match', expiresAtRegexp);
|
||||
|
||||
// # Close custom status modal
|
||||
cy.get('#custom_status_modal .modal-header .close').click();
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('Verify Accessibility Support in Modals & Dialogs', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// # Visit the test channel
|
||||
// # Visit the test channel, and wait for the page to fully load
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
|
||||
// # Open Add Members Dialog
|
||||
@@ -160,6 +160,7 @@ describe('Verify Accessibility Support in Modals & Dialogs', () => {
|
||||
|
||||
// # Search for a text and then check up and down arrow
|
||||
cy.findByRole('textbox', {name: 'Search for people or groups'}).
|
||||
wait(TIMEOUTS.HALF_SEC).
|
||||
typeWithForce('u').
|
||||
wait(TIMEOUTS.HALF_SEC).
|
||||
typeWithForce('{downarrow}{downarrow}{downarrow}{uparrow}');
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// Stage: @prod
|
||||
// Group: @channels @files_and_attachments
|
||||
|
||||
import * as TIMEOUTS from '../../../fixtures/timeouts';
|
||||
|
||||
describe('YouTube Video', () => {
|
||||
before(() => {
|
||||
// # Enable Link Previews
|
||||
@@ -27,10 +29,12 @@ describe('YouTube Video', () => {
|
||||
|
||||
it('MM-T2258 YouTube Video play, collapse', () => {
|
||||
// # Post message
|
||||
cy.postMessage('https://www.youtube.com/watch?v=gLNmtUEvI5A');
|
||||
const youtubeUrl = 'https://www.youtube.com/watch?v=gLNmtUEvI5A';
|
||||
cy.postMessage(youtubeUrl);
|
||||
cy.uiWaitUntilMessagePostedIncludes(youtubeUrl);
|
||||
cy.getLastPost().within(() => {
|
||||
// # Click play button
|
||||
cy.get('.play-button').click();
|
||||
cy.get('.play-button', {timeout: TIMEOUTS.TEN_SEC}).click();
|
||||
|
||||
// * Video should be loaded in the iframe
|
||||
cy.get('.video-div > iframe').should('exist');
|
||||
|
||||
@@ -87,8 +87,7 @@ describe('Integrations', () => {
|
||||
// # Type "/echo message 3"
|
||||
cy.uiGetPostTextBox().clear().type(`/echo ${message} 3{enter}`);
|
||||
|
||||
// * Verify that post is not shown after 1 second
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
// * Verify that post is not shown after pressing enter
|
||||
cy.getLastPost().within(() => {
|
||||
cy.findByText(message).should('not.exist');
|
||||
});
|
||||
|
||||
@@ -84,7 +84,8 @@ describe('Integrations', () => {
|
||||
loginAndVisitChannel(testUser, testChannelUrl);
|
||||
|
||||
// # In a GM use the /invite command to invite a user to a channel you have permission to add them to but place extra white space before the username
|
||||
cy.postMessage(`/groupmsg @${member1.username} @${member2.username} `);
|
||||
cy.postMessage(`/groupmsg @${member1.username},@${member2.username} `);
|
||||
cy.uiGetChannelHeaderButton().contains(member1.username).contains(member2.username);
|
||||
cy.postMessage(`/invite @${userToInviteGM.username} ~${testChannel.name} `);
|
||||
|
||||
// * User added to channel as expected
|
||||
@@ -133,7 +134,9 @@ describe('Integrations', () => {
|
||||
loginAndVisitChannel(testUser, testChannelUrl);
|
||||
|
||||
// # In a GM Use the /invite command to invite a channel to another channel (e.g., /invite @[channel name])
|
||||
cy.postMessage(`/groupmsg @${member1.username} @${member2.username} `);
|
||||
cy.postMessage(`/groupmsg @${member1.username},@${member2.username} `);
|
||||
cy.uiGetChannelHeaderButton().contains(member1.username).contains(member2.username);
|
||||
cy.reload(); // Required for Cypress to write in the right channel
|
||||
cy.postMessage(`/invite @${testChannel.name} `);
|
||||
|
||||
// * Error appears: "We couldn't find the user. They may have been deactivated by the System Administrator."
|
||||
@@ -159,7 +162,9 @@ describe('Integrations', () => {
|
||||
loginAndVisitChannel(testUser, testChannelUrl);
|
||||
|
||||
// # In a GM use the /invite command to invite someone to a channel they're already a member of
|
||||
cy.postMessage(`/groupmsg @${member1.username} @${member2.username} `);
|
||||
cy.postMessage(`/groupmsg @${member1.username},@${member2.username} `);
|
||||
cy.uiGetChannelHeaderButton().contains(member1.username).contains(member2.username);
|
||||
cy.reload(); // Required for Cypress to write in the right channel
|
||||
cy.postMessage(`/invite @${userToInvite.username} ~${testChannel.name} `);
|
||||
|
||||
// * Error appears: "[username] is already in the channel"
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('Integrations', () => {
|
||||
click();
|
||||
|
||||
// * Verify that only "Hello World" is posted in off-topic channel
|
||||
cy.uiWaitUntilMessagePostedIncludes('Hello World');
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#postMessageText_${postId}`).should('be.visible').and('have.text', 'Hello World');
|
||||
});
|
||||
@@ -103,6 +104,7 @@ describe('Integrations', () => {
|
||||
click();
|
||||
|
||||
// * Verify that both messages are posted in off-topic channel
|
||||
cy.uiWaitUntilMessagePostedIncludes('Hello World');
|
||||
cy.getLastPostId().then((postId) => {
|
||||
cy.get(`#postMessageText_${postId}`).should('be.visible').and('have.text', 'Hello World');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// Group: @channels @mark_as_unread
|
||||
|
||||
import {markAsUnreadFromPost, switchToChannel} from './helpers';
|
||||
import * as TIMEOUTS from '../../../fixtures/timeouts';
|
||||
|
||||
describe('Verify unread toast appears after repeated manual marking post as unread', () => {
|
||||
let firstPost;
|
||||
@@ -33,7 +34,10 @@ describe('Verify unread toast appears after repeated manual marking post as unre
|
||||
cy.visit(`/${team.name}/channels/${testChannel.name}`);
|
||||
switchToChannel(offTopicChannel);
|
||||
|
||||
cy.postMessageAs({
|
||||
// Ensure that the Off-Topic channel has loaded successfully, before posting messages in the other channel
|
||||
cy.get('#channelHeaderTitle').should('be.visible').and('contain.text', offTopicChannel.display_name);
|
||||
|
||||
cy.wait(TIMEOUTS.ONE_SEC).postMessageAs({
|
||||
sender: otherUser,
|
||||
message: 'First message',
|
||||
channelId: testChannel.id,
|
||||
|
||||
@@ -21,6 +21,9 @@ describe('Message Reply', () => {
|
||||
cy.apiInitSetup({loginAfter: true}).then(({team, channel}) => {
|
||||
newChannel = channel;
|
||||
cy.visit(`/${team.name}/channels/${channel.name}`);
|
||||
|
||||
// # Wait for the page to fully load before continuing
|
||||
cy.get('#sidebar-header-container').should('be.visible').and('have.text', team.display_name);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,9 +46,11 @@ describe('Message Reply', () => {
|
||||
cy.clickPostCommentIcon(postId);
|
||||
|
||||
// # Reply with the attachment
|
||||
cy.postMessageReplyInRHS('A reply to an older post with attachment');
|
||||
const replyText = 'A reply to an older post with attachment';
|
||||
cy.postMessageReplyInRHS(replyText);
|
||||
|
||||
// # Get the latest reply post
|
||||
cy.uiWaitUntilMessagePostedIncludes(replyText);
|
||||
cy.getLastPostId().then((replyId) => {
|
||||
// * Verify that the reply is in the channel view with matching text
|
||||
cy.get(`#post_${replyId}`).within(() => {
|
||||
|
||||
@@ -41,6 +41,10 @@ describe('Notifications', () => {
|
||||
// # Login as receiver and visit off-topic channel
|
||||
cy.apiLogin(receiver);
|
||||
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
|
||||
|
||||
// # Wait for the page to fully load before continuing
|
||||
cy.get('#channelHeaderDropdownButton').should('be.visible').and('have.text', testChannel.display_name);
|
||||
|
||||
cy.get(`#sidebarItem_${otherChannel.name}`).click();
|
||||
cy.get('#sidebarItem_off-topic').click();
|
||||
});
|
||||
@@ -284,4 +288,5 @@ function setNotificationSettings(desiredSettings = {first: true, username: true,
|
||||
|
||||
// # Navigate to a channel we are NOT going to post to
|
||||
cy.get(`#sidebarItem_${channelName}`).scrollIntoView().click({force: true});
|
||||
cy.get('#loadingSpinner').should('not.exist');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// Group: @channels @not_cloud @system_console
|
||||
|
||||
import {fileSizeToString} from '../../../utils';
|
||||
import * as TIMEOUTS from '../../../fixtures/timeouts';
|
||||
|
||||
describe('Unsaved Changes', () => {
|
||||
let defaultMaxFileSize;
|
||||
@@ -26,7 +27,7 @@ describe('Unsaved Changes', () => {
|
||||
it('MM-T955 Warning leaving changed page without saving: Leave page, discard changes', () => {
|
||||
// # Make a change on any page.
|
||||
cy.visit('/admin_console/environment/file_storage');
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber').clear().type('150');
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber', {timeout: TIMEOUTS.FIVE_SEC}).clear().type('150');
|
||||
|
||||
// # Click a navigation item in left nav.
|
||||
cy.findByText('Database').click();
|
||||
@@ -38,7 +39,7 @@ describe('Unsaved Changes', () => {
|
||||
// * Opens other page, changes discarded.
|
||||
cy.url().should('include', '/environment/database');
|
||||
cy.visit('/admin_console/environment/file_storage');
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber').should('have.value', fileSizeToString(defaultMaxFileSize).replace('MB', ''));
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber', {timeout: TIMEOUTS.FIVE_SEC}).should('have.value', fileSizeToString(defaultMaxFileSize).replace('MB', ''));
|
||||
});
|
||||
|
||||
it('MM-T956 Warning leaving changed page without saving: Cancel leaving page', () => {
|
||||
@@ -46,7 +47,7 @@ describe('Unsaved Changes', () => {
|
||||
|
||||
// # Make a change on any page.
|
||||
cy.visit('/admin_console/environment/file_storage');
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber').clear().type(draftValue);
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber', {timeout: TIMEOUTS.FIVE_SEC}).clear().type(draftValue);
|
||||
|
||||
// # Click a navigation item in left nav.
|
||||
cy.findByText('Database').click();
|
||||
@@ -57,7 +58,7 @@ describe('Unsaved Changes', () => {
|
||||
|
||||
// * Stays on current page, changes kept.
|
||||
cy.url().should('include', '/environment/file_storage');
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber').should('have.value', draftValue);
|
||||
cy.findByTestId('FileSettings.MaxFileSizenumber', {timeout: TIMEOUTS.FIVE_SEC}).should('have.value', draftValue);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('System Console > User Management > Users', () => {
|
||||
it('MM-T932 Users - Change a user\'s password', () => {
|
||||
// # Search for the user.
|
||||
cy.findByPlaceholderText('Search users').type(testUser.email).wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#systemUsersTable-cell-0_emailColumn').should('contain', testUser.email);
|
||||
|
||||
// # Open the actions menu.
|
||||
cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('User Management', () => {
|
||||
|
||||
// # Search for the user.
|
||||
cy.get('#input_searchTerm').clear().type(user.email, {delay: TIMEOUTS.ONE_HUNDRED_MILLIS}).wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#systemUsersTable-cell-0_emailColumn').should('contain', user.email);
|
||||
|
||||
cy.get('#actionMenuButton-systemUsersTable-0').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ describe('User Management', () => {
|
||||
|
||||
// # Search for the user.
|
||||
cy.get('#input_searchTerm').clear().type(gitlabUser.email).wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#systemUsersTable-cell-0_emailColumn').should('contain', gitlabUser.email);
|
||||
|
||||
// # Open actions menu.
|
||||
cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC);
|
||||
@@ -255,6 +256,7 @@ describe('User Management', () => {
|
||||
|
||||
// # Search for the user.
|
||||
cy.get('#input_searchTerm').clear().type(oldEmail).wait(TIMEOUTS.HALF_SEC);
|
||||
cy.get('#systemUsersTable-cell-0_emailColumn').should('contain', oldEmail);
|
||||
|
||||
cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC);
|
||||
cy.findByText('Update email').click().wait(TIMEOUTS.HALF_SEC);
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('unread_with_bottom_start_toast', () => {
|
||||
cy.apiCreateChannel(testTeam.id, 'channel-a', 'ChannelA').then(({channel}) => {
|
||||
cy.apiAddUserToChannel(channel.id, otherUser.id).then(() => {
|
||||
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
|
||||
cy.get('#loadingSpinner').should('not.exist'); // The channel should fully load once, before it can mark messages as "new"
|
||||
cy.uiClickSidebarItem('off-topic');
|
||||
cy.postMessage('hi');
|
||||
|
||||
@@ -73,6 +74,7 @@ describe('unread_with_bottom_start_toast', () => {
|
||||
cy.apiCreateChannel(testTeam.id, 'channel-b', 'ChannelB').then(({channel}) => {
|
||||
cy.apiAddUserToChannel(channel.id, otherUser.id).then(() => {
|
||||
cy.visit(`/${testTeam.name}/channels/${channel.name}`);
|
||||
cy.get('#loadingSpinner').should('not.exist'); // The channel should fully load once, before it can mark messages as "new"
|
||||
cy.uiClickSidebarItem('off-topic');
|
||||
cy.postMessage('hi');
|
||||
|
||||
@@ -114,6 +116,10 @@ describe('unread_with_bottom_start_toast', () => {
|
||||
cy.postMessageAs({sender: otherUser, message: `test message ${index}`, channelId: channel.id}).wait(50);
|
||||
}
|
||||
|
||||
// # Switch channel and back
|
||||
cy.uiClickSidebarItem('off-topic');
|
||||
cy.uiClickSidebarItem(channel.name);
|
||||
|
||||
cy.wait(TIMEOUTS.ONE_SEC);
|
||||
|
||||
// # Scroll to the top to find the oldest message
|
||||
@@ -123,6 +129,7 @@ describe('unread_with_bottom_start_toast', () => {
|
||||
// # Mark post as unread
|
||||
cy.uiClickPostDropdownMenu(postId, 'Mark as Unread');
|
||||
});
|
||||
cy.get('div.toast').should('be.visible').contains('30 new messages'); // The toast message should appear right away
|
||||
|
||||
// # Visit off-topic channel and switch back to test channel
|
||||
cy.uiClickSidebarItem('off-topic');
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
describe('post update dialog', () => {
|
||||
it('renders description correctly', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -84,7 +84,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
|
||||
it('description link navigates to run overview', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -101,7 +101,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
|
||||
it('prevents posting an update message with only whitespace', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -157,7 +157,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
cy.visit(`/${testTeam.name}/channels/${channelName}`);
|
||||
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -183,7 +183,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
const reminderTime = '1 day';
|
||||
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -249,7 +249,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
describe('prevents user from losing changes', () => {
|
||||
it('cancel, go back and save', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -279,7 +279,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
|
||||
it('click overview link, go back and save', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -310,7 +310,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
|
||||
it('cancel and discard explicitly', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -333,7 +333,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
|
||||
it('click overview link and discard explicitly', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -364,7 +364,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
describe('shows the last update in update message', () => {
|
||||
it('shows the default when we have not made an update before', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -383,7 +383,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
cy.updateStatus(firstMessage);
|
||||
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -399,7 +399,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
describe('the default reminder', () => {
|
||||
it('shows the configured default when we have not made a previous update', () => {
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -418,7 +418,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
cy.updateStatus(firstMessage, '15 minutes');
|
||||
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -437,7 +437,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
cy.updateStatus(firstMessage, '90 minutes');
|
||||
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
@@ -456,7 +456,7 @@ describe('channels > rhs > status update', {testIsolation: true}, () => {
|
||||
cy.updateStatus(firstMessage, '7 days');
|
||||
|
||||
// # Run the `/playbook update` slash command.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
cy.findByTestId('post_textbox').clear();
|
||||
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook check 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook check 1 1 ');
|
||||
|
||||
// * Verify the task is checked
|
||||
cy.get('[data-rbd-droppable-id="1"]').find('.checkbox').eq(1).should('be.checked');
|
||||
@@ -98,7 +98,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('check add', () => {
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 1 new-task');
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 1 new-task ');
|
||||
|
||||
// * Verify the task was added
|
||||
cy.get('[data-rbd-droppable-id="1"]').contains('new-task');
|
||||
@@ -106,7 +106,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('check remove', () => {
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 1 1 ');
|
||||
|
||||
// * Verify the task was added
|
||||
cy.get('[data-rbd-droppable-id="1"]').contains('Step 2').should('not.exist');
|
||||
@@ -114,7 +114,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('owner', () => {
|
||||
// # Run a slash command
|
||||
cy.uiPostMessageQuickly('/playbook owner');
|
||||
cy.uiPostMessageQuickly('/playbook owner ');
|
||||
|
||||
// * Verify the message.
|
||||
cy.verifyEphemeralMessage(`@${testUser.username} is the current owner for this playbook run.`);
|
||||
@@ -123,13 +123,13 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
cy.uiPostMessageQuickly(`/playbook owner @${testUser2.username}`);
|
||||
|
||||
// * Verify that the owner was set.
|
||||
cy.uiPostMessageQuickly('/playbook owner');
|
||||
cy.uiPostMessageQuickly('/playbook owner ');
|
||||
cy.verifyEphemeralMessage(`@${testUser2.username} is the current owner for this playbook run.`);
|
||||
});
|
||||
|
||||
it('timeline', () => {
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly('/playbook timeline');
|
||||
cy.uiPostMessageQuickly('/playbook timeline ');
|
||||
|
||||
// * Verify the message.
|
||||
cy.verifyEphemeralMessage(`Timeline for ${playbookRunName}`);
|
||||
@@ -137,7 +137,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('finish', () => {
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook finish');
|
||||
cy.uiPostMessageQuickly('/playbook finish ');
|
||||
|
||||
// * Verify confirm modal is visible.
|
||||
cy.get('#interactiveDialogModalLabel').should('exist');
|
||||
@@ -274,19 +274,19 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('check', () => {
|
||||
// # Run a slash command with not enough parameters
|
||||
cy.uiPostMessageQuickly('/playbook check 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook check 1 1 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Command expects three arguments: the run number, the checklist number and the item number.');
|
||||
|
||||
// # Run a slash command wrong run number
|
||||
cy.uiPostMessageQuickly('/playbook check 2 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook check 2 1 1 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Invalid run number');
|
||||
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly('/playbook check 0 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook check 0 1 1 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Become a participant to interact with this run');
|
||||
@@ -301,7 +301,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
cy.findByTestId('post_textbox').clear();
|
||||
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook check 1 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook check 1 1 1 ');
|
||||
cy.get('#rhsContainer').within(() => {
|
||||
// * Verify number of runs
|
||||
cy.get('[data-testid="run-list-card"]').should('have.length', 2);
|
||||
@@ -316,19 +316,19 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('check add', () => {
|
||||
// # Run a slash command with not enough parameters
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 1');
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 1 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Command expects two arguments: the run number and the checklist number.');
|
||||
|
||||
// # Run a slash command wrong run number
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 2 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 2 1 1 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Invalid run number');
|
||||
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 0 1 new-task');
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 0 1 new-task ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Become a participant to interact with this run');
|
||||
@@ -343,7 +343,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
cy.findByTestId('post_textbox').clear();
|
||||
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 1 1 new-task');
|
||||
cy.uiPostMessageQuickly('/playbook checkadd 1 1 new-task ');
|
||||
|
||||
cy.get('#rhsContainer').within(() => {
|
||||
// * Verify number of runs
|
||||
@@ -359,19 +359,19 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('check remove', () => {
|
||||
// # Run a slash command with not enough parameters
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 1 1 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Command expects three arguments: the run number, the checklist number and the item number.');
|
||||
|
||||
// # Run a slash command wrong run number
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 2 0 1');
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 2 0 1 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Invalid run number');
|
||||
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 0 1 0');
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 0 1 0 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Become a participant to interact with this run');
|
||||
@@ -386,7 +386,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
cy.findByTestId('post_textbox').clear();
|
||||
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 1 1 1');
|
||||
cy.uiPostMessageQuickly('/playbook checkremove 1 1 1 ');
|
||||
|
||||
cy.get('#rhsContainer').within(() => {
|
||||
// * Verify number of runs
|
||||
@@ -402,19 +402,19 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('owner', () => {
|
||||
// # Run a slash command with not enough parameters
|
||||
cy.uiPostMessageQuickly('/playbook owner');
|
||||
cy.uiPostMessageQuickly('/playbook owner ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('/playbook owner expects at most one argument.');
|
||||
|
||||
// # Run a slash command wrong run number
|
||||
cy.uiPostMessageQuickly('/playbook owner 2');
|
||||
cy.uiPostMessageQuickly('/playbook owner 2 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Invalid run number');
|
||||
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly('/playbook owner 0');
|
||||
cy.uiPostMessageQuickly('/playbook owner 0 ');
|
||||
|
||||
// * Verify the message.
|
||||
cy.verifyEphemeralMessage(`@${testUser.username} is the current owner for this playbook run.`);
|
||||
@@ -429,34 +429,34 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
cy.findByTestId('post_textbox').clear();
|
||||
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly(`/playbook owner 0 @${testUser2.username}`);
|
||||
cy.uiPostMessageQuickly(`/playbook owner 0 @${testUser2.username} `);
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Become a participant to interact with this run');
|
||||
|
||||
// # Run a slash command on a run with write access
|
||||
cy.uiPostMessageQuickly(`/playbook owner 1 @${testUser2.username}`);
|
||||
cy.uiPostMessageQuickly(`/playbook owner 1 @${testUser2.username} `);
|
||||
|
||||
// * Verify that the owner was set.
|
||||
cy.uiPostMessageQuickly('/playbook owner 1');
|
||||
cy.uiPostMessageQuickly('/playbook owner 1 ');
|
||||
cy.verifyEphemeralMessage(`@${testUser2.username} is the current owner for this playbook run.`);
|
||||
});
|
||||
|
||||
it('finish', () => {
|
||||
// # Run a slash command with not enough parameters
|
||||
cy.uiPostMessageQuickly('/playbook finish');
|
||||
cy.uiPostMessageQuickly('/playbook finish ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Command expects one argument: the run number.');
|
||||
|
||||
// # Run a slash command wrong run number
|
||||
cy.uiPostMessageQuickly('/playbook finish 2');
|
||||
cy.uiPostMessageQuickly('/playbook finish 2 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Invalid run number');
|
||||
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly('/playbook finish 0');
|
||||
cy.uiPostMessageQuickly('/playbook finish 0 ');
|
||||
|
||||
// * Verify the message.
|
||||
cy.verifyEphemeralMessage(`userID ${testUser2.id} is not an admin or channel member`);
|
||||
@@ -479,7 +479,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
});
|
||||
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook finish 1');
|
||||
cy.uiPostMessageQuickly('/playbook finish 1 ');
|
||||
|
||||
// * Verify confirm modal is visible.
|
||||
cy.get('#interactiveDialogModalLabel').should('exist');
|
||||
@@ -493,19 +493,19 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('timeline', () => {
|
||||
// # Run a slash command with not enough parameters
|
||||
cy.uiPostMessageQuickly('/playbook timeline');
|
||||
cy.uiPostMessageQuickly('/playbook timeline ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Command expects one argument: the run number.');
|
||||
|
||||
// # Run a slash command wrong run number
|
||||
cy.uiPostMessageQuickly('/playbook timeline 2');
|
||||
cy.uiPostMessageQuickly('/playbook timeline 2 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Invalid run number');
|
||||
|
||||
// # Run a slash command on a run with view access
|
||||
cy.uiPostMessageQuickly('/playbook timeline 0');
|
||||
cy.uiPostMessageQuickly('/playbook timeline 0 ');
|
||||
|
||||
// * Verify the message.
|
||||
cy.verifyEphemeralMessage(`Timeline for ${playbookRuns[1].name}`);
|
||||
@@ -513,13 +513,13 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
|
||||
it('update', () => {
|
||||
// # Run a slash command with not enough parameters
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Command expects one argument: the run number.');
|
||||
|
||||
// # Run a slash command wrong run number
|
||||
cy.uiPostMessageQuickly('/playbook update 2');
|
||||
cy.uiPostMessageQuickly('/playbook update 2 ');
|
||||
|
||||
// * Verify the expected error message.
|
||||
cy.verifyEphemeralMessage('Invalid run number');
|
||||
@@ -534,7 +534,7 @@ describe('channels > slash command > owner', {testIsolation: true}, () => {
|
||||
cy.findByTestId('post_textbox').clear();
|
||||
|
||||
// # Run a slash command with correct parameters
|
||||
cy.uiPostMessageQuickly('/playbook update 1');
|
||||
cy.uiPostMessageQuickly('/playbook update 1 ');
|
||||
|
||||
// # Get dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('channels > slash command > test', {testIsolation: true}, () => {
|
||||
EnableTesting: true,
|
||||
},
|
||||
});
|
||||
cy.apiReloadConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -132,6 +133,7 @@ describe('channels > slash command > test', {testIsolation: true}, () => {
|
||||
EnableTesting: false,
|
||||
},
|
||||
});
|
||||
cy.apiReloadConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -178,6 +180,7 @@ describe('channels > slash command > test', {testIsolation: true}, () => {
|
||||
EnableTesting: true,
|
||||
},
|
||||
});
|
||||
cy.apiReloadConfig();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -175,6 +175,7 @@ describe('playbooks > list', {testIsolation: true}, () => {
|
||||
// # Drop loaded fixture onto playbook list
|
||||
cy.findByTestId('playbook-list-scroll-container').selectFile(validPlaybookExport, {
|
||||
action: 'drag-drop',
|
||||
force: true,
|
||||
});
|
||||
|
||||
// * Verify that a new playbook was created.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const clientRequest = require('./client_request');
|
||||
const {
|
||||
dbGetActiveUserSessions,
|
||||
@@ -72,5 +74,19 @@ module.exports = (on, config) => {
|
||||
return launchOptions;
|
||||
});
|
||||
|
||||
// https://docs.cypress.io/guides/guides/screenshots-and-videos#Delete-videos-for-specs-without-failing-or-retried-tests
|
||||
on('after:spec', (spec, results) => {
|
||||
if (results && results.video) {
|
||||
// Do we have failures for any retry attempts?
|
||||
const failures = results.tests.some((test) =>
|
||||
test.attempts.some((attempt) => attempt.state === 'failed'),
|
||||
);
|
||||
if (!failures) {
|
||||
// delete the video if the spec passed and no tests retried
|
||||
fs.unlinkSync(results.video);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import * as TIMEOUTS from '../../fixtures/timeouts';
|
||||
const playbookRunStartCommand = '/playbook run';
|
||||
const playbookRunStartCommand = '/playbook run ';
|
||||
|
||||
Cypress.Commands.add('startPlaybookRun', (playbookName, playbookRunName) => {
|
||||
cy.get('#interactiveDialogModal').should('exist').within(() => {
|
||||
@@ -167,7 +167,7 @@ Cypress.Commands.add('selectReminderTime', (timeText) => {
|
||||
*/
|
||||
Cypress.Commands.add('updateStatus', (message, reminderQuery) => {
|
||||
// # Run the slash command to update status.
|
||||
cy.uiPostMessageQuickly('/playbook update');
|
||||
cy.uiPostMessageQuickly('/playbook update ');
|
||||
|
||||
// # Get the interactive dialog modal.
|
||||
cy.getStatusUpdateDialog().within(() => {
|
||||
|
||||
Ссылка в новой задаче
Block a user