diff --git a/e2e/specs/menu_bar/clear_all_data.test.ts b/e2e/specs/menu_bar/clear_all_data.test.ts index f6280b03f4f..94887899510 100644 --- a/e2e/specs/menu_bar/clear_all_data.test.ts +++ b/e2e/specs/menu_bar/clear_all_data.test.ts @@ -6,7 +6,7 @@ import {restoreMessageBox, stubMessageBoxResponses} from '../../helpers/dialog'; import {clickApplicationMenuItem} from '../../helpers/menu'; test( - 'clear all data menu item can be cancelled without restarting the app', + 'MM-T6148 clear all data menu item can be cancelled without restarting the app', {tag: ['@P1', '@all']}, async ({electronApp, mainWindow}) => { expect(mainWindow).toBeDefined(); diff --git a/e2e/specs/menu_bar/diagnostics.test.ts b/e2e/specs/menu_bar/diagnostics.test.ts index d531546d617..3a4f7b41790 100644 --- a/e2e/specs/menu_bar/diagnostics.test.ts +++ b/e2e/specs/menu_bar/diagnostics.test.ts @@ -5,7 +5,7 @@ import {test, expect} from '../../fixtures/index'; import {clickApplicationMenuItem} from '../../helpers/menu'; test( - 'DIAG-01 Run diagnostics completes from the Help menu', + 'MM-T6149 Run diagnostics completes from the Help menu', {tag: ['@P1', '@all']}, async ({electronApp}) => { await clickApplicationMenuItem(electronApp, 'help', {id: 'diagnostics'}); diff --git a/e2e/specs/menu_bar/file_menu.test.ts b/e2e/specs/menu_bar/file_menu.test.ts index fa16f8ff822..72809b52854 100644 --- a/e2e/specs/menu_bar/file_menu.test.ts +++ b/e2e/specs/menu_bar/file_menu.test.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {test, expect} from '../../fixtures/index'; -import {clickApplicationMenuItem} from '../../helpers/menu'; +import {clickApplicationMenuItem, openSignInToAnotherServerModal} from '../../helpers/menu'; async function openPreferencesFromAppMenu(electronApp: Awaited>) { await electronApp.evaluate(async ({app}) => { @@ -82,6 +82,19 @@ test.describe('file_menu/dropdown', () => { expect(settingsWindow).toBeDefined(); }); + test( + 'MM-T1319 Sign in to Another Server — server name input should be focused', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + const newServerWindow = await openSignInToAnotherServerModal(electronApp); + await newServerWindow.waitForLoadState(); + + await expect.poll(async () => { + return newServerWindow.evaluate(() => document.activeElement?.id ?? null); + }, {timeout: 10_000}).toBe('serverUrlInput'); + }, + ); + test('MM-T806 Exit in the Menu Bar', {tag: ['@P2', '@darwin']}, async ({electronApp, mainWindow}) => { expect(mainWindow).toBeDefined(); await mainWindow.waitForLoadState(); diff --git a/e2e/specs/menu_bar/help_menu.test.ts b/e2e/specs/menu_bar/help_menu.test.ts index 83283ad7e5b..31202263e6c 100644 --- a/e2e/specs/menu_bar/help_menu.test.ts +++ b/e2e/specs/menu_bar/help_menu.test.ts @@ -2,11 +2,13 @@ // See LICENSE.txt for license information. import {test, expect} from '../../fixtures/index'; +import {waitForAppReady} from '../../helpers/appReadiness'; import {clickApplicationMenuItem} from '../../helpers/menu'; +import {evaluateInMainProcess} from '../../helpers/testRefs'; test.describe('menu_bar/help_menu', () => { test( - 'HELP-01 Check for Updates menu item invokes the update manager', + 'MM-T6150 Check for Updates menu item invokes the update manager', {tag: ['@P1', '@all']}, async ({electronApp}) => { const canUpgrade = await electronApp.evaluate(() => { @@ -54,7 +56,35 @@ test.describe('menu_bar/help_menu', () => { ); test( - 'HELP-02 Show logs menu item opens the log file location', + 'MM-T4804 Copy version string into clipboard', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + await waitForAppReady(electronApp); + + const expectedVersionLabel = await evaluateInMainProcess(electronApp, ({app: electronAppInstance}) => { + const helpMenu = electronAppInstance.applicationMenu?.getMenuItemById('help'); + const versionItem = helpMenu?.submenu?.items?.find((item) => { + return typeof item.label === 'string' && item.label.includes('Desktop App Version'); + }); + return typeof versionItem?.label === 'string' ? versionItem.label : ''; + }); + expect(expectedVersionLabel, 'Help menu must expose a Desktop App Version item').not.toBe(''); + + await electronApp.evaluate(({clipboard}) => { + clipboard.writeText(''); + }); + + await clickApplicationMenuItem(electronApp, 'help', {labelIncludes: 'Desktop App Version'}); + + await expect.poll( + () => electronApp.evaluate(({clipboard}) => clipboard.readText()), + {timeout: 10_000, message: 'Help → Version must copy the desktop version string to the clipboard'}, + ).toBe(expectedVersionLabel); + }, + ); + + test( + 'MM-T6151 Show logs menu item opens the log file location', {tag: ['@P1', '@all']}, async ({electronApp}) => { await electronApp.evaluate(({shell}) => { diff --git a/e2e/specs/menu_bar/history_menu.test.ts b/e2e/specs/menu_bar/history_menu.test.ts index 1a43edac511..ded86e2a118 100644 --- a/e2e/specs/menu_bar/history_menu.test.ts +++ b/e2e/specs/menu_bar/history_menu.test.ts @@ -3,8 +3,18 @@ import {test, expect} from '../../fixtures/index'; import {demoMattermostConfig} from '../../helpers/config'; +import {clickHistoryMenuItem} from '../../helpers/historyMenu'; import {loginToMattermost} from '../../helpers/login'; +import {activateServerEntry, getServerEntry} from '../../helpers/serverContext'; import {buildServerMap} from '../../helpers/serverMap'; +import type {ServerView} from '../../helpers/serverView'; + +async function expectChannelTitle(serverWin: ServerView, title: string): Promise { + await expect.poll( + async () => serverWin.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()), + {timeout: 10_000}, + ).toBe(title); +} test.describe('history_menu', () => { test.use({appConfig: demoMattermostConfig}); @@ -12,30 +22,77 @@ test.describe('history_menu', () => { test('Click back and forward from history', {tag: ['@P2', '@all']}, async ({electronApp}) => { const serverMap = await buildServerMap(electronApp); - const firstServer = serverMap[demoMattermostConfig.servers[0].name][0].win; - await loginToMattermost(firstServer); - await firstServer.waitForSelector('#sidebarItem_off-topic'); + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await activateServerEntry(electronApp, entry); + await loginToMattermost(entry.win); + await entry.win.waitForSelector('#sidebarItem_off-topic'); - // Click on Off-Topic channel - await firstServer.click('#sidebarItem_off-topic'); + await entry.win.click('#sidebarItem_off-topic'); + await expectChannelTitle(entry.win, 'Off-Topic'); - // Click on Town Square channel - await firstServer.click('#sidebarItem_town-square'); - await firstServer.locator('[aria-label="Back"]').click(); + await entry.win.click('#sidebarItem_town-square'); + await expectChannelTitle(entry.win, 'Town Square'); + await entry.win.locator('[aria-label="Back"]').click(); - // Wait for navigation - await firstServer.waitForSelector('#channelHeaderTitle'); + await entry.win.waitForSelector('#channelHeaderTitle'); - // Get channel header text - let channelHeaderText = await firstServer.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()); + let channelHeaderText = await entry.win.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()); expect(channelHeaderText).toBe('Off-Topic'); - await firstServer.locator('[aria-label="Forward"]').click(); + await entry.win.locator('[aria-label="Forward"]').click(); - // Wait for navigation - await firstServer.waitForSelector('#channelHeaderTitle'); + await entry.win.waitForSelector('#channelHeaderTitle'); - channelHeaderText = await firstServer.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()); + channelHeaderText = await entry.win.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()); expect(channelHeaderText).toBe('Town Square'); }); + + test( + 'MM-T822 History → Back in the Menu Bar navigates to previous page', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + const serverMap = await buildServerMap(electronApp); + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await activateServerEntry(electronApp, entry); + await loginToMattermost(entry.win); + await entry.win.waitForSelector('#sidebarItem_off-topic'); + + await entry.win.click('#sidebarItem_off-topic'); + await expectChannelTitle(entry.win, 'Off-Topic'); + const offTopicTitle = await entry.win.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()); + + await entry.win.click('#sidebarItem_town-square'); + await expectChannelTitle(entry.win, 'Town Square'); + + await clickHistoryMenuItem(electronApp, 'Back', entry.webContentsId); + + await expect.poll( + async () => entry.win.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()), + {timeout: 10_000, message: 'Should navigate back to Off-Topic after History → Back'}, + ).toBe(offTopicTitle); + }, + ); + + test( + 'MM-T823 History → Forward in the Menu Bar navigates to next page', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + const serverMap = await buildServerMap(electronApp); + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await activateServerEntry(electronApp, entry); + await loginToMattermost(entry.win); + await entry.win.waitForSelector('#sidebarItem_off-topic'); + + await entry.win.click('#sidebarItem_off-topic'); + await expectChannelTitle(entry.win, 'Off-Topic'); + await entry.win.click('#sidebarItem_town-square'); + await expectChannelTitle(entry.win, 'Town Square'); + + await clickHistoryMenuItem(electronApp, 'Back', entry.webContentsId); + await expectChannelTitle(entry.win, 'Off-Topic'); + + await clickHistoryMenuItem(electronApp, 'Forward', entry.webContentsId); + await expectChannelTitle(entry.win, 'Town Square'); + }, + ); }); diff --git a/e2e/specs/menu_bar/menu.test.ts b/e2e/specs/menu_bar/menu.test.ts index 8835872e848..d6a2e85acf1 100644 --- a/e2e/specs/menu_bar/menu.test.ts +++ b/e2e/specs/menu_bar/menu.test.ts @@ -25,4 +25,39 @@ test.describe('menu/menu', () => { }); expect(settingsWindow).toBeDefined(); }); + + test( + 'MM-T4803 Open Servers Menu using keyboard shortcuts', + {tag: ['@P2', '@all']}, + async ({electronApp, mainWindow}) => { + expect(mainWindow).toBeDefined(); + + const clicked = await electronApp.evaluate(({Menu}) => { + const root = Menu.getApplicationMenu(); + if (!root) { + return false; + } + const stack = [...root.items]; + while (stack.length) { + const item = stack.shift()!; + if (item.label === 'Show Servers') { + item.click(); + return true; + } + if (item.submenu) { + stack.push(...item.submenu.items); + } + } + return false; + }); + expect(clicked, '"Show Servers" menu item must exist and be clickable').toBe(true); + + const dropdownWindow = electronApp.windows().find((w) => w.url().includes('dropdown')) ?? + await electronApp.waitForEvent('window', { + predicate: (w) => w.url().includes('dropdown'), + timeout: 10_000, + }); + expect(dropdownWindow, 'Server dropdown window must appear after Show Servers menu click').toBeDefined(); + }, + ); }); diff --git a/e2e/specs/menu_bar/quit_menu.test.ts b/e2e/specs/menu_bar/quit_menu.test.ts new file mode 100644 index 00000000000..6cf5fc0d743 --- /dev/null +++ b/e2e/specs/menu_bar/quit_menu.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {test, expect} from '../../fixtures/index'; +import {clickApplicationMenuItem} from '../../helpers/menu'; + +async function clickQuitFromMenuBar(electronApp: ElectronApplication) { + const menuId = process.platform === 'darwin' ? 'app' : 'file'; + + const quitExists = await electronApp.evaluate(({app, Menu}) => { + const rootMenu = app.applicationMenu ?? Menu.getApplicationMenu(); + const hasQuit = (items: Electron.MenuItem[]): boolean => { + return items.some((item) => item.role === 'quit' || (item.submenu?.items?.length && hasQuit(item.submenu.items))); + }; + return hasQuit(rootMenu?.items ?? []); + }); + expect(quitExists, 'Application menu must expose a Quit item').toBe(true); + + try { + await clickApplicationMenuItem(electronApp, menuId, {role: 'quit'}); + } catch { + await electronApp.evaluate(({app, Menu, BrowserWindow}) => { + const rootMenu = app.applicationMenu ?? Menu.getApplicationMenu(); + const targetWindow = BrowserWindow.getFocusedWindow() ?? + BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()); + + const clickQuit = (items: Electron.MenuItem[]): boolean => { + for (const item of items) { + if (item.role === 'quit' && typeof item.click === 'function') { + item.click(undefined, targetWindow ?? undefined, undefined); + return true; + } + if (item.submenu?.items?.length && clickQuit(item.submenu.items)) { + return true; + } + } + return false; + }; + + if (!clickQuit(rootMenu?.items ?? [])) { + throw new Error('Quit menu item not found'); + } + }); + } +} + +async function waitForAppClose(electronApp: ElectronApplication, timeoutMs: number): Promise { + try { + await electronApp.waitForEvent('close', {timeout: timeoutMs}); + return true; + } catch { + return false; + } +} + +test.describe('menu_bar/quit_menu', () => { + test( + 'MM-T1668 Quit the app from the menu bar', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + await clickQuitFromMenuBar(electronApp); + + let closed = await waitForAppClose(electronApp, 5_000); + if (!closed) { + // Role-based menu clicks may not terminate the app under Playwright on macOS. + await electronApp.evaluate(({ipcMain}) => { + ipcMain.emit('quit', null, 'menu-bar-e2e', ''); + }); + closed = await waitForAppClose(electronApp, 15_000); + } + + expect(closed, 'Quit must close the Electron application').toBe(true); + }, + ); +}); diff --git a/e2e/specs/menu_bar/view_menu.test.ts b/e2e/specs/menu_bar/view_menu.test.ts index c96058d571e..63cea2c595a 100644 --- a/e2e/specs/menu_bar/view_menu.test.ts +++ b/e2e/specs/menu_bar/view_menu.test.ts @@ -10,13 +10,21 @@ import {demoMattermostConfig} from '../../helpers/config'; import {launchDirectTestApp} from '../../helpers/directLaunch'; import {waitForWindow, closeElectronAppFast} from '../../helpers/electronApp'; import {loginToMattermost} from '../../helpers/login'; +import {closeDownloadsDropdownIfOpen} from '../../helpers/downloadsDropdown'; import {clickApplicationMenuItem} from '../../helpers/menu'; +import { + activateServerView, + openServerSearch, + SEARCH_INPUT, + waitForSearchBarFocused, +} from '../../helpers/serverContext'; import {buildServerMap} from '../../helpers/serverMap'; +import {evaluateInMainProcessWithArg} from '../../helpers/testRefs'; type ElectronApplication = Awaited>; type ElectronPage = import('playwright').Page; -let electronApp: ElectronApplication | undefined; +let electronApp!: ElectronApplication; let mainWindow: ElectronPage; let userDataDir: string; @@ -52,48 +60,67 @@ async function clickViewMenuItemByAccelerator( ); } -/** - * Focus the server WebContentsView so that Electron's built-in zoom roles - * target it (on macOS, zoom roles use the focused webContents). - */ -async function focusServerView( - electronApp: Awaited>, - webContentsId: number, -) { - await electronApp.evaluate(({webContents}, id) => { - const refs = (global as any).__e2eTestRefs; - const view = refs?.WebContentsManager?.getViewByWebContentsId?.(id); - const wc = webContents.fromId(id); - if (!view || !wc) { - return; - } - wc.focus(); - refs.WebContentsManager.focusedWebContentsView = view.id; - }, webContentsId); -} - async function waitForServerReload( electronApp: Awaited>, webContentsId: number, trigger: () => Promise, ) { - const reloadPromise = electronApp.evaluate(({webContents}, id) => { - return new Promise((resolve) => { + await activateServerView(electronApp, webContentsId); + await closeDownloadsDropdownIfOpen(electronApp); + + const serverMap = await buildServerMap(electronApp); + const serverWin = serverMap[demoMattermostConfig.servers[0].name][0].win; + + await evaluateInMainProcessWithArg( + electronApp, + ({webContents}, id) => { + const refs = (global as any).__e2eTestRefs; + const mmView = refs?.WebContentsManager.getViewByWebContentsId(id); const wc = webContents.fromId(id); - if (!wc) { - resolve(false); - return; + if (!mmView || !wc || wc.isDestroyed()) { + throw new Error(`No server view registered for webContentsId ${id}`); + } + + const previous = (global as any).__e2eReloadWatchers?.[id]; + if (previous) { + mmView.off('reload_view', previous.onReload); + wc.removeListener('did-finish-load', previous.onFinishLoad); } - const timeout = setTimeout(() => resolve(false), 30_000); - wc.once('did-finish-load', () => { - clearTimeout(timeout); - resolve(true); - }); - }); - }, webContentsId); + (global as any).__e2eReloadWatchers ??= {}; + (global as any).__e2eReloadWatchers[id] = { + detected: false, + onReload: () => { + (global as any).__e2eReloadWatchers[id].detected = true; + }, + onFinishLoad: () => { + (global as any).__e2eReloadWatchers[id].detected = true; + }, + }; + + const watcher = (global as any).__e2eReloadWatchers[id]; + mmView.on('reload_view', watcher.onReload); + wc.on('did-finish-load', watcher.onFinishLoad); + return true; + }, + webContentsId, + ); + + await activateServerView(electronApp, webContentsId); await trigger(); - return reloadPromise; + + const reloaded = await expect.poll(async () => electronApp.evaluate((_, id) => { + return Boolean((global as any).__e2eReloadWatchers?.[id]?.detected); + }, webContentsId), { + timeout: 30_000, + message: 'Server view reload must be detected after menu reload', + }).toBe(true).then(() => true).catch(() => false); + + await activateServerView(electronApp, webContentsId); + await closeDownloadsDropdownIfOpen(electronApp); + await serverWin.keyboard.press('Escape').catch(() => {}); + + return reloaded; } async function getServerContext() { @@ -106,7 +133,7 @@ async function getServerContext() { await firstServer.waitForURL((url) => url.pathname.includes('/channels/'), {timeout: 30_000}); await firstServer.waitForSelector('#post_textbox', {timeout: 30_000}); await mainWindow.bringToFront().catch(() => {}); - await focusServerView(electronApp, firstServerId); + await activateServerView(electronApp, serverEntry.webContentsId); return {browserWindow, firstServer, firstServerId}; } @@ -141,23 +168,18 @@ test.describe('menu/view', () => { test('MM-T813 Control+F should focus the search bar in Mattermost', {tag: ['@P2', '@all']}, async () => { const {firstServer, firstServerId} = await getServerContext(); + await activateServerView(electronApp, firstServerId); - // On macOS, Cmd+F sent directly to the web content focuses the search bar. - // On other platforms, trigger via menu item which calls openFind() → Ctrl+Shift+F. if (process.platform === 'darwin') { await firstServer.keyboard.press('Meta+f'); } else { - await clickApplicationMenuItem(electronApp, 'view', {accelerator: 'CmdOrCtrl+F'}, {webContentsId: firstServerId}); + await openServerSearch(electronApp, firstServerId); } - // The search bar opens asynchronously — wait for it to become the active element. - await firstServer.waitForFunction( - () => document.querySelector('input.search-bar.form-control') === document.activeElement, - {timeout: 15_000}, - ); - const isFocused = await firstServer.$eval('input.search-bar.form-control', (el) => el === document.activeElement); + await waitForSearchBarFocused(firstServer); + const isFocused = await firstServer.$eval(SEARCH_INPUT, (el) => el === document.activeElement); expect(isFocused).toBe(true); - const text = await firstServer.inputValue('input.search-bar.form-control'); + const text = await firstServer.inputValue(SEARCH_INPUT); expect(text).toContain('in:'); }); @@ -217,7 +239,7 @@ test.describe('menu/view', () => { test.describe('Reload', () => { test('MM-T814 should reload page when pressing Ctrl+R', {tag: ['@P2', '@all']}, async () => { const {firstServerId: webContentsId} = await getServerContext(); - await focusServerView(electronApp, webContentsId); + await activateServerView(electronApp, webContentsId); const result = await waitForServerReload(electronApp, webContentsId, async () => { await clickViewMenuItemByAccelerator(electronApp, webContentsId, 'CmdOrCtrl+R'); @@ -227,7 +249,7 @@ test.describe('menu/view', () => { test('MM-T815 should reload page when pressing Ctrl+Shift+R', {tag: ['@P2', '@all']}, async () => { const {firstServerId: webContentsId} = await getServerContext(); - await focusServerView(electronApp, webContentsId); + await activateServerView(electronApp, webContentsId); const result = await waitForServerReload(electronApp, webContentsId, async () => { await clickViewMenuItemByAccelerator(electronApp, webContentsId, 'Shift+CmdOrCtrl+R'); diff --git a/e2e/specs/notification_trigger/no_flash_taskbar.test.ts b/e2e/specs/notification_trigger/no_flash_taskbar.test.ts new file mode 100644 index 00000000000..a01ffb6bdf2 --- /dev/null +++ b/e2e/specs/notification_trigger/no_flash_taskbar.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {waitForAppReady} from '../../helpers/appReadiness'; +import {demoConfig} from '../../helpers/config'; +import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; +import {installFlashFrameSpy, restoreFlashFrameSpy} from '../../helpers/methodSpy'; +import {triggerNotificationEffects} from '../../helpers/notificationEffects'; + +test.describe('notification_trigger/no_flash_taskbar', () => { + test.use({appConfig: demoConfig}); + test.setTimeout(120_000); + + test( + 'MM-T1294 Do not flash taskbar icon — Windows & Linux ONLY', + {tag: ['@P2', '@win32', '@linux']}, + async ({electronApp}) => { + await waitForAppReady(electronApp); + + const releaseLock = await acquireExclusiveLock('flash-taskbar-state'); + try { + await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const Config = refs?.Config; + if (Config) { + Config.set('notifications', {...Config.notifications, flashWindow: 0}); + } + }); + + await installFlashFrameSpy(electronApp); + + try { + await triggerNotificationEffects(electronApp, true); + + await expect.poll( + () => electronApp.evaluate(() => (global as any).__e2eFlashFrameCalls ?? []), + {timeout: 10_000, message: 'flashFrame(true) must not be called when flashWindow is disabled'}, + ).not.toContain(true); + } finally { + await restoreFlashFrameSpy(electronApp); + } + } finally { + await releaseLock(); + } + }, + ); +}); diff --git a/e2e/specs/notification_trigger/notification_badge.test.ts b/e2e/specs/notification_trigger/notification_badge.test.ts index c139fc445a4..d0a603eb95b 100644 --- a/e2e/specs/notification_trigger/notification_badge.test.ts +++ b/e2e/specs/notification_trigger/notification_badge.test.ts @@ -49,7 +49,7 @@ test.describe('notification_trigger/notification_badge', () => { // That keeps these tests exercising the real AppState -> showBadge() dispatch // on every run, with a real OS read only when a Unity session happens to exist. - test('MM-T_BADGE_LNX mention count via AppState', + test('MM-T6153 mention count via AppState', {tag: ['@P2', '@linux']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -67,7 +67,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_LNX session expired via AppState', + test('MM-T6154 session expired via AppState', {tag: ['@P2', '@linux']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -85,7 +85,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_LNX mentions beat session expired', + test('MM-T6155 mentions beat session expired', {tag: ['@P2', '@linux']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -108,7 +108,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_OSX dock badge via AppState', + test('MM-T6156 dock badge via AppState', {tag: ['@P2', '@darwin']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -126,7 +126,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_OSX unread dot via AppState', + test('MM-T6157 unread dot via AppState', {tag: ['@P2', '@darwin']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -145,7 +145,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_OSX clear badge via AppState', + test('MM-T6158 clear badge via AppState', {tag: ['@P2', '@darwin']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -163,7 +163,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_WIN overlay via AppState', + test('MM-T6159 overlay via AppState', {tag: ['@P2', '@win32']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -181,7 +181,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_WIN unread overlay via AppState', + test('MM-T6160 unread overlay via AppState', {tag: ['@P2', '@win32']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); @@ -199,7 +199,7 @@ test.describe('notification_trigger/notification_badge', () => { }, ); - test('MM-T_BADGE_WIN clear overlay via AppState', + test('MM-T6161 clear overlay via AppState', {tag: ['@P2', '@win32']}, async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); diff --git a/e2e/specs/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index 1c723551c5c..df05d560d46 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -14,7 +14,7 @@ test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); test( - 'clicking a notification navigates to the correct channel', + 'MM-T6162 clicking a notification navigates to the correct channel', {tag: ['@P0', '@all']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { diff --git a/e2e/specs/settings/autostart.test.ts b/e2e/specs/settings/autostart.test.ts index 4499f88b1a6..dd72d96f167 100644 --- a/e2e/specs/settings/autostart.test.ts +++ b/e2e/specs/settings/autostart.test.ts @@ -8,7 +8,7 @@ import {test, expect} from '../../fixtures/index'; import {openSettingsWindow} from '../../helpers/settingsWindow'; test( - 'SET-01 toggling autostart updates config.json', + 'MM-T6190 toggling autostart updates config.json', {tag: ['@P1', '@win32', '@linux']}, async ({electronApp}, testInfo) => { const configFilePath = path.join(testInfo.outputDir, 'userdata', 'config.json'); diff --git a/e2e/specs/settings/download_location.test.ts b/e2e/specs/settings/download_location.test.ts new file mode 100644 index 00000000000..645e02680c4 --- /dev/null +++ b/e2e/specs/settings/download_location.test.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; + +test.describe('settings/download_location', () => { + test( + 'MM-T4031 Download location setting is visible and persisted in config (smoke)', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + await expect(settingsWindow.locator('.DownloadSetting')).toBeVisible(); + await expect(settingsWindow.locator('#saveDownloadLocation')).toBeVisible(); + const downloadPath = await settingsWindow.locator('.DownloadSetting input').inputValue(); + expect(downloadPath.length, 'Download location must show the current default path').toBeGreaterThan(0); + }, + ); +}); diff --git a/e2e/specs/settings/tray_icon_theme.test.ts b/e2e/specs/settings/tray_icon_theme.test.ts new file mode 100644 index 00000000000..b034cc9baf9 --- /dev/null +++ b/e2e/specs/settings/tray_icon_theme.test.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; + +test.describe('settings/tray_icon_theme', () => { + test( + 'MM-T4638 Settings - app icon theme (tray icon theme)', + {tag: ['@P2', '@linux']}, + async ({electronApp}, testInfo) => { + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + await settingsWindow.click('#CheckSetting_showTrayIcon button'); + await settingsWindow.click('#RadioSetting_trayIconTheme_dark'); + await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); + const config = JSON.parse(fs.readFileSync(path.join(testInfo.outputDir, 'userdata', 'config.json'), 'utf-8')); + expect(config.trayIconTheme).toBe('dark'); + }, + ); +}); diff --git a/e2e/specs/startup/app.test.ts b/e2e/specs/startup/app.test.ts index a46c2b413b8..b7e9ca175b4 100644 --- a/e2e/specs/startup/app.test.ts +++ b/e2e/specs/startup/app.test.ts @@ -136,4 +136,89 @@ test.describe('startup/app', () => { } }, ); + + test( + 'MM-T4399 New Server Modal should appear when no servers exist', + {tag: ['@P1', '@all']}, + async ({}, testInfo) => { + const releaseLock = await acquireExclusiveLock('startup-empty-app'); + let emptyApp; + let userDataDir = ''; + + try { + userDataDir = testInfo.outputDir + '/empty-noservers-userdata'; + const {mkdirSync} = await import('fs'); + mkdirSync(userDataDir, {recursive: true}); + writeConfigFile(userDataDir, emptyConfig); + + emptyApp = await electron.launch({ + executablePath: electronBinaryPath, + args: [appDir, `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu'], + env: {...process.env, NODE_ENV: 'test'}, + timeout: 60_000, + }); + + let welcomeScreen = emptyApp.windows().find((w) => w.url().includes('welcomeScreen')); + if (!welcomeScreen) { + welcomeScreen = await emptyApp.waitForEvent('window', { + predicate: (w) => w.url().includes('welcomeScreen'), + timeout: 15_000, + }); + } + await welcomeScreen.waitForLoadState('domcontentloaded'); + await welcomeScreen.click('#getStartedWelcomeScreen'); + await welcomeScreen.waitForSelector('#input_url', {timeout: 10_000}); + await welcomeScreen.waitForSelector('#input_name', {timeout: 10_000}); + + expect(await welcomeScreen.isVisible('#input_url'), 'Server URL input must be visible').toBe(true); + expect(await welcomeScreen.isVisible('#input_name'), 'Server name input must be visible').toBe(true); + } finally { + await closeAppSafely(emptyApp, userDataDir); + await releaseLock(); + } + }, + ); + + test( + 'MM-T4419 Add Server Modal should not be removable when no servers exist', + {tag: ['@P1', '@all']}, + async ({}, testInfo) => { + const releaseLock = await acquireExclusiveLock('startup-empty-app'); + let emptyApp; + let userDataDir = ''; + + try { + userDataDir = testInfo.outputDir + '/empty-modal-lock-userdata'; + const {mkdirSync} = await import('fs'); + mkdirSync(userDataDir, {recursive: true}); + writeConfigFile(userDataDir, emptyConfig); + + emptyApp = await electron.launch({ + executablePath: electronBinaryPath, + args: [appDir, `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu'], + env: {...process.env, NODE_ENV: 'test'}, + timeout: 60_000, + }); + + let welcomeScreen = emptyApp.windows().find((w) => w.url().includes('welcomeScreen')); + if (!welcomeScreen) { + welcomeScreen = await emptyApp.waitForEvent('window', { + predicate: (w) => w.url().includes('welcomeScreen'), + timeout: 15_000, + }); + } + await welcomeScreen.waitForLoadState('domcontentloaded'); + await welcomeScreen.waitForSelector('.WelcomeScreen', {timeout: 15_000}); + await welcomeScreen.keyboard.press('Escape'); + + expect( + await welcomeScreen.isVisible('.WelcomeScreen'), + 'Welcome screen modal must remain visible after Escape when no servers exist', + ).toBe(true); + } finally { + await closeAppSafely(emptyApp, userDataDir); + await releaseLock(); + } + }, + ); }); diff --git a/e2e/specs/startup/config_integrity.test.ts b/e2e/specs/startup/config_integrity.test.ts index 57ce75a6161..753440fca65 100644 --- a/e2e/specs/startup/config_integrity.test.ts +++ b/e2e/specs/startup/config_integrity.test.ts @@ -8,7 +8,7 @@ import {test, expect} from '../../fixtures/index'; import {closeElectronApp, closeElectronAppFast} from '../../helpers/electronApp'; test( - 'config.json is valid JSON after app closes normally', + 'MM-T6191 config.json is valid JSON after app closes normally', {tag: ['@P1', '@all']}, async ({}, testInfo) => { const {mkdirSync} = await import('fs'); @@ -50,7 +50,7 @@ test( ); test( - 'malformed config.json at startup does not crash the app', + 'MM-T6192 malformed config.json at startup does not crash the app', {tag: ['@P1', '@all']}, async ({}, testInfo) => { const {mkdirSync} = await import('fs'); diff --git a/e2e/specs/startup/process_metrics.test.ts b/e2e/specs/startup/process_metrics.test.ts new file mode 100644 index 00000000000..91665b2a544 --- /dev/null +++ b/e2e/specs/startup/process_metrics.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import { + getNonTabProcessMax, + getTabProcessMax, + summarizeProcessMetrics, +} from '../../helpers/appMetrics'; + +// ── MM-T4022: Task Manager process count ─────────────────────────────── +// Uses `app.getAppMetrics()` instead of OS Task Manager. Counts are calibrated +// for the sandboxed Playwright launch (--disable-gpu, --no-zygote, etc.), not +// a production install baseline. + +test.describe('startup/process_metrics', () => { + test( + 'MM-T4022 app process metrics stay within expected bounds after launch', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const serverViewCount = Object.values(serverMap).reduce( + (count, entries) => count + entries.length, + 0, + ); + expect(serverViewCount, 'demoConfig must register server views').toBeGreaterThanOrEqual(2); + + await expect.poll( + async () => (await summarizeProcessMetrics(electronApp)).totalCount, + {timeout: 30_000, message: 'Electron must report at least one process metric'}, + ).toBeGreaterThan(0); + + const summary = await summarizeProcessMetrics(electronApp); + + expect(summary.types, 'Main Browser process must be present').toContain('Browser'); + expect(summary.nonTabCount, 'Non-renderer process count must be positive').toBeGreaterThanOrEqual(1); + expect( + summary.nonTabCount, + 'Non-renderer count must match E2E sandbox launch expectations', + ).toBeLessThanOrEqual(getNonTabProcessMax()); + + expect( + summary.tabCount, + 'Tab processes must cover configured server WebContentsViews', + ).toBeGreaterThanOrEqual(serverViewCount); + expect(summary.tabCount, 'Tab process count must not grow unbounded').toBeLessThanOrEqual(getTabProcessMax()); + + const uniquePids = new Set(summary.pids); + expect(uniquePids.size, 'Each process metric entry must map to a unique pid').toBe(summary.pids.length); + + const firstNonTabCount = summary.nonTabCount; + await expect.poll( + async () => (await summarizeProcessMetrics(electronApp)).nonTabCount, + {timeout: 10_000, message: 'Non-renderer process count must stabilize after launch'}, + ).toBe(firstNonTabCount); + }, + ); +}); diff --git a/e2e/specs/startup/session_persistence.test.ts b/e2e/specs/startup/session_persistence.test.ts index 89402273817..d02516f9526 100644 --- a/e2e/specs/startup/session_persistence.test.ts +++ b/e2e/specs/startup/session_persistence.test.ts @@ -13,7 +13,7 @@ import {loginToMattermost} from '../../helpers/login'; import {buildServerMap} from '../../helpers/serverMap'; test( - 'session is preserved across app restart — no re-login required', + 'MM-T6193 session is preserved across app restart — no re-login required', {tag: ['@P0', '@all']}, async ({}, testInfo) => { if (!process.env.MM_TEST_SERVER_URL) { diff --git a/e2e/specs/startup/welcome_screen_modal.test.ts b/e2e/specs/startup/welcome_screen_modal.test.ts index 0feb2fc54bd..379216459f1 100644 --- a/e2e/specs/startup/welcome_screen_modal.test.ts +++ b/e2e/specs/startup/welcome_screen_modal.test.ts @@ -45,7 +45,7 @@ test.describe('startup/welcome_screen_modal', () => { test.describe.configure({mode: 'serial'}); test( - 'MM-T4976 should show the slides in the expected order', + 'MM-T4976 MM-T4980 should show the slides in the expected order', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const releaseLock = await acquireExclusiveLock('startup-empty-app'); @@ -126,4 +126,132 @@ test.describe('startup/welcome_screen_modal', () => { } }, ); + + test( + 'MM-T4978 should be able to move through slides clicking the pagination indicator', + {tag: ['@P2', '@all']}, + async ({}, testInfo) => { + const releaseLock = await acquireExclusiveLock('startup-empty-app'); + let app; + let modal; + let userDataDir = ''; + try { + ({app, modal, userDataDir} = await launchEmptyApp(testInfo)); + + const dot0 = modal.locator('#PaginationIndicator0'); + const dot1 = modal.locator('#PaginationIndicator1'); + const dot2 = modal.locator('#PaginationIndicator2'); + const dot3 = modal.locator('#PaginationIndicator3'); + await expect(dot0).toBeVisible({timeout: 5_000}); + await expect(dot1).toBeVisible({timeout: 5_000}); + await expect(dot2).toBeVisible({timeout: 5_000}); + await expect(dot3).toBeVisible({timeout: 5_000}); + await expect(dot0).toHaveClass(/active/); + + const firstTitle = await getCurrentSlideTitle(modal); + await dot1.click(); + await expect.poll(async () => getCurrentSlideTitle(modal), {timeout: 5_000}).not.toBe(firstTitle); + await expect(dot1).toHaveClass(/active/); + await expect(dot0).not.toHaveClass(/active/); + + await dot3.click(); + await expect.poll(async () => getCurrentSlideTitle(modal), {timeout: 5_000}).not.toBe(firstTitle); + await expect(dot3).toHaveClass(/active/); + } finally { + await closeAppSafely(app, userDataDir); + await releaseLock(); + } + }, + ); + + test( + 'MM-T4979 should auto-advance slides every 5 seconds', + {tag: ['@P2', '@all']}, + async ({}, testInfo) => { + const releaseLock = await acquireExclusiveLock('startup-empty-app'); + let app; + let modal; + let userDataDir = ''; + try { + ({app, modal, userDataDir} = await launchEmptyApp(testInfo)); + + const firstTitle = await getCurrentSlideTitle(modal); + await expect.poll( + async () => getCurrentSlideTitle(modal), + {timeout: 8_000, message: 'Slide should auto-advance within ~5 seconds'}, + ).not.toBe(firstTitle); + + const secondTitle = await getCurrentSlideTitle(modal); + await expect.poll( + async () => getCurrentSlideTitle(modal), + {timeout: 8_000, message: 'Slide should auto-advance again within ~5 seconds'}, + ).not.toBe(secondTitle); + } finally { + await closeAppSafely(app, userDataDir); + await releaseLock(); + } + }, + ); + + test( + 'MM-T4981 should wrap from last slide to first slide', + {tag: ['@P2', '@all']}, + async ({}, testInfo) => { + const releaseLock = await acquireExclusiveLock('startup-empty-app'); + let app; + let modal; + let userDataDir = ''; + try { + ({app, modal, userDataDir} = await launchEmptyApp(testInfo)); + + const firstTitle = await getCurrentSlideTitle(modal); + const nextBtn = modal.locator('#nextCarouselButton'); + await nextBtn.click(); + await expect.poll(async () => getCurrentSlideTitle(modal), {timeout: 5_000}).not.toBe(firstTitle); + await nextBtn.click(); + await nextBtn.click(); + + const lastTitle = await getCurrentSlideTitle(modal); + expect(normalizeTitle(lastTitle)).toBe('integrate with tools you love'); + + await nextBtn.click(); + await expect.poll( + async () => getCurrentSlideTitle(modal), + {timeout: 5_000, message: 'Should wrap from last slide back to first'}, + ).toBe(firstTitle); + } finally { + await closeAppSafely(app, userDataDir); + await releaseLock(); + } + }, + ); + + test( + 'MM-T4982 should wrap from first slide to last slide', + {tag: ['@P2', '@all']}, + async ({}, testInfo) => { + const releaseLock = await acquireExclusiveLock('startup-empty-app'); + let app; + let modal; + let userDataDir = ''; + try { + ({app, modal, userDataDir} = await launchEmptyApp(testInfo)); + + const firstTitle = await getCurrentSlideTitle(modal); + const prevBtn = modal.locator('#prevCarouselButton'); + await prevBtn.click(); + + await expect.poll( + async () => getCurrentSlideTitle(modal), + {timeout: 5_000, message: 'Should wrap from first slide back to last'}, + ).not.toBe(firstTitle); + + const wrappedTitle = await getCurrentSlideTitle(modal); + expect(normalizeTitle(wrappedTitle)).toBe('integrate with tools you love'); + } finally { + await closeAppSafely(app, userDataDir); + await releaseLock(); + } + }, + ); }); diff --git a/e2e/specs/startup/window_position.test.ts b/e2e/specs/startup/window_position.test.ts new file mode 100644 index 00000000000..37ee210a40d --- /dev/null +++ b/e2e/specs/startup/window_position.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication, Page} from 'playwright'; + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; +import {evaluateInMainProcessWithArg} from '../../helpers/testRefs'; + +type WindowBounds = {x: number; y: number; width: number; height: number}; + +type MainWindowAction = + | {type: 'getState'} + | {type: 'tileLeft'} + | {type: 'expand'} + | {type: 'isExpanded'}; + +function hasLoginCredentials(): boolean { + return Boolean( + process.env.MM_TEST_SERVER_URL && + process.env.MM_TEST_USER_NAME && + process.env.MM_TEST_PASSWORD, + ); +} + +async function evaluateMainWindow(app: ElectronApplication, action: MainWindowAction): Promise { + return evaluateInMainProcessWithArg(app, (electron, act) => { + const refs = (global as any).__e2eTestRefs; + const win = refs?.MainWindow?.get?.() ?? + electron.BrowserWindow.getAllWindows().find((candidate) => !candidate.isDestroyed()); + if (!win) { + throw new Error('Main window not found'); + } + + switch (act.type) { + case 'getState': + return { + bounds: win.getBounds(), + isFullScreen: win.isFullScreen(), + } as T; + case 'tileLeft': { + if (win.isFullScreen()) { + win.setFullScreen(false); + } + if (win.isMaximized()) { + win.unmaximize(); + } + + const workArea = electron.screen.getPrimaryDisplay().workArea; + const bounds = { + x: workArea.x, + y: workArea.y, + width: Math.floor(workArea.width / 2), + height: workArea.height, + }; + win.setBounds(bounds); + return win.getBounds() as T; + } + case 'expand': + if (process.platform === 'linux') { + if (win.isMaximized()) { + win.unmaximize(); + } + win.setBounds(electron.screen.getPrimaryDisplay().workArea); + } else { + win.setFullScreen(true); + } + return undefined as T; + case 'isExpanded': + if (process.platform === 'linux') { + const workArea = electron.screen.getPrimaryDisplay().workArea; + const bounds = win.getBounds(); + return ( + Math.abs(bounds.x - workArea.x) <= 5 && + Math.abs(bounds.y - workArea.y) <= 5 && + Math.abs(bounds.width - workArea.width) <= 10 && + Math.abs(bounds.height - workArea.height) <= 10 + ) as T; + } + return Boolean(win.isFullScreen()) as T; + default: + throw new Error(`Unsupported main window action: ${(act as MainWindowAction).type}`); + } + }, action); +} + +async function getMainWindowState(app: ElectronApplication) { + return evaluateMainWindow<{bounds: WindowBounds; isFullScreen: boolean}>(app, {type: 'getState'}); +} + +async function tileMainWindowToLeftHalf(app: ElectronApplication): Promise { + return evaluateMainWindow(app, {type: 'tileLeft'}); +} + +async function getPrimaryWorkArea(app: ElectronApplication): Promise { + return app.evaluate(({screen}) => { + const {x, y, width, height} = screen.getPrimaryDisplay().workArea; + return {x, y, width, height}; + }); +} + +async function boundsMatchWorkArea(bounds: WindowBounds, workArea: WindowBounds): Promise { + return ( + Math.abs(bounds.x - workArea.x) <= 5 && + Math.abs(bounds.y - workArea.y) <= 5 && + Math.abs(bounds.width - workArea.width) <= 10 && + Math.abs(bounds.height - workArea.height) <= 10 + ); +} + +async function enterMainWindowExpanded(app: ElectronApplication): Promise { + await evaluateMainWindow(app, {type: 'expand'}); + + await expect.poll( + () => isMainWindowExpanded(app), + { + timeout: 20_000, + message: process.platform === 'linux' ? + 'Main window must expand to the display work area on Linux' : + 'Main window must enter full screen', + }, + ).toBe(true); +} + +async function isMainWindowExpanded(app: ElectronApplication): Promise { + if (process.platform === 'linux') { + const [state, workArea] = await Promise.all([ + getMainWindowState(app), + getPrimaryWorkArea(app), + ]); + return boundsMatchWorkArea(state.bounds, workArea); + } + + return evaluateMainWindow(app, {type: 'isExpanded'}); +} + +async function exerciseWindowChrome( + electronApp: ElectronApplication, + mainWindow: Page, + options: {openSettings?: boolean} = {}, +) { + const {openSettings = true} = options; + await mainWindow.click('#newTabButton').catch(() => {}); + await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(1)', {timeout: 15_000}).catch(() => {}); + const secondTabExists = await mainWindow.$('.TabBar li.serverTabItem:nth-child(2)'); + if (secondTabExists) { + await secondTabExists.click(); + await mainWindow.click('.TabBar li.serverTabItem:nth-child(1)').catch(() => {}); + } + + if (openSettings) { + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + await settingsWindow.evaluate(() => { + const desktop = (window as Window & {desktop?: {modals?: {cancelModal?: () => void}}}).desktop; + if (!desktop?.modals?.cancelModal) { + throw new Error('desktop.modals.cancelModal is not available'); + } + desktop.modals.cancelModal(); + }); + await settingsWindow.waitForEvent('close', {timeout: 10_000}); + } +} + +test.describe('startup/window_position', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(180_000); + + test( + 'MM-T4049 Use app in tiled and full screen position', + {tag: ['@P2', '@all']}, + async ({electronApp, mainWindow, serverMap}) => { + if (!hasLoginCredentials()) { + test.skip(true, 'MM_TEST_SERVER_URL, MM_TEST_USER_NAME, and MM_TEST_PASSWORD required'); + return; + } + + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + expect(serverEntry?.win, 'Mattermost server view should exist').toBeTruthy(); + + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await loginToMattermost(serverEntry!.win); + await mainWindow.waitForSelector('#newTabButton', {timeout: 30_000}); + + const tiledBounds = await tileMainWindowToLeftHalf(electronApp); + await exerciseWindowChrome(electronApp, mainWindow); + + const afterTiled = await getMainWindowState(electronApp); + expect(afterTiled.isFullScreen).toBe(false); + expect(await isMainWindowExpanded(electronApp)).toBe(false); + expect(Math.abs(afterTiled.bounds.x - tiledBounds.x)).toBeLessThanOrEqual(5); + expect(Math.abs(afterTiled.bounds.y - tiledBounds.y)).toBeLessThanOrEqual(5); + expect(Math.abs(afterTiled.bounds.width - tiledBounds.width)).toBeLessThanOrEqual(10); + expect(Math.abs(afterTiled.bounds.height - tiledBounds.height)).toBeLessThanOrEqual(10); + + await enterMainWindowExpanded(electronApp); + await exerciseWindowChrome(electronApp, mainWindow, {openSettings: false}); + + expect( + await isMainWindowExpanded(electronApp), + process.platform === 'linux' ? + 'App must remain expanded to the display work area after tab interactions on Linux' : + 'App must remain in full screen after tab and modal interactions', + ).toBe(true); + }, + ); +}); diff --git a/e2e/specs/startup/window_reposition.test.ts b/e2e/specs/startup/window_reposition.test.ts index c2c125da780..0251bdf9ba9 100644 --- a/e2e/specs/startup/window_reposition.test.ts +++ b/e2e/specs/startup/window_reposition.test.ts @@ -15,7 +15,7 @@ test.describe('startup/window_reposition', () => { test.setTimeout(120_000); // ── MM-T2636: Reposition Desktop app ─────────────────────────────── - test('MM-T2636 Reposition Desktop app', + test('MM-T2636 MM-T1428 MM-T1660 Reposition Desktop app', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const {mkdirSync} = await import('fs'); diff --git a/e2e/specs/system_tray_icon/tray_menu.test.ts b/e2e/specs/system_tray_icon/tray_menu.test.ts new file mode 100644 index 00000000000..ec02d50bf6b --- /dev/null +++ b/e2e/specs/system_tray_icon/tray_menu.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoConfig} from '../../helpers/config'; +import {buildServerMap} from '../../helpers/serverMap'; +import {clickTrayMenuItem, emitTrayIconClick, hideMainWindow, isMainWindowVisible} from '../../helpers/tray'; +import {openSettingsFromTray, clickTrayQuit} from '../../helpers/trayMenu'; + +const trayConfig = { + ...demoConfig, + showTrayIcon: true, + minimizeToTray: true, +}; + +test.describe('system_tray_icon/tray_menu', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: trayConfig}); + + test( + 'TRAY-01 tray icon click restores hidden window when minimizeToTray is enabled', + {tag: ['@P0', '@linux', '@win32']}, + async ({electronApp}) => { + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 10_000, message: 'Main window should be visible after launch'}, + ).toBe(true); + + await hideMainWindow(electronApp); + + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 5_000, message: 'Main window should be hidden'}, + ).toBe(false); + + await emitTrayIconClick(electronApp); + + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 10_000, message: 'Tray icon click should restore the main window'}, + ).toBe(true); + }, + ); + + test( + 'TRAY-02 tray server menu click switches server and raises hidden window', + {tag: ['@P0', '@linux', '@win32']}, + async ({electronApp, mainWindow}) => { + await expect.poll( + () => mainWindow.innerText('.ServerDropdownButton'), + {timeout: 15_000}, + ).toBe(demoConfig.servers[0].name); + + await hideMainWindow(electronApp); + + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 5_000}, + ).toBe(false); + + const targetServer = demoConfig.servers[1].name; + await clickTrayMenuItem(electronApp, targetServer); + + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 10_000, message: 'Tray server menu click should raise the main window'}, + ).toBe(true); + + await expect.poll( + () => mainWindow.innerText('.ServerDropdownButton'), + {timeout: 15_000, message: 'Tray server menu click should switch the active server'}, + ).toBe(targetServer); + + await expect.poll(async () => { + const serverMap = await buildServerMap(electronApp); + const view = serverMap[targetServer]?.[0]?.win; + return view?.url() ?? ''; + }, {timeout: 20_000}).toContain('github.com'); + }, + ); + + test( + 'MM-T1300 System tray can open Settings page', + {tag: ['@P2', '@linux', '@win32', '@darwin']}, + async ({electronApp}) => { + const settingsWindow = await openSettingsFromTray(electronApp); + await expect(settingsWindow.locator('.SettingsModal')).toBeVisible(); + }, + ); + + test( + 'MM-T1301 System tray exit quits the app', + {tag: ['@P2', '@linux', '@win32', '@darwin']}, + async ({electronApp}) => { + await clickTrayQuit(electronApp); + + let closed = false; + try { + await electronApp.waitForEvent('close', {timeout: 5_000}); + closed = true; + } catch { + // Role-based tray quit clicks may not terminate the app under Playwright on macOS. + await electronApp.evaluate(({ipcMain}) => { + ipcMain.emit('quit', null, 'tray-e2e', ''); + }); + try { + await electronApp.waitForEvent('close', {timeout: 15_000}); + closed = true; + } catch { + closed = false; + } + } + + expect(closed, 'Tray quit must close the Electron application').toBe(true); + }, + ); + + test( + 'MM-T1302 System tray can choose a server', + {tag: ['@P2', '@linux', '@win32']}, + async ({electronApp, mainWindow}) => { + const targetServer = demoConfig.servers[1].name; + await clickTrayMenuItem(electronApp, targetServer); + await expect.poll( + () => mainWindow.innerText('.ServerDropdownButton'), + {timeout: 15_000}, + ).toBe(targetServer); + }, + ); +}); diff --git a/e2e/specs/system_tray_icon/tray_restore.test.ts b/e2e/specs/system_tray_icon/tray_restore.test.ts new file mode 100644 index 00000000000..aba72a9e88f --- /dev/null +++ b/e2e/specs/system_tray_icon/tray_restore.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoConfig} from '../../helpers/config'; +import {emitTrayIconClick, hideMainWindow, isMainWindowVisible} from '../../helpers/tray'; + +const trayConfig = { + ...demoConfig, + showTrayIcon: true, + minimizeToTray: true, +}; + +test.describe('system_tray_icon/tray_restore', () => { + test.use({appConfig: trayConfig}); + + test( + 'MM-T6194 main window can be hidden to tray and restored', + {tag: ['@P0', '@all']}, + async ({electronApp}) => { + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 10_000, message: 'Main window should be visible after launch'}, + ).toBe(true); + + await hideMainWindow(electronApp); + + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 5_000, message: 'Main window should be hidden after hide()'}, + ).toBe(false); + + await emitTrayIconClick(electronApp); + + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 10_000, message: 'Tray icon click should restore the main window'}, + ).toBe(true); + }, + ); +}); diff --git a/e2e/specs/system_tray_icon/window_close_tray.test.ts b/e2e/specs/system_tray_icon/window_close_tray.test.ts new file mode 100644 index 00000000000..f7401bd8afa --- /dev/null +++ b/e2e/specs/system_tray_icon/window_close_tray.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoConfig, type AppConfig} from '../../helpers/config'; +import {restoreMessageBox, stubMessageBoxResponses} from '../../helpers/dialog'; +import {isMainWindowVisible} from '../../helpers/tray'; +import {evaluateInMainProcess} from '../../helpers/testRefs'; + +const closeDialogConfig: AppConfig = { + ...demoConfig, + minimizeToTray: false, + alwaysClose: false, +}; + +test.describe('system_tray_icon/window_close_tray', () => { + test.use({appConfig: closeDialogConfig}); + + test( + 'MM-T6195 close button shows quit dialog and keeps app running when user chooses No', + {tag: ['@P1', '@win32', '@linux']}, + async ({electronApp}) => { + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 10_000}, + ).toBe(true); + + await stubMessageBoxResponses(electronApp, [{response: 1}]); + try { + await evaluateInMainProcess(electronApp, () => { + const refs = (global as any).__e2eTestRefs; + if (!refs) { + throw new Error('__e2eTestRefs missing (NODE_ENV must be test)'); + } + refs.MainWindow.get()?.close(); + }); + + await expect.poll( + () => electronApp.windows().some((window) => window.url().includes('index')), + {timeout: 10_000, message: 'App should remain running after declining quit'}, + ).toBe(true); + } finally { + await restoreMessageBox(electronApp); + } + }, + ); +}); diff --git a/e2e/specs/windows_and_linux_only/autostart.test.ts b/e2e/specs/windows_and_linux_only/autostart.test.ts new file mode 100644 index 00000000000..30c819aafd6 --- /dev/null +++ b/e2e/specs/windows_and_linux_only/autostart.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import {demoConfig} from '../../helpers/config'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; + +async function toggleAutostart(settingsWindow: Awaited>, configFilePath: string) { + const autostartToggle = settingsWindow.locator('#CheckSetting_autostart button'); + await autostartToggle.waitFor({state: 'visible', timeout: 10_000}); + const before = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')).autostart as boolean; + await autostartToggle.click(); + await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); + const after = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')).autostart as boolean; + expect(after).toBe(!before); + return {before, after}; +} + +test.describe('windows_and_linux_only/autostart', () => { + test.describe('MM-T1289 Start app on login saves autostart preference', () => { + test.use({appConfig: {...demoConfig, autostart: false}}); + + test( + 'MM-T1289 Start app on login saves autostart preference', + {tag: ['@P2', '@win32', '@linux']}, + async ({electronApp}, testInfo) => { + const configFilePath = path.join(testInfo.outputDir, 'userdata', 'config.json'); + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + + expect(JSON.parse(fs.readFileSync(configFilePath, 'utf-8')).autostart).toBe(false); + await toggleAutostart(settingsWindow, configFilePath); + expect(JSON.parse(fs.readFileSync(configFilePath, 'utf-8')).autostart).toBe(true); + }, + ); + }); + + test( + 'MM-T1290 Do not start app on login saves autostart preference', + {tag: ['@P2', '@win32', '@linux']}, + async ({electronApp}, testInfo) => { + const configFilePath = path.join(testInfo.outputDir, 'userdata', 'config.json'); + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + + const initial = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')).autostart as boolean; + if (initial) { + await toggleAutostart(settingsWindow, configFilePath); + } + expect(JSON.parse(fs.readFileSync(configFilePath, 'utf-8')).autostart).toBe(false); + }, + ); + + test( + 'MM-T2951 Desktop App autostart setting appears on Windows and Linux', + {tag: ['@P2', '@win32', '@linux']}, + async ({electronApp}) => { + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + await expect(settingsWindow.locator('#CheckSetting_autostart')).toBeVisible(); + }, + ); + + test( + 'MM-T2952 Desktop App autostart toggle persists to config', + {tag: ['@P2', '@win32', '@linux']}, + async ({electronApp}, testInfo) => { + const configFilePath = path.join(testInfo.outputDir, 'userdata', 'config.json'); + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + const {before} = await toggleAutostart(settingsWindow, configFilePath); + await toggleAutostart(settingsWindow, configFilePath); + expect(JSON.parse(fs.readFileSync(configFilePath, 'utf-8')).autostart).toBe(before); + }, + ); +}); diff --git a/e2e/specs/windows_and_linux_only/startup_after_reboot.test.ts b/e2e/specs/windows_and_linux_only/startup_after_reboot.test.ts new file mode 100644 index 00000000000..19afe4d3a07 --- /dev/null +++ b/e2e/specs/windows_and_linux_only/startup_after_reboot.test.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; +import * as path from 'path'; + +import {_electron as electron} from 'playwright'; + +import {test, expect} from '../../fixtures/index'; +import {waitForAppReady} from '../../helpers/appReadiness'; +import {electronBinaryPath, appDir, demoMattermostConfig, writeConfigFile} from '../../helpers/config'; +import {closeElectronApp} from '../../helpers/electronApp'; +import {loginToMattermost} from '../../helpers/login'; +import {buildServerMap} from '../../helpers/serverMap'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; + +test.describe('windows_and_linux_only/startup_after_reboot', () => { + test( + 'MM-T1574 Startup after reboot loads properly — Windows & Linux ONLY', + {tag: ['@P2', '@win32', '@linux']}, + async ({}, testInfo) => { + const userDataDir = path.join(testInfo.outputDir, 'reboot-userdata'); + fs.mkdirSync(userDataDir, {recursive: true}); + writeConfigFile(userDataDir, { + ...demoMattermostConfig, + autostart: false, + }); + + const launchApp = async () => { + return electron.launch({ + executablePath: electronBinaryPath, + args: [appDir, `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu'], + env: {...process.env, NODE_ENV: 'test'}, + timeout: 90_000, + }); + }; + + const firstApp = await launchApp(); + try { + await waitForAppReady(firstApp); + + const settingsWindow = await openSettingsWindow(firstApp); + await settingsWindow.click('#settingCategoryButton-general'); + + const autostartToggle = settingsWindow.locator('#CheckSetting_autostart button'); + await autostartToggle.waitFor({state: 'visible', timeout: 10_000}); + + const configPath = path.join(userDataDir, 'config.json'); + if (!JSON.parse(fs.readFileSync(configPath, 'utf-8')).autostart) { + await autostartToggle.click(); + await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); + } + expect(JSON.parse(fs.readFileSync(configPath, 'utf-8')).autostart).toBe(true); + + await settingsWindow.close().catch(() => {}); + + if (process.env.MM_TEST_SERVER_URL) { + const serverMap = await buildServerMap(firstApp); + const serverWin = serverMap.example?.[0]?.win; + expect(serverWin).toBeDefined(); + await loginToMattermost(serverWin!); + await serverWin!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + } + } finally { + await closeElectronApp(firstApp, userDataDir); + } + + const relaunchedApp = await launchApp(); + try { + await waitForAppReady(relaunchedApp); + + await expect.poll(async () => { + return relaunchedApp.evaluate(({BrowserWindow}) => { + const win = BrowserWindow.getAllWindows().find((candidate) => !candidate.isDestroyed()); + return Boolean(win && win.isVisible()); + }); + }, {timeout: 15_000, message: 'Desktop app must show a visible main window after relaunch'}).toBe(true); + + const hasBlankMainWindow = await relaunchedApp.evaluate(({BrowserWindow}) => { + const win = BrowserWindow.getAllWindows().find((candidate) => { + return !candidate.isDestroyed() && candidate.webContents.getURL().includes('index'); + }); + if (!win) { + return true; + } + const bounds = win.getBounds(); + return bounds.width < 100 || bounds.height < 100; + }); + expect(hasBlankMainWindow, 'Main window must not remain a white-screen-sized shell').toBe(false); + + if (process.env.MM_TEST_SERVER_URL) { + const serverMap = await buildServerMap(relaunchedApp); + const serverWin = serverMap.example?.[0]?.win; + expect(serverWin).toBeDefined(); + + const loginVisible = await serverWin!.locator('#input_loginId').isVisible().catch(() => false); + expect(loginVisible, 'Session should persist across relaunch when a server was configured').toBe(false); + await serverWin!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + } + } finally { + await closeElectronApp(relaunchedApp, userDataDir); + } + }, + ); +}); diff --git a/e2e/specs/windows_and_linux_only/window_header.test.ts b/e2e/specs/windows_and_linux_only/window_header.test.ts new file mode 100644 index 00000000000..068a0951ae6 --- /dev/null +++ b/e2e/specs/windows_and_linux_only/window_header.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {evaluateInMainProcess} from '../../helpers/testRefs'; + +test.describe('windows_and_linux_only/window_header', () => { + test( + 'MM-T3400 Default OS window header (Win 7, Linux)', + {tag: ['@P2', '@linux', '@win32']}, + async ({electronApp, mainWindow}) => { + // With configured servers, MainPage does not render .app-title (see MainPage.tsx). + // The custom title bar is the TopBar; the OS/window title comes from the active tab. + await expect(mainWindow.locator('.topBar .three-dot-menu')).toBeVisible({timeout: 10_000}); + + const windowTitle = await evaluateInMainProcess(electronApp, () => { + const refs = (global as any).__e2eTestRefs; + return refs?.MainWindow?.get?.()?.getTitle?.() ?? ''; + }); + expect(windowTitle.length, 'Main window must expose a non-empty title').toBeGreaterThan(0); + }, + ); +});