From 92d866ee760cbc95ed0169c8e586f690311a0512 Mon Sep 17 00:00:00 2001 From: Zafar Saeed Khan Date: Fri, 21 Nov 2025 18:38:06 +0100 Subject: [PATCH 01/23] fix: remove call from the call state when a user is left the group (#19790) --- .../repositories/calling/CallingRepository.ts | 15 ++++++++-- .../calling/enum/LeaveCallReason.ts | 2 +- .../conversation/ConversationRepository.ts | 28 +++++++++---------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/script/repositories/calling/CallingRepository.ts b/src/script/repositories/calling/CallingRepository.ts index 9be32d19777..87c498bc8e5 100644 --- a/src/script/repositories/calling/CallingRepository.ts +++ b/src/script/repositories/calling/CallingRepository.ts @@ -35,16 +35,16 @@ import 'webrtc-adapter'; import { AUDIO_STATE, - ENV as AVS_ENV, - STATE as CALL_STATE, CALL_TYPE, CONV_TYPE, + ENV as AVS_ENV, ERROR, getAvsInstance, LOG_LEVEL, QUALITY, REASON, RESOLUTION, + STATE as CALL_STATE, VIDEO_STATE, VSTREAMS, Wcall, @@ -1666,6 +1666,12 @@ export class CallingRepository { } }; + /** + * Leaves the call in the given conversation and on the basis of the given reason, + * Remove the call from the call state. + * @param conversationId + * @param reason + */ readonly leaveCall = (conversationId: QualifiedId, reason: LEAVE_CALL_REASON): void => { const call = this.findCall(conversationId); if (call) { @@ -1674,6 +1680,11 @@ export class CallingRepository { if (call.getSelfParticipant().sharesScreen()) { this.stopScreenShare(call.getSelfParticipant(), call.conversation, call); } + + // If the user is not part of the conversation, the call must be removed from the state + if (reason === LEAVE_CALL_REASON.USER_IS_REMOVED_FROM_CONVERSATION) { + this.removeCall(call); + } } if (isTelemetryEnabledAtCurrentEnvironment()) { diff --git a/src/script/repositories/calling/enum/LeaveCallReason.ts b/src/script/repositories/calling/enum/LeaveCallReason.ts index cf23c5e9748..89d2de3d597 100644 --- a/src/script/repositories/calling/enum/LeaveCallReason.ts +++ b/src/script/repositories/calling/enum/LeaveCallReason.ts @@ -25,7 +25,7 @@ export enum LEAVE_CALL_REASON { MANUAL_LEAVE_TO_JOIN_ANOTHER_CALL = 'manual_leave_to_join_another_call', MANUAL_LEAVE_BY_UI_CLICK = 'manual_leave_by_ui_click', USER_MANUALY_LEFT_CONVERSATION = 'user_manualy_left_conversation', - USER_IS_REMOVED_BY_AN_ADMIN_OR_LEFT_ON_ANOTHER_CLIENT = 'user_is_removed_by_an_admin_or_left_on_another_client', + USER_IS_REMOVED_FROM_CONVERSATION = 'user_is_removed_from_conversation', ABORTED_BECAUSE_FAILED_TO_UPDATE_MISSING_CLIENTS = 'abort_failed_to_update_missing_clients', ABORTED_BECAUSE_FAILED_TO_SEND_CALLING_MESSAGE = 'abort_failed_to_update_missing_clients', ABORTED_BECAUSE_USER_CANCELLED_MESSAGE_SENDING_BECAUSE_OF_A_DEGRADATION_WARNING = 'abort_failed_because_user_cancelled_message_sending_because_of_a_degradation_warning', diff --git a/src/script/repositories/conversation/ConversationRepository.ts b/src/script/repositories/conversation/ConversationRepository.ts index eb7b5c1175d..29d73f02a90 100644 --- a/src/script/repositories/conversation/ConversationRepository.ts +++ b/src/script/repositories/conversation/ConversationRepository.ts @@ -18,38 +18,38 @@ */ import { + ADD_PERMISSION, Conversation as BackendConversation, + CONVERSATION_CELLS_STATE, CONVERSATION_TYPE, DefaultConversationRoleName as DefaultRole, - NewConversation, MessageSendingStatus, + NewConversation, RemoteConversations, - ADD_PERMISSION, - CONVERSATION_CELLS_STATE, } from '@wireapp/api-client/lib/conversation'; import { - MemberLeaveReason, ConversationReceiptModeUpdateData, + MemberLeaveReason, RECEIPT_MODE, } from '@wireapp/api-client/lib/conversation/data'; import {CONVERSATION_TYPING} from '@wireapp/api-client/lib/conversation/data/ConversationTypingData'; import { + CONVERSATION_EVENT, + ConversationAddPermissionUpdateEvent, ConversationCreateEvent, ConversationEvent, ConversationMemberJoinEvent, ConversationMemberLeaveEvent, ConversationMemberUpdateEvent, ConversationMessageTimerUpdateEvent, + ConversationMLSResetEvent, + ConversationProtocolUpdateEvent, ConversationReceiptModeUpdateEvent, ConversationRenameEvent, ConversationTypingEvent, - CONVERSATION_EVENT, - ConversationProtocolUpdateEvent, - ConversationAddPermissionUpdateEvent, - ConversationMLSResetEvent, } from '@wireapp/api-client/lib/event'; -import {BackendErrorLabel} from '@wireapp/api-client/lib/http/'; import type {BackendError} from '@wireapp/api-client/lib/http/'; +import {BackendErrorLabel} from '@wireapp/api-client/lib/http/'; import {CONVERSATION_PROTOCOL} from '@wireapp/api-client/lib/team'; import type {QualifiedId} from '@wireapp/api-client/lib/user/'; import {BaseCreateConversationResponse} from '@wireapp/core/lib/conversation'; @@ -118,16 +118,16 @@ import {ConversationLabelRepository} from './ConversationLabelRepository'; import {ConversationDatabaseData, ConversationMapper} from './ConversationMapper'; import {ConversationRoleRepository} from './ConversationRoleRepository'; import { + isBackendProteus1to1Conversation, + isConnectionRequestConversation, isMixedConversation, isMLSCapableConversation, isMLSConversation, + isProteus1to1ConversationWithUser, isProteusConversation, MLSCapableConversation, MLSConversation, - isProteus1to1ConversationWithUser, ProteusConversation, - isConnectionRequestConversation, - isBackendProteus1to1Conversation, } from './ConversationSelectors'; import {ConversationService} from './ConversationService'; import {ConversationState} from './ConversationState'; @@ -160,7 +160,7 @@ import {MessageRepository} from './MessageRepository'; import {NOTIFICATION_STATE} from './NotificationSetting'; import {Config} from '../../Config'; -import {BaseError, BASE_ERROR_TYPE} from '../../error/BaseError'; +import {BASE_ERROR_TYPE, BaseError} from '../../error/BaseError'; import {ConversationError} from '../../error/ConversationError'; import {isMemberMessage} from '../../guards/Message'; import * as LegalHoldEvaluator from '../../legal-hold/LegalHoldEvaluator'; @@ -4026,7 +4026,7 @@ export class ConversationRepository { conversationEntity.status(ConversationStatus.PAST_MEMBER); this.callingRepository.leaveCall( conversationEntity.qualifiedId, - LEAVE_CALL_REASON.USER_IS_REMOVED_BY_AN_ADMIN_OR_LEFT_ON_ANOTHER_CLIENT, + LEAVE_CALL_REASON.USER_IS_REMOVED_FROM_CONVERSATION, ); if (this.userState.self().isTemporaryGuest()) { From 59de699473d1360de799bb98fb20da36f2381fb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Nov 2025 15:05:51 +0000 Subject: [PATCH 02/23] chore(deps-dev): bump rimraf from 6.1.0 to 6.1.2 in /server (#19791) Bumps [rimraf](https://github.com/isaacs/rimraf) from 6.1.0 to 6.1.2. - [Changelog](https://github.com/isaacs/rimraf/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/rimraf/compare/v6.1.0...v6.1.2) --- updated-dependencies: - dependency-name: rimraf dependency-version: 6.1.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- server/package.json | 2 +- server/yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/package.json b/server/package.json index 4e645f0c470..97ebb299e97 100644 --- a/server/package.json +++ b/server/package.json @@ -33,7 +33,7 @@ "@types/node": "22.5.5", "browserslist": "^4.28.0", "jest": "29.7.0", - "rimraf": "6.1.0", + "rimraf": "6.1.2", "typescript": "5.6.3" }, "resolutions": { diff --git a/server/yarn.lock b/server/yarn.lock index a776c859411..a5ee8c766c4 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -4796,15 +4796,15 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:6.1.0": - version: 6.1.0 - resolution: "rimraf@npm:6.1.0" +"rimraf@npm:6.1.2": + version: 6.1.2 + resolution: "rimraf@npm:6.1.2" dependencies: - glob: "npm:^11.0.3" + glob: "npm:^13.0.0" package-json-from-dist: "npm:^1.0.1" bin: rimraf: dist/esm/bin.mjs - checksum: 10/ce376c041ef4212dce2b30690dff3c09fc34253ec21821dffec77731061241888c04c3baf0b052bc5a1698b9f348c08ef83bddbd6e2553e79bf939bedb1a31a9 + checksum: 10/add8e566fe903f59d7b55c6c2382320c48302778640d1951baf247b3b451af496c2dee7195c204a8c646fd6327feadd1f5b61ce68c1362d4898075a726d83cc6 languageName: node linkType: hard @@ -5555,7 +5555,7 @@ __metadata: nocache: "npm:4.0.0" opn: "npm:6.0.0" pm2: "npm:6.0.13" - rimraf: "npm:6.1.0" + rimraf: "npm:6.1.2" typescript: "npm:5.6.3" languageName: unknown linkType: soft From bd2f77acdfd694f5306d3c96222bf6e180fc114d Mon Sep 17 00:00:00 2001 From: Mark Brockhoff <95471369+markbrockhoff@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:00:33 +0100 Subject: [PATCH 03/23] refactor(WPB-21957): create fixture for creating teams + refactor edit tests with it (#19804) This is an attempt to resolve the race conditions happening when two users connect in a 1:1 for the first time starting the conversation as proteus but it's migrated to MLS automatically. This leads to a message sent during the migration being lost. Team members don't need to go through this step as they are aware of the spoken protocol of other members after login. --- test/e2e_tests/backend/authRepository.e2e.ts | 17 ++++ test/e2e_tests/backend/backendClient.e2e.ts | 4 +- test/e2e_tests/backend/teamRepository.e2e.ts | 16 ++++ test/e2e_tests/specs/Edit/edit.spec.ts | 85 +++++++++---------- test/e2e_tests/test.fixtures.ts | 88 +++++++++++++++++--- test/e2e_tests/utils/userActions.ts | 14 +++- 6 files changed, 163 insertions(+), 61 deletions(-) diff --git a/test/e2e_tests/backend/authRepository.e2e.ts b/test/e2e_tests/backend/authRepository.e2e.ts index a2e0ba8746e..29b12dbfbad 100644 --- a/test/e2e_tests/backend/authRepository.e2e.ts +++ b/test/e2e_tests/backend/authRepository.e2e.ts @@ -63,6 +63,23 @@ export class AuthRepositoryE2E extends BackendClientE2E { }); } + async upgradeUserToTeamOwner(user: User, teamName: string) { + const res = this.axiosInstance.post( + 'upgrade-personal-to-team', + { + name: teamName, + icon: 'default', + }, + { + headers: { + Authorization: `Bearer ${user.token}`, + }, + }, + ); + const data = (await res).data; + return {teamId: data.team_id, teamName: data.team_name}; + } + public async activateAccount(email: string, code: string) { await this.axiosInstance.post('activate', { code, diff --git a/test/e2e_tests/backend/backendClient.e2e.ts b/test/e2e_tests/backend/backendClient.e2e.ts index f2bcf49a901..bb8a9492101 100644 --- a/test/e2e_tests/backend/backendClient.e2e.ts +++ b/test/e2e_tests/backend/backendClient.e2e.ts @@ -17,10 +17,10 @@ * */ -import {MINIMUM_API_VERSION} from '@wireapp/api-client/lib/Config'; import axios, {AxiosInstance} from 'axios'; -const TEST_API_VERSION = `v${MINIMUM_API_VERSION}`; +// ToDo: Fix via export from api-client +const TEST_API_VERSION = `v13`; export class BackendClientE2E { readonly axiosInstance: AxiosInstance; diff --git a/test/e2e_tests/backend/teamRepository.e2e.ts b/test/e2e_tests/backend/teamRepository.e2e.ts index ba701206ee5..7cdcd117cc1 100644 --- a/test/e2e_tests/backend/teamRepository.e2e.ts +++ b/test/e2e_tests/backend/teamRepository.e2e.ts @@ -42,6 +42,22 @@ export class TeamRepositoryE2E extends BackendClientE2E { return (await response).data.id; } + async acceptTeamInvitation(teamInvitationCode: string, user: Pick) { + await this.axiosInstance.post( + 'teams/invitations/accept', + { + code: teamInvitationCode, + password: user.password, + }, + { + headers: { + Authorization: `Bearer ${user.token}`, + 'Content-Type': 'application/json', + }, + }, + ); + } + async addServiceToTeamWhitelist(teamId: string, service: Service, token: string) { await this.axiosInstance.post( `teams/${teamId}/services/whitelist`, diff --git a/test/e2e_tests/specs/Edit/edit.spec.ts b/test/e2e_tests/specs/Edit/edit.spec.ts index 11ef8a85c5b..dcd0ced94e0 100644 --- a/test/e2e_tests/specs/Edit/edit.spec.ts +++ b/test/e2e_tests/specs/Edit/edit.spec.ts @@ -19,26 +19,21 @@ import {User} from 'test/e2e_tests/data/user'; import {PageManager} from 'test/e2e_tests/pageManager'; -import {test as baseTest, expect, withConversation, withLogin} from 'test/e2e_tests/test.fixtures'; +import {test, expect, withConnectedUser, withLogin} from 'test/e2e_tests/test.fixtures'; import {createGroup} from 'test/e2e_tests/utils/userActions'; -const test = baseTest.extend<{userA: User; userB: User}>({ - userA: async ({createUser}, use) => { - await use(await createUser()); - }, - userB: async ({createUser}, use) => { - await use(await createUser()); - }, -}); - test.describe('Edit', () => { - test.beforeEach(async ({api, userA, userB}) => { - await api.connectUsers(userA, userB); - }); + let userA: User; + let userB: User; - test('I can edit my message in 1:1', {tag: ['@TC-679', '@regression']}, async ({createPage, userA, userB}) => { - const pages = (await PageManager.from(createPage(withLogin(userA), withConversation(userB)))).webapp.pages; + test.beforeEach(async ({createTeam}) => { + const team = await createTeam('Test Team', {withMembers: 1}); + userA = team.owner; + userB = team.members[0]; + }); + test('I can edit my message in 1:1', {tag: ['@TC-679', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; await pages.conversation().sendMessage('Test Message'); const message = pages.conversation().getMessage({sender: userA}); @@ -52,36 +47,32 @@ test.describe('Edit', () => { await expect(message).toContainText('Edited Message'); }); - test( - 'I can edit my message in a group conversation', - {tag: ['@TC-680', '@regression']}, - async ({createPage, userA, userB}) => { - const pages = (await PageManager.from(createPage(withLogin(userA)))).webapp.pages; + test('I can edit my message in a group conversation', {tag: ['@TC-680', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA)))).webapp.pages; + await createGroup(pages, 'Test Group', [userB]); - await createGroup(pages, 'Test Group', [userB]); - await pages.conversationList().openConversation('Test Group'); - await pages.conversation().sendMessage('Test Message'); + await pages.conversationList().openConversation('Test Group'); + await pages.conversation().sendMessage('Test Message'); - const message = pages.conversation().getMessage({sender: userA}); - await expect(message).toContainText('Test Message'); + const message = pages.conversation().getMessage({sender: userA}); + await expect(message).toContainText('Test Message'); - await pages.conversation().editMessage(message); - await expect(pages.conversation().messageInput).toContainText('Test Message'); + await pages.conversation().editMessage(message); + await expect(pages.conversation().messageInput).toContainText('Test Message'); - // Overwrite the text in the message input and send it - await pages.conversation().sendMessage('Edited Message'); - await expect(message).toContainText('Edited Message'); - }, - ); + // Overwrite the text in the message input and send it + await pages.conversation().sendMessage('Edited Message'); + await expect(message).toContainText('Edited Message'); + }); test( 'I see changed message if message was edited from another device', {tag: ['@TC-682', '@regression']}, - async ({createPage, userA, userB}) => { - const deviceA = (await PageManager.from(createPage(withLogin(userA), withConversation(userB)))).webapp.pages; + async ({createPage}) => { + const deviceA = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + // Device 2 is intentionally created after device 1 to ensure the history info warning is confirmed const deviceB = (await PageManager.from(createPage(withLogin(userA)))).webapp.pages; - await deviceB.historyInfo().clickConfirmButton(); await deviceB.conversationList().openConversation(userB.fullName); @@ -101,10 +92,10 @@ test.describe('Edit', () => { }, ); - test('I cannot edit another users message', {tag: ['@TC-683', '@regression']}, async ({createPage, userA, userB}) => { + test('I cannot edit another users message', {tag: ['@TC-683', '@regression']}, async ({createPage}) => { const [userAPages, userBPages] = await Promise.all([ - PageManager.from(createPage(withLogin(userA), withConversation(userB))).then(pm => pm.webapp.pages), - PageManager.from(createPage(withLogin(userB), withConversation(userA))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userA), withConnectedUser(userB))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userB), withConnectedUser(userA))).then(pm => pm.webapp.pages), ]); await userAPages.conversation().sendMessage('Test Message'); @@ -119,8 +110,8 @@ test.describe('Edit', () => { test( 'I can edit my last message by pressing the up arrow key', {tag: ['@TC-686', '@regression']}, - async ({createPage, userA, userB}) => { - const pages = (await PageManager.from(createPage(withLogin(userA), withConversation(userB)))).webapp.pages; + async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; await pages.conversation().sendMessage('Test Message'); await expect(pages.conversation().getMessage({content: 'Test Message'})).toBeVisible(); @@ -133,10 +124,10 @@ test.describe('Edit', () => { test( 'Editing a message does not create unread dot on receiver side', {tag: ['@TC-690', '@regression']}, - async ({createPage, userA, userB}) => { + async ({createPage}) => { const [userAPages, userBPages] = await Promise.all([ - PageManager.from(createPage(withLogin(userA), withConversation(userB))).then(pm => pm.webapp.pages), - PageManager.from(createPage(withLogin(userB), withConversation(userA))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userA), withConnectedUser(userB))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userB), withConnectedUser(userA))).then(pm => pm.webapp.pages), ]); await test.step('Create group as second conversation', async () => { @@ -184,10 +175,10 @@ test.describe('Edit', () => { test( 'I can see the changed message was edited from another user', {tag: ['@TC-692', '@regression']}, - async ({createPage, userA, userB}) => { + async ({createPage}) => { const [userAPages, userBPages] = await Promise.all([ - PageManager.from(createPage(withLogin(userA), withConversation(userB))).then(pm => pm.webapp.pages), - PageManager.from(createPage(withLogin(userB), withConversation(userA))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userA), withConnectedUser(userB))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userB), withConnectedUser(userA))).then(pm => pm.webapp.pages), ]); await userAPages.conversation().sendMessage('Test'); @@ -208,7 +199,7 @@ test.describe('Edit', () => { test( 'I want to see the last edited text including a timestamp in message detail view if the message has been edited', {tag: ['@TC-3563', '@regression']}, - async ({createPage, userA, userB}) => { + async ({createPage}) => { const pages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages); await createGroup(pages, 'Test Group', [userB]); // The message detail view is only available for group conversations diff --git a/test/e2e_tests/test.fixtures.ts b/test/e2e_tests/test.fixtures.ts index 3089139c30e..80ae8be7ac6 100644 --- a/test/e2e_tests/test.fixtures.ts +++ b/test/e2e_tests/test.fixtures.ts @@ -22,6 +22,7 @@ import {test as baseTest, type BrowserContext, type Page} from '@playwright/test import {ApiManagerE2E} from './backend/apiManager.e2e'; import {getUser, User} from './data/user'; import {PageManager} from './pageManager'; +import {connectWithUser} from './utils/userActions'; type PagePlugin = (page: Page) => void | Promise; @@ -34,9 +35,26 @@ type Fixtures = { * @param setup Array of PagePlugins, effectively functions which will be applied to the page in the given order */ createPage: (...setup: PagePlugin[]) => Promise; - createUser: (options?: {disableTelemetry?: boolean}) => Promise; + /** + * Create a new user + * Note: The created user will be deleted automatically once the test is finished + * @param options Options to set on the new user e.g. declining telemetry + */ + createUser: (options?: Parameters[1]) => Promise; + /** + * Creates a team and the associated owner, optionally adding members to it + * Note: The team and owner are automatically deleted when the test completes. + * @param options.withMembers Can either be the number of team members to create or an array of existing members to add to the team + * @returns an object containing the teams owner and an array of members. The size of the members array matches the number or array length passed to `withMembers` + */ + createTeam: ( + teamName: string, + options?: Parameters[1] & {withMembers?: number | User[]}, + ) => Promise<{owner: User; members: User[]}>; }; +export {expect} from '@playwright/test'; + export const test = baseTest.extend({ api: async ({}, use) => { // Create a new instance of ApiManager for each test @@ -68,21 +86,46 @@ export const test = baseTest.extend({ const users: User[] = []; await use(async options => { - const {disableTelemetry = true} = options ?? {}; - - const user = getUser(); - await api.createPersonalUser(user); - - if (disableTelemetry) { - await api.properties.putProperty({settings: {privacy: {telemetry_data_sharing: false}}}, user.token); - } - + const user = await createUser(api, options); users.push(user); return user; }); await Promise.all(users.map(user => api.deletePersonalUser(user))); }, + createTeam: async ({api}, use) => { + const teamOwners: User[] = []; + + await use(async (teamName, {withMembers, ...options} = {}) => { + const owner = await createUser(api, options); + const {teamId} = await api.auth.upgradeUserToTeamOwner(owner, teamName); + + owner.teamId = teamId; + teamOwners.push(owner); + + let members: User[] = []; + if (withMembers !== undefined) { + // Depending on the type of withMembers, either create the number of users or use the given array of users + members = + typeof withMembers === 'number' + ? await Promise.all(Array.from({length: withMembers}, () => createUser(api, options))) + : withMembers; + + await Promise.all( + members.map(async member => { + const invitationId = await api.team.inviteUserToTeam(member.email, owner); + const invitationCode = await api.brig.getTeamInvitationCodeForEmail(owner.teamId, invitationId); + await api.team.acceptTeamInvitation(invitationCode, member); + }), + ); + } + + return {owner, members}; + }); + + // Deletes each created team and the owner / members associated with it + await Promise.all(teamOwners.map(owner => api.team.deleteTeam(owner, owner.teamId))); + }, }); /** PagePlugin to log in as the given user */ @@ -94,6 +137,17 @@ export const withLogin = await pageManager.webapp.pages.login().login(await user); }; +/** + * PagePlugin to connect with the given user + * Note: This plugin only works if the users are in the same team + */ +export const withConnectedUser = + (user: User | Promise): PagePlugin => + async page => { + const pageManager = PageManager.from(page); + await connectWithUser(pageManager, await user); + }; + /** PagePlugin to open a conversation with the given user */ export const withConversation = (user: Pick): PagePlugin => @@ -101,4 +155,16 @@ export const withConversation = await PageManager.from(page).webapp.pages.conversationList().openConversation(user.fullName); }; -export {expect} from '@playwright/test'; +const createUser = async (api: ApiManagerE2E, options?: {disableTelemetry?: boolean}) => { + const {disableTelemetry = true} = options ?? {}; + + const user = getUser(); + await api.createPersonalUser(user); + + // Optionally decline to send telemetry via the api. This avoids the user being prompted for it in the UI upon first login + if (disableTelemetry) { + await api.properties.putProperty({settings: {privacy: {telemetry_data_sharing: false}}}, user.token); + } + + return user; +}; diff --git a/test/e2e_tests/utils/userActions.ts b/test/e2e_tests/utils/userActions.ts index eaf220f1281..af146d19680 100644 --- a/test/e2e_tests/utils/userActions.ts +++ b/test/e2e_tests/utils/userActions.ts @@ -114,7 +114,7 @@ export const handleAppLockState = async (pageManager: PageManager, appLockPassCo export async function loginAndSetup(user: User, pageManager: PageManager) { const {modals, components} = pageManager.webapp; await pageManager.openMainPage(); - await loginUser(user, pageManager); // Verwendet die bestehende loginUser-Funktion + await loginUser(user, pageManager); await modals.dataShareConsent().clickDecline(); await components.conversationSidebar().isPageLoaded(); } @@ -143,6 +143,18 @@ export async function connectUsersManually( await userBPages.connectRequest().clickConnectButton(); } +/** + * Opens the connections tab, searches for the given user and starts a conversation with him + * Note: This util only works if both users are part of the same team. + */ +export async function connectWithUser(senderPageManager: PageManager, receiver: Pick) { + const {pages, modals, components} = senderPageManager.webapp; + await components.conversationSidebar().clickConnectButton(); + await pages.startUI().searchInput.fill(receiver.username); + await pages.startUI().selectUser(receiver.username); + await modals.userProfile().clickStartConversation(); +} + /** * Blocks a user from the conversation list * @param pageManager PageManager of the blocking user From d70f2fa664d0971a48f8c2c504c214e675c2f239 Mon Sep 17 00:00:00 2001 From: Mark Brockhoff <95471369+markbrockhoff@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:50:34 +0100 Subject: [PATCH 04/23] test(WPB-19966): write "Reply" regression tests (#19760) * test(TC-8038): add test for not replying to a ping * test(TC-8039): add test for not replying to timed messages * test(TC-2994): add test for quote in reply to vanish if source message is removed * test(TC-2996): add test for searching message * test(TC-2997): test for quote in reply to be truncated * test(TC-3002): reply to a picture * test(TC-3003): reply to an audio message * test(TC-3004): reply to video * test(TC-3005): reply to link * test(TC3006): reply to file * test(TC-3007): reply to reply * test(TC-3008): reply to link with text * test(TC-3011): Reply with timed message * test(TC-3013): test clicking the reply * test(TC-3014): test replying in a conversation I'm no longer part of * test(TC-3016): click on mention in reply opens user profile * fix: remove visibility check from sendMessage * test(TC-3009): add test case for replying to a location + util for sending location via testservice * refactor: replace createPagesForUser with new fixtures * refactor(test): use new fixtures for reply tests * refactor: remove no longer used PagePlugin withConversation --- .../conversationRepository.e2e.ts | 77 ++-- test/e2e_tests/backend/apiManager.e2e.ts | 9 - .../backend/testServiceClient.e2e.ts | 11 + test/e2e_tests/pageManager/index.ts | 4 + .../webapp/modals/confirm.modal.ts | 28 ++ .../webapp/pages/collection.page.ts | 41 +++ .../webapp/pages/conversation.page.ts | 21 ++ test/e2e_tests/specs/Reply/reply.spec.ts | 338 ++++++++++++++++++ test/e2e_tests/test.fixtures.ts | 7 - 9 files changed, 473 insertions(+), 63 deletions(-) create mode 100644 test/e2e_tests/pageManager/webapp/modals/confirm.modal.ts create mode 100644 test/e2e_tests/pageManager/webapp/pages/collection.page.ts create mode 100644 test/e2e_tests/specs/Reply/reply.spec.ts diff --git a/test/e2e_tests/backend/ConversationRepository/conversationRepository.e2e.ts b/test/e2e_tests/backend/ConversationRepository/conversationRepository.e2e.ts index c30c0a212ea..b6a728ac77f 100644 --- a/test/e2e_tests/backend/ConversationRepository/conversationRepository.e2e.ts +++ b/test/e2e_tests/backend/ConversationRepository/conversationRepository.e2e.ts @@ -68,58 +68,41 @@ export class ConversationRepositoryE2E extends BackendClientE2E { } } - async getMLSConversationWithUser(token: string, conversationPartnerId: string, timeout = 10000) { - const retryDelay = 1000; - const maxRetries = timeout / retryDelay; - let mlsConversationId = null; - - for (let attempt = 0; attempt < maxRetries && !mlsConversationId; attempt++) { - const listIdsResponse = await this.axiosInstance.post( - 'conversations/list-ids', - {}, - { - headers: { - Authorization: `Bearer ${token}`, - }, + async getConversationWithUser(token: string, conversationPartnerId: string) { + const listIdsResponse = await this.axiosInstance.post( + 'conversations/list-ids', + {}, + { + headers: { + Authorization: `Bearer ${token}`, }, - ); + }, + ); - const qualifiedIds = listIdsResponse.data.qualified_conversations; + const qualifiedIds = listIdsResponse.data.qualified_conversations; - if (!qualifiedIds) { - throw new Error('No qualified conversations found'); - } + if (!qualifiedIds) { + throw new Error('No qualified conversations found'); + } - const response = await this.axiosInstance.post( - 'conversations/list', - { - qualified_ids: qualifiedIds, - }, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, + const response = await this.axiosInstance.post( + 'conversations/list/v2', + { + qualified_ids: qualifiedIds, + }, + { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', }, - ); - - // Filtering out conversations that are not MLS; - const responseData = response.data; - mlsConversationId = - responseData.found.find( - (conversation: {protocol: string; members: {others: {conversation_role: string; id: string}[]}}) => - conversation.protocol === 'mls' && - conversation.members.others.some( - (member: {conversation_role: string; id: string}) => - member.conversation_role === 'wire_member' && member.id === conversationPartnerId, - ), - )?.qualified_id?.id ?? null; + }, + ); - await new Promise(resolve => setTimeout(resolve, retryDelay)); - } - if (!mlsConversationId) { - throw new Error(`No MLS conversation found with user ID: ${conversationPartnerId}`); - } - return mlsConversationId; + // Find a conversation with the given user + const conversation = response.data.found.find( + (conversation: {members: {others: {conversation_role: string; id: string}[]}}) => + conversation.members.others.some((member: {id: string}) => member.id === conversationPartnerId), + ); + return conversation?.qualified_id?.id; } } diff --git a/test/e2e_tests/backend/apiManager.e2e.ts b/test/e2e_tests/backend/apiManager.e2e.ts index 31123a0d436..cdfcbc244d8 100644 --- a/test/e2e_tests/backend/apiManager.e2e.ts +++ b/test/e2e_tests/backend/apiManager.e2e.ts @@ -71,15 +71,6 @@ export class ApiManagerE2E { } } - async sendMessageToPersonalConversation(sender: User, receiver: User, text: string) { - const senderToken = sender.token ?? (await this.auth.loginUser(sender)).data.access_token; - const receiverId = receiver.id ?? (await this.auth.loginUser(receiver)).data.user; - const conversationId = await this.conversation.getMLSConversationWithUser(senderToken, receiverId); - - // Using the first device from the list of devices - await this.testService.sendText(sender.devices[0], conversationId, text); - } - async createPersonalUser(user: User, invitationCode?: string) { // 1. Register const registerResponse = await this.auth.registerUser(user, invitationCode); diff --git a/test/e2e_tests/backend/testServiceClient.e2e.ts b/test/e2e_tests/backend/testServiceClient.e2e.ts index 673f9662535..2b39e2e238c 100644 --- a/test/e2e_tests/backend/testServiceClient.e2e.ts +++ b/test/e2e_tests/backend/testServiceClient.e2e.ts @@ -51,4 +51,15 @@ export class TestServiceClientE2E { text, }); } + + async sendLocation( + instanceId: string, + conversationId: string, + message: {locationName: string; latitude: number; longitude: number; zoom: number}, + ) { + return await this.axiosInstance.post(`/api/v1/instance/${instanceId}/sendLocation`, { + conversationId, + ...message, + }); + } } diff --git a/test/e2e_tests/pageManager/index.ts b/test/e2e_tests/pageManager/index.ts index a068d98116a..8a12eccbd9b 100644 --- a/test/e2e_tests/pageManager/index.ts +++ b/test/e2e_tests/pageManager/index.ts @@ -36,6 +36,7 @@ import {AppLockModal} from './webapp/modals/appLock.modal'; import {BlockWarningModal} from './webapp/modals/blockWarning.modal'; import {CallNotEstablishedModal} from './webapp/modals/callNotEstablished.modal'; import {CancelRequestModal} from './webapp/modals/cancelRequest.modal'; +import {ConfirmModal} from './webapp/modals/confirm.modal'; import {ConfirmLogoutModal} from './webapp/modals/confirmLogout.modal'; import {ConversationNotConnectedModal} from './webapp/modals/conversationNotConnected.modal'; import {CopyPasswordModal} from './webapp/modals/copyPassword.modal'; @@ -54,6 +55,7 @@ import {VerifyEmailModal} from './webapp/modals/verifyEmail.modal'; import {AccountPage} from './webapp/pages/account.page'; import {AudioVideoSettingsPage} from './webapp/pages/audioVideoSettings.page'; import {CallingPage} from './webapp/pages/calling.page'; +import {CollectionPage} from './webapp/pages/collection.page'; import {ConnectRequestPage} from './webapp/pages/connectRequest.page'; import {ConversationPage} from './webapp/pages/conversation.page'; import {ConversationDetailsPage} from './webapp/pages/conversationDetails.page'; @@ -165,6 +167,7 @@ export class PageManager { conversationDetails: () => this.getOrCreate('webapp.pages.conversationDetails', () => new ConversationDetailsPage(this.page)), conversation: () => this.getOrCreate('webapp.pages.conversation', () => new ConversationPage(this.page)), + collection: () => this.getOrCreate('webapp.pages.collection', () => new CollectionPage(this.page)), cellsConversation: () => this.getOrCreate('webapp.pages.cellsConversation', () => new CellsConversationPage(this.page)), cellsConversationFiles: () => @@ -220,6 +223,7 @@ export class PageManager { marketingConsent: () => this.getOrCreate('webapp.modals.marketingConsent', () => new MarketingConsentModal(this.page)), acknowledge: () => this.getOrCreate('webapp.modals.marketingConsent', () => new AcknowledgeModal(this.page)), + confirm: () => this.getOrCreate('webapp.modals.confirm', () => new ConfirmModal(this.page)), cellsFileDetailView: () => this.getOrCreate('webapp.modals.cellsFileDetailView', () => new CellsFileDetailViewModal(this.page)), cancelRequest: () => diff --git a/test/e2e_tests/pageManager/webapp/modals/confirm.modal.ts b/test/e2e_tests/pageManager/webapp/modals/confirm.modal.ts new file mode 100644 index 00000000000..5a21594fec6 --- /dev/null +++ b/test/e2e_tests/pageManager/webapp/modals/confirm.modal.ts @@ -0,0 +1,28 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {Page} from '@playwright/test'; + +import {BaseModal} from './base.modal'; + +export class ConfirmModal extends BaseModal { + constructor(page: Page) { + super(page, 'modal-template-confirm'); + } +} diff --git a/test/e2e_tests/pageManager/webapp/pages/collection.page.ts b/test/e2e_tests/pageManager/webapp/pages/collection.page.ts new file mode 100644 index 00000000000..0cd0e29b48c --- /dev/null +++ b/test/e2e_tests/pageManager/webapp/pages/collection.page.ts @@ -0,0 +1,41 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {Locator, Page} from '@playwright/test'; + +/** POM for the "collection". This page is accessible by searching for a message within a conversation. */ +export class CollectionPage { + readonly page: Locator; + + constructor(page: Page) { + this.page = page.locator('#collection'); + } + + get searchInput() { + return this.page.getByRole('textbox', {name: 'Search text messages'}); + } + + get searchItems() { + return this.page.getByTestId('full-search-item'); + } + + async searchForMessages(search: string) { + await this.searchInput.fill(search); + } +} diff --git a/test/e2e_tests/pageManager/webapp/pages/conversation.page.ts b/test/e2e_tests/pageManager/webapp/pages/conversation.page.ts index 98fa1234fc1..d1d9bfde7d8 100644 --- a/test/e2e_tests/pageManager/webapp/pages/conversation.page.ts +++ b/test/e2e_tests/pageManager/webapp/pages/conversation.page.ts @@ -23,6 +23,8 @@ import {User} from 'test/e2e_tests/data/user'; import {downloadAssetAndGetFilePath} from 'test/e2e_tests/utils/asset.util'; import {selectById, selectByClass, selectByDataAttribute} from 'test/e2e_tests/utils/selector.util'; +import {ConfirmModal} from '../modals/confirm.modal'; + type EmojiReaction = 'plus-one' | 'heart' | 'joy'; export class ConversationPage { @@ -34,6 +36,7 @@ export class ConversationPage { readonly createGroupSubmitButton: Locator; readonly messageInput: Locator; readonly sendMessageButton: Locator; + readonly searchButton: Locator; readonly conversationTitle: Locator; readonly watermark: Locator; readonly timerMessageButton: Locator; @@ -76,6 +79,7 @@ export class ConversationPage { this.messageInput = page.locator(selectByDataAttribute('input-message')); this.watermark = page.locator(`${selectByDataAttribute('no-conversation')} svg`); this.sendMessageButton = page.locator(selectByDataAttribute('do-send-message')); + this.searchButton = page.getByRole('button', {name: 'Search'}); this.conversationTitle = page.locator('[data-uie-name="status-conversation-title-bar-label"]'); this.openGroupInformationViaName = page.locator(selectByDataAttribute('status-conversation-title-bar-label')); this.timerMessageButton = page.locator(selectByDataAttribute('do-set-ephemeral-timer')); @@ -162,6 +166,17 @@ export class ConversationPage { await this.createGroupSubmitButton.click(); } + async replyToMessage(message: Locator) { + await message.hover(); + await message.getByRole('group').getByTestId('do-reply-message').click(); + } + + async sendTimedMessage(message: string) { + await this.timerMessageButton.click(); + await this.timerTenSecondsButton.click(); + await this.sendMessage(message); + } + async sendMessageWithUserMention(userFullName: string, messageText?: string) { await this.messageInput.fill(`@`); await this.page @@ -242,6 +257,12 @@ export class ConversationPage { await menu.getByRole('button', {name: 'Details'}).click(); } + async deleteMessage(message: Locator, deleteFor: 'Me' | 'Everyone') { + const menu = await this.openMessageOptions(message); + await menu.getByRole('button', {name: `Delete for ${deleteFor}…`}).click(); + await new ConfirmModal(this.page).clickAction(); + } + async reactOnMessage(message: Locator, emojiType: EmojiReaction) { await message.hover(); const reactionButton = message.getByRole('group').getByRole('button').first(); diff --git a/test/e2e_tests/specs/Reply/reply.spec.ts b/test/e2e_tests/specs/Reply/reply.spec.ts new file mode 100644 index 00000000000..91a37de68cf --- /dev/null +++ b/test/e2e_tests/specs/Reply/reply.spec.ts @@ -0,0 +1,338 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {User} from 'test/e2e_tests/data/user'; +import {PageManager} from 'test/e2e_tests/pageManager'; +import {ConfirmModal} from 'test/e2e_tests/pageManager/webapp/modals/confirm.modal'; +import {test, expect, withLogin, withConnectedUser} from 'test/e2e_tests/test.fixtures'; +import {getAudioFilePath, getTextFilePath, getVideoFilePath, shareAssetHelper} from 'test/e2e_tests/utils/asset.util'; +import {getImageFilePath} from 'test/e2e_tests/utils/sendImage.util'; +import {createGroup} from 'test/e2e_tests/utils/userActions'; + +test.describe('Reply', () => { + let userA: User; + let userB: User; + + test.beforeEach(async ({createTeam, createUser}) => { + const team = await createTeam('Test Team', {withMembers: 1}); + userA = team.owner; + userB = team.members[0]; + }); + + test('I should not be able to reply to a ping', {tag: ['@TC-8038', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + + await pages.conversation().sendPing(); + const ping = pages.conversation().systemMessages.last(); + await expect(pages.conversation().systemMessages.last()).toContainText('pinged'); + + await pages.conversation().systemMessages.last().hover(); + await expect(ping.getByTestId('do-reply-message')).not.toBeAttached(); + }); + + test('I should not be able to reply to timed messages', {tag: ['@TC-8039', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + + await pages.conversation().sendTimedMessage('Gone in 10s'); + const message = pages.conversation().getMessage({content: 'Gone in 10s'}); + await message.hover(); + + await expect(message.getByTestId('do-reply-message')).not.toBeAttached(); + }); + + test( + 'I want to see a placeholder text as quote when original message is not available anymore', + {tag: ['@TC-2994', '@regression']}, + async ({createPage}) => { + const [userAPages, userBPages] = await Promise.all([ + PageManager.from(createPage(withLogin(userA), withConnectedUser(userB))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userB), withConnectedUser(userA))).then(pm => pm.webapp.pages), + ]); + + await userAPages.conversation().sendMessage('Test'); + + const messageToReplyTo = userBPages.conversation().getMessage({content: 'Test'}); + await userBPages.conversation().replyToMessage(messageToReplyTo); + await userBPages.conversation().sendMessage('Reply'); + + const replyMessage = userBPages.conversation().getMessage({content: 'Reply'}); + await expect(replyMessage.getByTestId('quote-item')).toContainText('Test'); + + const messageToDelete = userAPages.conversation().getMessage({content: 'Test', sender: userA}); + await userAPages.conversation().deleteMessage(messageToDelete, 'Everyone'); + + await expect(replyMessage.getByTestId('quote-item')).toContainText('You cannot see this message'); + }, + ); + + test( + 'I should not see the quoted message when searching for original message in collections', + {tag: ['@TC-2996', '@regression']}, + async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + await pages.conversation().sendMessage('Test'); + + const messageToReplyTo = pages.conversation().getMessage({content: 'Test'}); + await pages.conversation().replyToMessage(messageToReplyTo); + await pages.conversation().sendMessage('Reply'); + + await pages.conversation().searchButton.click(); + await pages.collection().searchForMessages('Test'); + + // Only the original message should be shown since the reply doesn't contain the search term + await expect(pages.collection().searchItems).toHaveCount(1); + await expect(pages.collection().searchItems).not.toContainText('Reply'); + }, + ); + + test( + 'I want to see truncated quote preview if quote is too long', + {tag: ['@TC-2997', '@regression']}, + async ({createPage}) => { + const longMessage = + 'This is a very long message which should be truncated within the UI since it is as already stated very long.'; + + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + await pages.conversation().sendMessage(longMessage); + + const messageToReplyTo = pages.conversation().getMessage({content: longMessage}); + await pages.conversation().replyToMessage(messageToReplyTo); + await pages.conversation().sendMessage('Reply'); + + // Since the text is truncated using CSS the only reliable way for testing it is truncated is to assert the existence of the show more button + const quoteInReply = pages.conversation().getMessage({content: 'Reply'}).getByTestId('quote-item'); + await expect(quoteInReply.getByRole('button', {name: 'Show more'})).toBeVisible(); + }, + ); + + test('I want to reply to a picture', {tag: ['@TC-3002', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + const {page} = pages.conversation(); + await shareAssetHelper(getImageFilePath(), page, page.getByRole('button', {name: 'Add picture'})); + + const messageWithImage = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(messageWithImage); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await expect(reply.getByTestId('quote-item').getByTestId('image-asset-img')).toBeVisible(); + }); + + test('I want to reply to an audio message', {tag: ['@TC-3003', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + const {page} = pages.conversation(); + await shareAssetHelper(getAudioFilePath(), page, page.getByRole('button', {name: 'Add file'})); + + const messageWithAudio = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(messageWithAudio); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await expect(reply.getByTestId('quote-item').getByTestId('audio-asset')).toBeVisible(); + }); + + test('I want to reply to a video message', {tag: ['@TC-3004', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + const {page} = pages.conversation(); + await shareAssetHelper(getVideoFilePath(), page, page.getByRole('button', {name: 'Add file'})); + + const messageWithVideo = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(messageWithVideo); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await expect(reply.getByTestId('quote-item').getByTestId('video-asset')).toBeVisible(); + }); + + test('I want to reply to a link', {tag: ['@TC-3005', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + await pages.conversation().sendMessage('https://www.lidl.de/'); + + const messageWithLink = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(messageWithLink); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await expect(reply.getByTestId('quote-item').getByTestId('markdown-link')).toBeVisible(); + }); + + test('I want to reply to a file', {tag: ['@TC-3006', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + const {page} = pages.conversation(); + await shareAssetHelper(getTextFilePath(), page, page.getByRole('button', {name: 'Add file'})); + + const messageWithFile = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(messageWithFile); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await expect(reply.getByTestId('quote-item').getByTestId('file-asset')).toBeVisible(); + }); + + test('I want to reply to a reply', {tag: ['@TC-3007', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + await pages.conversation().sendMessage('Message'); + + const message = pages.conversation().getMessage({content: 'Message'}); + await pages.conversation().replyToMessage(message); + await pages.conversation().sendMessage('Reply 1'); + + const reply1 = pages.conversation().getMessage({content: 'Reply 1'}); + await pages.conversation().replyToMessage(reply1); + await pages.conversation().sendMessage('Reply 2'); + + const reply = pages.conversation().getMessage({content: 'Reply 2'}); + await expect(reply.getByTestId('quote-item')).toContainText('Reply 1'); + }); + + test('I want to reply to a link mixed with text', {tag: ['@TC-3008', '@regression']}, async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + await pages.conversation().sendMessage('Link: https://www.lidl.de/'); + + const messageWithLink = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(messageWithLink); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await expect(reply.getByTestId('quote-item')).toContainText('Link: https://www.lidl.de'); + await expect(reply.getByTestId('quote-item').getByTestId('markdown-link')).toBeVisible(); + }); + + test('I want to reply to a location share', {tag: ['@TC-3009', '@regression']}, async ({createPage, api}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + + await test.step('Prerequisite: Send location via TestService', async () => { + const {instanceId} = await api.testService.createInstance( + userA.password, + userA.email, + 'Test Service Device', + false, + ); + const conversationId = await api.conversation.getConversationWithUser(userA.token, userB.id!); + await api.testService.sendLocation(instanceId, conversationId, { + locationName: 'Test Location', + latitude: 52.5170365, + longitude: 13.404954, + zoom: 42, + }); + }); + + const messageWithLink = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(messageWithLink); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await expect(reply.getByTestId('quote-item')).toContainText('Test Location'); + await expect(reply.getByTestId('quote-item').getByRole('link', {name: 'Open Map'})).toBeVisible(); + }); + + test( + 'I want to send a timed message as a reply to any type of a message', + {tag: ['@TC-3011', '@regression']}, + async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + await pages.conversation().sendMessage('Message'); + + const message = pages.conversation().getMessage({sender: userA}); + await pages.conversation().replyToMessage(message); + await pages.conversation().sendTimedMessage('Timed Reply'); + + const reply = pages.conversation().getMessage({content: 'Timed Reply'}); + await expect(reply.getByTestId('quote-item')).toContainText('Message'); + }, + ); + + test( + 'I want to click the quoted message to jump to the original message', + {tag: ['@TC-3013', '@regression']}, + async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + await pages.conversation().sendMessage('Message'); + await pages.conversation().sendMessage('Line\n'.repeat(50)); // Send a message with a lot of lines to test the scrolling behavior + + // .first() is needed as the reply quotes the original message, so we need to make sure the first one is used + const message = pages.conversation().getMessage({content: 'Message', sender: userA}).first(); + await pages.conversation().replyToMessage(message); + await pages.conversation().sendMessage('Reply'); + await expect(message).not.toBeInViewport(); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + await reply + .getByTestId('quote-item') + .getByRole('button', {name: /Original message from/}) + .click(); + + // Validate the chat scrolled up, bringing the original message back into view + await expect(message).toBeInViewport(); + }, + ); + + test( + 'I should not be able to send a reply after I got removed from the conversation', + {tag: ['@TC-3014', '@regression']}, + async ({createPage}) => { + const [userAPages, userBPages] = await Promise.all([ + PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages), + PageManager.from(createPage(withLogin(userB))).then(pm => pm.webapp.pages), + ]); + await createGroup(userAPages, 'Test Group', [userB]); + + await Promise.all([ + userAPages.conversationList().openConversation('Test Group'), + userBPages.conversationList().openConversation('Test Group'), + ]); + + await userAPages.conversation().sendMessage('Message'); + const message = userBPages.conversation().getMessage({content: 'Message', sender: userA}); + await expect(message).toBeVisible(); + + await userAPages.conversation().clickConversationInfoButton(); + await userAPages.conversation().removeMemberFromGroup(userB.fullName); + await new ConfirmModal(userAPages.conversation().page).clickAction(); + await expect(userBPages.conversation().getMessage({content: `${userA.fullName} removed you`})).toBeVisible(); + + await message.hover(); + await expect(message.getByTestId('do-reply-message')).not.toBeAttached(); + }, + ); + + test( + 'I want to reply with mention and tap on the mention in the reply opens the user profile', + {tag: ['@TC-3016', '@regression']}, + async ({createPage}) => { + const pages = (await PageManager.from(createPage(withLogin(userA), withConnectedUser(userB)))).webapp.pages; + + await pages.conversation().sendMessageWithUserMention(userB.fullName, 'Message'); + const message = pages.conversation().getMessage({content: 'Message'}); + + await pages.conversation().replyToMessage(message); + await pages.conversation().sendMessage('Reply'); + + const reply = pages.conversation().getMessage({content: 'Reply'}); + + await reply + .getByTestId('quote-item') + .getByRole('button', {name: `@${userB.fullName}`}) + // There seems to be a bug where clicking the "@" in front of the mention won't do anything, so we have to move the position a bit to the right to hit the name + .click({position: {x: 16, y: 8}}); + + await expect(pages.conversationDetails().conversationDetails).toBeVisible(); + }, + ); +}); diff --git a/test/e2e_tests/test.fixtures.ts b/test/e2e_tests/test.fixtures.ts index 80ae8be7ac6..c41ffbd4341 100644 --- a/test/e2e_tests/test.fixtures.ts +++ b/test/e2e_tests/test.fixtures.ts @@ -148,13 +148,6 @@ export const withConnectedUser = await connectWithUser(pageManager, await user); }; -/** PagePlugin to open a conversation with the given user */ -export const withConversation = - (user: Pick): PagePlugin => - async page => { - await PageManager.from(page).webapp.pages.conversationList().openConversation(user.fullName); - }; - const createUser = async (api: ApiManagerE2E, options?: {disableTelemetry?: boolean}) => { const {disableTelemetry = true} = options ?? {}; From 6dcc1a6c6b251577147767323b4a288f55a6f5ac Mon Sep 17 00:00:00 2001 From: Arjita Date: Tue, 25 Nov 2025 10:12:47 +0100 Subject: [PATCH 05/23] fix: accessibility improvements login(https://wearezeta.atlassian.net/browse/WPB-20819) (#19714) * fix: make password toggle button accessible and localised(WPB-21228) * fix: translate type * fix: Login - New view is not announced * fix: make back button accessible WPB-21466 * fix: make external link 2fa accessible(WPB-21279) * fix: password toggle button text alternative(WPB-21228) * fix: make verify account header focusable using screenkey and login subheading style adjustments * fix: add toggle password show/hide label * fix: address review comments * chore: bump core packages * fix: pipeline issues * fix: PR comments * fix: pipeline issues --- package.json | 3 +- src/i18n/en-US.json | 4 ++ src/script/auth/component/AccountForm.tsx | 4 ++ src/script/auth/component/BackButton.tsx | 12 +++-- src/script/auth/component/ClientItem.tsx | 2 + .../component/JoinGuestLinkPasswordModal.tsx | 2 + src/script/auth/component/LoginForm.tsx | 2 + src/script/auth/component/RouteA11y.tsx | 25 +++++++++ src/script/auth/hooks/useRouteA11y.ts | 53 +++++++++++++++++++ .../auth/page/CreatePersonalAccount.tsx | 4 +- src/script/auth/page/Login.tsx | 52 +++++++++++++++--- src/script/auth/page/Root.tsx | 11 ++++ src/script/auth/page/SetAccountType.tsx | 4 +- src/script/auth/page/SetPassword.tsx | 2 + src/script/auth/page/SingleSignOn.tsx | 8 ++- src/script/auth/page/Success.tsx | 10 ++-- src/script/auth/page/VerifyEmailCode.tsx | 15 ++++-- .../GuestLinkPasswordForm.tsx | 4 ++ .../PasswordAdvancedSecurityForm.tsx | 4 ++ src/types/i18n.d.ts | 8 ++- yarn.lock | 30 +---------- 21 files changed, 201 insertions(+), 58 deletions(-) create mode 100644 src/script/auth/component/RouteA11y.tsx create mode 100644 src/script/auth/hooks/useRouteA11y.ts diff --git a/package.json b/package.json index b3b4b16dfef..48e0f31d86a 100644 --- a/package.json +++ b/package.json @@ -237,7 +237,8 @@ "xml2js": "0.5.0", "@stablelib/utf8": "1.0.2", "dexie-encrypted@2.0.0": "patch:dexie-encrypted@npm%3A2.0.0#./.yarn/patches/dexie-encrypted-npm-2.0.0-eb61eb5975.patch", - "axios": "^1.9.0" + "axios": "^1.9.0", + "js-yaml": "^4.1.0" }, "version": "0.27.0", "packageManager": "yarn@4.1.1" diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index b4b5dbc0845..45f60d255d5 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -270,6 +270,8 @@ "authLoginTitle": "Log in", "authPlaceholderEmail": "Email", "authPlaceholderPassword": "Password", + "showTogglePasswordLabel": "Show password", + "hideTogglePasswordLabel": "Hide password", "authPostedResend": "Resend to {email}", "authPostedResendAction": "No email showing up?", "authPostedResendDetail": "Check your email inbox and follow the instructions.", @@ -1969,6 +1971,8 @@ "verify.headline": "You’ve got mail", "verify.resendCode": "Resend code", "verify.subhead": "Enter the six-digit verification code we sent to{newline}{email}", + "verify.codeLabel": "Six-digit code", + "verify.codePlaceholder": "Input field, enter digit", "videoCallMenuMoreAddReaction": "Add reaction", "videoCallMenuMoreAudioSettings": "Audio Settings", "videoCallMenuMoreChangeView": "Change view", diff --git a/src/script/auth/component/AccountForm.tsx b/src/script/auth/component/AccountForm.tsx index c20b3aa7e86..40e65d4a014 100644 --- a/src/script/auth/component/AccountForm.tsx +++ b/src/script/auth/component/AccountForm.tsx @@ -248,6 +248,8 @@ const AccountFormComponent = ({ placeholder={t('accountForm.passwordPlaceholder')} pattern={ValidationUtil.getNewPasswordPattern(Config.getConfig().NEW_PASSWORD_MINIMUM_LENGTH)} data-uie-name="enter-password" + showTogglePasswordLabel={t('showTogglePasswordLabel')} + hideTogglePasswordLabel={t('hideTogglePasswordLabel')} /> {t('accountForm.passwordHelp', {minPasswordLength: String(Config.getConfig().NEW_PASSWORD_MINIMUM_LENGTH)})} @@ -269,6 +271,8 @@ const AccountFormComponent = ({ placeholder={t('accountForm.confirmPasswordPlaceholder')} pattern={`^${registrationData.password?.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`} data-uie-name="enter-confirm-password" + showTogglePasswordLabel={t('showTogglePasswordLabel')} + hideTogglePasswordLabel={t('hideTogglePasswordLabel')} /> diff --git a/src/script/auth/component/BackButton.tsx b/src/script/auth/component/BackButton.tsx index 31284face23..3bdf333bbb0 100644 --- a/src/script/auth/component/BackButton.tsx +++ b/src/script/auth/component/BackButton.tsx @@ -27,12 +27,14 @@ export const BackButton = () => { const navigate = useNavigate(); return ( - navigate(-1)} - direction="left" - data-uie-name="go-index" aria-label={t('createPersonalAccount.goBack')} - color={COLOR.TEXT} - /> + data-uie-name="go-index" + css={{background: 'none', border: 'none', cursor: 'pointer'}} + > +