diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index 5c46344..c1adceb 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -1,11 +1,33 @@ +import { englishStrippedStr } from '../localization/englishStrippedStr'; import { testCommunityName } from './constants/community'; -import { Conversation, HomeScreen } from './locators'; -import { test_Alice_1W_Bob_1W, test_Alice_2W } from './setup/sessionTest'; -import { joinCommunity } from './utilities/join_community'; -import { sendMessage } from './utilities/message'; +import { Conversation, Global, HomeScreen } from './locators'; +import { newUser } from './setup/new_user'; +import { recoverFromSeed } from './setup/recovery_using_seed'; +import { + sessionTestTwoWindows, + test_Alice_1W_Bob_1W, + test_Alice_2W, +} from './setup/sessionTest'; +import { + assertAdminIsKnown, + joinCommunity, + joinOrOpenCommunity, +} from './utilities/join_community'; +import { sendMessage, waitForMessageStatus } from './utilities/message'; import { replyTo } from './utilities/reply_message'; import { sendMedia } from './utilities/send_media'; -import { clickOn, clickOnWithText } from './utilities/utils'; +import { + clickOn, + clickOnWithText, + hasElementBeenDeleted, + hasElementPoppedUpThatShouldnt, + scrollToBottomIfNecessary, + typeIntoInput, + waitForTestIdWithText, +} from './utilities/utils'; + +const banUserString = englishStrippedStr('banUser').toString(); +const unbanUserString = englishStrippedStr('banUnbanUser').toString(); test_Alice_2W( 'Join community and sync', @@ -48,3 +70,82 @@ test_Alice_1W_Bob_1W( }); }, ); + +sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { + assertAdminIsKnown(); + const msg1 = `Ban me but unban me later! - ${Date.now()}`; + const msg2 = `I'm banned :( - ${Date.now()}`; + const msg3 = `Freedom! - ${Date.now()}`; + await Promise.all([ + recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { + fallbackName: 'Admin', + }), + newUser(windowB, 'Bob'), + ]); + await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); + await sendMessage(windowB, msg1); + await windowA.bringToFront(); + await scrollToBottomIfNecessary(windowA); + await clickOnWithText(windowA, Conversation.messageContent, msg1, { + rightButton: true, + }); + await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { + strictMode: false, + }); + await clickOn(windowA, Conversation.banUserButton); + await typeIntoInput(windowB, Conversation.messageInput.selector, msg2); + await clickOn(windowB, Conversation.sendMessageButton); + await waitForMessageStatus(windowB, msg2, 'failed'); + await clickOnWithText(windowA, Conversation.messageContent, msg1, { + rightButton: true, + }); + await clickOnWithText(windowA, Global.contextMenuItem, unbanUserString, { + strictMode: false, + }); + await clickOn(windowA, Conversation.unbanUserButton); + await sendMessage(windowB, msg3); + await waitForTestIdWithText( + windowA, + Conversation.messageContent.selector, + msg3, + ); +}); + +sessionTestTwoWindows('Ban And delete all', async ([windowA, windowB]) => { + assertAdminIsKnown(); + const msg1 = `Ban and delete! - ${Date.now()}`; + const msg2 = `Did that work? - ${Date.now()}`; + await Promise.all([ + recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { + fallbackName: 'Admin', + }), + newUser(windowB, 'Bob'), + ]); + await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); + await sendMessage(windowB, msg1); + await windowA.bringToFront(); + await scrollToBottomIfNecessary(windowA); + await clickOnWithText(windowA, Conversation.messageContent, msg1, { + rightButton: true, + }); + await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { + strictMode: false, + }); + await clickOn(windowA, Conversation.banAndDeleteAllButton); + await hasElementBeenDeleted( + windowA, + Conversation.messageContent.strategy, + Conversation.messageContent.selector, + 10_000, + msg1, + ); + await typeIntoInput(windowB, Conversation.messageInput.selector, msg2); + await clickOn(windowB, Conversation.sendMessageButton); + await waitForMessageStatus(windowB, msg2, 'failed'); + await hasElementPoppedUpThatShouldnt( + windowA, + Conversation.messageContent.strategy, + Conversation.messageContent.selector, + msg2, + ); +}); diff --git a/tests/automation/disappearing_message_checks.spec.ts b/tests/automation/disappearing_message_checks.spec.ts index b1c46f1..4305e12 100644 --- a/tests/automation/disappearing_message_checks.spec.ts +++ b/tests/automation/disappearing_message_checks.spec.ts @@ -16,7 +16,7 @@ import { import { test_Alice_1W_Bob_1W } from './setup/sessionTest'; import { createContact } from './utilities/create_contact'; import { joinCommunity } from './utilities/join_community'; -import { waitForSentTick } from './utilities/message'; +import { waitForMessageStatus } from './utilities/message'; import { sendLinkPreview, sendMedia, @@ -150,7 +150,7 @@ test_Alice_1W_Bob_1W( await typeIntoInput(aliceWindow1, 'message-input-text-area', longText); await sleepFor(100); await clickOn(aliceWindow1, Conversation.sendMessageButton); - await waitForSentTick(aliceWindow1, longText); + await waitForMessageStatus(aliceWindow1, longText, 'sent'); await waitForTextMessage(bobWindow1, longText); // Wait 30 seconds for long text to disappear await sleepFor(30000); diff --git a/tests/automation/enforce_localized_str.spec.ts b/tests/automation/enforce_localized_str.spec.ts index 39eef6e..9e58390 100644 --- a/tests/automation/enforce_localized_str.spec.ts +++ b/tests/automation/enforce_localized_str.spec.ts @@ -297,6 +297,12 @@ function getExpectedStringFromKey( return 'Plus loads more exclusive features'; case 'remove': return 'Remove'; + case 'communityJoinedAlready': + return 'You are already a member of this community.'; + case 'banUser': + return 'Ban User'; + case 'banUnbanUser': + return 'Unban User'; default: // returning null means we don't have an expected string yet for this key. // This will make the test fail diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index 8079f18..c9c8b38 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -82,6 +82,11 @@ export class Conversation extends Locator { static readonly acceptMessageRequestButton = this.testId( 'accept-message-request', ); + static readonly banAndDeleteAllButton = this.testId( + 'ban-user-delete-all-confirm-button', + ); + static readonly banUserButton = this.testId('ban-user-confirm-button'); + static readonly banUserInput = this.testId('ban-user-input'); static readonly blockMessageRequestButton = this.testId( 'decline-and-block-message-request', ); @@ -101,7 +106,6 @@ export class Conversation extends Locator { static readonly mentionsContainer = this.testId('mentions-container'); // This is also the locator for emojis static readonly mentionsItem = this.testId('mentions-container-row'); // This is also the locator for emojis static readonly messageContent = this.testId('message-content'); - static readonly messageInput = this.testId('message-input-text-area'); static readonly messageRequestAcceptControlMessage = this.testId( 'message-request-response-message', @@ -109,6 +113,7 @@ export class Conversation extends Locator { static readonly microphoneButton = this.testId('microphone-button'); static readonly scrollToBottomButton = this.testId('scroll-to-bottom-button'); static readonly sendMessageButton = this.testId('send-message-button'); + static readonly unbanUserButton = this.testId('unban-user-confirm-button'); } export class ConversationSettings extends Locator { diff --git a/tests/automation/setup/recovery_using_seed.ts b/tests/automation/setup/recovery_using_seed.ts index de3f0a0..a6047a3 100644 --- a/tests/automation/setup/recovery_using_seed.ts +++ b/tests/automation/setup/recovery_using_seed.ts @@ -8,19 +8,31 @@ import { waitForLoadingAnimationToFinish, } from '../utilities/utils'; -export async function recoverFromSeed(window: Page, recoveryPhrase: string) { +export async function recoverFromSeed( + window: Page, + recoveryPhrase: string, + options?: { fallbackName?: string }, +) { await clickOn(window, Onboarding.iHaveAnAccountButton); await typeIntoInput(window, 'recovery-phrase-input', recoveryPhrase); await clickOn(window, Global.continueButton); await waitForLoadingAnimationToFinish(window, 'loading-animation'); - const displayName = await doesElementExist( + const displayNameInput = await doesElementExist( window, 'data-testid', 'display-name-input', ); - if (displayName) { - throw new Error(`Display name was not found when restoring from seed`); + if (displayNameInput) { + if (!options?.fallbackName) { + throw new Error(`Display name was not found when restoring from seed`); + } + // Fallback for when name might be missing (but it's okay) + await typeIntoInput( + window, + Onboarding.displayNameInput.selector, + options.fallbackName, + ); + await clickOn(window, Global.continueButton); } - return { window }; } diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index dcb6367..3d1029e 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -74,6 +74,7 @@ export type WithRightButton = { rightButton?: boolean }; export type MediaType = 'audio' | 'file' | 'image' | 'video'; export type Strategy = ':has-text' | 'class' | 'data-testid'; +export type MessageStatus = 'failed' | 'read' | 'sent'; export type DataTestId = | DMTimeOption @@ -82,6 +83,9 @@ export type DataTestId = | 'audio-player' | 'avatar-edit-profile-dialog' | 'back-button' + | 'ban-user-confirm-button' + | 'ban-user-delete-all-confirm-button' + | 'ban-user-input' | 'blocked-contacts-settings-row' | 'call-button' | 'call-notification-answered-a-call' @@ -211,6 +215,7 @@ export type DataTestId = | 'swarm-image' | 'theme-section' | 'tooltip-character-count' + | 'unban-user-confirm-button' | 'unblock-button-settings-screen' | 'update-group-info-name-input' | 'update-profile-info-name-input' diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index e11a306..da4af45 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -17,7 +17,7 @@ import { test_Alice_2W, } from './setup/sessionTest'; import { createContact } from './utilities/create_contact'; -import { sendMessage, waitForReadTick } from './utilities/message'; +import { sendMessage, waitForMessageStatus } from './utilities/message'; import { compareElementScreenshot } from './utilities/screenshot'; import { checkModalStrings, @@ -330,7 +330,7 @@ test_Alice_1W_Bob_1W( HomeScreen.conversationItemName, alice.userName, ); - await waitForReadTick(aliceWindow1, 'Testing read receipts'); + await waitForMessageStatus(aliceWindow1, 'Testing read receipts', 'read'); }, ); diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index b893900..9a16c7d 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -1,8 +1,10 @@ -import { Page } from '@playwright/test'; +import { type Page, test } from '@playwright/test'; +import { englishStrippedStr } from '../../localization/englishStrippedStr'; import { type DefaultCommunity, testCommunityLink, + testCommunityName, } from '../constants/community'; import { Global, HomeScreen } from '../locators'; import { @@ -63,3 +65,45 @@ export const leaveCommunity = async (window: Page, communityName: string) => { ); console.log('Left community'); }; + +/** + * There is a race condition where two workers joining the community + * with the same (admin) account can throw the "You are already a member" error + * If joining errors (race condition), attempt to find the on-screen error. + * If the error is visible, the conversation already exists, that's fine, + * just navigate back and open the convo. + */ +export const joinOrOpenCommunity = async (window: Page) => { + try { + await joinCommunity(window); + } catch (joinError) { + try { + await waitForTestIdWithText( + window, + Global.errorMessage.selector, + englishStrippedStr('communityJoinedAlready').toString(), + ); + await clickOn(window, Global.backButton); + await clickOn(window, Global.backButton); + await clickOnWithText( + window, + HomeScreen.conversationItemName, + testCommunityName, + ); + } catch (waitError) { + // The error message we expected wasn't there, so this is a real failure + throw joinError; // Throw the original join error, not the wait timeout + } + } +}; + +export const assertAdminIsKnown = () => { + if (!process.env.SOGS_ADMIN_SEED) { + console.error('SOGS_ADMIN_SEED required.'); + console.error( + 'Promote a user to admin and set their seed as an env variable to run this test.', + ); + console.error('CI runs will use a seed saved as a GitHub secret.'); + test.skip(); + } +}; diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index f42dff4..7bb00fe 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -1,36 +1,22 @@ import { Page } from '@playwright/test'; +import { MessageStatus } from '../types/testing'; // eslint-disable-next-line import/no-cycle import { clickOnElement, typeIntoInput } from './utils'; -export const waitForSentTick = async (window: Page, message: string) => { - // wait for confirmation tick to send reply message - const selc = `css=[data-testid=message-content]:has-text("${message}"):has([data-testid=msg-status][data-testtype=sent])`; - console.info('waiting for sent tick of message: ', message); +export const waitForMessageStatus = async ( + window: Page, + message: string, + status: MessageStatus, +) => { + const selc = `css=[data-testid=message-content]:has-text("${message}"):has([data-testid=msg-status][data-testtype=${status}])`; + const logSig = `${status} status of message '${message}'`; + console.info(`waiting for ${logSig}`); - const tickMessageSent = await window.waitForSelector(selc, { - timeout: 30000, + const messageStatus = await window.waitForSelector(selc, { + timeout: 20_000, }); - console.info( - 'found the tick of message sent: ', - message, - Boolean(tickMessageSent), - ); -}; - -export const waitForReadTick = async (window: Page, message: string) => { - // wait for confirmation tick to send reply message - const selc = `css=[data-testid=message-content]:has-text("${message}"):has([data-testid=msg-status][data-testtype=read])`; - console.info('waiting for read tick of message: ', message); - - const tickMessageRead = await window.waitForSelector(selc, { - timeout: 30000, - }); - console.info( - 'found the tick of message read: ', - message, - Boolean(tickMessageRead), - ); + console.info(`${logSig} is ${Boolean(messageStatus)}`); }; export const sendMessage = async (window: Page, message: string) => { @@ -42,5 +28,5 @@ export const sendMessage = async (window: Page, message: string) => { strategy: 'data-testid', selector: 'send-message-button', }); - await waitForSentTick(window, message); + await waitForMessageStatus(window, message, 'sent'); }; diff --git a/tests/automation/utilities/send_media.ts b/tests/automation/utilities/send_media.ts index 448714e..fad280e 100644 --- a/tests/automation/utilities/send_media.ts +++ b/tests/automation/utilities/send_media.ts @@ -4,7 +4,7 @@ import { englishStrippedStr } from '../../localization/englishStrippedStr'; import { sleepFor } from '../../promise_utils'; import { Conversation, Global, Settings } from '../locators'; import { MediaType } from '../types/testing'; -import { waitForSentTick } from './message'; +import { waitForMessageStatus } from './message'; import { checkModalStrings, clickOn, @@ -81,7 +81,7 @@ export const sendMedia = async ( strategy: 'data-testid', selector: 'send-message-button', }); - await waitForSentTick(window, testMessage); + await waitForMessageStatus(window, testMessage, 'sent'); if (shouldCheckMediaPreview) { await verifyMediaPreviewLoaded(window, testMessage); } @@ -161,7 +161,7 @@ export const sendLinkPreview = async (window: Page, testLink: string) => { selector: 'send-message-button', }); - await waitForSentTick(window, testLink); + await waitForMessageStatus(window, testLink, 'sent'); }; export const trustUser = async ( diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 3260bc2..9390dec 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -5,7 +5,7 @@ import { ElementHandle, Page } from '@playwright/test'; import { sleepFor } from '../../promise_utils'; -import { CTA } from '../locators'; +import { Conversation, CTA } from '../locators'; import { DataTestId, DMTimeOption, @@ -21,6 +21,7 @@ import { sendMessage } from './message'; type ElementOptions = { maxWait?: number; rightButton?: boolean; + strictMode?: boolean; }; // TODO Unify element interaction functions to use locator objects the way clickOn and clickOnWithText do @@ -278,7 +279,10 @@ export async function clickOn( builtSelector = `css=[${locator.strategy}=${locator.selector}]`; } - const sharedOpts = { timeout: options?.maxWait, strict: true }; + const sharedOpts = { + timeout: options?.maxWait, + strict: options?.strictMode ?? true, + }; await window.click( builtSelector, options?.rightButton ? { ...sharedOpts, button: 'right' } : sharedOpts, @@ -311,7 +315,10 @@ export async function clickOnWithText( }]:has-text("${text.replace(/"/g, '\\"')}")`; } - const sharedOpts = { timeout: options?.maxWait, strict: true }; + const sharedOpts = { + timeout: options?.maxWait, + strict: options?.strictMode ?? true, + }; await window.click( builtSelector, options?.rightButton ? { ...sharedOpts, button: 'right' } : sharedOpts, @@ -715,3 +722,14 @@ export async function assertUrlIsReachable(url: string): Promise { ); } } + +export async function scrollToBottomIfNecessary(window: Page): Promise { + const canScroll = await doesElementExist( + window, + Conversation.scrollToBottomButton.strategy, + Conversation.scrollToBottomButton.selector, + ); + if (canScroll) { + await clickOn(window, Conversation.scrollToBottomButton); + } +} diff --git a/tests/localization/lib b/tests/localization/lib index bb08513..c0714a8 160000 --- a/tests/localization/lib +++ b/tests/localization/lib @@ -1 +1 @@ -Subproject commit bb08513f637e7f0fb4de2675b5682a1e129ff4e0 +Subproject commit c0714a8916a38672584323e6084e8cedc36d7243