diff --git a/e2e-tests/poms/webapp/callCell.page.ts b/e2e-tests/poms/webapp/callCell.page.ts index 38322a2dac9..62cb56e95ce 100644 --- a/e2e-tests/poms/webapp/callCell.page.ts +++ b/e2e-tests/poms/webapp/callCell.page.ts @@ -24,6 +24,7 @@ export const callCell = (page: Page) => { return Object.assign(callCell, { acceptCallButton: callCell.getByRole('button', {name: 'Accept'}), + declineButton: callCell.getByRole('button', {name: 'Hang up'}), goFullScreen: page.locator('[data-uie-name="do-maximize-call"]'), }); }; diff --git a/e2e-tests/poms/webapp/conversation.page.ts b/e2e-tests/poms/webapp/conversation.page.ts index b59dc1c375a..f56f42c5f17 100644 --- a/e2e-tests/poms/webapp/conversation.page.ts +++ b/e2e-tests/poms/webapp/conversation.page.ts @@ -24,7 +24,7 @@ import {User} from '../../actions/createUser'; export const conversation = (page: Page) => { const conversationTitle = page.getByTestId('status-conversation-title-bar-label'); const startCallButton = page.getByTestId('do-call'); - + const systemMessages = page.locator('[data-uie-name="item-message"].system-message:not([data-uie-send-status="1"])'); /** * The attribute 'send-status' will be 1 while the message is being sent, since we only want to assert on sent messages these messages will be excluded. See: {@see StatusTypes} * Status type -1 ensures that system messages do NOT count as sent messages @@ -67,6 +67,7 @@ export const conversation = (page: Page) => { return { conversationTitle, startCallButton, + systemMessages, sendMessage, getMessage, }; diff --git a/e2e-tests/poms/webapp/conversationList.page.ts b/e2e-tests/poms/webapp/conversationList.page.ts index c26f2336f60..444429f7f9e 100644 --- a/e2e-tests/poms/webapp/conversationList.page.ts +++ b/e2e-tests/poms/webapp/conversationList.page.ts @@ -20,8 +20,10 @@ import {Page} from '@playwright/test'; export const conversationsList = (page: Page) => { + const items = page.getByTestId('item-conversation'); + const getConversation = (conversationName: string, options?: {protocol?: 'mls' | 'proteus'}) => { - let conversation = page.getByTestId('item-conversation').filter({hasText: conversationName}); + let conversation = items.filter({hasText: conversationName}); if (options?.protocol) { conversation = conversation.and(page.locator(`[data-protocol="${options.protocol}"]`)); @@ -39,6 +41,7 @@ export const conversationsList = (page: Page) => { const createGroupButton = page.getByTestId('conversation-list-header').getByTestId('go-create-group'); return { + items, getConversation, createGroupButton, }; diff --git a/e2e-tests/poms/webapp/conversationsSidebar.page.ts b/e2e-tests/poms/webapp/conversationsSidebar.page.ts index 99d5b42401f..7b583b9d8ac 100644 --- a/e2e-tests/poms/webapp/conversationsSidebar.page.ts +++ b/e2e-tests/poms/webapp/conversationsSidebar.page.ts @@ -25,5 +25,6 @@ export const conversationsSidebar = (page: Page) => { return { userAvatar: sidebar.getByTestId('element-avatar-user'), connectButton: sidebar.getByTestId('go-people'), + archiveButton: sidebar.getByTestId('go-archive'), }; }; diff --git a/e2e-tests/poms/webapp/settings.page.ts b/e2e-tests/poms/webapp/settings.page.ts new file mode 100644 index 00000000000..18e8965d15e --- /dev/null +++ b/e2e-tests/poms/webapp/settings.page.ts @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2026 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'; + +export const settingsPage = (page: Page) => { + return { + accountButton: page.getByRole('button', {name: 'Account'}), + }; +}; diff --git a/e2e-tests/specs/regression/menuBar.spec.ts b/e2e-tests/specs/regression/menuBar.spec.ts new file mode 100644 index 00000000000..dd76cdfa2b5 --- /dev/null +++ b/e2e-tests/specs/regression/menuBar.spec.ts @@ -0,0 +1,276 @@ +/* + * Wire + * Copyright (C) 2026 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 {MenuItem} from 'electron'; + +import {conversationsList} from './../../poms/webapp/conversationList.page'; + +import {connectWithUser} from '../../actions/connectWithUser'; +import {type App} from '../../actions/createApp'; +import {createGroup} from '../../actions/createGroup'; +import {loginUser} from '../../actions/loginUser'; +import {test, expect, Page} from '../../fixtures'; +import {callCell} from '../../poms/webapp/callCell.page'; +import {conversation} from '../../poms/webapp/conversation.page'; +import {conversationsSidebar} from '../../poms/webapp/conversationsSidebar.page'; +import {loginPage} from '../../poms/webapp/login.page'; +import {settingsPage} from '../../poms/webapp/settings.page'; + +/** + * Triggers an Electron application menu item by matching its label. + * Supports cross-platform variants by accepting either a single string or an array of strings. + * * @param {object} app - The Playwright Electron application instance + * @param {string[]} labels - The menu label(s) to match against (e.g., 'Settings' or ['Preferences', 'Settings']) + * @returns A Promise resolving to the serialized accelerator string of the clicked MenuItem + */ + +const triggerApplicationMenu = async (app: App, labels: string[]): Promise> => { + return await app.evaluate(async ({Menu, BrowserWindow}, targets) => { + const menu = Menu.getApplicationMenu(); + + const target = menu?.items.flatMap(item => item.submenu?.items ?? []).find(item => targets.includes(item.label)); + + if (!target) { + throw new Error('Menu item not found'); + } + + const targetWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + if (!targetWindow) { + throw new Error('No Electron window found to send the menu event to'); + } + + // Programmatically trigger the menu item's click action + target.click(target, targetWindow); + return { + accelerator: target.accelerator, + }; + }, labels); +}; + +test.describe('Menu Bar', () => { + test('Open preferences/settings with menu bar', {tag: ['@TC-11010', '@regression']}, async ({app, createUser}) => { + const user = await createUser(); + await loginUser(app.page, user); + + // Access the native Electron application menu and click the appropriate item + const menuItem = await triggerApplicationMenu(app, ['Preferences', 'Settings']); + + expect(menuItem.accelerator).toMatch(/^(Command\+,|Ctrl\+,)$/); + await expect(settingsPage(app.page).accountButton).toBeVisible(); + }); + + test( + 'Verify switching to next and previous conversation using menu bar', + {tag: ['@TC-11068', '@regression']}, + async ({app, createUser}) => { + const user = await createUser(); + await loginUser(app.page, user); + + await test.step('Create multiple group conversations', async () => { + await createGroup(app.page, 'Group 1', []); + await createGroup(app.page, 'Group 2', []); + await expect(conversationsList(app.page).items).toHaveCount(2); + }); + + await test.step('Verify navigation to the next conversation and validate its keyboard shortcut', async () => { + await conversationsList(app.page).getConversation('Group 1').open(); + const menuItem = await triggerApplicationMenu(app, ['Next Conversation']); + + expect(menuItem.accelerator).toMatch(/^(Alt\+(Cmd|Shift)\+Up)$/); + await expect(conversation(app.page).conversationTitle).toContainText('Group 2'); + }); + + await test.step('Navigate to the previous conversation via the application menu', async () => { + const menuItem = await triggerApplicationMenu(app, ['Previous Conversation']); + + expect(menuItem.accelerator).toMatch(/^(Alt\+(Cmd|Shift)\+Down)$/); + await expect(conversation(app.page).conversationTitle).toContainText('Group 1'); + }); + }, + ); + + test( + 'Verify I can create a group conversation with menu bar', + {tag: ['@TC-11066', '@regression']}, + async ({app, createUser}) => { + const user = await createUser(); + await loginUser(app.page, user); + + const menuItem = await triggerApplicationMenu(app, ['Create Group']); + + expect(menuItem.accelerator).toBe('CmdOrCtrl+N'); + await expect(app.page.getByRole('dialog').getByText('Create group')).toBeVisible(); + }, + ); + + test('Sign out with menu bar', {tag: ['@TC-11041', '@regression']}, async ({app, createUser}) => { + const user = await createUser(); + await loginUser(app.page, user); + + await triggerApplicationMenu(app, ['Log Out']); + await app.page.getByRole('dialog').getByRole('button', {name: 'Log out'}).click(); + await expect(loginPage(app.page).loginButton).toBeVisible(); + }); + + test( + 'Verify adding people to the conversation with menu bar', + {tag: ['@TC-11046', '@regression']}, + async ({app, createUser}) => { + const user = await createUser(); + await loginUser(app.page, user); + + await createGroup(app.page, 'Test group', []); + await conversationsList(app.page).getConversation('Test group').open(); + + const menuItem = await triggerApplicationMenu(app, ['Add People...']); + + expect(menuItem.accelerator).toBe('Shift+CmdOrCtrl+K'); + await expect(app.page.getByRole('complementary').filter({hasText: 'Add participants'})).toBeVisible(); + }, + ); + + test( + 'Archive a 1:1 and a group conversation with menu bar', + {tag: ['@TC-11067', '@regression']}, + async ({app, createUser, createTeam, createPage}) => { + const userB = await createUser(); + const {owner: userA} = await createTeam('Test Team', {users: [userB]}); + const userAPage = app.page; + const userBPage = await createPage(); + + await Promise.all([loginUser(userAPage, userA), loginUser(userBPage, userB)]); + await connectWithUser(userAPage, userB); + + await createGroup(app.page, 'Test group', []); + await expect(conversationsList(app.page).items).toHaveCount(2); + + await test.step('Archive in 1:1 conversation', async () => { + await conversationsList(app.page).getConversation(userB.fullName, {protocol: 'mls'}).open(); + const menuItem = await triggerApplicationMenu(app, ['Archive']); + + expect(menuItem.accelerator).toBe('CmdOrCtrl+D'); + await expect(conversationsList(app.page).getConversation(userB.fullName, {protocol: 'mls'})).not.toBeVisible(); + await expect(conversationsList(app.page).items).toHaveCount(1); + }); + + await test.step('Archive group conversation', async () => { + await conversationsList(userAPage).getConversation('Test group').open(); + await triggerApplicationMenu(app, ['Archive']); + await expect(conversationsList(app.page).getConversation(userB.fullName, {protocol: 'mls'})).not.toBeVisible(); + await expect(conversationsList(app.page).items).toHaveCount(0); + }); + + await test.step('Confirm that conversations were moved to archive folder', async () => { + await conversationsSidebar(userAPage).archiveButton.click(); + await expect(conversationsList(app.page).items).toHaveCount(2); + }); + }, + ); + + const testCases = [ + { + name: 'Delete conversation content with menu bar', + tag: '@TC-11042', + menuItem: 'Delete Content...', + verifyDirect: async (page: Page) => { + await expect(page.getByRole('dialog').getByText('Clear content?')).toBeVisible(); + await page.getByRole('dialog').getByRole('button', {name: 'Cancel'}).click(); + }, + verifyGroup: async (page: Page) => { + await expect(page.getByRole('dialog').getByText('Clear content?')).toBeVisible(); + }, + }, + { + name: 'Verify I can ping 1:1 and group conversation using the menu bar', + tag: '@TC-11043', + menuItem: 'Ping', + verifyDirect: async (page: Page) => { + await expect(conversation(page).systemMessages.filter({hasText: 'You pinged'})).toBeVisible(); + }, + verifyGroup: async (page: Page) => { + await expect(conversation(page).systemMessages.filter({hasText: 'You pinged'})).toBeVisible(); + }, + }, + { + name: 'Verify starting a call via the menu bar', + tag: '@TC-11044', + menuItem: 'Call', + verifyDirect: async (page: Page) => { + await expect(callCell(page)).toBeVisible(); + await callCell(page).declineButton.click(); + }, + verifyGroup: async (page: Page) => { + await expect(callCell(page)).toBeVisible(); + }, + }, + ]; + + testCases.forEach(({name, tag, menuItem, verifyDirect, verifyGroup}) => { + test(name, {tag: [tag, '@regression']}, async ({app, createUser, createTeam, createPage}) => { + const userB = await createUser(); + const {owner: userA} = await createTeam('Test Team', {users: [userB]}); + const userAPage = app.page; + const userBPage = await createPage(); + + await Promise.all([loginUser(userAPage, userA), loginUser(userBPage, userB)]); + await connectWithUser(userAPage, userB); + + await test.step('User A actions in 1:1 conversation', async () => { + await conversationsList(userAPage).getConversation(userB.fullName, {protocol: 'mls'}).open(); + await triggerApplicationMenu(app, [menuItem]); + await verifyDirect(app.page); + }); + + await test.step('User A actions in group conversation', async () => { + await createGroup(app.page, 'Test group', [userB]); + await conversationsList(userAPage).getConversation('Test group').open(); + await triggerApplicationMenu(app, [menuItem]); + await verifyGroup(app.page); + }); + }); + }); + + test( + 'Verify opening people popover with menu bar in the conversation', + {tag: ['@TC-11045', '@regression']}, + async ({app, createUser, createTeam, createPage}) => { + const userB = await createUser(); + const {owner: userA} = await createTeam('Test Team', {users: [userB]}); + const userAPage = app.page; + const userBPage = await createPage(); + + await Promise.all([loginUser(userAPage, userA), loginUser(userBPage, userB)]); + await connectWithUser(userAPage, userB); + + await test.step('User A can open people popover in 1:1 conversation', async () => { + await conversationsList(userAPage).getConversation(userB.fullName, {protocol: 'mls'}).open(); + const menuResult = await triggerApplicationMenu(app, ['People']); + expect(menuResult?.accelerator).toBe('CmdOrCtrl+I'); + await expect(app.page.getByTestId('status-profile-picture')).toBeVisible(); + }); + + await test.step('User A can open conversation details in group conversation', async () => { + await createGroup(app.page, 'Test group', [userB]); + await conversationsList(userAPage).getConversation('Test group').open(); + await triggerApplicationMenu(app, ['People']); + await expect(app.page.getByTestId('list-users')).toBeVisible(); + }); + }, + ); +});