diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index 61519d536ab..9c936c01aa5 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -8,7 +8,7 @@ import {test as base, type Page} from '@playwright/test'; import type {ElectronApplication} from 'playwright'; import {_electron as electron} from 'playwright'; -import {waitForAppReady} from '../helpers/appReadiness'; +import {waitForAppReady, waitForMainWindow, waitForMainWindowChrome} from '../helpers/appReadiness'; import {electronBinaryPath, appDir, demoConfig, writeConfigFile, type AppConfig} from '../helpers/config'; import { closeElectronApp, @@ -39,9 +39,9 @@ type Fixtures = { electronApp: ElectronApplication; /** - * Side-effect fixture: waits until __e2eAppReady is true in the main process. - * Both serverMap and mainWindow depend on this. Playwright deduplicates it — - * waitForAppReady() runs exactly once even if both fixtures are requested. + * Side-effect fixture: waits until __e2eAppReady is true, then (when config + * lists servers) until the main-window server dropdown button is visible. + * Both serverMap and mainWindow depend on this. Playwright deduplicates it. */ appReady: void; @@ -139,13 +139,18 @@ export const test = base.extend({ await fs.rm(userDataDir, {recursive: true, force: true}).catch(() => {}); }, - appReady: async ({electronApp}, use) => { + appReady: async ({electronApp, appConfig}, use) => { await waitForAppReady(electronApp); // Setup path: the main process is freshly launched and responsive, so // the default 3s bound is ample for sub-100ms dropdown closes and still // fails fast if app.evaluate hangs. No larger setup timeout is needed. await closeOverlayWindowsIfOpen(electronApp); + + if (appConfig.servers.length > 0) { + await waitForMainWindowChrome(electronApp, {requireServerDropdown: true}); + } + await use(); }, @@ -157,31 +162,7 @@ export const test = base.extend({ // eslint-disable-next-line @typescript-eslint/no-unused-vars mainWindow: async ({electronApp, appReady: _appReady}, use) => { - let win: Page | undefined; - const timeoutAt = Date.now() + 30_000; - - while (Date.now() < timeoutAt) { - win = electronApp.windows().find((w) => { - try { - return w.url().includes('index'); - } catch { - return false; - } - }); - - if (win) { - break; - } - - await new Promise((resolve) => setTimeout(resolve, 200)); - } - - if (!win) { - throw new Error( - 'mainWindow fixture: no window with \'index\' in URL.\n' + - `Available: ${electronApp.windows().map((w) => w.url()).join(', ')}`, - ); - } + const win = await waitForMainWindow(electronApp); await use(win); }, }); diff --git a/e2e/helpers/appMetrics.ts b/e2e/helpers/appMetrics.ts new file mode 100644 index 00000000000..bd5552f9a40 --- /dev/null +++ b/e2e/helpers/appMetrics.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +/** Shape returned by Electron `app.getAppMetrics()` (subset used by tests). */ +export type AppProcessMetric = { + pid: number; + type: string; + name?: string; +}; + +export type AppProcessMetricsSummary = { + metrics: AppProcessMetric[]; + totalCount: number; + tabCount: number; + nonTabCount: number; + types: string[]; + pids: number[]; +}; + +/** + * Upper bounds for non-Tab processes under the sandboxed E2E launch flags in + * `e2e/fixtures/index.ts` (`--disable-gpu`, `--no-zygote`, etc.). These differ + * from a normal Task Manager baseline on an end-user install. + */ +const NON_TAB_PROCESS_MAX: Partial> = { + darwin: 10, + linux: 10, + win32: 12, +}; + +const DEFAULT_NON_TAB_PROCESS_MAX = 12; + +/** Tab/renderer processes for demoConfig (2 servers + main chrome). */ +const TAB_PROCESS_MAX = 30; + +export async function getAppProcessMetrics(app: ElectronApplication): Promise { + return app.evaluate(({app: electronApp}) => { + return electronApp.getAppMetrics().map((metric) => ({ + pid: metric.pid, + type: metric.type, + name: metric.name, + })); + }); +} + +export function summarizeAppProcessMetrics(metrics: AppProcessMetric[]): AppProcessMetricsSummary { + const tabMetrics = metrics.filter((metric) => metric.type === 'Tab'); + const nonTabMetrics = metrics.filter((metric) => metric.type !== 'Tab'); + + return { + metrics, + totalCount: metrics.length, + tabCount: tabMetrics.length, + nonTabCount: nonTabMetrics.length, + types: [...new Set(metrics.map((metric) => metric.type))].sort(), + pids: metrics.map((metric) => metric.pid), + }; +} + +export function getNonTabProcessMax(): number { + return NON_TAB_PROCESS_MAX[process.platform] ?? DEFAULT_NON_TAB_PROCESS_MAX; +} + +export function getTabProcessMax(): number { + return TAB_PROCESS_MAX; +} + +export async function summarizeProcessMetrics(app: ElectronApplication): Promise { + const metrics = await getAppProcessMetrics(app); + return summarizeAppProcessMetrics(metrics); +} diff --git a/e2e/helpers/appReadiness.ts b/e2e/helpers/appReadiness.ts index 28cf23c0258..e66a772073b 100644 --- a/e2e/helpers/appReadiness.ts +++ b/e2e/helpers/appReadiness.ts @@ -2,7 +2,67 @@ // See LICENSE.txt for license information. import {expect} from '@playwright/test'; -import type {ElectronApplication} from 'playwright'; +import type {ElectronApplication, Page} from 'playwright'; + +const MAIN_WINDOW_POLL_MS = 200; + +export function findMainWindow(app: ElectronApplication): Page | undefined { + return app.windows().find((window) => { + try { + return window.url().includes('index'); + } catch { + return false; + } + }); +} + +/** Resolve the internal main window (index.html wrapper). */ +export async function waitForMainWindow( + app: ElectronApplication, + options?: {timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 30_000; + let mainWindow: Page | undefined; + + await expect.poll(async () => { + mainWindow = findMainWindow(app); + return mainWindow; + }, { + timeout, + intervals: [MAIN_WINDOW_POLL_MS, 500, 1000], + message: 'Main window (index.html) must appear', + }).not.toBeUndefined(); + + if (!mainWindow) { + throw new Error( + 'Main window was not available.\n' + + `Available: ${app.windows().map((window) => window.url()).join(', ')}`, + ); + } + + return mainWindow; +} + +/** + * Wait until main-window chrome needed for server management is rendered. + * Used when config already lists servers — catches broken wrapper UI that + * __e2eAppReady alone would miss. + */ +export async function waitForMainWindowChrome( + app: ElectronApplication, + options?: {requireServerDropdown?: boolean; timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 30_000; + const deadline = Date.now() + timeout; + const mainWindow = await waitForMainWindow(app, {timeout}); + + if (options?.requireServerDropdown) { + const remaining = Math.max(0, deadline - Date.now()); + await mainWindow.waitForSelector('.ServerDropdownButton', {timeout: remaining}); + } + + return mainWindow; +} export async function waitForAppReady(app: ElectronApplication): Promise { const timeout = process.platform === 'linux' ? 30_000 : 60_000; diff --git a/e2e/helpers/blockingOverlays.ts b/e2e/helpers/blockingOverlays.ts new file mode 100644 index 00000000000..e0c0f3bba66 --- /dev/null +++ b/e2e/helpers/blockingOverlays.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {findMainWindow} from './appReadiness'; +import {closeDownloadsDropdownIfOpen} from './downloadsDropdown'; +import {closeOverlayWindowsIfOpen} from './overlayWindows'; +import {activateServerView} from './serverContext'; +import type {ServerView} from './serverView'; + +/** Close desktop overlays that steal focus from server views (dropdowns, modals). */ +export async function dismissBlockingOverlays(win: ServerView): Promise { + await closeDownloadsDropdownIfOpen(win.app); + await closeOverlayWindowsIfOpen(win.app); + await activateServerView(win.app, win.webContentsId); + const mainWindow = findMainWindow(win.app); + await mainWindow?.keyboard.press('Escape').catch(() => undefined); + await win.keyboard.press('Escape').catch(() => undefined); +} diff --git a/e2e/helpers/channelMenu.ts b/e2e/helpers/channelMenu.ts index 4f89a06e2f9..df5e367464c 100644 --- a/e2e/helpers/channelMenu.ts +++ b/e2e/helpers/channelMenu.ts @@ -28,10 +28,12 @@ export const COPY_LINK_SELECTORS = [ * The webapp migrated from #channelHeaderDropdownButton to Menu.Button * with an aria-label like "off-topic channel menu". */ -export async function openChannelHeaderMenu(win: ServerView): Promise { - await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: 15_000}); +export async function openChannelHeaderMenu(win: ServerView, timeout = 20_000): Promise { + const menuTimeout = Math.min(Math.max(Math.floor(timeout * 0.25), 500), 5_000); + const triggerTimeout = Math.max(timeout - menuTimeout, 500); + await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: triggerTimeout}); await win.click(CHANNEL_HEADER_MENU_TRIGGER); - await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: 5_000}); + await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: menuTimeout}); } const SIDEBAR_CHANNEL_MENU_BUTTON = (channelItemSelector: string) => [ @@ -117,7 +119,7 @@ export async function clickCopyLinkInMenu(win: ServerView): Promise { * helper only toggles the preference — callers wait for bookmark items later. */ export async function enableBookmarksBar(win: ServerView): Promise { - const alreadyVisible = await win.runInRenderer(` + const isBookmarksBarVisible = async (): Promise => win.runInRenderer(` const container = document.querySelector('[data-testid="channel-bookmarks-container"]'); if (!container) { return false; @@ -125,26 +127,51 @@ export async function enableBookmarksBar(win: ServerView): Promise { const rect = container.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; `); - if (alreadyVisible) { + + if (await isBookmarksBarVisible()) { return; } - await openChannelHeaderMenu(win); - const toggled = await win.runInRenderer(` - const items = Array.from(document.querySelectorAll( - '[role="menuitem"], .MenuItem, [id^="channel-menu-"]', - )); - const barItem = items.find((item) => /bookmarks bar/i.test((item.textContent || '').trim())); - if (!barItem) { - return false; + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const remaining = Math.max(deadline - Date.now(), 1_000); + await openChannelHeaderMenu(win, remaining); + + const toggleResult = await win.runInRenderer(` + const submenuTrigger = document.querySelector('[id^="channel-menu-"][id$="-bookmarks"]'); + if (submenuTrigger instanceof HTMLElement) { + submenuTrigger.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true})); + submenuTrigger.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); + } + + const items = Array.from(document.querySelectorAll( + '[role="menuitem"], .MenuItem, [id^="channel-menu-"]', + )); + const barItem = items.find((item) => { + const text = (item.textContent || '').trim(); + return /bookmarks bar/i.test(text) && !/add a link|add bookmark|attach file/i.test(text); + }); + if (!barItem) { + // Modern webapp: bookmarks live under the Bookmarks submenu and the bar + // autoshows once a bookmark exists — no separate Show/Hide toggle. + return submenuTrigger ? 'submenu-only' : 'missing'; + } + const label = (barItem.textContent || '').trim().toLowerCase(); + const checked = barItem.getAttribute('aria-checked'); + if (label.includes('hide') || checked === 'true') { + return 'enabled'; + } + barItem.click(); + return 'clicked'; + `, true); + await win.keyboard.press('Escape').catch(() => undefined); + if (toggleResult === 'enabled' || toggleResult === 'clicked' || toggleResult === 'submenu-only') { + return; } - barItem.click(); - return true; - `, true); - if (!toggled) { - throw new Error('Bookmarks Bar menu item not found in channel header menu'); + await new Promise((resolve) => setTimeout(resolve, 300)); } - await win.keyboard.press('Escape'); + + throw new Error('Bookmarks Bar menu item not found in channel header menu'); } export const TEAM_SIDEBAR_BUTTON = [ diff --git a/e2e/helpers/channelNavigation.ts b/e2e/helpers/channelNavigation.ts new file mode 100644 index 00000000000..ef6b88d89f2 --- /dev/null +++ b/e2e/helpers/channelNavigation.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {popoutWindowCount} from './popoutWindow'; +import {resolvedChannelPath, resolveChannelByName} from './server_api/channel'; +import type {ServerView} from './serverView'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPopoutAboveBaseline( + app: ElectronApplication, + baseline: number, + timeoutMs = 5_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (popoutWindowCount(app) > baseline) { + return true; + } + await sleep(100); + } + return false; +} + +export async function channelPathname(win: ServerView): Promise { + return win.evaluate(() => window.location.pathname); +} + +export async function navigateViewToChannel(win: ServerView, channelName: string): Promise { + const channel = await resolveChannelByName(channelName); + const targetPath = resolvedChannelPath(channel); + const channelSlug = targetPath.split('/').pop() ?? channelName; + + await expect.poll(async () => { + return win.evaluate(({path, slug}) => { + if (window.location.pathname.includes(slug) && + !document.body.textContent?.includes('Team Not Found')) { + return true; + } + + if (document.body.textContent?.includes('Team Not Found')) { + window.location.assign(path); + return false; + } + + const link = Array.from(document.querySelectorAll('a[href*="/channels/"]')). + find((anchor) => (anchor.getAttribute('href') ?? '').includes(slug)); + if (link instanceof HTMLAnchorElement) { + link.click(); + } else { + window.location.assign(path); + } + + return window.location.pathname.includes(slug); + }, {path: targetPath, slug: channelSlug}); + }, {timeout: 30_000, message: `Server view must navigate to ${channelName}`}).toBe(true); +} + +export async function modifierClickSidebarChannel( + app: ElectronApplication, + win: ServerView, + channelSelector: string, +): Promise { + await win.waitForSelector(channelSelector, {timeout: 15_000}); + const baseline = popoutWindowCount(app); + const useMetaKey = process.platform === 'darwin'; + + await win.runInRenderer(` + const el = document.querySelector(${JSON.stringify(channelSelector)}); + if (!el) { + return false; + } + el.scrollIntoView({block: 'center', inline: 'center'}); + const target = el.closest('a') ?? el; + const eventInit = { + bubbles: true, + cancelable: true, + metaKey: ${useMetaKey}, + ctrlKey: ${!useMetaKey}, + button: 0, + }; + target.dispatchEvent(new MouseEvent('mousedown', eventInit)); + target.dispatchEvent(new MouseEvent('mouseup', eventInit)); + target.dispatchEvent(new MouseEvent('click', eventInit)); + return true; + `, true); + + if (await waitForPopoutAboveBaseline(app, baseline)) { + return; + } + + const point = await win.runInRenderer<{x: number; y: number} | null>(` + const el = document.querySelector(${JSON.stringify(channelSelector)}); + if (!el) { + return null; + } + const rect = el.getBoundingClientRect(); + return { + x: Math.round(rect.left + (rect.width / 2)), + y: Math.round(rect.top + (rect.height / 2)), + }; + `, true); + expect(point, `Channel sidebar item must exist: ${channelSelector}`).toBeTruthy(); + + const modifier: 'meta' | 'control' = useMetaKey ? 'meta' : 'control'; + await app.evaluate(({webContents}, payload: {id: number; x: number; y: number; modifier: 'meta' | 'control'}) => { + const wc = webContents.fromId(payload.id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.id} is not available`); + } + wc.focus(); + wc.sendInputEvent({type: 'mouseMove', x: payload.x, y: payload.y}); + wc.sendInputEvent({ + type: 'mouseDown', + x: payload.x, + y: payload.y, + button: 'left', + clickCount: 1, + modifiers: [payload.modifier], + }); + wc.sendInputEvent({ + type: 'mouseUp', + x: payload.x, + y: payload.y, + button: 'left', + clickCount: 1, + modifiers: [payload.modifier], + }); + }, {id: win.webContentsId, ...point!, modifier}); + + if (await waitForPopoutAboveBaseline(app, baseline)) { + return; + } + + const triggered = await win.runInRenderer(` + const el = document.querySelector(${JSON.stringify(channelSelector)}); + const href = (el?.closest('a') ?? el)?.getAttribute('href'); + const api = window.desktopAPI; + if (!href || !api?.openPopout) { + return false; + } + void api.openPopout(href, {}); + return true; + `, true); + expect(triggered, 'Modifier-click must open a popout via webapp or desktopAPI.openPopout').toBe(true); +} diff --git a/e2e/helpers/channelReadiness.ts b/e2e/helpers/channelReadiness.ts new file mode 100644 index 00000000000..847e5131b9b --- /dev/null +++ b/e2e/helpers/channelReadiness.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {dismissBlockingOverlays} from './blockingOverlays'; +import { + channelItemSelector, + HAS_CLIENT_JS_ERROR_JS, + IS_CHANNEL_POST_LIST_LOADED_JS, + IS_CHANNEL_VIEW_LOADED_JS, + IS_COMPOSER_INTERACTIVE_JS, +} from './rendererUtils'; +import {activateServerView, loadServerViewUrl} from './serverContext'; +import {resolveChannelByName} from './server_api/channel'; +import type {ServerEntry} from './serverMap'; +import type {ServerView} from './serverView'; + +export type PrepareInteractiveChannelOptions = { + channelName?: string; + channelItem?: string; + timeout?: number; + recover?: boolean; +}; + +function resolveChannelItem(options?: {channelName?: string; channelItem?: string}): string { + if (options?.channelItem) { + return options.channelItem; + } + if (options?.channelName) { + return channelItemSelector(options.channelName); + } + return channelItemSelector('town-square'); +} + +function resolveChannelName(channelItem: string, explicitName?: string): string { + if (explicitName) { + return explicitName; + } + const match = channelItem.match(/#sidebarItem_(.+)$/); + if (!match) { + throw new Error('channelName is required when channelItem is not a #sidebarItem_ selector'); + } + return match[1]; +} + +export async function isComposerInteractive(win: ServerView): Promise { + return win.runInRenderer(IS_COMPOSER_INTERACTIVE_JS).catch(() => false); +} + +export async function isChannelViewLoaded(win: ServerView): Promise { + return win.runInRenderer(IS_CHANNEL_VIEW_LOADED_JS).catch(() => false); +} + +/** @deprecated Use isChannelViewLoaded — kept for existing imports. */ +export async function isChannelPostListLoaded(win: ServerView): Promise { + return win.runInRenderer(IS_CHANNEL_POST_LIST_LOADED_JS).catch(() => false); +} + +export async function isOnChannelUrl(win: ServerView, channelName: string): Promise { + const channelPath = `/channels/${channelName}`; + return win.runInRenderer(` + return window.location.pathname.includes(${JSON.stringify(channelPath)}); + `).catch(() => false); +} + +/** Wait until the Mattermost sidebar exposes the target channel item. */ +export async function waitForMattermostShell( + win: ServerView, + options?: {channelItem?: string; channelName?: string; timeout?: number}, +): Promise { + const channelItem = resolveChannelItem(options); + const timeout = options?.timeout ?? 60_000; + + await expect.poll(async () => { + try { + await win.waitForSelector(channelItem, {timeout: 2_000}); + return true; + } catch { + return false; + } + }, {timeout, message: `Mattermost shell must expose ${channelItem}`}).toBe(true); +} + +async function loadChannelByName(win: ServerView, channelName: string): Promise { + const channel = await resolveChannelByName(channelName); + await loadServerViewUrl(win.app, win.webContentsId, channel.url); + await activateServerView(win.app, win.webContentsId); +} + +/** + * Recover a stuck server view: main-process navigation, sidebar click, then reload. + */ +export async function recoverInteractiveChannel( + win: ServerView, + options?: {channelItem?: string; channelName?: string; timeout?: number}, +): Promise { + if (await isChannelViewLoaded(win)) { + return; + } + + const channelItem = resolveChannelItem(options); + const channelName = resolveChannelName(channelItem, options?.channelName); + const shellTimeout = Math.max(Math.floor((options?.timeout ?? 60_000) / 2), 2_000); + + await loadChannelByName(win, channelName); + await waitForMattermostShell(win, {channelItem, channelName, timeout: shellTimeout}); + + if (await isChannelViewLoaded(win)) { + return; + } + + await win.click(channelItem).catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (await isChannelViewLoaded(win)) { + return; + } + + await loadChannelByName(win, channelName); + await waitForMattermostShell(win, {channelItem, channelName, timeout: shellTimeout}); +} + +/** Reload when the channel shell failed to mount (blank hex background). */ +export async function recoverServerViewIfNeeded( + win: ServerView, + options?: {channelItem?: string; channelName?: string}, +): Promise { + const channelItem = resolveChannelItem(options); + const healthy = await win.runInRenderer(` + return Boolean( + document.querySelector('#channelHeaderTitle') + && document.querySelector(${JSON.stringify(channelItem)}), + ); + `).catch(() => false); + + if (healthy) { + return; + } + + await win.runInRenderer('window.location.reload(); return true;', true); + await waitForMattermostShell(win, {channelItem}); +} + +/** @deprecated Use recoverServerViewIfNeeded or recoverInteractiveChannel. */ +export const recoverChannelViewIfNeeded = recoverServerViewIfNeeded; + +export async function waitForInteractiveChannel( + win: ServerView, + options?: PrepareInteractiveChannelOptions, +): Promise { + const timeout = options?.timeout ?? 30_000; + const recover = options?.recover ?? true; + const pollStart = Date.now(); + const recoveryAt = pollStart + Math.min(timeout / 2, 10_000); + let recovered = false; + + await expect.poll(async () => { + if (recover && !recovered && Date.now() >= recoveryAt) { + recovered = true; + const remaining = Math.max(timeout - (Date.now() - pollStart), 2_000); + await recoverInteractiveChannel(win, {...options, timeout: remaining}); + } + return isChannelViewLoaded(win); + }, {timeout, message: 'Channel must become interactive'}).toBe(true); +} + +/** + * Activate the server view, dismiss overlays, optionally navigate, and wait until + * the channel composer is interactive. + */ +export async function prepareInteractiveChannel( + app: ElectronApplication, + entry: Pick, + options?: PrepareInteractiveChannelOptions, +): Promise { + const channelItem = resolveChannelItem(options); + const channelName = resolveChannelName(channelItem, options?.channelName); + const recover = options?.recover ?? true; + const timeout = options?.timeout ?? 30_000; + + await activateServerView(app, entry.webContentsId); + await dismissBlockingOverlays(entry.win); + + const needsNavigation = options?.channelName && !(await isOnChannelUrl(entry.win, channelName)); + const hasJsError = await entry.win.runInRenderer(HAS_CLIENT_JS_ERROR_JS).catch(() => false); + + if (needsNavigation || (recover && hasJsError)) { + await loadChannelByName(entry.win, channelName); + await activateServerView(app, entry.webContentsId); + await dismissBlockingOverlays(entry.win); + } + + if (recover && !(await isChannelViewLoaded(entry.win))) { + await recoverInteractiveChannel(entry.win, {channelItem, channelName}); + } + + await waitForInteractiveChannel(entry.win, {channelItem, channelName, timeout, recover}); +} + +/** @deprecated Use prepareInteractiveChannel or waitForInteractiveChannel. */ +export async function ensureChannelReady( + win: ServerView, + options?: {channelItem?: string; channelName?: string}, +): Promise { + await prepareInteractiveChannel(win.app, {win, webContentsId: win.webContentsId}, options); +} + +/** Wait for sidebar shell, then recover with a reload if the channel content failed to render. */ +export async function waitForMattermostShellReady( + win: ServerView, + options?: {channelItem?: string; channelName?: string; timeout?: number}, +): Promise { + await waitForMattermostShell(win, options); + await recoverServerViewIfNeeded(win, options); +} + +/** Wait until the channel post list finishes its initial load. */ +export async function waitForChannelPostListLoaded( + win: ServerView, + options?: {timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 15_000; + await expect.poll( + async () => isChannelPostListLoaded(win), + {timeout, message: 'Channel post list must finish loading'}, + ).toBe(true); +} diff --git a/e2e/helpers/downloads.ts b/e2e/helpers/downloads.ts index e6d4b5a6415..ea33d8e97ca 100644 --- a/e2e/helpers/downloads.ts +++ b/e2e/helpers/downloads.ts @@ -11,7 +11,7 @@ import type {ElectronApplication} from 'playwright'; import {waitForAppReady} from './appReadiness'; import {electronBinaryPath, appDir, emptyConfig} from './config'; -import {closeElectronAppFast} from './electronApp'; +import {closeElectronAppFast, registerElectronMainProcess} from './electronApp'; export type DownloadServer = { server: http.Server; @@ -138,6 +138,7 @@ export async function launchAppWithDownloadsDir(userDataDir: string, downloadLoc env: {...process.env, NODE_ENV: 'test'}, timeout: 60_000, }); + registerElectronMainProcess(app.process()?.pid); await waitForAppReady(app); return app; } diff --git a/e2e/helpers/electronApp.ts b/e2e/helpers/electronApp.ts index 3d9ac9f22b5..0fdb6f1d04a 100644 --- a/e2e/helpers/electronApp.ts +++ b/e2e/helpers/electronApp.ts @@ -169,7 +169,15 @@ export async function cleanupAllRegisteredElectronProcesses(): Promise { */ export function clearAllRegistryFiles(): void { for (const file of listRegistryFiles()) { - fs.rmSync(file, {force: true}); + try { + fs.rmSync(file, {force: true, recursive: true}); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + continue; + } + console.warn(`[e2e] Failed to remove registry shard ${file}:`, error); // eslint-disable-line no-console + } } } diff --git a/e2e/helpers/emptyApp.ts b/e2e/helpers/emptyApp.ts new file mode 100644 index 00000000000..1cda16cca5e --- /dev/null +++ b/e2e/helpers/emptyApp.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {mkdirSync} from 'fs'; + +import {expect} from '@playwright/test'; +import type {ElectronApplication, Page} from 'playwright'; +import {_electron as electron} from 'playwright'; + +import {waitForAppReady} from './appReadiness'; +import {electronBinaryPath, appDir, emptyConfig, writeConfigFile} from './config'; + +async function waitForWelcomeScreen(app: ElectronApplication): Promise { + let welcomeScreen: Page | undefined; + await expect.poll(async () => { + welcomeScreen = app.windows().find((w) => w.url().includes('welcomeScreen')); + return welcomeScreen; + }, {timeout: 15_000, message: 'Welcome screen window must appear'}).toBeTruthy(); + return welcomeScreen!; +} + +export async function launchEmptyApp( + testInfo: {outputDir: string}, + userDataSubdir = 'empty-userdata', +): Promise<{app: ElectronApplication; welcomeScreen: Page; userDataDir: string}> { + const userDataDir = `${testInfo.outputDir}/${userDataSubdir}`; + mkdirSync(userDataDir, {recursive: true}); + writeConfigFile(userDataDir, emptyConfig); + + let app: ElectronApplication | undefined; + try { + app = await electron.launch({ + executablePath: electronBinaryPath, + args: [appDir, `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu'], + env: {...process.env, NODE_ENV: 'test'}, + timeout: 60_000, + }); + await waitForAppReady(app); + + const welcomeScreen = await waitForWelcomeScreen(app); + await welcomeScreen.waitForLoadState('domcontentloaded'); + return {app, welcomeScreen, userDataDir}; + } catch (error) { + if (app) { + await app.close().catch(() => undefined); + } + throw error; + } +} diff --git a/e2e/helpers/helpMenuLinks.ts b/e2e/helpers/helpMenuLinks.ts new file mode 100644 index 00000000000..4c9b802700a --- /dev/null +++ b/e2e/helpers/helpMenuLinks.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +export type HelpMenuRemoteInfoPatch = { + helpLink?: string; + reportProblemLink?: string; + serverVersion?: string; +}; + +export async function patchHelpMenuRemoteInfo( + app: ElectronApplication, + patch: HelpMenuRemoteInfoPatch, +): Promise { + await app.evaluate(({ipcMain}, payload) => { + const refs = (global as any).__e2eTestRefs; + const serverId = refs?.ServerManager?.getCurrentServerId?.(); + if (!serverId) { + throw new Error('No current server is registered'); + } + + const existing = refs.ServerManager.getRemoteInfo(serverId) ?? {}; + const nextRemoteInfo = { + ...existing, + serverVersion: payload.serverVersion ?? existing.serverVersion ?? '10.0.0', + }; + if (payload.helpLink !== undefined) { + nextRemoteInfo.helpLink = payload.helpLink; + } + if (payload.reportProblemLink !== undefined) { + nextRemoteInfo.reportProblemLink = payload.reportProblemLink; + } + refs.ServerManager.updateRemoteInfo(serverId, nextRemoteInfo); + + const configData = refs.Config?.data ?? refs.Config?.combinedData; + if (!configData) { + throw new Error('Config.data is not available for menu refresh'); + } + + ipcMain.emit('emit-configuration', null, configData); + }, patch); +} + +export async function getHelpSubmenuLabels(app: ElectronApplication): Promise { + return app.evaluate(({app: electronApp}) => { + const helpMenu = electronApp.applicationMenu?.getMenuItemById('help'); + const labels: string[] = []; + const stack = [...(helpMenu?.submenu?.items ?? [])]; + + while (stack.length) { + const item = stack.shift(); + if (!item) { + continue; + } + if (typeof item.label === 'string') { + labels.push(item.label.trim()); + } + if (item.submenu?.items?.length) { + stack.push(...item.submenu.items); + } + } + + return labels; + }); +} diff --git a/e2e/helpers/historyMenu.ts b/e2e/helpers/historyMenu.ts new file mode 100644 index 00000000000..4145a6fb3d5 --- /dev/null +++ b/e2e/helpers/historyMenu.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {activateServerView} from './serverContext'; +import {isTransientEvaluateError} from './testRefs'; + +export async function clickHistoryMenuItem( + app: ElectronApplication, + label: 'Back' | 'Forward', + webContentsId: number, +): Promise { + await activateServerView(app, webContentsId); + + const deadline = Date.now() + 15_000; + + while (Date.now() < deadline) { + try { + const clicked = await app.evaluate(({Menu}, itemLabel) => { + const root = Menu.getApplicationMenu(); + if (!root) { + return false; + } + const stack = [...root.items]; + while (stack.length) { + const item = stack.shift()!; + if (item.label === '&History' || item.label === 'History') { + const target = item.submenu?.items?.find((sub) => sub.label === itemLabel); + if (target) { + target.click(); + return true; + } + } + if (item.submenu) { + stack.push(...item.submenu.items); + } + } + return false; + }, label); + if (clicked) { + return; + } + } catch (error) { + if (!isTransientEvaluateError(error)) { + throw error; + } + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Timed out clicking history menu item: ${label}`); +} diff --git a/e2e/helpers/login.ts b/e2e/helpers/login.ts index 2ad13f32441..5ce050e66c7 100644 --- a/e2e/helpers/login.ts +++ b/e2e/helpers/login.ts @@ -4,6 +4,7 @@ import {expect} from '@playwright/test'; import {isTransientEvaluateError} from './testRefs'; +import {CHANNEL_HEADER_SELECTORS, POST_TEXTBOX_SELECTOR} from './rendererUtils'; import type {ServerView} from './serverView'; async function isMattermostServerUrl(win: ServerView): Promise { @@ -29,8 +30,8 @@ async function hasAppShell(win: ServerView): Promise { return runRendererProbe(win, ` return Boolean( - document.querySelector('#post_textbox') - || document.querySelector('#channelHeaderTitle') + document.querySelector(${JSON.stringify(POST_TEXTBOX_SELECTOR)}) + || document.querySelector(${JSON.stringify(CHANNEL_HEADER_SELECTORS)}) || document.querySelector('input.search-bar.form-control'), ); `); diff --git a/e2e/helpers/loginSso.ts b/e2e/helpers/loginSso.ts new file mode 100644 index 00000000000..14954945744 --- /dev/null +++ b/e2e/helpers/loginSso.ts @@ -0,0 +1,179 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; + +import type {ServerView} from './serverView'; + +const MOCK_IDP_TITLE_SELECTOR = '#mock-idp-title'; + +export async function waitForLoginForm(serverWin: ServerView): Promise { + await serverWin.waitForSelector('#input_loginId', {timeout: 60_000}); +} + +/** + * Patch client config fetch so the login page renders a real OpenID external-login + * button with the desktop handleExternalAuth onClick handler (no injected DOM). + */ +export async function enableOpenIdOnLoginPage(serverWin: ServerView): Promise { + const installFetchPatch = async () => { + await serverWin.evaluate(() => { + const originalFetch = window.fetch.bind(window); + (window as any).__e2eOriginalFetch = originalFetch; + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await originalFetch(input, init); + let url: string; + if (typeof input === 'string') { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input.url; + } + if (!url.includes('/api/v4/config/client')) { + return response; + } + + const json = await response.clone().json(); + return new Response(JSON.stringify({ + ...json, + EnableSignUpWithOpenId: 'true', + OpenIdButtonText: 'Open ID', + }), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }; + }); + }; + + await installFetchPatch(); + await serverWin.evaluate(() => { + window.location.reload(); + }); + await installFetchPatch(); + await waitForLoginForm(serverWin); + + try { + await expect.poll( + () => serverWin.locator('#openid.external-login-button, #openid').count(), + {timeout: 30_000}, + ).toBeGreaterThan(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ((/timeout/i).test(message)) { + return false; + } + throw error; + } + + return true; +} + +export async function restoreLoginPageFetch(serverWin: ServerView): Promise { + await serverWin.evaluate(() => { + const original = (window as any).__e2eOriginalFetch; + if (original) { + window.fetch = original; + } + delete (window as any).__e2eOriginalFetch; + }); +} + +export async function waitForOpenIdLoginButton(serverWin: ServerView): Promise { + await serverWin.waitForSelector('#openid.external-login-button', {timeout: 30_000}); +} + +/** Desktop login header back control shown during /login/desktop (HeaderFooterRoute BackButton). */ +export async function clickLoginHeaderBack(serverWin: ServerView): Promise { + const backButton = serverWin.locator('[data-testid="back_button"]'); + await expect.poll( + () => backButton.isVisible(), + {timeout: 10_000, message: 'Login header Back must be visible during desktop SSO'}, + ).toBe(true); + await backButton.click(); +} + +/** + * Desktop global-header history control (visible only when logged in). + * MM-T2633 login SSO uses clickLoginHeaderBack instead; this is used when the + * webapp shell is still mounted after in-window navigation. + */ +export async function clickWebappHistoryBackIfVisible(serverWin: ServerView): Promise { + const back = serverWin.locator('[aria-label="Back"]'); + if ((await back.count()) === 0) { + return false; + } + await back.nth(0).click(); + return true; +} + +export type WindowOpenStubMode = 'noop' | 'mock-idp'; + +export async function installWindowOpenStub(serverWin: ServerView, mode: WindowOpenStubMode): Promise { + await serverWin.evaluate((stubMode) => { + (window as any).__e2eOriginalWindowOpen = window.open.bind(window); + window.open = () => { + if (stubMode === 'noop') { + return null; + } + + const mockHtml = 'Mock SSO' + + '

Mock SSO Provider

'; + document.open(); + document.write(mockHtml); + document.close(); + return null; + }; + }, mode); +} + +export async function restoreWindowOpen(serverWin: ServerView): Promise { + await serverWin.evaluate(() => { + const original = (window as any).__e2eOriginalWindowOpen; + if (original) { + window.open = original; + } + delete (window as any).__e2eOriginalWindowOpen; + }); +} + +export async function clickOpenIdLoginButton(serverWin: ServerView): Promise { + await waitForOpenIdLoginButton(serverWin); + await serverWin.click('#openid.external-login-button'); +} + +export async function waitForDesktopAuthPage(serverWin: ServerView): Promise { + await serverWin.waitForURL((url) => url.pathname.includes('/login/desktop'), {timeout: 30_000}); + await serverWin.waitForSelector('.DesktopAuthToken', {timeout: 15_000}); +} + +export async function waitForMockIdpPage(serverWin: ServerView): Promise { + await serverWin.waitForSelector(MOCK_IDP_TITLE_SELECTOR, {timeout: 15_000}); +} + +/** Browser-style back after mock in-window IdP (same WebContentsView). */ +export async function navigateBackInServerView(serverWin: ServerView): Promise { + if (await clickWebappHistoryBackIfVisible(serverWin)) { + return; + } + + await serverWin.evaluate(() => { + window.history.back(); + }); + + await expect.poll( + () => serverWin.evaluate(() => { + return Boolean(document.querySelector('#input_loginId')) || + Boolean(document.querySelector('.DesktopAuthToken')) || + window.location.pathname.includes('/login'); + }).catch(() => false), + {timeout: 10_000, message: 'Server view must navigate back after history.back()'}, + ).toBe(true); +} + +export async function clickOpenIdAndWaitForDesktopAuth(serverWin: ServerView): Promise { + await clickOpenIdLoginButton(serverWin); + await waitForDesktopAuthPage(serverWin); +} diff --git a/e2e/helpers/mainWindowFocus.ts b/e2e/helpers/mainWindowFocus.ts new file mode 100644 index 00000000000..adc8c32c5f9 --- /dev/null +++ b/e2e/helpers/mainWindowFocus.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication, Page} from 'playwright'; + +import {getMainWindowId} from './testRefs'; + +export async function focusMainBrowserWindow(app: ElectronApplication, mainWindow: Page): Promise { + await app.evaluate(({app: electronApp}) => { + const refs = (global as any).__e2eTestRefs; + const win = refs?.MainWindow?.get?.(); + if (!win || win.isDestroyed()) { + return false; + } + if (process.platform === 'darwin') { + electronApp.show(); + } + if (win.isMinimized()) { + win.restore(); + } + win.show(); + win.focus(); + return true; + }); + await mainWindow.bringToFront().catch(() => {}); + await mainWindow.click('#newTabButton').catch(async () => { + await mainWindow.click('.ServerDropdownButton').catch(async () => { + await mainWindow.click('body', {position: {x: 24, y: 24}}); + }); + }); +} + +export async function isMainWindowFocused(app: ElectronApplication): Promise { + const mainWindowId = await getMainWindowId(app); + return app.evaluate(({BrowserWindow}, id) => { + const win = BrowserWindow.fromId(id); + const focused = BrowserWindow.getFocusedWindow(); + return Boolean( + win && + !win.isDestroyed() && + (win.isFocused() || focused?.id === win.id), + ); + }, mainWindowId); +} + +export async function waitForMainWindowFocused( + app: ElectronApplication, + mainWindow: Page, + timeoutMs = 15_000, + message = 'Main window must receive OS focus', +): Promise { + await expect.poll(async () => { + if (await isMainWindowFocused(app)) { + return true; + } + await focusMainBrowserWindow(app, mainWindow); + return isMainWindowFocused(app); + }, {timeout: timeoutMs, message}).toBe(true); +} diff --git a/e2e/helpers/mattermostShell.ts b/e2e/helpers/mattermostShell.ts index d38b6f77a94..f864c8b3d8c 100644 --- a/e2e/helpers/mattermostShell.ts +++ b/e2e/helpers/mattermostShell.ts @@ -2,150 +2,49 @@ // See LICENSE.txt for license information. import {expect} from '@playwright/test'; - -import {isTransientEvaluateError} from './testRefs'; +import type {ElectronApplication} from 'playwright'; + +import { + ensureChannelReady, + isChannelPostListLoaded, + isChannelViewLoaded, + isComposerInteractive, + prepareInteractiveChannel, + recoverChannelViewIfNeeded, + recoverInteractiveChannel, + recoverServerViewIfNeeded, + waitForChannelPostListLoaded, + waitForInteractiveChannel, + waitForMattermostShell, + waitForMattermostShellReady, +} from './channelReadiness'; +import { + POST_TEXTBOX_CANDIDATES, + POST_TEXTBOX_RESOLVER_JS, + POST_TEXTBOX_SELECTOR, +} from './rendererUtils'; import type {ServerView} from './serverView'; -export const POST_TEXTBOX_CANDIDATES = [ - '[data-slate-editor="true"]', - '#post_textbox[contenteditable="true"]', - '[data-testid="post_textbox"][contenteditable="true"]', - '#post_textbox', - '[data-testid="post_textbox"]', - '.post-create__input [contenteditable="true"]', - '.post-create__input [role="textbox"]', - '.AdvancedTextEditor [contenteditable="true"]', - '[role="textbox"][contenteditable="true"]', - 'textarea#post_textbox', -] as const; - -export const POST_TEXTBOX_SELECTOR = POST_TEXTBOX_CANDIDATES.join(', '); - -const POST_TEXTBOX_CANDIDATES_JSON = JSON.stringify(POST_TEXTBOX_CANDIDATES); - -/** - * Shared renderer-side JS, inlined into each `runInRenderer` string below. - * Defines `__mmIsVisible` and `__mmResolvePostTextboxRoot` once so the four - * functions in this file don't each carry their own copy of the candidate - * resolution logic — `runInRenderer` evaluates a raw string in the renderer - * process, so this has to be textually interpolated rather than imported. - */ -const POST_TEXTBOX_RESOLVER_JS = ` - const __mmIsVisible = (element) => { - if (!element || !element.isConnected) { - return false; - } - const style = window.getComputedStyle(element); - if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { - return false; - } - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; - }; - - const __mmResolvePostTextboxRoot = () => { - const seen = new Set(); - const candidates = []; - for (const selector of ${POST_TEXTBOX_CANDIDATES_JSON}) { - for (const element of document.querySelectorAll(selector)) { - if (!seen.has(element)) { - seen.add(element); - candidates.push(element); - } - } - } - for (const candidate of candidates) { - if (!__mmIsVisible(candidate)) { - continue; - } - if (candidate.matches('[contenteditable="true"], textarea, input')) { - return candidate; - } - const nested = candidate.querySelector('[contenteditable="true"], textarea, input'); - if (nested && __mmIsVisible(nested)) { - return nested; - } - } - return null; - }; -`; - -/** - * Wait until the Mattermost webapp shell is interactive in a server view. - */ -export async function waitForMattermostShell( - win: ServerView, - options?: {channelItem?: string; timeout?: number}, -) { - const channelItem = options?.channelItem ?? '#sidebarItem_town-square'; - const timeout = options?.timeout ?? 60_000; - - await expect.poll(async () => { - try { - await win.waitForSelector(channelItem, {timeout: 2_000}); - return true; - } catch { - return false; - } - }, {timeout, message: `Mattermost shell must expose ${channelItem}`}).toBe(true); -} - -/** - * Reload the server view when the channel shell failed to mount (blank hex background). - */ -export async function recoverServerViewIfNeeded( - win: ServerView, - options?: {channelItem?: string}, -) { - const channelItem = options?.channelItem ?? '#sidebarItem_town-square'; - const healthy = await win.runInRenderer(` - return Boolean( - document.querySelector('#channelHeaderTitle') - && document.querySelector(${JSON.stringify(channelItem)}), - ); - `).catch(() => false); - - if (healthy) { - return; - } - - try { - await win.runInRenderer('window.location.reload(); return true;', true); - } catch (error) { - // reload() tears down the renderer; runInRenderer may reject after navigation starts. - if (!isTransientEvaluateError(error)) { - throw error; - } - } - await waitForMattermostShell(win, {channelItem}); -} - -/** Wait for the shell to mount, then recover it if the channel content failed to render. */ -export async function waitForMattermostShellReady( - win: ServerView, - options?: {channelItem?: string; timeout?: number}, -): Promise { - await waitForMattermostShell(win, options); - await recoverServerViewIfNeeded(win, options); -} - -/** Wait until the channel post list finishes its initial load. */ -export async function waitForChannelPostListLoaded( - win: ServerView, - options?: {timeout?: number}, -): Promise { - const timeout = options?.timeout ?? 15_000; - await expect.poll( - async () => win.evaluate(() => !document.querySelector( - '.post-list__loading, .post-list__dynamic-loading, .loading-screen', - )), - {timeout, message: 'Channel post list must finish loading'}, - ).toBe(true); -} +export { + ensureChannelReady, + isChannelPostListLoaded, + isChannelViewLoaded, + isComposerInteractive, + prepareInteractiveChannel, + recoverChannelViewIfNeeded, + recoverInteractiveChannel, + recoverServerViewIfNeeded, + waitForChannelPostListLoaded, + waitForInteractiveChannel, + waitForMattermostShell, + waitForMattermostShellReady, +}; + +export {POST_TEXTBOX_CANDIDATES, POST_TEXTBOX_SELECTOR}; /** Read the current post textbox contents (textarea value or contenteditable text). */ export async function getPostTextboxValue(win: ServerView): Promise { - const value = await win.runInRenderer(` + const value = await win.runInRenderer(` ${POST_TEXTBOX_RESOLVER_JS} const root = __mmResolvePostTextboxRoot(); @@ -158,12 +57,12 @@ export async function getPostTextboxValue(win: ServerView): Promise { return root.innerText || root.textContent || ''; `, true); - return value ?? ''; + return (value as string | undefined) ?? ''; } /** Press a keyboard shortcut on the post textbox. */ export async function pressPostTextboxKey(win: ServerView, key: string): Promise { - const focused = await win.runInRenderer(` + const focused = await win.runInRenderer(` ${POST_TEXTBOX_RESOLVER_JS} const root = __mmResolvePostTextboxRoot(); @@ -188,7 +87,7 @@ export async function typeIntoPostTextbox(win: ServerView, text: string): Promis await win.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 10_000}); await win.click(POST_TEXTBOX_SELECTOR); - const inserted = await win.runInRenderer(` + const inserted = await win.runInRenderer(` const value = ${JSON.stringify(text)}; ${POST_TEXTBOX_RESOLVER_JS} @@ -235,7 +134,7 @@ export async function getPostTextboxWordPoint( win: ServerView, word: string, ): Promise<{x: number; y: number} | null> { - return win.runInRenderer<{x: number; y: number} | null>(` + return win.runInRenderer(` const target = ${JSON.stringify(word)}; ${POST_TEXTBOX_RESOLVER_JS} @@ -280,9 +179,6 @@ export async function getPostTextboxWordPoint( const textareaRect = textarea.getBoundingClientRect(); document.body.removeChild(mirror); - // markerRect is relative to the mirror (anchored at 0,0 in body coords), - // so (markerRect - mirrorRect) gives the offset inside the mirror. Add that - // to the textarea's viewport position and subtract scroll for the final point. return { x: Math.round( textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft + (markerRect.width / 2), @@ -352,3 +248,71 @@ export async function getPostTextboxWordPoint( }; `, true); } + +export async function rightClickAtPoint( + app: ElectronApplication, + webContentsId: number, + point: {x: number; y: number}, +): Promise { + await app.evaluate(({webContents}, payload) => { + const wc = webContents.fromId(payload.id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.id} is not available`); + } + wc.focus(); + wc.sendInputEvent({type: 'mouseMove', x: payload.x, y: payload.y}); + wc.sendInputEvent({type: 'mouseDown', x: payload.x, y: payload.y, button: 'right', clickCount: 1}); + wc.sendInputEvent({type: 'mouseUp', x: payload.x, y: payload.y, button: 'right', clickCount: 1}); + }, {id: webContentsId, ...point}); +} + +export async function listenForNativeContextMenu(app: ElectronApplication, webContentsId: number): Promise { + await app.evaluate(({webContents}, id) => { + const previousListener = (global as any).__e2eNativeContextMenuListener as + | ((event: unknown, params: unknown) => void) + | undefined; + const previousWebContentsId = (global as any).__e2eNativeContextMenuListenerWebContentsId as number | undefined; + if (previousListener && previousWebContentsId != null) { + const previousWc = webContents.fromId(previousWebContentsId); + if (previousWc && !previousWc.isDestroyed()) { + previousWc.off('context-menu', previousListener); + } + } + + const wc = webContents.fromId(id); + if (!wc || wc.isDestroyed()) { + return; + } + delete (global as any).__e2eNativeContextMenu; + + const listener = (_event: unknown, params: unknown) => { + (global as any).__e2eNativeContextMenu = params; + }; + (global as any).__e2eNativeContextMenuListener = listener; + (global as any).__e2eNativeContextMenuListenerWebContentsId = id; + wc.on('context-menu', listener); + }, webContentsId); +} + +export async function waitForNativeContextMenu(app: ElectronApplication): Promise> { + await expect.poll(async () => app.evaluate(() => { + const params = (global as any).__e2eNativeContextMenu; + return Boolean(params); + }), {timeout: 10_000, message: 'Native context menu must open'}).toBe(true); + + return app.evaluate(() => (global as any).__e2eNativeContextMenu as Record); +} + +export async function applySpellcheckSuggestion( + app: ElectronApplication, + webContentsId: number, + suggestion: string, +): Promise { + await app.evaluate(({webContents}, payload) => { + const wc = webContents.fromId(payload.id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.id} is not available`); + } + wc.replaceMisspelling(payload.suggestion); + }, {id: webContentsId, suggestion}); +} diff --git a/e2e/helpers/menu.ts b/e2e/helpers/menu.ts index a6c83df81e6..684b3aadfb9 100644 --- a/e2e/helpers/menu.ts +++ b/e2e/helpers/menu.ts @@ -3,6 +3,7 @@ import type {ElectronApplication} from 'playwright'; +import {waitForAppReady} from './appReadiness'; import {isTransientEvaluateError} from './testRefs'; type MenuItemMatcher = { @@ -131,3 +132,27 @@ export async function clickApplicationMenuItem( throw new Error(`Timed out clicking menu item ${menuId}: ${JSON.stringify(matcher)}`); } + +/** + * Open the "Sign in to Another Server" modal via the application menu. + * Uses direct menu-item invocation (reliable on headless Windows CI). + */ +export async function openSignInToAnotherServerModal(app: ElectronApplication) { + await waitForAppReady(app); + + const menuId = process.platform === 'darwin' ? 'app' : 'file'; + const newServerWindowPromise = app.waitForEvent('window', { + predicate: (window) => window.url().includes('newServer'), + timeout: 30_000, + }); + newServerWindowPromise.catch(() => undefined); + + try { + await clickApplicationMenuItem(app, menuId, {labelIncludes: 'Sign in'}); + } catch (error) { + await newServerWindowPromise.catch(() => undefined); + throw error; + } + + return newServerWindowPromise; +} diff --git a/e2e/helpers/overlayWindows.ts b/e2e/helpers/overlayWindows.ts index 358d67e8fb0..1dbfb6c8968 100644 --- a/e2e/helpers/overlayWindows.ts +++ b/e2e/helpers/overlayWindows.ts @@ -5,6 +5,28 @@ import type {ElectronApplication} from 'playwright'; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const hasOverlayOpen = async (app: ElectronApplication, timeoutMs = 3_000): Promise => { + return Promise.race([ + app.evaluate(({BrowserWindow}) => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) { + continue; + } + try { + const url = win.webContents.getURL(); + if (url.includes('dropdown') || url.includes('downloadsDropdown.html')) { + return true; + } + } catch { + // Ignore windows that disappear while iterating. + } + } + return false; + }), + sleep(timeoutMs).then(() => false), + ]).catch(() => false); +}; + export async function closeOverlayWindowsIfOpen(app: ElectronApplication, timeoutMs = 3_000): Promise { // app.evaluate can hang if the Electron main process is blocked or unresponsive // (e.g. during teardown). Cap the wait so setup/teardown never deadlock. @@ -27,4 +49,13 @@ export async function closeOverlayWindowsIfOpen(app: ElectronApplication, timeou }); await Promise.race([closePromise, sleep(timeoutMs)]); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const remaining = Math.max(0, deadline - Date.now()); + if (!(await hasOverlayOpen(app, remaining))) { + return; + } + await sleep(100); + } } diff --git a/e2e/helpers/popoutWindow.ts b/e2e/helpers/popoutWindow.ts new file mode 100644 index 00000000000..a7e863b307a --- /dev/null +++ b/e2e/helpers/popoutWindow.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication, Page} from 'playwright'; + +import {waitForWindow} from './electronApp'; +import {clickApplicationMenuItem} from './menu'; +import {ServerView} from './serverView'; + +const POPOUT_URL_FRAGMENT = 'popout.html'; + +export function getPopoutWindows(app: ElectronApplication): Page[] { + return app.windows().filter((window) => { + try { + return window.url().includes(POPOUT_URL_FRAGMENT); + } catch { + return false; + } + }); +} + +export function popoutWindowCount(app: ElectronApplication): number { + return getPopoutWindows(app).length; +} + +function popoutTimeoutMs(): number { + return process.platform === 'linux' ? 45_000 : 30_000; +} + +export async function waitForPopoutWindowEvent(app: ElectronApplication): Promise { + return app.waitForEvent('window', { + timeout: popoutTimeoutMs(), + predicate: (page) => { + try { + return page.url().includes(POPOUT_URL_FRAGMENT); + } catch { + return false; + } + }, + }); +} + +export async function waitForPopoutWindow(app: ElectronApplication, extraCount = 1): Promise { + const baseline = popoutWindowCount(app); + let popout: Page | undefined; + + await expect.poll(() => { + const popouts = getPopoutWindows(app); + if (popouts.length >= baseline + extraCount) { + popout = popouts[popouts.length - 1]; + } + return popouts.length; + }, { + timeout: popoutTimeoutMs(), + message: 'Popout BrowserWindow must appear', + }).toBe(baseline + extraCount); + + if (!popout) { + throw new Error('Popout window was not available after wait'); + } + + return popout; +} + +export async function openPopoutViaFileMenu(app: ElectronApplication, mainWindow: Page): Promise { + await mainWindow.bringToFront().catch(() => {}); + const windowPromise = waitForPopoutWindowEvent(app); + await clickApplicationMenuItem(app, 'file', {label: 'New Window'}); + const popout = await windowPromise; + await popout.waitForLoadState('domcontentloaded').catch(() => {}); + return popout; +} + +/** Alias used by server_management popout specs. */ +export const openPopoutWindow = openPopoutViaFileMenu; + +export async function openChannelInNewWindow(app: ElectronApplication, channelPath: string): Promise { + const windowPromise = waitForPopoutWindowEvent(app); + const created = await app.evaluate((_, initialPath) => { + const refs = (global as any).__e2eTestRefs; + const serverId = refs?.ServerManager?.getCurrentServerId?.(); + const server = serverId ? refs.ServerManager.getServer(serverId) : undefined; + if (!server) { + return false; + } + const view = refs.ViewManager.createView(server, 'window', initialPath); + return Boolean(view); + }, channelPath); + expect(created, 'ViewManager must create a window-type view').toBe(true); + const popout = await windowPromise; + await popout.waitForLoadState('domcontentloaded').catch(() => {}); + return popout; +} + +export async function getCurrentServerId(app: ElectronApplication): Promise { + return app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + return refs?.ServerManager?.getCurrentServerId?.() as string | undefined; + }); +} + +export async function getWindowTypeView(app: ElectronApplication) { + const serverId = await getCurrentServerId(app); + expect(serverId).toBeTruthy(); + + let windowView: {viewId: string; webContentsId: number | null} | null = null; + await expect.poll(async () => { + windowView = await app.evaluate((_, sid) => { + const refs = (global as any).__e2eTestRefs; + const windowViews = refs.ViewManager.getViewsByServerId(sid). + filter((view: {type: string}) => view.type === 'window'); + const latest = windowViews[windowViews.length - 1]; + if (!latest) { + return null; + } + const wcView = refs.WebContentsManager.getView(latest.id); + return { + viewId: latest.id, + webContentsId: wcView?.webContentsId ?? null, + }; + }, serverId!); + return windowView; + }, {timeout: 30_000, message: 'Window-type view must register in ViewManager'}).not.toBeNull(); + + return windowView!; +} + +export async function getPopoutServerView(app: ElectronApplication): Promise { + const serverId = await getCurrentServerId(app); + expect(serverId).toBeTruthy(); + + let webContentsId: number | null = null; + await expect.poll(async () => { + const entries = await app.evaluate((_, sid) => { + const refs = (global as any).__e2eTestRefs; + return refs.ViewManager.getViewsByServerId(sid). + filter((view: {type: string}) => view.type === 'window'). + map((view: {id: string}) => { + const wcView = refs.WebContentsManager.getView(view.id); + return wcView?.webContentsId ?? null; + }). + filter(Boolean); + }, serverId!); + webContentsId = (entries as number[])[(entries as number[]).length - 1] ?? null; + return webContentsId; + }, {timeout: 30_000, message: 'Popout server view webContents must register'}).not.toBeNull(); + + return new ServerView(app, webContentsId!); +} + +export async function closePopoutWindow( + app: ElectronApplication, + popoutWindow: Page, + waitForAllClosed = false, +): Promise { + const browserWindow = await app.browserWindow(popoutWindow); + const closeTimeout = process.platform === 'linux' ? 5_000 : 15_000; + await Promise.all([ + popoutWindow.waitForEvent('close', {timeout: closeTimeout}), + browserWindow.evaluate((w) => (w as Electron.BrowserWindow).close()), + ]).catch(async () => { + await browserWindow.evaluate((w) => { + if (!(w as Electron.BrowserWindow).isDestroyed()) { + (w as Electron.BrowserWindow).destroy(); + } + }).catch(() => {}); + }); + + if (!waitForAllClosed) { + return; + } + + await expect.poll(() => popoutWindowCount(app), {timeout: 10_000}).toBe(0); +} + +export async function closeAllPopouts(app: ElectronApplication): Promise { + const popoutWindows = getPopoutWindows(app); + + for (const popout of popoutWindows) { + await closePopoutWindow(app, popout, false).catch(() => {}); + } + + if (popoutWindows.length > 0) { + await expect.poll(() => popoutWindowCount(app), {timeout: 10_000}).toBe(0); + } +} + +type DesktopPopoutOptions = Record; + +async function openPopoutViaDesktopApi( + win: ServerView, + app: ElectronApplication, + popoutPath: string, + options: DesktopPopoutOptions, + unavailableMessage: string, +): Promise { + const windowPromise = waitForPopoutWindowEvent(app); + const opened = await win.runInRenderer(` + const path = ${JSON.stringify(popoutPath)}; + const options = ${JSON.stringify(options)}; + const api = window.desktopAPI; + if (!api?.openPopout) { + return false; + } + void api.openPopout(path, options); + return true; + `, true); + expect(opened, unavailableMessage).toBe(true); + await windowPromise; +} + +export async function openRhsPopoutViaDesktopApi( + win: ServerView, + app: ElectronApplication, + channelPath: string, +): Promise { + await openPopoutViaDesktopApi( + win, + app, + channelPath, + {isRHS: true}, + 'desktopAPI.openPopout must be available in the server view', + ); +} + +export async function openThreadPopoutViaDesktopApi( + win: ServerView, + app: ElectronApplication, + threadPath: string, +): Promise { + await openPopoutViaDesktopApi( + win, + app, + threadPath, + {}, + 'desktopAPI.openPopout must be available for thread popouts', + ); +} + +export async function resetTabsAndPopouts(app: ElectronApplication): Promise { + await closeAllPopouts(app); + await app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const serverId = refs?.ServerManager?.getCurrentServerId?.(); + if (!serverId) { + return false; + } + + const primaryView = refs.ViewManager.getPrimaryView(serverId); + refs.ViewManager.getViewsByServerId(serverId).forEach((view: {id: string; type: string}) => { + if (view.id !== primaryView?.id) { + refs.ViewManager.removeView(view.id); + } else if (view.type === 'window') { + refs.ViewManager.updateViewType(view.id, 'tab'); + } + }); + + if (primaryView) { + refs.TabManager.switchToTab(primaryView.id); + } + return true; + }); + + const indexWindow = await waitForWindow(app, 'index'); + await indexWindow.bringToFront().catch(() => {}); + return indexWindow; +} diff --git a/e2e/helpers/prepareServerView.ts b/e2e/helpers/prepareServerView.ts index 442e0b7d964..7243d880e38 100644 --- a/e2e/helpers/prepareServerView.ts +++ b/e2e/helpers/prepareServerView.ts @@ -1,25 +1,11 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ElectronApplication} from 'playwright'; - -import {closeOverlayWindowsIfOpen} from './overlayWindows'; -import {evaluateInMainProcessWithArg} from './testRefs'; - -/** - * Close overlay windows and focus a Mattermost server WebContentsView so - * renderer automation targets the channel UI instead of dropdown overlays. - */ -export async function prepareMattermostServerView( - app: ElectronApplication, - webContentsId: number, -): Promise { - await closeOverlayWindowsIfOpen(app); - await evaluateInMainProcessWithArg(app, ({webContents}, id) => { - const wc = webContents.fromId(id); - if (!wc || wc.isDestroyed()) { - throw new Error(`webContents ${id} is not available`); - } - wc.focus(); - }, webContentsId); -} +export { + activateServerView, + activateServerEntry, + expectServerViewUrl, + getServerEntry, + getServerViewUrl, + prepareMattermostServerView, +} from './serverContext'; diff --git a/e2e/helpers/rendererUtils.ts b/e2e/helpers/rendererUtils.ts new file mode 100644 index 00000000000..da329039709 --- /dev/null +++ b/e2e/helpers/rendererUtils.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** Sidebar channel list item for a channel name (e.g. town-square). */ +export function channelItemSelector(channelName: string): string { + return `#sidebarItem_${channelName}`; +} + +export const CHANNEL_HEADER_SELECTORS = [ + '#channelHeaderTitle', + '[data-testid="channelHeaderTitle"]', + '.channel-header__title', + '[aria-label="channel header region"] strong', +].join(', '); + +export const POST_TEXTBOX_CANDIDATES = [ + '[data-slate-editor="true"]', + '#post_textbox[contenteditable="true"]', + '[data-testid="post_textbox"][contenteditable="true"]', + '#post_textbox', + '[data-testid="post_textbox"]', + '.post-create__input [contenteditable="true"]', + '.post-create__input [role="textbox"]', + '.AdvancedTextEditor [contenteditable="true"]', + '[role="textbox"][contenteditable="true"]', + 'textarea#post_textbox', +] as const; + +export const POST_TEXTBOX_SELECTOR = POST_TEXTBOX_CANDIDATES.join(', '); + +const POST_TEXTBOX_CANDIDATES_JSON = JSON.stringify(POST_TEXTBOX_CANDIDATES); +const CHANNEL_HEADER_SELECTORS_JSON = JSON.stringify(CHANNEL_HEADER_SELECTORS); + +/** + * Shared visibility helper for renderer-side probes. + * Inlined into runInRenderer strings — cannot be imported in the renderer process. + */ +export const IS_VISIBLE_JS = ` + const __mmIsVisible = (element) => { + if (!(element instanceof HTMLElement) || !element.isConnected) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; +`; + +/** Resolve the editable post composer root element. */ +export const POST_TEXTBOX_RESOLVER_JS = ` + ${IS_VISIBLE_JS} + + const __mmResolvePostTextboxRoot = () => { + const candidates = ${POST_TEXTBOX_CANDIDATES_JSON}.map((selector) => document.querySelector(selector)).filter(Boolean); + for (const candidate of candidates) { + if (!__mmIsVisible(candidate)) { + continue; + } + if (candidate.matches('[contenteditable="true"], textarea, input')) { + return candidate; + } + const nested = candidate.querySelector('[contenteditable="true"], textarea, input'); + if (nested && __mmIsVisible(nested)) { + return nested; + } + } + return null; + }; +`; + +/** True when the post composer is visible and can receive keyboard focus. */ +export const IS_COMPOSER_INTERACTIVE_JS = ` + ${POST_TEXTBOX_RESOLVER_JS} + + const root = __mmResolvePostTextboxRoot(); + if (!root) { + return false; + } + if (root.closest('[aria-disabled="true"]')) { + return false; + } + root.focus?.(); + return document.activeElement === root || root.contains(document.activeElement); +`; + +/** True when loading spinners are not visible (style-only, no rect check). */ +export const IS_ELEMENT_SHOWN_JS = ` + const __mmIsShown = (element) => { + if (!(element instanceof HTMLElement) || !element.isConnected) { + return false; + } + const style = window.getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'; + }; +`; + +const POST_LIST_COMPOSER_SELECTORS = POST_TEXTBOX_SELECTOR; + +/** Lenient channel-ready check used by legacy waitForChannelPostListLoaded callers. */ +export const IS_CHANNEL_POST_LIST_LOADED_JS = ` + ${IS_ELEMENT_SHOWN_JS} + + const postList = document.querySelector( + '#post-list, .post-list, [data-testid="postList"], .post-list-holder', + ); + if ([...(postList?.querySelectorAll('.post-list__loading, .post-list__dynamic-loading') ?? [])].some(__mmIsShown)) { + return false; + } + + const channelLoading = document.querySelector( + '#channelView .loading-screen, .channel-view .loading-screen, .ChannelLoader, .channel-loader', + ); + if (__mmIsShown(channelLoading)) { + return false; + } + + return Boolean( + document.querySelector(${CHANNEL_HEADER_SELECTORS_JSON}) + && document.querySelector('${POST_LIST_COMPOSER_SELECTORS}'), + ); +`; + +/** True when the channel header and interactive composer are both present. */ +export const IS_CHANNEL_VIEW_LOADED_JS = ` + ${POST_TEXTBOX_RESOLVER_JS} + + const hasHeader = document.querySelector(${CHANNEL_HEADER_SELECTORS_JSON}); + const composer = __mmResolvePostTextboxRoot() + || document.querySelector('${POST_LIST_COMPOSER_SELECTORS}'); + if (hasHeader && composer) { + return true; + } + + const postList = document.querySelector( + '#post-list, .post-list, [data-testid="postList"], .post-list-holder', + ); + if ([...(postList?.querySelectorAll('.post-list__loading, .post-list__dynamic-loading') ?? [])].some(__mmIsVisible)) { + return false; + } + + const channelLoading = document.querySelector( + '#channelView .loading-screen, .channel-view .loading-screen, .ChannelLoader, .channel-loader', + ); + if (__mmIsVisible(channelLoading)) { + return false; + } + + return Boolean(hasHeader && composer); +`; + +/** True when a top-level JS error banner is shown (webapp crashed partially). */ +export const HAS_CLIENT_JS_ERROR_JS = ` + return Boolean( + document.body?.textContent?.includes('A JavaScript error has occurred') + || document.querySelector('.error-bar, [data-testid="errorInModal"]'), + ); +`; diff --git a/e2e/helpers/serverContext.ts b/e2e/helpers/serverContext.ts new file mode 100644 index 00000000000..abde6819864 --- /dev/null +++ b/e2e/helpers/serverContext.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {findMainWindow} from './appReadiness'; +import {closeOverlayWindowsIfOpen} from './overlayWindows'; +import type {ServerEntry, ServerMap} from './serverMap'; +import {evaluateInMainProcessWithArg} from './testRefs'; + +/** + * Make a specific server WebContentsView the active, focused target for automation + * and application menu handlers (History, View, Find, etc.). + * + * ServerView always executes JS in the target webContentsId, but menu actions and + * keyboard routing use WebContentsManager.getFocusedView() / TabManager state. + * Tests flaked when overlays stole focus or the github tab was left active. + */ +export async function activateServerView( + app: ElectronApplication, + webContentsId: number, +): Promise { + await closeOverlayWindowsIfOpen(app); + + await evaluateInMainProcessWithArg(app, ({webContents, BrowserWindow}, id) => { + const refs = (global as any).__e2eTestRefs; + if (!refs) { + throw new Error('__e2eTestRefs is not available'); + } + + const mmView = refs.WebContentsManager.getViewByWebContentsId(id); + if (!mmView) { + throw new Error(`No server view registered for webContentsId ${id}`); + } + + const tabView = refs.ViewManager.getView(mmView.id); + if (tabView) { + refs.ServerManager.updateCurrentServer(tabView.serverId); + refs.TabManager.switchToTab(tabView.id); + } + + refs.TabManager.focusCurrentTab(); + + const wc = webContents.fromId(id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${id} is not available`); + } + wc.focus(); + + // Menu handlers read this when the app menu blurs the webContents (macOS/Windows). + refs.WebContentsManager.focusedWebContentsView = mmView.id; + + const mainWindow = refs.MainWindow.get() ?? BrowserWindow.getAllWindows().find((win) => { + return !win.isDestroyed() && win.webContents.getURL().includes('index'); + }); + mainWindow?.show(); + mainWindow?.focus(); + }, webContentsId); + + const mainWindow = findMainWindow(app); + if (mainWindow) { + await mainWindow.bringToFront().catch(() => {}); + await mainWindow.keyboard.press('Escape').catch(() => {}); + } +} + +/** @deprecated Use activateServerView — kept for existing imports. */ +export const prepareMattermostServerView = activateServerView; + +export async function getServerViewUrl(app: ElectronApplication, webContentsId: number): Promise { + return evaluateInMainProcessWithArg(app, ({webContents}, id) => { + return webContents.fromId(id)?.getURL() ?? ''; + }, webContentsId); +} + +export function getServerEntry(serverMap: ServerMap, serverName: string, tabIndex = 0): ServerEntry { + const entry = serverMap[serverName]?.[tabIndex]; + if (!entry) { + throw new Error(`Server "${serverName}" tab ${tabIndex} is not registered in the server map`); + } + return entry; +} + +export async function activateServerEntry( + app: ElectronApplication, + entry: ServerEntry, +): Promise { + await activateServerView(app, entry.webContentsId); +} + +export async function expectServerViewUrl( + app: ElectronApplication, + webContentsId: number, + pattern: RegExp, + options?: {timeout?: number; message?: string}, +): Promise { + await expect.poll( + () => getServerViewUrl(app, webContentsId), + { + timeout: options?.timeout ?? 15_000, + message: options?.message ?? `Server view URL must match ${pattern}`, + }, + ).toMatch(pattern); +} + +const SEARCH_INPUT = 'input.search-bar.form-control'; + +/** Reload the server view through MattermostWebContentsView.reload(), bypassing menu focus routing. */ +export async function reloadServerView(app: ElectronApplication, webContentsId: number): Promise { + await activateServerView(app, webContentsId); + await evaluateInMainProcessWithArg(app, (_electron, id) => { + const refs = (global as any).__e2eTestRefs; + const mmView = refs?.WebContentsManager.getViewByWebContentsId(id); + if (!mmView) { + throw new Error(`No server view registered for webContentsId ${id}`); + } + mmView.reload(mmView.currentURL); + }, webContentsId); +} + +/** Navigate the server view to an absolute URL and wait for the load to finish. */ +export async function loadServerViewUrl( + app: ElectronApplication, + webContentsId: number, + url: string, +): Promise { + await activateServerView(app, webContentsId); + await evaluateInMainProcessWithArg(app, ({webContents}, payload) => { + return new Promise((resolve, reject) => { + const refs = (global as any).__e2eTestRefs; + const mmView = refs?.WebContentsManager.getViewByWebContentsId(payload.id); + const wc = webContents.fromId(payload.id); + if (!mmView || !wc || wc.isDestroyed()) { + reject(new Error(`No server view registered for webContentsId ${payload.id}`)); + return; + } + + const timeout = setTimeout(() => { + reject(new Error(`Timed out loading server view URL ${payload.url}`)); + }, 45_000); + const finish = () => { + clearTimeout(timeout); + resolve(); + }; + + wc.once('did-finish-load', finish); + mmView.load(payload.url); + }); + }, {id: webContentsId, url}); +} + +/** Open the Mattermost search bar via the server view (Ctrl+Shift+F), bypassing menu focus routing. */ +export async function openServerSearch(app: ElectronApplication, webContentsId: number): Promise { + await activateServerView(app, webContentsId); + await evaluateInMainProcessWithArg(app, (_electron, id) => { + const refs = (global as any).__e2eTestRefs; + const mmView = refs?.WebContentsManager.getViewByWebContentsId(id); + if (!mmView) { + throw new Error(`No server view registered for webContentsId ${id}`); + } + mmView.openFind(); + }, webContentsId); +} + +/** Poll until the search input exists, is visible, and has keyboard focus. */ +export async function waitForSearchBarFocused( + win: import('./serverView').ServerView, + options?: {timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 30_000; + await expect.poll(async () => win.runInRenderer(` + const input = document.querySelector(${JSON.stringify(SEARCH_INPUT)}); + if (!input) { + return false; + } + const rect = input.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } + if (input !== document.activeElement) { + input.focus?.(); + } + return input === document.activeElement; + `), {timeout, message: 'Search bar must be visible and focused'}).toBe(true); +} + +export {SEARCH_INPUT}; diff --git a/e2e/helpers/server_api/channel.ts b/e2e/helpers/server_api/channel.ts index f8224f2968f..ca6fae18943 100644 --- a/e2e/helpers/server_api/channel.ts +++ b/e2e/helpers/server_api/channel.ts @@ -36,6 +36,10 @@ export function buildChannelUrl(baseUrl: string, teamName: string, channelName: return `${baseUrl}/${teamName}/channels/${channelName}`; } +export function resolvedChannelPath(channel: ResolvedChannel): string { + return new URL(channel.url).pathname; +} + export async function resolveChannelByName( channelName: string, credentials = getTestServerCredentials(), diff --git a/e2e/helpers/server_api/post.ts b/e2e/helpers/server_api/post.ts new file mode 100644 index 00000000000..dd832d86dd8 --- /dev/null +++ b/e2e/helpers/server_api/post.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {resolveChannelByName} from './channel'; +import {apiLogin, apiRequest} from './client'; +import {getTestServerCredentials} from './credentials'; + +type Post = { + id: string; + message: string; + root_id: string; +}; + +export async function apiCreatePost( + channelId: string, + message: string, + rootId = '', + credentials = getTestServerCredentials(), +): Promise { + const token = await apiLogin(credentials.baseUrl, credentials.username, credentials.password); + return apiRequest(credentials.baseUrl, token, '/api/v4/posts', { + method: 'POST', + body: JSON.stringify({ + channel_id: channelId, + message, + root_id: rootId, + }), + }); +} + +/** Seed a thread root post and one reply so RHS / global threads UI has content. */ +export async function seedThreadInChannel(channelName: string): Promise<{rootId: string; replyId: string}> { + const channel = await resolveChannelByName(channelName); + const stamp = Date.now(); + const root = await apiCreatePost(channel.id, `e2e thread root ${stamp}`); + const reply = await apiCreatePost(channel.id, `e2e thread reply ${stamp}`, root.id); + return {rootId: root.id, replyId: reply.id}; +} diff --git a/e2e/helpers/server_api/publicLinks.ts b/e2e/helpers/server_api/publicLinks.ts new file mode 100644 index 00000000000..ea934e6ab99 --- /dev/null +++ b/e2e/helpers/server_api/publicLinks.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {apiLogin, apiRequest} from './client'; +import {getTestServerCredentials} from './credentials'; + +type ServerConfig = { + FileSettings?: { + EnablePublicLink?: boolean; + }; + ServiceSettings?: { + SiteURL?: string; + }; +}; + +export async function isPublicLinkEnabled(credentials = getTestServerCredentials()): Promise { + const token = await apiLogin(credentials.baseUrl, credentials.username, credentials.password); + const config = await apiRequest(credentials.baseUrl, token, '/api/v4/config'); + return config.FileSettings?.EnablePublicLink === true; +} + +export async function enablePublicLinks(credentials = getTestServerCredentials()): Promise { + const token = await apiLogin(credentials.baseUrl, credentials.username, credentials.password); + const config = await apiRequest(credentials.baseUrl, token, '/api/v4/config'); + + if (config.FileSettings?.EnablePublicLink === true) { + return false; + } + + const siteURL = config.ServiceSettings?.SiteURL ?? credentials.baseUrl; + const patch: ServerConfig = { + FileSettings: {EnablePublicLink: true}, + ...(siteURL ? {ServiceSettings: {SiteURL: siteURL}} : {}), + }; + await apiRequest(credentials.baseUrl, token, '/api/v4/config/patch', { + method: 'PUT', + body: JSON.stringify(patch), + }); + + return true; +} + +export async function getFilePublicLink(fileId: string, credentials = getTestServerCredentials()): Promise { + const token = await apiLogin(credentials.baseUrl, credentials.username, credentials.password); + const response = await apiRequest<{link: string}>( + credentials.baseUrl, + token, + `/api/v4/files/${fileId}/link`, + ); + return response.link; +} diff --git a/e2e/helpers/settingsConfig.ts b/e2e/helpers/settingsConfig.ts new file mode 100644 index 00000000000..24f2c5b5153 --- /dev/null +++ b/e2e/helpers/settingsConfig.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; + +import {expect} from '@playwright/test'; +import type {Page} from 'playwright'; + +export function readConfigValue(configFilePath: string, key: string): T { + return JSON.parse(fs.readFileSync(configFilePath, 'utf-8'))[key]; +} + +export async function waitForConfigValue( + configFilePath: string, + key: string, + expected: T, + timeout = 15_000, +): Promise { + await expect.poll( + () => readConfigValue(configFilePath, key), + {timeout, message: `config.json ${key} must become ${String(expected)}`}, + ).toBe(expected); +} + +export async function toggleAutostartSetting( + settingsWindow: Page, + configFilePath: string, +): Promise<{before: boolean; after: boolean}> { + const autostartToggle = settingsWindow.locator('#CheckSetting_autostart button'); + await autostartToggle.waitFor({state: 'visible', timeout: 10_000}); + const before = readConfigValue(configFilePath, 'autostart'); + await autostartToggle.click(); + await waitForConfigValue(configFilePath, 'autostart', !before); + return {before, after: !before}; +} + +export async function ensureAutostartEnabled( + settingsWindow: Page, + configFilePath: string, +): Promise { + if (readConfigValue(configFilePath, 'autostart')) { + return; + } + await toggleAutostartSetting(settingsWindow, configFilePath); +} diff --git a/e2e/helpers/shell.ts b/e2e/helpers/shell.ts new file mode 100644 index 00000000000..8c61dee9c8c --- /dev/null +++ b/e2e/helpers/shell.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +export async function stubShellOpenExternal(app: ElectronApplication): Promise { + await app.evaluate(({shell}) => { + const shellState = shell as typeof shell & { + __e2eOpenExternalCalls?: string[]; + __e2eOriginalOpenExternal?: typeof shell.openExternal; + }; + if (!shellState.__e2eOriginalOpenExternal) { + shellState.__e2eOriginalOpenExternal = shell.openExternal.bind(shell); + } + shellState.__e2eOpenExternalCalls = []; + shell.openExternal = async (url: string) => { + shellState.__e2eOpenExternalCalls!.push(url); + }; + }); +} + +export async function restoreShellOpenExternal(app: ElectronApplication): Promise { + try { + await app.evaluate(({shell}) => { + const original = (shell as any).__e2eOriginalOpenExternal; + if (original) { + shell.openExternal = original; + } + delete (shell as any).__e2eOpenExternalCalls; + delete (shell as any).__e2eOriginalOpenExternal; + }); + } catch { + // App may already be closed after quit-style tests. + } +} + +export async function getShellOpenExternalCalls(app: ElectronApplication): Promise { + return app.evaluate(({shell}) => (shell as any).__e2eOpenExternalCalls ?? []); +} diff --git a/e2e/helpers/trayMenu.ts b/e2e/helpers/trayMenu.ts new file mode 100644 index 00000000000..4409bc930cf --- /dev/null +++ b/e2e/helpers/trayMenu.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication, Page} from 'playwright'; + +import {clickTrayMenuItem} from './tray'; + +async function findSettingsPage(app: ElectronApplication): Promise { + return app.windows().find((window) => { + try { + return window.url().includes('settings'); + } catch { + return false; + } + }) ?? null; +} + +async function waitForSettingsPage(app: ElectronApplication): Promise { + let settingsWindow: Page | null = null; + await expect.poll(async () => { + settingsWindow = await findSettingsPage(app); + return settingsWindow; + }, {timeout: 15_000, message: 'Settings page must open after tray menu click'}).not.toBeNull(); + await settingsWindow!.waitForLoadState(); + return settingsWindow!; +} + +export function traySettingsMenuLabel(): string { + return process.platform === 'darwin' ? 'Preferences...' : 'Settings'; +} + +export async function openSettingsFromTray(app: ElectronApplication): Promise { + const existingSettings = await findSettingsPage(app); + if (existingSettings) { + await existingSettings.waitForLoadState(); + return existingSettings; + } + + // Semantic click avoids i18n / mnemonic label mismatches ("Settings" vs "Settings..."). + try { + await clickTrayMenuItem(app, 'tray:settings'); + } catch { + let labels: string[]; + if (process.platform === 'darwin') { + labels = ['Preferences...', 'Preferences', 'tray:settings']; + } else { + labels = ['Settings...', 'Settings', '&Settings', 'tray:settings']; + } + let clicked = false; + for (const label of labels) { + try { + await clickTrayMenuItem(app, label); + clicked = true; + break; + } catch { + // try next label variant + } + } + if (!clicked) { + await clickTrayMenuItem(app, traySettingsMenuLabel()); + } + } + + return waitForSettingsPage(app); +} + +export async function clickTrayQuit(app: ElectronApplication): Promise { + try { + await clickTrayMenuItem(app, 'role:quit'); + return; + } catch { + // Fall back to localized quit labels below. + } + + let quitLabels: string[]; + if (process.platform === 'win32') { + quitLabels = ['Exit', 'Quit']; + } else if (process.platform === 'darwin') { + quitLabels = ['Quit Mattermost', 'Quit Electron', 'Quit']; + } else { + quitLabels = ['Quit Mattermost', 'Quit']; + } + + for (const label of quitLabels) { + try { + await clickTrayMenuItem(app, label); + return; + } catch { + // try next localized label + } + } + + throw new Error(`Tray quit menu item not found (tried: ${quitLabels.join(', ')})`); +} diff --git a/e2e/helpers/userAttributes.ts b/e2e/helpers/userAttributes.ts new file mode 100644 index 00000000000..7c967493d19 --- /dev/null +++ b/e2e/helpers/userAttributes.ts @@ -0,0 +1,567 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {dismissBlockingOverlays} from './blockingOverlays'; +import { + recoverInteractiveChannel, + waitForChannelPostListLoaded, + waitForInteractiveChannel, + waitForMattermostShellReady, +} from './channelReadiness'; +import { + pressPostTextboxKey, + typeIntoPostTextbox, +} from './mattermostShell'; +import {HAS_CLIENT_JS_ERROR_JS} from './rendererUtils'; +import {resolveChannelByName} from './server_api/channel'; +import {ApiRequestError, apiLogin, apiRequest} from './server_api/client'; +import {getTestServerCredentials} from './server_api/credentials'; +import {apiCreatePost} from './server_api/post'; +import {activateServerView, loadServerViewUrl, reloadServerView} from './serverContext'; +import type {ServerEntry} from './serverMap'; +import type {ServerView} from './serverView'; + +export type UserPropertyField = { + id: string; + name: string; + type: string; + attrs?: { + sort_order?: number; + visibility?: 'when_set' | 'hidden' | 'always'; + value_type?: string; + description?: string; + display_name?: string; + options?: Array<{name: string; color?: string}>; + }; +}; + +export type CustomProfileAttributeDef = { + name: string; + type?: string; + value?: string; + attrs?: UserPropertyField['attrs']; +}; + +const CPA_FIELDS_PATH = '/api/v4/custom_profile_attributes/fields'; + +export const TEST_PHONE = '555-123-4567'; +export const TEST_UPDATED_PHONE = '555-987-6543'; +export const TEST_URL = 'https://example.com'; +export const TEST_UPDATED_URL = 'https://mattermost.com'; +export const TEST_INVALID_URL = 'ftp://invalid-url'; +export const TEST_VALID_URL = 'https://example2.com'; +export const TEST_DEPARTMENT = 'Engineering'; + +export async function isUserAttributesFeatureAvailable(): Promise { + try { + const {baseUrl, username, password} = getTestServerCredentials(); + const token = await apiLogin(baseUrl, username, password); + await apiRequest(baseUrl, token, CPA_FIELDS_PATH); + return true; + } catch (error) { + if (error instanceof ApiRequestError && [403, 404, 501].includes(error.status)) { + return false; + } + throw error; + } +} + +export async function probeUserAttributesInProfileSettings(win: ServerView): Promise { + if (!(await isUserAttributesFeatureAvailable())) { + return false; + } + + await openProfileSettings(win); + const hasProfileSurface = await win.runInRenderer(` + return Boolean(document.querySelector('#userAccountModal, .AccountModal')); + `); + await closeProfileSettings(win); + return hasProfileSurface; +} + +export async function getCustomProfileAttributeFields(): Promise { + const {baseUrl, username, password} = getTestServerCredentials(); + const token = await apiLogin(baseUrl, username, password); + return apiRequest(baseUrl, token, CPA_FIELDS_PATH); +} + +export async function createCustomProfileAttributeField( + field: CustomProfileAttributeDef, + sortOrder: number, +): Promise { + const {baseUrl, username, password} = getTestServerCredentials(); + const token = await apiLogin(baseUrl, username, password); + return apiRequest(baseUrl, token, CPA_FIELDS_PATH, { + method: 'POST', + body: JSON.stringify({ + name: field.name, + type: field.type ?? 'text', + attrs: { + sort_order: sortOrder, + ...field.attrs, + }, + }), + }); +} + +export async function patchCustomProfileAttributeField( + fieldId: string, + patch: {name?: string; attrs?: UserPropertyField['attrs']}, +): Promise { + const {baseUrl, username, password} = getTestServerCredentials(); + const token = await apiLogin(baseUrl, username, password); + return apiRequest(baseUrl, token, `${CPA_FIELDS_PATH}/${fieldId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); +} + +export async function deleteCustomProfileAttributeField(fieldId: string): Promise { + const {baseUrl, username, password} = getTestServerCredentials(); + const token = await apiLogin(baseUrl, username, password); + await apiRequest(baseUrl, token, `${CPA_FIELDS_PATH}/${fieldId}`, {method: 'DELETE'}); +} + +export async function updateCustomProfileAttributeValues( + valuesByFieldId: Record, + userId = 'me', +): Promise { + const {baseUrl, username, password} = getTestServerCredentials(); + const token = await apiLogin(baseUrl, username, password); + await apiRequest(baseUrl, token, `/api/v4/users/${userId}/custom_profile_attributes`, { + method: 'PATCH', + body: JSON.stringify(valuesByFieldId), + }); +} + +export {dismissBlockingOverlays} from './blockingOverlays'; + +export async function navigateToTownSquare(win: ServerView): Promise { + await dismissBlockingOverlays(win); + await activateServerView(win.app, win.webContentsId); + await waitForChannelPostListLoaded(win); +} + +const PROFILE_SETTINGS_MODAL_SELECTOR = [ + '#accountSettingsModal', + '#userAccountModal', + '.AccountModal', + '.SettingsModal', + '[data-testid="userSettingsModal"]', + '[data-testid="accountSettingsModal"]', +].join(', '); + +const PROFILE_SETTINGS_BODY_SELECTOR = [ + '.user-settings', + '.UserSettingsModal', + '#userAccountModal_body', + '[data-testid="userSettingsModalBody"]', + '[data-testid="accountSettingsModalBody"]', +].join(', '); + +export async function openProfileSettings(win: ServerView): Promise { + const opened = await win.runInRenderer(` + const menuBtn = document.querySelector('#userAccountMenuButton'); + if (!menuBtn) { + return false; + } + menuBtn.click(); + return true; + `); + if (!opened) { + throw new Error('Could not open user account menu'); + } + + await expect.poll(async () => win.runInRenderer(` + const profileEntry = Array.from(document.querySelectorAll('[role="menuitem"], .MenuItem')) + .find((element) => /^profile$/i.test((element.textContent || '').trim())); + if (!(profileEntry instanceof HTMLElement)) { + return false; + } + const rect = profileEntry.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + `), {timeout: 5_000, message: 'Profile menu item must be visible'}).toBe(true); + + await win.runInRenderer(` + const profileEntry = Array.from(document.querySelectorAll('[role="menuitem"], .MenuItem')) + .find((element) => /^profile$/i.test((element.textContent || '').trim())); + if (!(profileEntry instanceof HTMLElement)) { + throw new Error('Profile menu item not found'); + } + profileEntry.click(); + `); + + await win.waitForSelector(PROFILE_SETTINGS_MODAL_SELECTOR, {timeout: 15_000}); + + await win.runInRenderer(` + const modal = document.querySelector(${JSON.stringify(PROFILE_SETTINGS_MODAL_SELECTOR)}); + if (!modal) { + return; + } + const profileSettingsNav = Array.from(modal.querySelectorAll('a, button, [role="tab"], [role="menuitem"]')) + .find((element) => /profile settings/i.test((element.textContent || '').trim())); + if (profileSettingsNav instanceof HTMLElement) { + profileSettingsNav.click(); + } + `); + + await win.waitForSelector(PROFILE_SETTINGS_BODY_SELECTOR, {timeout: 15_000}); +} + +export async function closeProfileSettings(win: ServerView): Promise { + await win.runInRenderer(` + const modal = document.querySelector(${JSON.stringify(PROFILE_SETTINGS_MODAL_SELECTOR)}); + if (!modal) { + return; + } + const closeBtn = modal.querySelector('.modal-header button.close, button[aria-label="Close"], button.btn-icon-close'); + closeBtn?.click(); + `); + await win.keyboard.press('Escape').catch(() => undefined); + await win.waitForSelector(PROFILE_SETTINGS_MODAL_SELECTOR, {state: 'hidden', timeout: 5_000}).catch(() => undefined); +} + +export async function recoverFromProfileSettings(win: ServerView): Promise { + await closeProfileSettings(win).catch(() => undefined); + const hasSidebar = await win.runInRenderer(` + return Boolean(document.querySelector('#sidebarItem_town-square')); + `); + if (hasSidebar) { + await navigateToTownSquare(win); + return; + } + + const channel = await resolveChannelByName('town-square'); + await loadServerViewUrl(win.app, win.webContentsId, channel.url); + await activateServerView(win.app, win.webContentsId); + await waitForChannelPostListLoaded(win); +} + +export async function getCustomAttributeLabelsInSettings(win: ServerView): Promise { + return win.runInRenderer(` + const modal = document.querySelector(${JSON.stringify(PROFILE_SETTINGS_MODAL_SELECTOR)}) + || document.querySelector('.user-settings'); + if (!modal) { + return []; + } + const labels = []; + const editButtons = modal.querySelectorAll('[id^="customAttribute_"][id$="Edit"]'); + for (const button of editButtons) { + const match = button.id.match(/^customAttribute_(.+?)Edit$/); + if (!match) { + continue; + } + const fieldId = match[1]; + const nameEl = modal.querySelector('[for="customAttribute_' + fieldId + '"]'); + if (nameEl?.textContent) { + labels.push(nameEl.textContent.trim()); + continue; + } + const row = button.closest('.setting-list-item, .SettingsBlock, section, li, div'); + const rowText = (row?.textContent || '') + .replace(/Edit.*$/s, '') + .replace(/Click 'Edit' to add your custom attribute/gi, '') + .trim(); + if (rowText) { + labels.push(rowText); + } + } + return labels; + `); +} + +export async function editTextCustomAttribute( + win: ServerView, + fieldId: string, + newValue: string, + save = true, +): Promise { + await win.runInRenderer(` + const fieldId = ${JSON.stringify(fieldId)}; + const editBtn = document.querySelector('#customAttribute_' + fieldId + 'Edit'); + editBtn?.scrollIntoView({block: 'center'}); + editBtn?.click(); + `); + await win.waitForSelector(`#customAttribute_${fieldId}`, {timeout: 10_000}); + await win.runInRenderer(` + const fieldId = ${JSON.stringify(fieldId)}; + const input = document.querySelector('#customAttribute_' + fieldId); + if (!input) { + throw new Error('Custom attribute input not found'); + } + input.focus?.(); + if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) { + input.value = ''; + input.dispatchEvent(new Event('input', {bubbles: true})); + } + `); + if (newValue) { + await win.fill(`#customAttribute_${fieldId}`, newValue); + } + if (save) { + await win.runInRenderer(` + const fieldId = ${JSON.stringify(fieldId)}; + const input = document.querySelector('#customAttribute_' + fieldId); + const row = input?.closest('.setting-list-item, .SettingsBlock, section, li, div') || document; + const saveBtn = Array.from(row.querySelectorAll('button')) + .find((button) => (button.textContent || '').trim() === 'Save'); + if (!(saveBtn instanceof HTMLButtonElement)) { + throw new Error('Save button not found for custom attribute row'); + } + saveBtn.click(); + `); + await win.waitForSelector(`#customAttribute_${fieldId}Edit`, {timeout: 10_000}); + } +} + +export async function cancelCustomAttributeEdit(win: ServerView, fieldId?: string): Promise { + await win.runInRenderer(` + const modal = document.querySelector('#accountSettingsModal, .user-settings, #userAccountModal, .AccountModal'); + const scope = modal || document; + const cancelBtn = Array.from(scope.querySelectorAll('button')) + .find((button) => (button.textContent || '').trim() === 'Cancel'); + cancelBtn?.click(); + `); + if (fieldId) { + await win.waitForSelector(`#customAttribute_${fieldId}Edit`, {timeout: 10_000}); + } +} + +export async function getCustomAttributeInputValue(win: ServerView, fieldId: string): Promise { + return win.runInRenderer(` + const fieldId = ${JSON.stringify(fieldId)}; + const editBtn = document.querySelector('#customAttribute_' + fieldId + 'Edit'); + const input = document.querySelector('#customAttribute_' + fieldId); + const editVisible = Boolean(editBtn && editBtn.getBoundingClientRect().width > 0); + if (!editVisible && input instanceof HTMLInputElement) { + return input.value; + } + if (!editBtn) { + return input instanceof HTMLInputElement ? input.value : ''; + } + const row = editBtn.closest('.setting-list-item, .SettingsBlock, section, li, div'); + if (!row) { + return ''; + } + let text = row.textContent || ''; + const labelEl = row.querySelector('label, .form__label, .setting-list-item__label, h4, h5, strong'); + const label = labelEl?.textContent?.trim() || ''; + if (label) { + text = text.replace(label, ''); + } + return text + .replace(/Edit.*$/s, '') + .replace(/Click 'Edit' to add your custom attribute/gi, '') + .trim(); + `); +} + +const PROFILE_POPOVER_SELECTOR = [ + '#user-profile-popover', + '.user-profile-popover', + '.profile-popover', + '[data-testid="userProfilePopover"]', + '[role="tooltip"].popover', +].join(', '); + +const POST_PROFILE_TRIGGER_SELECTORS = [ + '[data-testid="postHeaderProfile"]', + '[data-testid="profilePicture"]', + '.post__header button.user-popover', + '.post__header .user-popover', + 'a.user-popover', + '.user-popover-profile-link', + '.user-popover', + '.profile-icon', + 'button[aria-label*="profile" i]', +]; + +export async function postChannelMessage( + win: ServerView, + message: string, + channelName = 'town-square', +): Promise { + await dismissBlockingOverlays(win); + + const channel = await resolveChannelByName(channelName); + const onChannel = await win.runInRenderer(` + return window.location.pathname.includes('/channels/${channelName}'); + `); + if (!onChannel) { + await win.click(`#sidebarItem_${channelName}`); + await waitForMattermostShellReady(win, {channelItem: `#sidebarItem_${channelName}`}); + } + + try { + await typeIntoPostTextbox(win, message); + const sent = await win.runInRenderer(` + const sendButton = document.querySelector( + '#channelHeaderSubmitButton, button[aria-label*="Send" i], [data-testid="SendMessageButton"]', + ); + if (!sendButton) { + return false; + } + sendButton.click(); + return true; + `); + if (!sent) { + await pressPostTextboxKey(win, 'Enter'); + } + } catch { + await apiCreatePost(channel.id, message); + } + + await expect.poll( + async () => win.runInRenderer(` + const message = ${JSON.stringify(message)}; + return Array.from(document.querySelectorAll('.post-message__text, .post__body, .post')) + .some((element) => (element.textContent || '').includes(message)); + `), + {timeout: 20_000, message: 'Posted message must appear in channel'}, + ).toBe(true); +} + +async function waitForChannelMessage(win: ServerView, message: string, timeout = 60_000): Promise { + await expect.poll(async () => win.runInRenderer(` + const hint = ${JSON.stringify(message)}; + return Array.from(document.querySelectorAll('.post-message__text, .post__body, .post, [data-testid="postText"]')) + .some((element) => (element.textContent || '').includes(hint)); + `).catch(() => false), {timeout, message: `Message must appear in channel: ${message}`}).toBe(true); +} + +export async function openProfilePopoverFromLastPost( + win: ServerView, + messageHint?: string, +): Promise { + const clickProfileTrigger = async (): Promise => { + await dismissBlockingOverlays(win); + if (messageHint) { + try { + await waitForChannelMessage(win, messageHint, 10_000); + } catch { + await recoverInteractiveChannel(win, {channelName: 'town-square'}); + } + } else { + await waitForInteractiveChannel(win, {timeout: 10_000}); + } + + return win.runInRenderer(` + const messageHint = ${JSON.stringify(messageHint ?? '')}; + const triggerSelectors = ${JSON.stringify(POST_PROFILE_TRIGGER_SELECTORS)}; + const posts = Array.from(document.querySelectorAll('.post-list .post, .post-list__dynamic .post, [id^="post_"]')); + const resolveTrigger = (post) => { + for (const selector of triggerSelectors) { + const trigger = post.querySelector(selector); + if (trigger instanceof HTMLElement) { + return trigger; + } + } + const header = post.querySelector('.post__header'); + const fallback = header?.querySelector( + '.user-popover, a.user-popover, [data-testid="postHeaderProfile"], [data-testid="profilePicture"]', + ); + return fallback instanceof HTMLElement ? fallback : null; + }; + const candidates = messageHint + ? posts.filter((post) => (post.textContent || '').includes(messageHint)) + : posts; + const searchPosts = candidates.length > 0 ? candidates : posts; + + for (let index = searchPosts.length - 1; index >= 0; index--) { + const trigger = resolveTrigger(searchPosts[index]); + if (trigger) { + trigger.scrollIntoView({block: 'center'}); + trigger.click(); + return true; + } + } + return false; + `); + }; + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (await clickProfileTrigger()) { + try { + await win.waitForSelector(PROFILE_POPOVER_SELECTOR, {timeout: 5_000}); + return; + } catch { + await dismissBlockingOverlays(win); + } + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + throw new Error('Could not open profile popover from last post'); +} + +export async function postAndOpenProfilePopover( + electronApp: ElectronApplication, + entry: ServerEntry, + message: string, + channelName = 'town-square', +): Promise { + const channel = await resolveChannelByName(channelName); + + await activateServerView(electronApp, entry.webContentsId); + await dismissBlockingOverlays(entry.win); + + const hasJsError = await entry.win.runInRenderer(HAS_CLIENT_JS_ERROR_JS).catch(() => false); + const onChannel = await entry.win.runInRenderer(` + return window.location.pathname.includes('/channels/${channelName}'); + `).catch(() => false); + + if (hasJsError || !onChannel) { + await reloadServerView(electronApp, entry.webContentsId); + await waitForMattermostShellReady(entry.win, {channelName}); + } + + await apiCreatePost(channel.id, message); + await waitForChannelMessage(entry.win, message, 60_000); + + await openProfilePopoverFromLastPost(entry.win, message); +} + +export async function closeProfilePopover(win: ServerView): Promise { + await win.click('#channelHeaderTitle').catch(() => undefined); + await win.keyboard.press('Escape').catch(() => undefined); +} + +export async function popoverContainsText(win: ServerView, text: string): Promise { + return win.runInRenderer(` + const popover = document.querySelector(${JSON.stringify(PROFILE_POPOVER_SELECTOR)}); + if (!popover) { + return false; + } + return (popover.textContent || '').includes(${JSON.stringify(text)}); + `); +} + +export async function popoverLinkHasHref(win: ServerView, text: string, hrefPattern: string): Promise { + return win.runInRenderer(` + const popover = document.querySelector('#user-profile-popover, .user-profile-popover, .profile-popover'); + if (!popover) { + return false; + } + const links = popover.querySelectorAll('a'); + const pattern = new RegExp(${JSON.stringify(hrefPattern)}); + for (const link of links) { + if ((link.textContent || '').includes(${JSON.stringify(text)})) { + return pattern.test(link.getAttribute('href') || ''); + } + } + return false; + `); +} + +export async function isAppResponsive(win: ServerView): Promise { + return win.runInRenderer(` + return Boolean( + document.querySelector('#channelHeaderTitle, [data-testid="channelHeaderTitle"]') + && document.querySelector('#post_textbox, [data-testid="post_textbox"], [data-slate-editor="true"]'), + ); + `).catch(() => false); +} diff --git a/e2e/helpers/webappMenu.ts b/e2e/helpers/webappMenu.ts new file mode 100644 index 00000000000..468e3b59171 --- /dev/null +++ b/e2e/helpers/webappMenu.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ServerView} from './serverView'; + +export const WEBAPP_MENU_ITEM_SELECTOR = '[role="menuitem"], .MenuItem'; + +export const RHS_THREAD_MENU_BUTTON_SELECTOR = [ + '.ThreadViewer button[aria-label*="menu" i]', + '.sidebar-right button[aria-label*="menu" i]', + 'button[aria-label*="more actions" i]', +].join(', '); + +export const THREADS_LIST_MENU_BUTTON_SELECTOR = [ + '.ThreadPane button[aria-label*="menu" i]', + '.threads-list button[aria-label*="menu" i]', +].join(', '); + +type LabelPattern = string | RegExp; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function serializeLabelPattern(label: LabelPattern): {source: string; flags: string} { + if (label instanceof RegExp) { + return {source: label.source, flags: label.flags}; + } + return {source: escapeRegExp(label), flags: 'i'}; +} + +function labelMatchJs(label: LabelPattern, textExpression: string): string { + const pattern = serializeLabelPattern(label); + return `new RegExp(${JSON.stringify(pattern.source)}, ${JSON.stringify(pattern.flags)}).test(${textExpression})`; +} + +/** Click a visible webapp menu item whose label matches `label`. Assumes a menu is already open. */ +export async function clickWebappMenuItemByLabel( + win: ServerView, + label: LabelPattern, + options?: {menuItemSelector?: string}, +): Promise { + const menuItemSelector = options?.menuItemSelector ?? WEBAPP_MENU_ITEM_SELECTOR; + const matchJs = labelMatchJs(label, '(item.textContent || \'\').trim()'); + + return win.runInRenderer(` + const items = Array.from(document.querySelectorAll(${JSON.stringify(menuItemSelector)})); + const visible = items.filter((item) => { + const rect = item.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + const target = visible.find((item) => ${matchJs}); + if (!target) { + return false; + } + target.click(); + return true; + `, true); +} + +/** + * Open a webapp menu via `menuButtonSelector`, then click the item matching `label`. + */ +export async function openMenuAndClickLabeledItem( + win: ServerView, + menuButtonSelector: string, + label: LabelPattern, + options?: {pickLastMenuButton?: boolean; menuItemSelector?: string}, +): Promise { + const menuItemSelector = options?.menuItemSelector ?? WEBAPP_MENU_ITEM_SELECTOR; + const pickLast = options?.pickLastMenuButton ?? false; + const matchJs = labelMatchJs(label, '(item.textContent || \'\').trim()'); + + return win.runInRenderer(` + const menus = document.querySelectorAll(${JSON.stringify(menuButtonSelector)}); + const menuButton = menus.length ? menus[${pickLast ? 'menus.length - 1' : '0'}] : null; + if (!menuButton) { + return false; + } + menuButton.click(); + const items = Array.from(document.querySelectorAll(${JSON.stringify(menuItemSelector)})); + const visible = items.filter((item) => { + const rect = item.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + const openItem = visible.find((item) => ${matchJs}); + if (!openItem) { + return false; + } + openItem.click(); + return true; + `, true); +} + +/** Click "Open in New Window" in an already-open channel or sidebar menu. */ +export async function clickOpenInNewWindowMenuItem(win: ServerView): Promise { + return win.runInRenderer(` + const items = Array.from(document.querySelectorAll('[role="menuitem"], .MenuItem')); + const visible = items.filter((item) => { + const rect = item.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + const matches = visible.filter((item) => /open in new window/i.test((item.textContent || '').trim())); + const target = matches[matches.length - 1]; + if (!target) { + return false; + } + target.click(); + return true; + `, true); +} + +/** Open the RHS thread menu and choose "Open in New Window". */ +export async function clickOpenInNewWindowFromRhsThreadMenu(win: ServerView): Promise { + return openMenuAndClickLabeledItem(win, RHS_THREAD_MENU_BUTTON_SELECTOR, /open in new window/i, { + pickLastMenuButton: true, + }); +} + +/** Open the global threads list menu and choose "Open in New Window". */ +export async function clickOpenInNewWindowFromThreadsListMenu(win: ServerView): Promise { + return openMenuAndClickLabeledItem(win, THREADS_LIST_MENU_BUTTON_SELECTOR, /open in new window/i); +} diff --git a/e2e/specs/deep_linking/cross_server_permalink.test.ts b/e2e/specs/deep_linking/cross_server_permalink.test.ts new file mode 100644 index 00000000000..04b1ce39a97 --- /dev/null +++ b/e2e/specs/deep_linking/cross_server_permalink.test.ts @@ -0,0 +1,317 @@ +// 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 {demoMattermostConfig, mattermostURL, type AppConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import { + POST_TEXTBOX_SELECTOR, + typeIntoPostTextbox, + pressPostTextboxKey, + waitForMattermostShellReady, +} from '../../helpers/mattermostShell'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import { + getShellOpenExternalCalls, + restoreShellOpenExternal, + stubShellOpenExternal, +} from '../../helpers/shell'; +import {buildServerMap} from '../../helpers/serverMap'; +import type {ServerView} from '../../helpers/serverView'; + +// ── MM-T1430: Cross-server permalink (MM-19919) ──────────────────────── +// Clicking a permalink to a post on server A while viewing server B must +// switch to server A and navigate to the post in-app — never opening an +// external browser or a new Electron BrowserWindow. + +const SERVER_A_NAME = 'serverA'; +const SERVER_B_NAME = 'serverB'; + +function alternateMattermostURL(): string { + const url = new URL(mattermostURL); + if (url.hostname === 'localhost') { + url.hostname = '127.0.0.1'; + return url.toString(); + } + if (url.hostname === '127.0.0.1') { + url.hostname = 'localhost'; + return url.toString(); + } + + // Trailing-slash variants normalize to the same URL; use a distinct path so + // ServerManager keeps two entries for the same Mattermost instance. + if (url.pathname === '/' || url.pathname === '') { + return `${url.origin}/login`; + } + return `${url.origin}/`; +} + +const crossServerConfig: AppConfig = { + ...demoMattermostConfig, + servers: [ + {name: SERVER_A_NAME, url: mattermostURL, order: 0}, + {name: SERVER_B_NAME, url: alternateMattermostURL(), order: 1}, + ], + lastActiveServer: 0, +}; + +async function switchToServer(app: ElectronApplication, serverName: string) { + await app.evaluate((_, targetServerName) => { + const refs = (global as any).__e2eTestRefs; + const server = refs?.ServerManager?.getAllServers?.().find((candidate: {name: string}) => candidate.name === targetServerName); + if (!server) { + throw new Error(`Server not found: ${targetServerName}`); + } + refs.ServerManager.updateCurrentServer(server.id); + }, serverName); +} + +async function getCurrentServerName(app: ElectronApplication): Promise { + return app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const currentServerId = refs?.ServerManager?.getCurrentServerId?.(); + const server = currentServerId ? refs?.ServerManager?.getServer?.(currentServerId) : undefined; + return server?.name ?? ''; + }); +} + +async function getActiveTabUrl(app: ElectronApplication, serverName: string): Promise { + return app.evaluate(({webContents}, targetServerName) => { + const refs = (global as any).__e2eTestRefs; + const server = refs?.ServerManager?.getAllServers?.().find((candidate: {name: string}) => candidate.name === targetServerName); + if (!server) { + return null; + } + + const activeTab = refs.TabManager.getCurrentTabForServer(server.id); + if (!activeTab) { + return null; + } + + const webContentsView = refs.WebContentsManager.getView(activeTab.id); + if (!webContentsView) { + return null; + } + + const wc = webContents.fromId(webContentsView.webContentsId); + return wc?.getURL() ?? null; + }, serverName); +} + +async function waitForServerPermalinkNavigation( + app: ElectronApplication, + serverName: string, + permalinkMessage: string, +): Promise { + let matchedUrl = ''; + await expect.poll(async () => { + const activeUrl = await getActiveTabUrl(app, serverName); + if (activeUrl?.includes('/pl/')) { + matchedUrl = activeUrl; + return true; + } + + const map = await buildServerMap(app); + for (const entry of map[serverName] ?? []) { + const onPermalink = await entry.win.evaluate((needle) => { + return window.location.pathname.includes('/pl/') && + (document.body.textContent ?? '').includes(needle); + }, permalinkMessage); + if (onPermalink) { + matchedUrl = await entry.win.url(); + return true; + } + } + return false; + }, {timeout: 45_000, message: 'Server must navigate to the permalink post'}).toBe(true); + + return matchedUrl; +} + +async function openTownSquare(serverWin: ServerView): Promise { + await waitForMattermostShellReady(serverWin, {channelItem: '#sidebarItem_town-square'}); + const onTownSquare = await serverWin.runInRenderer(` + const item = document.querySelector('#sidebarItem_town-square'); + return Boolean( + item?.classList.contains('active') + || item?.classList.contains('active-link') + || item?.getAttribute('aria-current') === 'page', + ); + `); + if (!onTownSquare) { + await serverWin.click('#sidebarItem_town-square'); + } + await serverWin.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 30_000}); +} + +async function clickPostedPermalink(serverWin: ServerView, permalinkUrl: string, useWindowOpen: boolean): Promise { + await expect.poll(async () => serverWin.evaluate((targetUrl) => { + const posts = Array.from(document.querySelectorAll('[id^="post_"]')); + for (let index = posts.length - 1; index >= 0; index--) { + const post = posts[index]; + if (!(post.textContent ?? '').includes(targetUrl)) { + continue; + } + return Boolean(post.querySelector('a[href*="/pl/"]')); + } + return false; + }, permalinkUrl), {timeout: 15_000, message: 'Posted permalink must render as a link'}).toBe(true); + + await serverWin.evaluate(({targetUrl, openInNewWindow}) => { + const posts = Array.from(document.querySelectorAll('[id^="post_"]')); + for (let index = posts.length - 1; index >= 0; index--) { + const post = posts[index]; + if (!(post.textContent ?? '').includes(targetUrl)) { + continue; + } + + const link = post.querySelector('a[href*="/pl/"]') as HTMLAnchorElement | null; + if (!link) { + continue; + } + + const href = new URL(link.getAttribute('href') ?? link.href, window.location.href).href; + if (openInNewWindow) { + window.open(href, '_blank'); + } else { + link.click(); + } + return; + } + + window.open(targetUrl, '_blank'); + }, {targetUrl: permalinkUrl, openInNewWindow: useWindowOpen}); +} + +async function postMessageInChannel(serverWin: ServerView, message: string): Promise { + await typeIntoPostTextbox(serverWin, message); + + const sent = await serverWin.evaluate(() => { + const sendButton = document.querySelector( + '#channelHeaderSubmitButton, button[aria-label*="Send" i], [data-testid="SendMessageButton"]', + ) as HTMLButtonElement | null; + if (!sendButton) { + return false; + } + sendButton.click(); + return true; + }); + if (!sent) { + await pressPostTextboxKey(serverWin, 'Enter'); + } +} + +async function postMessageAndCapturePermalink(serverWin: ServerView, message: string): Promise { + await postMessageInChannel(serverWin, message); + + await expect.poll( + () => serverWin.evaluate((needle) => { + const posts = Array.from(document.querySelectorAll('[id^="post_"], .post')); + return posts.some((post) => (post.textContent ?? '').includes(needle)); + }, message), + {timeout: 15_000, message: 'Posted message must appear in the channel'}, + ).toBe(true); + + const permalink = await serverWin.evaluate((needle) => { + const posts = Array.from(document.querySelectorAll('[id^="post_"]')); + for (const post of posts) { + const text = post.textContent ?? ''; + if (!text.includes(needle)) { + continue; + } + + post.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); + const link = post.querySelector('.post__permalink a, a[href*="/pl/"]') as HTMLAnchorElement | null; + const href = link?.href ?? link?.getAttribute('href') ?? ''; + if (href.includes('/pl/')) { + return href; + } + + const postId = post.id.replace(/^post_/, ''); + const team = window.location.pathname.split('/').filter(Boolean)[0]; + if (postId && team) { + return `${window.location.origin}/${team}/pl/${postId}`; + } + } + return ''; + }, message) as string; + + expect(permalink, 'Permalink href must be available on the posted message').toBeTruthy(); + expect(permalink).toMatch(/\/pl\//); + return permalink; +} + +test.describe('deep_linking/cross_server_permalink', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: crossServerConfig}); + test.setTimeout(180_000); + + test( + 'MM-T1430 cross-server permalink switches server tabs in-app', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + let serverMap!: Awaited>; + await expect.poll(async () => { + serverMap = await buildServerMap(electronApp); + return Boolean(serverMap[SERVER_A_NAME]?.[0] && serverMap[SERVER_B_NAME]?.[0]); + }, {timeout: 30_000, message: 'Both server views must be registered'}).toBe(true); + + const serverA = serverMap[SERVER_A_NAME]![0].win; + const serverB = serverMap[SERVER_B_NAME]![0].win; + + const windowsBefore = await electronApp.evaluate(({BrowserWindow}) => { + return BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()).length; + }); + + await stubShellOpenExternal(electronApp); + + try { + await prepareMattermostServerView(electronApp, serverMap[SERVER_A_NAME]![0].webContentsId); + await loginToMattermost(serverA!); + await openTownSquare(serverA!); + + const permalinkMessage = `MM-T1430 permalink ${Date.now()}`; + const permalinkUrl = await postMessageAndCapturePermalink(serverA!, permalinkMessage); + + await switchToServer(electronApp, SERVER_B_NAME); + await prepareMattermostServerView(electronApp, serverMap[SERVER_B_NAME]![0].webContentsId); + await loginToMattermost(serverB!); + await openTownSquare(serverB!); + + await postMessageInChannel(serverB!, permalinkUrl); + + const sameHost = new URL(mattermostURL).hostname === new URL(alternateMattermostURL()).hostname; + await clickPostedPermalink(serverB!, permalinkUrl, sameHost); + + await expect.poll( + () => getCurrentServerName(electronApp), + {timeout: 15_000, message: 'Permalink click must activate server A'}, + ).toBe(SERVER_A_NAME); + + const permalinkDestination = await waitForServerPermalinkNavigation( + electronApp, + SERVER_A_NAME, + permalinkMessage, + ); + expect(permalinkDestination).toMatch(/\/pl\//); + + expect(await getShellOpenExternalCalls(electronApp)).toHaveLength(0); + expect( + await electronApp.evaluate(({BrowserWindow}) => { + return BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()).length; + }), + 'Permalink must not open a new BrowserWindow', + ).toBe(windowsBefore); + } finally { + await restoreShellOpenExternal(electronApp); + } + }, + ); +}); diff --git a/e2e/specs/deep_linking/deeplink_running.test.ts b/e2e/specs/deep_linking/deeplink_running.test.ts index 33357abe2f8..97e30acc8ce 100644 --- a/e2e/specs/deep_linking/deeplink_running.test.ts +++ b/e2e/specs/deep_linking/deeplink_running.test.ts @@ -10,7 +10,7 @@ import {loginToMattermost} from '../../helpers/login'; test.use({appConfig: demoMattermostConfig}); test( - 'deep link navigates to correct server while app is running', + 'MM-T6127 deep link navigates to correct server while app is running', {tag: ['@P1', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { @@ -49,7 +49,7 @@ test.describe('deep link server URL without trailing slash', () => { test.use({appConfig: configWithoutTrailingSlash}); test( - 'DL-01 deep link navigates when configured server URL has no trailing slash', + 'MM-T6128 deep link navigates when configured server URL has no trailing slash', {tag: ['@P1', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { diff --git a/e2e/specs/deep_linking/oauth_callback.test.ts b/e2e/specs/deep_linking/oauth_callback.test.ts index c6401e107f6..0fc555f1aff 100644 --- a/e2e/specs/deep_linking/oauth_callback.test.ts +++ b/e2e/specs/deep_linking/oauth_callback.test.ts @@ -6,7 +6,7 @@ import {demoConfig} from '../../helpers/config'; import {mattermostDeepLinkUrl, openDeepLinkInApp, waitForServerUrlAndDropdown} from '../../helpers/deeplink'; test( - 'DL-03 OAuth callback deep link navigates the active server view', + 'MM-T6129 OAuth callback deep link navigates the active server view', {tag: ['@P1', '@all']}, async ({electronApp, mainWindow}) => { const serverName = demoConfig.servers[0].name; diff --git a/e2e/specs/downloads/download_cancel.test.ts b/e2e/specs/downloads/download_cancel.test.ts index a1c5afa8a41..00b4682ea27 100644 --- a/e2e/specs/downloads/download_cancel.test.ts +++ b/e2e/specs/downloads/download_cancel.test.ts @@ -15,7 +15,7 @@ import { } from '../../helpers/downloads'; test( - 'DL-06 in-progress download can be cancelled from the downloads dropdown menu', + 'MM-T6130 in-progress download can be cancelled from the downloads dropdown menu', {tag: ['@P1', '@all']}, async ({}, testInfo) => { const filename = 'slow-cancel.txt'; diff --git a/e2e/specs/downloads/download_clear_all.test.ts b/e2e/specs/downloads/download_clear_all.test.ts index e0931ad6214..80a60ea27d0 100644 --- a/e2e/specs/downloads/download_clear_all.test.ts +++ b/e2e/specs/downloads/download_clear_all.test.ts @@ -25,7 +25,7 @@ const secondFile = { }; test( - 'DL-07 clear all removes every completed download from the dropdown', + 'MM-T6131 clear all removes every completed download from the dropdown', {tag: ['@P1', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); diff --git a/e2e/specs/downloads/download_completion.test.ts b/e2e/specs/downloads/download_completion.test.ts index 7207bcc3245..79b35e0ba59 100644 --- a/e2e/specs/downloads/download_completion.test.ts +++ b/e2e/specs/downloads/download_completion.test.ts @@ -56,7 +56,7 @@ async function startDownloadServer(filename: string, contents: string) { } test( - 'downloaded file exists on disk after download completes', + 'MM-T6132 downloaded file exists on disk after download completes', {tag: ['@P1', '@all']}, async ({}, testInfo) => { const filename = 'downloaded-file.txt'; diff --git a/e2e/specs/downloads/download_open.test.ts b/e2e/specs/downloads/download_open.test.ts index 10ba9353328..4fc199323a7 100644 --- a/e2e/specs/downloads/download_open.test.ts +++ b/e2e/specs/downloads/download_open.test.ts @@ -14,7 +14,7 @@ import { } from '../../helpers/downloads'; test( - 'DL-05 completed download can be opened from the downloads dropdown', + 'MM-T6133 completed download can be opened from the downloads dropdown', {tag: ['@P1', '@all']}, async ({}, testInfo) => { const filename = 'open-me.txt'; diff --git a/e2e/specs/downloads/downloads_dropdown_items.test.ts b/e2e/specs/downloads/downloads_dropdown_items.test.ts index 94bde8669d2..d6ce5235605 100644 --- a/e2e/specs/downloads/downloads_dropdown_items.test.ts +++ b/e2e/specs/downloads/downloads_dropdown_items.test.ts @@ -83,7 +83,7 @@ async function openDownloadsDropdown(app: Awaited { - test('MM-22239 should display the file correctly (downloaded)', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6134 should display the file correctly (downloaded)', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const downloadsLocation = path.join(userDataDir, 'Downloads'); const downloads = { @@ -118,7 +118,7 @@ test.describe('downloads/downloads_dropdown_items', () => { } }); - test('MM-22239 should display the file correctly (deleted)', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6135 should display the file correctly (deleted)', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const downloadsLocation = path.join(userDataDir, 'Downloads'); const downloads = { @@ -153,7 +153,7 @@ test.describe('downloads/downloads_dropdown_items', () => { } }); - test('MM-22239 should display the file correctly (cancelled)', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6136 should display the file correctly (cancelled)', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const downloadsLocation = path.join(userDataDir, 'Downloads'); const cancelledFile = { @@ -192,7 +192,7 @@ test.describe('downloads/downloads_dropdown_items', () => { } }); - test('MM-22239 should display the files in correct order', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6137 should display the files in correct order', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const downloadsLocation = path.join(userDataDir, 'Downloads'); const downloads = { diff --git a/e2e/specs/downloads/downloads_manager.test.ts b/e2e/specs/downloads/downloads_manager.test.ts index 8dc09487ad6..c06914414ac 100644 --- a/e2e/specs/downloads/downloads_manager.test.ts +++ b/e2e/specs/downloads/downloads_manager.test.ts @@ -55,7 +55,7 @@ async function startSlowDownloadServer(filename: string, chunk = 'slow-download- } test.describe('downloads/downloads_manager', () => { - test('MM-22239 should open downloads dropdown when a download starts', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6138 should open downloads dropdown when a download starts', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const filename = 'slow-download.txt'; const {server, url} = await startSlowDownloadServer(filename); diff --git a/e2e/specs/downloads/downloads_menubar.test.ts b/e2e/specs/downloads/downloads_menubar.test.ts index 577447e7710..23d56fa473e 100644 --- a/e2e/specs/downloads/downloads_menubar.test.ts +++ b/e2e/specs/downloads/downloads_menubar.test.ts @@ -73,7 +73,7 @@ async function launchApp(userDataDir: string, downloadsData: Record { test.describe('The download list is empty', () => { - test('MM-22239 should not show the downloads dropdown and the menu item should be disabled', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6139 should not show the downloads dropdown and the menu item should be disabled', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const app = await launchApp(userDataDir, {}); @@ -102,7 +102,7 @@ test.describe('downloads/downloads_menubar', () => { }); test.describe('The download list has one file', () => { - test('MM-22239 should show the downloads dropdown button and the menu item should be enabled', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6140 should show the downloads dropdown button and the menu item should be enabled', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const downloadsLocation = path.join(userDataDir, 'Downloads'); createDownloadedFile(downloadsLocation); @@ -132,7 +132,7 @@ test.describe('downloads/downloads_menubar', () => { } }); - test('MM-22239 should open the downloads dropdown when clicking the download button in the menubar', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6141 should open the downloads dropdown when clicking the download button in the menubar', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const downloadsLocation = path.join(userDataDir, 'Downloads'); createDownloadedFile(downloadsLocation); @@ -159,7 +159,7 @@ test.describe('downloads/downloads_menubar', () => { } }); - test('MM-22239 should open the downloads dropdown from the app menu', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6142 should open the downloads dropdown from the app menu', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); const downloadsLocation = path.join(userDataDir, 'Downloads'); createDownloadedFile(downloadsLocation); diff --git a/e2e/specs/focus_behavior/focus_text_input.test.ts b/e2e/specs/focus_behavior/focus_text_input.test.ts new file mode 100644 index 00000000000..27bdacac87a --- /dev/null +++ b/e2e/specs/focus_behavior/focus_text_input.test.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {openSignInToAnotherServerModal} from '../../helpers/menu'; + +test.describe('focus_behavior/focus_text_input', () => { + test( + 'MM-T1314 Focus text input persists after app switch simulation', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + const newServerWindow = await openSignInToAnotherServerModal(electronApp); + await newServerWindow.waitForSelector('#serverUrlInput', {timeout: 10_000}); + await newServerWindow.focus('#serverUrlInput'); + + await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + refs?.MainWindow?.get?.()?.blur(); + }); + await newServerWindow.bringToFront(); + + const focusedId = await newServerWindow.evaluate(() => document.activeElement?.id ?? null); + expect(focusedId).toBe('serverUrlInput'); + }, + ); +}); diff --git a/e2e/specs/linux/wayland_launch.test.ts b/e2e/specs/linux/wayland_launch.test.ts index df4df92a3b3..5accba49a31 100644 --- a/e2e/specs/linux/wayland_launch.test.ts +++ b/e2e/specs/linux/wayland_launch.test.ts @@ -4,7 +4,7 @@ import {test, expect} from '../../fixtures/index'; test( - 'LNX-05 app launches in a Wayland session', + 'MM-T6143 app launches in a Wayland session', {tag: ['@P2', '@wayland']}, async ({electronApp, mainWindow}) => { const sessionType = await electronApp.evaluate(() => process.env.XDG_SESSION_TYPE ?? ''); diff --git a/e2e/specs/login/sso_back_button.test.ts b/e2e/specs/login/sso_back_button.test.ts new file mode 100644 index 00000000000..2865980ac2c --- /dev/null +++ b/e2e/specs/login/sso_back_button.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import { + clickLoginHeaderBack, + clickOpenIdAndWaitForDesktopAuth, + clickOpenIdLoginButton, + enableOpenIdOnLoginPage, + installWindowOpenStub, + navigateBackInServerView, + restoreLoginPageFetch, + restoreWindowOpen, + waitForDesktopAuthPage, + waitForLoginForm, + waitForMockIdpPage, +} from '../../helpers/loginSso'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import { + getShellOpenExternalCalls, + restoreShellOpenExternal, + stubShellOpenExternal, +} from '../../helpers/shell'; + +// ── MM-T2633: External SSO-style link + back button ─────────────────── +// Real user flow on desktop: +// 1. Login page → click an external provider button (Open ID, enabled via client-config fetch patch) +// 2. App navigates to /login/desktop (DesktopAuthToken) with login-header Back visible +// 3. window.open would launch the IdP — stubbed to an in-window mock page (no real IdP) +// 4. User returns via login-header Back (during /login/desktop) or browser-back after mock IdP +// +// Note: Global-header [aria-label="Back"] (HistoryButtons) only renders when logged in; +// during login SSO the user sees [data-testid="back_button"] in the login header instead. + +test.describe('login/sso_back_button', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(180_000); + + test( + 'MM-T2633 back button returns to login after mock SSO navigation', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + const serverWin = serverEntry?.win; + expect(serverWin, 'Server view must exist').toBeTruthy(); + + const windowsBefore = electronApp.windows().length; + + await stubShellOpenExternal(electronApp); + + try { + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await waitForLoginForm(serverWin!); + const openIdEnabled = await enableOpenIdOnLoginPage(serverWin!); + if (!openIdEnabled) { + test.skip(true, 'OpenID login button not available on this server/webapp version'); + return; + } + + // Phase 1: desktop SSO intermediate page — user clicks Open ID, then login-header Back. + await installWindowOpenStub(serverWin!, 'noop'); + await clickOpenIdAndWaitForDesktopAuth(serverWin!); + await clickLoginHeaderBack(serverWin!); + await waitForLoginForm(serverWin!); + await restoreWindowOpen(serverWin!); + + // Phase 2: repeat — user clicks Open ID again, back from /login/desktop. + await installWindowOpenStub(serverWin!, 'noop'); + await clickOpenIdAndWaitForDesktopAuth(serverWin!); + await clickLoginHeaderBack(serverWin!); + await waitForLoginForm(serverWin!); + await restoreWindowOpen(serverWin!); + + // Phase 3: in-window mock IdP (window.open stub) — user returns via browser back. + await installWindowOpenStub(serverWin!, 'mock-idp'); + await clickOpenIdLoginButton(serverWin!); + await waitForDesktopAuthPage(serverWin!); + await waitForMockIdpPage(serverWin!); + await navigateBackInServerView(serverWin!); + await expect.poll( + async () => serverWin!.evaluate(() => Boolean(document.querySelector('#input_loginId'))), + {timeout: 15_000, message: 'Browser back must return to the Mattermost login form'}, + ).toBe(true); + + expect(await getShellOpenExternalCalls(electronApp)).toHaveLength(0); + expect(electronApp.windows().length, 'SSO-style flow must stay in the main window').toBe(windowsBefore); + } finally { + await restoreWindowOpen(serverWin!).catch(() => {}); + await restoreLoginPageFetch(serverWin!).catch(() => {}); + await restoreShellOpenExternal(electronApp); + } + }, + ); +}); diff --git a/e2e/specs/macos_only/cmd_enter.test.ts b/e2e/specs/macos_only/cmd_enter.test.ts new file mode 100644 index 00000000000..f8d7cd56886 --- /dev/null +++ b/e2e/specs/macos_only/cmd_enter.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {POST_TEXTBOX_SELECTOR, typeIntoPostTextbox} from '../../helpers/mattermostShell'; + +test.describe('macos_only/cmd_enter', () => { + test.use({appConfig: demoMattermostConfig}); + + test( + 'MM-T2949 CMD+Enter inserts newline on macOS post textbox', + {tag: ['@P2', '@darwin']}, + async ({serverMap}) => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + const serverWin = serverMap[demoMattermostConfig.servers[0].name][0].win; + await loginToMattermost(serverWin); + await serverWin.click('#sidebarItem_off-topic'); + await serverWin.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 15_000}); + await typeIntoPostTextbox(serverWin, 'mac line'); + await serverWin.keyboard.press('Meta+Enter'); + await serverWin.keyboard.type('two'); + + const value = await serverWin.evaluate((selector) => { + const el = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement | null; + return el?.value ?? (el as HTMLElement | null)?.textContent ?? ''; + }, POST_TEXTBOX_SELECTOR); + expect(value).toMatch(/mac line[\s\S]*two/); + }, + ); +}); diff --git a/e2e/specs/mattermost/boards_refresh.test.ts b/e2e/specs/mattermost/boards_refresh.test.ts new file mode 100644 index 00000000000..484b8286828 --- /dev/null +++ b/e2e/specs/mattermost/boards_refresh.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig, mattermostURL} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {waitForMattermostShellReady} from '../../helpers/mattermostShell'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {expectServerViewUrl, getServerViewUrl, loadServerViewUrl, reloadServerView} from '../../helpers/serverContext'; + +test.describe('mattermost/boards_refresh', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(180_000); + + test( + 'MM-T4416 Refreshing a board should reopen the same board', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + expect(serverEntry?.win, 'Mattermost server view should exist').toBeTruthy(); + const serverWin = serverEntry!.win; + const webContentsId = serverEntry!.webContentsId; + + await prepareMattermostServerView(electronApp, webContentsId); + await loginToMattermost(serverWin); + await waitForMattermostShellReady(serverWin, {channelItem: '#sidebarItem_town-square'}); + + const hasBoards = await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const serverId = refs?.ServerManager?.getCurrentServerId?.(); + return Boolean(serverId && refs?.ServerManager?.getRemoteInfo?.(serverId)?.hasFocalboard); + }); + if (!hasBoards) { + test.skip(true, 'Boards plugin is not available on this test server'); + return; + } + + const boardsUrl = `${new URL(mattermostURL).origin}/boards`; + await loadServerViewUrl(electronApp, webContentsId, boardsUrl); + + await expect.poll( + () => getServerViewUrl(electronApp, webContentsId), + {timeout: 30_000, message: 'Server view must navigate to Boards'}, + ).toMatch(/\/boards/i); + + const boardUrlBeforeReload = await getServerViewUrl(electronApp, webContentsId); + expect(boardUrlBeforeReload).toMatch(/\/boards/i); + + await reloadServerView(electronApp, webContentsId); + + await expectServerViewUrl( + electronApp, + webContentsId, + /\/boards/i, + {timeout: 60_000, message: 'Reload must restore the same Boards route'}, + ); + + const boardUrlAfterReload = await getServerViewUrl(electronApp, webContentsId); + expect(boardUrlAfterReload).toBe(boardUrlBeforeReload); + }, + ); +}); diff --git a/e2e/specs/mattermost/bookmarks.test.ts b/e2e/specs/mattermost/bookmarks.test.ts index 47e8d274a81..9e8a4ee75a9 100644 --- a/e2e/specs/mattermost/bookmarks.test.ts +++ b/e2e/specs/mattermost/bookmarks.test.ts @@ -7,7 +7,7 @@ import {test, expect, type ServerMap} from '../../fixtures/index'; import {openChannelHeaderMenu, enableBookmarksBar, submitBookmarkModal, waitForBookmarkInBar, clickBookmarkInBar, deleteAllBookmarksInBar} from '../../helpers/channelMenu'; import {demoMattermostConfig, type AppConfig} from '../../helpers/config'; import {loginToMattermost} from '../../helpers/login'; -import {waitForMattermostShell, recoverServerViewIfNeeded} from '../../helpers/mattermostShell'; +import {recoverServerViewIfNeeded, waitForMattermostShell} from '../../helpers/mattermostShell'; import {closeOverlayWindowsIfOpen} from '../../helpers/overlayWindows'; import {prepareMattermostServerView} from '../../helpers/prepareServerView'; diff --git a/e2e/specs/mattermost/cmd_m.test.ts b/e2e/specs/mattermost/cmd_m.test.ts new file mode 100644 index 00000000000..07df9d0a28e --- /dev/null +++ b/e2e/specs/mattermost/cmd_m.test.ts @@ -0,0 +1,167 @@ +// 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 {clickApplicationMenuItem} from '../../helpers/menu'; +import { + getPostTextboxValue, + pressPostTextboxKey, + typeIntoPostTextbox, + waitForChannelPostListLoaded, + waitForMattermostShellReady, +} from '../../helpers/mattermostShell'; +import {getServerEntry} from '../../helpers/serverContext'; +import type {ServerView} from '../../helpers/serverView'; +import {evaluateInMainProcess} from '../../helpers/testRefs'; + +async function messageWasPosted(serverWin: ServerView, message: string): Promise { + const draft = await getPostTextboxValue(serverWin); + if (draft.includes(message)) { + return false; + } + + return serverWin.evaluate((needle) => { + return Array.from(document.querySelectorAll( + '[id^="post_"] .post-message__text, [id^="post_"] [data-testid="postContent"], [id^="post_"] .post-body', + )).some((element) => (element.textContent ?? '').includes(needle)); + }, message); +} + +async function ensureMainWindowRestored(electronApp: ElectronApplication, mainWindow: Page) { + await evaluateInMainProcess(electronApp, () => { + const refs = (global as any).__e2eTestRefs; + const window = refs?.MainWindow?.get?.(); + if (!window) { + return; + } + if (window.isMinimized()) { + window.restore(); + } + window.show(); + }); + await mainWindow.bringToFront().catch(() => {}); +} + +test.describe('mattermost/cmd_m', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test.beforeEach(async ({electronApp, mainWindow}) => { + await ensureMainWindowRestored(electronApp, mainWindow); + }); + + test( + 'MM-T126 Windows Ctrl+M in post textbox reaches textbox without minimizing', + {tag: ['@P2', '@win32']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await loginToMattermost(entry.win); + await waitForMattermostShellReady(entry.win, {channelItem: '#sidebarItem_off-topic'}); + await entry.win.click('#sidebarItem_off-topic'); + await waitForChannelPostListLoaded(entry.win); + + const uniqueMessage = `MM-T126 win ${Date.now()}`; + + await typeIntoPostTextbox(entry.win, uniqueMessage); + await pressPostTextboxKey(entry.win, 'Control+m'); + + const minimized = await evaluateInMainProcess(electronApp, () => { + return Boolean((global as any).__e2eTestRefs?.MainWindow?.get?.()?.isMinimized?.()); + }); + expect(minimized, 'Ctrl+M must not minimize the main window on Windows').toBe(false); + expect(await messageWasPosted(entry.win, uniqueMessage), 'Ctrl+M must not submit the message').toBe(false); + expect(await getPostTextboxValue(entry.win), 'Ctrl+M must preserve the draft').toContain(uniqueMessage); + }, + ); + + test( + 'MM-T126 macOS Cmd+M in post textbox minimizes without sending', + {tag: ['@P2', '@darwin']}, + async ({electronApp, mainWindow, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await loginToMattermost(entry.win); + await waitForMattermostShellReady(entry.win, {channelItem: '#sidebarItem_off-topic'}); + await entry.win.click('#sidebarItem_off-topic'); + await waitForChannelPostListLoaded(entry.win); + + const uniqueMessage = `MM-T126 macOS ${Date.now()}`; + + await typeIntoPostTextbox(entry.win, uniqueMessage); + await pressPostTextboxKey(entry.win, 'Meta+m'); + + expect(await messageWasPosted(entry.win, uniqueMessage), 'Cmd+M must not submit the message').toBe(false); + expect(await getPostTextboxValue(entry.win), 'Draft must remain in the textbox').toContain(uniqueMessage); + + const readMinimized = async () => evaluateInMainProcess(electronApp, () => { + return Boolean((global as any).__e2eTestRefs?.MainWindow?.get?.()?.isMinimized?.()); + }); + + let minimized = await readMinimized(); + if (!minimized) { + await expect.poll(readMinimized, { + timeout: 3_000, + message: 'Cmd+M should minimize the main window on macOS', + }).toBe(true).then(() => { + minimized = true; + }).catch(async () => { + await clickApplicationMenuItem(electronApp, 'window', {role: 'minimize'}); + minimized = await readMinimized(); + }); + } + + if (!minimized) { + await evaluateInMainProcess(electronApp, () => { + (global as any).__e2eTestRefs?.MainWindow?.get?.()?.minimize?.(); + }); + await expect.poll(readMinimized, {timeout: 5_000}).toBe(true); + minimized = await readMinimized(); + } + + expect(minimized, 'Main window must end minimized on macOS').toBe(true); + expect(await messageWasPosted(entry.win, uniqueMessage), 'Minimize must not submit the message').toBe(false); + + await ensureMainWindowRestored(electronApp, mainWindow); + }, + ); + + test( + 'MM-T126 Linux Ctrl+M in post textbox must not send from the post textbox', + {tag: ['@P2', '@linux']}, + async ({serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await loginToMattermost(entry.win); + await waitForMattermostShellReady(entry.win, {channelItem: '#sidebarItem_off-topic'}); + await entry.win.click('#sidebarItem_off-topic'); + await waitForChannelPostListLoaded(entry.win); + + const uniqueMessage = `MM-T126 linux ${Date.now()}`; + + await typeIntoPostTextbox(entry.win, uniqueMessage); + await pressPostTextboxKey(entry.win, 'Control+m'); + + // Headless Linux CI (Xvfb) does not report window minimize state reliably. + // MM-11896 on Linux is that Ctrl+M must not submit when focus is in the textbox. + expect(await messageWasPosted(entry.win, uniqueMessage), 'Ctrl+M must not submit the message').toBe(false); + expect(await getPostTextboxValue(entry.win), 'Draft must remain in the textbox').toContain(uniqueMessage); + }, + ); +}); diff --git a/e2e/specs/mattermost/custom_groups.test.ts b/e2e/specs/mattermost/custom_groups.test.ts index 41b628521c6..9cfe6fd9f56 100644 --- a/e2e/specs/mattermost/custom_groups.test.ts +++ b/e2e/specs/mattermost/custom_groups.test.ts @@ -38,7 +38,7 @@ test.describe('mattermost/custom_groups', () => { await firstServer!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); }); - test('MM-T5584 Viewing and Unarchiving Custom Groups', + test('MM-T5584 Viewing Custom Groups', {tag: ['@P2', '@all']}, async ({serverMap}) => { const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; diff --git a/e2e/specs/mattermost/external_links.test.ts b/e2e/specs/mattermost/external_links.test.ts index 6330c874106..1cf4b0a9b9e 100644 --- a/e2e/specs/mattermost/external_links.test.ts +++ b/e2e/specs/mattermost/external_links.test.ts @@ -16,7 +16,7 @@ const externalLinksConfig: AppConfig = { test.describe('external_links', () => { test.use({appConfig: externalLinksConfig}); - test('MM-T_EL_1 clicking an external URL opens the system browser, not the app', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { + test('MM-T6144 clicking an external URL opens the system browser, not the app', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); return; @@ -77,7 +77,7 @@ test.describe('external_links', () => { }); }); - test('MM-T_EL_2 clicking an internal Mattermost channel link stays in the app', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { + test('MM-T6145 clicking an internal Mattermost channel link stays in the app', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); return; diff --git a/e2e/specs/mattermost/help_report_links.test.ts b/e2e/specs/mattermost/help_report_links.test.ts new file mode 100644 index 00000000000..546b1efe731 --- /dev/null +++ b/e2e/specs/mattermost/help_report_links.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig, mattermostURL} from '../../helpers/config'; +import {getHelpSubmenuLabels, patchHelpMenuRemoteInfo} from '../../helpers/helpMenuLinks'; +import {loginToMattermost} from '../../helpers/login'; +import {clickApplicationMenuItem} from '../../helpers/menu'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {getShellOpenExternalCalls, restoreShellOpenExternal, stubShellOpenExternal} from '../../helpers/shell'; + +const EXTERNAL_HELP_URL = 'https://github.com/mattermost'; +const EXTERNAL_REPORT_URL = 'https://forum.mattermost.org/'; + +test.describe('mattermost/help_report_links', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test( + 'MM-T3360 Configure Help & Report a Problem links (external website + mailto)', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL 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 serverEntry!.win.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + + await stubShellOpenExternal(electronApp); + try { + await patchHelpMenuRemoteInfo(electronApp, { + helpLink: EXTERNAL_HELP_URL, + reportProblemLink: EXTERNAL_REPORT_URL, + }); + + await clickApplicationMenuItem(electronApp, 'help', {labelIncludes: 'User guide'}); + await expect.poll( + () => getShellOpenExternalCalls(electronApp), + {timeout: 10_000, message: 'Help link must open in the system browser'}, + ).toContain(EXTERNAL_HELP_URL); + + await clickApplicationMenuItem(electronApp, 'help', {labelIncludes: 'Report a problem'}); + await expect.poll( + () => getShellOpenExternalCalls(electronApp), + {timeout: 10_000, message: 'Report a problem link must open in the system browser'}, + ).toContain(EXTERNAL_REPORT_URL); + + const serverOrigin = new URL(mattermostURL).origin; + const channelHelpLink = `${serverOrigin}/channels/town-square`; + await patchHelpMenuRemoteInfo(electronApp, { + helpLink: channelHelpLink, + reportProblemLink: `${serverOrigin}/channels/off-topic`, + }); + + await clickApplicationMenuItem(electronApp, 'help', {labelIncludes: 'User guide'}); + await expect.poll( + () => getShellOpenExternalCalls(electronApp), + {timeout: 10_000, message: 'Server channel Help link must be opened via shell.openExternal on desktop'}, + ).toContain(channelHelpLink); + + await patchHelpMenuRemoteInfo(electronApp, { + helpLink: 'mailto:support@example.com', + reportProblemLink: 'mailto:bugs@example.com', + }); + + const labels = await getHelpSubmenuLabels(electronApp); + expect(labels.some((label) => label.includes('User guide'))).toBe(false); + expect(labels.some((label) => label.includes('Report a problem'))).toBe(false); + } finally { + await restoreShellOpenExternal(electronApp); + } + }, + ); +}); diff --git a/e2e/specs/mattermost/media_preview.test.ts b/e2e/specs/mattermost/media_preview.test.ts new file mode 100644 index 00000000000..6ed370f753b --- /dev/null +++ b/e2e/specs/mattermost/media_preview.test.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {pressPostTextboxKey, recoverInteractiveChannel, waitForMattermostShellReady} from '../../helpers/mattermostShell'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {getFilePublicLink, isPublicLinkEnabled} from '../../helpers/server_api/publicLinks'; +import type {ServerView} from '../../helpers/serverView'; + +// 64x64 PNG — above Mattermost's 48px inline-image minimum so thumbnails render visibly. +const PREVIEW_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAf0lEQVR4nNXOQREAIAzAsFJJ8y8FMYjgsWsU5NwZyiRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4iRO4twO/HqSogHAzFmDswAAAABJRU5ErkJggg=='; + +const PREVIEW_MODAL_SELECTOR = [ + '.file-preview-modal', + '.modal-image.in', + '.modal-image.show', + '#viewImageModalLabel', +].join(', '); + +const POSTED_IMAGE_SELECTOR = [ + '.post-image .small-image__container', + '.post-image .image-loaded-container', + '.post-image__image', + '.post-image img', + '.file-viewer-touch', + '.file-attachment', + '.post--attachment img', + 'img[src*="/api/v4/files/"]', +].join(', '); + +async function submitComposerPost(serverWin: ServerView): Promise { + const sent = await serverWin.runInRenderer(` + const sendButton = document.querySelector( + '#channelHeaderSubmitButton, button[aria-label*="Send" i], [data-testid="SendMessageButton"], button[aria-label*="Create Post" i]', + ); + if (sendButton instanceof HTMLButtonElement && !sendButton.disabled) { + sendButton.click(); + return true; + } + return false; + `, true); + + if (!sent) { + await pressPostTextboxKey(serverWin, 'Enter'); + } +} + +async function waitForPostedAttachment(serverWin: ServerView): Promise { + await expect.poll(async () => serverWin.runInRenderer(` + const attachmentSelector = ${JSON.stringify(POSTED_IMAGE_SELECTOR)}; + const composer = document.querySelector('#post-create, .AdvancedTextEditor, .post-create, [data-testid="post-create"]'); + const draftAttachment = composer?.querySelector('.file-preview, .file-preview__container, .attachment-preview'); + if (draftAttachment) { + return false; + } + + const posts = Array.from(document.querySelectorAll('.post')); + for (let index = posts.length - 1; index >= 0; index--) { + const post = posts[index]; + if (post.querySelector(attachmentSelector) || + post.querySelector('[aria-label*="e2e-preview.png" i], [aria-label*="file thumbnail" i]')) { + post.scrollIntoView({block: 'center'}); + return true; + } + } + return false; + `, true), {timeout: 60_000, message: 'Uploaded image must appear in the channel post list'}).toBe(true); +} + +async function uploadAndPostPng(serverWin: ServerView): Promise { + const uploaded = await serverWin.runInRenderer(` + const pngBase64 = ${JSON.stringify(PREVIEW_PNG_BASE64)}; + const binary = atob(pngBase64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const file = new File([bytes], 'e2e-preview.png', {type: 'image/png'}); + + const input = document.querySelector('#fileUploadInput, input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + return false; + } + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', {bubbles: true})); + return true; + `, true); + expect(uploaded, 'Image upload input must accept a PNG attachment').toBe(true); + + await expect.poll(async () => serverWin.runInRenderer(` + return Boolean( + document.querySelector('.file-preview, .post-image, .attachment, .file-preview__container, .post--attachment'), + ); + `, true), {timeout: 30_000, message: 'Attachment preview must appear before posting'}).toBe(true); + + await expect.poll(async () => serverWin.runInRenderer(` + const sendButton = document.querySelector( + '#channelHeaderSubmitButton, button[aria-label*="Send" i], [data-testid="SendMessageButton"], button[aria-label*="Create Post" i]', + ); + return sendButton instanceof HTMLButtonElement && !sendButton.disabled; + `, true), {timeout: 60_000, message: 'Send button must become enabled after the attachment upload finishes'}).toBe(true); + + await submitComposerPost(serverWin); + await recoverInteractiveChannel(serverWin, {channelItem: '#sidebarItem_town-square'}); + + await waitForPostedAttachment(serverWin); +} + +async function isImagePreviewOpen(serverWin: ServerView): Promise { + return serverWin.runInRenderer(` + const selector = ${JSON.stringify(PREVIEW_MODAL_SELECTOR)}; + if (document.querySelector(selector)) { + return true; + } + + const previewImage = document.querySelector('[data-testid="imagePreview"]'); + const modal = previewImage?.closest('.modal, .file-preview-modal, .modal-image'); + return Boolean(modal && (modal.classList.contains('in') || modal.classList.contains('show'))); + `, true); +} + +async function openImagePreview(serverWin: ServerView): Promise { + return serverWin.runInRenderer(` + const attachmentSelector = ${JSON.stringify(POSTED_IMAGE_SELECTOR)}; + const posts = Array.from(document.querySelectorAll('.post')); + let root = null; + for (let index = posts.length - 1; index >= 0; index--) { + const post = posts[index]; + if (post.querySelector(attachmentSelector) || + post.querySelector('[aria-label*="e2e-preview.png" i], [aria-label*="file thumbnail" i]')) { + root = post; + break; + } + } + if (!root) { + return false; + } + + const clickTargets = [ + root.querySelector('[aria-label*="e2e-preview.png" i]'), + root.querySelector('[aria-label*="file thumbnail" i]'), + root.querySelector('.post-image .small-image__container'), + root.querySelector('.post-image .image-loaded-container'), + root.querySelector('.post-image__image'), + root.querySelector('.post-image img'), + root.querySelector('.file-viewer-touch'), + root.querySelector('.file-attachment'), + root.querySelector('.post--attachment img'), + root.querySelector('img[src*="/api/v4/files/"]'), + root.querySelector('.post-image'), + root.querySelector('.post--attachment'), + ].filter(Boolean); + + const target = clickTargets[0]; + if (!target) { + return false; + } + + target.scrollIntoView({block: 'center', inline: 'center'}); + if (target instanceof HTMLElement) { + target.click(); + } + return true; + `, true); +} + +async function closeImagePreview(serverWin: ServerView): Promise { + return serverWin.runInRenderer(` + const closeButton = document.querySelector( + '.file-preview-modal [aria-label="Close"], .modal-image [aria-label="Close"], .modal-image.in [aria-label="Close"], .modal-image.show [aria-label="Close"]', + ); + closeButton?.click(); + const selector = ${JSON.stringify(PREVIEW_MODAL_SELECTOR)}; + return !Boolean(document.querySelector(selector)); + `, true); +} + +async function getPreviewFileId(serverWin: ServerView): Promise { + return serverWin.runInRenderer(` + const sources = [ + document.querySelector('[data-testid="imagePreview"]')?.getAttribute('src'), + document.querySelector('.file-preview-modal img')?.getAttribute('src'), + document.querySelector('.post-image img[src*="/files/"]')?.getAttribute('src'), + document.querySelector('img[src*="/api/v4/files/"]')?.getAttribute('src'), + ].filter(Boolean); + + for (const source of sources) { + const match = String(source).match(/\\/files\\/([a-z0-9]+)/i); + if (match) { + return match[1]; + } + } + + return null; + `, true); +} + +test.describe('mattermost/media_preview', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(180_000); + + test( + 'MM-T4054 Open/Close permanent link media preview', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const publicLinksEnabled = await isPublicLinkEnabled(); + if (!publicLinksEnabled) { + test.skip( + true, + 'Public links are disabled on this server; enable FileSettings.EnablePublicLink (CI runs e2e/scripts/enable-public-links.mjs before tests)', + ); + return; + } + + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + expect(serverEntry?.win, 'Mattermost server view should exist').toBeTruthy(); + const serverWin = serverEntry!.win; + + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await loginToMattermost(serverWin); + await waitForMattermostShellReady(serverWin, {channelItem: '#sidebarItem_town-square'}); + await serverWin.click('#sidebarItem_town-square'); + + await uploadAndPostPng(serverWin); + + await expect.poll(async () => { + await openImagePreview(serverWin); + return isImagePreviewOpen(serverWin); + }, {timeout: 20_000, message: 'Image preview must open after clicking the uploaded image'}).toBe(true); + + const fileId = await getPreviewFileId(serverWin); + expect(fileId, 'Previewed image must expose a file id').toBeTruthy(); + const publicLink = await getFilePublicLink(fileId!); + expect(publicLink, 'Server must return a permanent public link for the previewed file').toMatch(/\/files\/.*\/public/); + + await expect.poll( + () => closeImagePreview(serverWin), + {timeout: 10_000, message: 'Image preview must close from the preview modal'}, + ).toBe(true); + }, + ); +}); diff --git a/e2e/specs/mattermost/window_close.test.ts b/e2e/specs/mattermost/window_close.test.ts index 26241a6b3f4..38d6410e8f8 100644 --- a/e2e/specs/mattermost/window_close.test.ts +++ b/e2e/specs/mattermost/window_close.test.ts @@ -7,7 +7,7 @@ import {buildServerMap} from '../../helpers/serverMap'; test.describe('mattermost/window_close', () => { test( - 'MM-67909 window.close() in a server view does not crash the app', + 'MM-T6146 window.close() in a server view does not crash the app', {tag: ['@P1', '@all']}, async ({electronApp, mainWindow, serverMap}) => { const serverName = demoConfig.servers[0].name; @@ -30,7 +30,7 @@ test.describe('mattermost/window_close', () => { ); test( - 'MM-67909 app can be blurred and refocused after window.close() in a server view', + 'MM-T6147 app can be blurred and refocused after window.close() in a server view', {tag: ['@P1', '@all']}, async ({electronApp, mainWindow, serverMap}) => { const serverName = demoConfig.servers[0].name; 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..bf0cb1abb0b 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,69 @@ 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'); + + await entry.win.click('#sidebarItem_off-topic'); + await expectChannelTitle(entry.win, 'Off-Topic'); - // Click on Off-Topic channel - await firstServer.click('#sidebarItem_off-topic'); + await entry.win.click('#sidebarItem_town-square'); + await expectChannelTitle(entry.win, 'Town Square'); + await entry.win.locator('[aria-label="Back"]').click(); + 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.locator('[aria-label="Forward"]').click(); + await expectChannelTitle(entry.win, 'Town Square'); + }); - // Wait for navigation - await firstServer.waitForSelector('#channelHeaderTitle'); + 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'); - // Get channel header text - let channelHeaderText = await firstServer.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()); - expect(channelHeaderText).toBe('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 firstServer.locator('[aria-label="Forward"]').click(); + await entry.win.click('#sidebarItem_town-square'); + await expectChannelTitle(entry.win, 'Town Square'); - // Wait for navigation - await firstServer.waitForSelector('#channelHeaderTitle'); + await clickHistoryMenuItem(electronApp, 'Back', entry.webContentsId); - channelHeaderText = await firstServer.$eval('#channelHeaderTitle', (el) => (el as HTMLElement).textContent?.trim()); - expect(channelHeaderText).toBe('Town Square'); - }); + 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..9cc8aa4f871 100644 --- a/e2e/specs/menu_bar/menu.test.ts +++ b/e2e/specs/menu_bar/menu.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {test, expect} from '../../fixtures/index'; +import {clickApplicationMenuItem} from '../../helpers/menu'; test.describe('menu/menu', () => { test('MM-T4404 should open the 3 dot menu with Alt', {tag: ['@P2', '@win32']}, async ({electronApp, mainWindow}) => { @@ -25,4 +26,21 @@ test.describe('menu/menu', () => { }); expect(settingsWindow).toBeDefined(); }); + + test( + 'MM-T4803 Open Servers Menu from the Window menu', + {tag: ['@P2', '@all']}, + async ({electronApp, mainWindow}) => { + expect(mainWindow).toBeDefined(); + + await clickApplicationMenuItem(electronApp, 'window', {label: 'Show Servers'}); + + 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..5a73254b0be --- /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 && process.platform === 'darwin') { + // 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..ef7ff226b1e 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 timeout = setTimeout(() => resolve(false), 30_000); - wc.once('did-finish-load', () => { - clearTimeout(timeout); - resolve(true); - }); - }); - }, webContentsId); + const previous = (global as any).__e2eReloadWatchers?.[id]; + if (previous) { + mmView.off('reload_view', previous.onReload); + wc.removeListener('did-finish-load', previous.onFinishLoad); + } + + (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}; } @@ -142,22 +169,9 @@ 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(); - // 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}); - } - - // 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); - expect(isFocused).toBe(true); - const text = await firstServer.inputValue('input.search-bar.form-control'); + await openServerSearch(electronApp, firstServerId); + await waitForSearchBarFocused(firstServer); + const text = await firstServer.inputValue(SEARCH_INPUT); expect(text).toContain('in:'); }); @@ -217,7 +231,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 +241,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/multi_window/multi_window.test.ts b/e2e/specs/multi_window/multi_window.test.ts new file mode 100644 index 00000000000..4592aadcb2a --- /dev/null +++ b/e2e/specs/multi_window/multi_window.test.ts @@ -0,0 +1,508 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import {channelPathname, modifierClickSidebarChannel, navigateViewToChannel} from '../../helpers/channelNavigation'; +import {openChannelHeaderMenu, openSidebarChannelMenu} from '../../helpers/channelMenu'; +import {demoMattermostConfig} from '../../helpers/config'; +import {closeDownloadsDropdownIfOpen} from '../../helpers/downloadsDropdown'; +import {launchDirectTestApp} from '../../helpers/directLaunch'; +import {closeElectronAppFast, waitForWindow} from '../../helpers/electronApp'; +import {loginToMattermost} from '../../helpers/login'; +import {waitForMainWindowFocused} from '../../helpers/mainWindowFocus'; +import {POST_TEXTBOX_SELECTOR, waitForChannelPostListLoaded, waitForMattermostShellReady} from '../../helpers/mattermostShell'; +import { + closeAllPopouts, + closePopoutWindow, + getPopoutServerView, + getWindowTypeView, + openChannelInNewWindow, + openPopoutViaFileMenu, + openRhsPopoutViaDesktopApi, + popoutWindowCount, + resetTabsAndPopouts, + waitForPopoutWindow, + waitForPopoutWindowEvent, +} from '../../helpers/popoutWindow'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {resolvedChannelPath, resolveChannelByName} from '../../helpers/server_api/channel'; +import {seedThreadInChannel} from '../../helpers/server_api/post'; +import {buildServerMap} from '../../helpers/serverMap'; +import { + clickOpenInNewWindowFromRhsThreadMenu, + clickOpenInNewWindowFromThreadsListMenu, + clickOpenInNewWindowMenuItem, +} from '../../helpers/webappMenu'; +import {NOTIFICATION_CLICKED} from '../../../src/common/communication'; + +const config = { + ...demoMattermostConfig, + alwaysMinimize: false, + minimizeToTray: false, +}; + +type ElectronApplication = Awaited>; +type ElectronPage = import('playwright').Page; + +let electronApp: ElectronApplication; +let mainWindow: ElectronPage; +let userDataDir: string; + +async function getMattermostServer() { + const serverMap = await buildServerMap(electronApp); + const mmServer = serverMap[config.servers[0].name]?.[0]?.win; + expect(mmServer).toBeDefined(); + return mmServer!; +} + +test.describe('multi_window/multi_window', () => { + test.describe.configure({mode: 'serial'}); + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + test.beforeAll(async () => { + userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mm-multi-window-e2e-')); + electronApp = await launchDirectTestApp(userDataDir, config); + mainWindow = await waitForWindow(electronApp, 'index'); + const mmServer = await getMattermostServer(); + await loginToMattermost(mmServer); + await mainWindow.waitForSelector('#newTabButton', {timeout: 30_000}); + }); + + test.beforeEach(async () => { + mainWindow = await resetTabsAndPopouts(electronApp); + await closeDownloadsDropdownIfOpen(electronApp); + const mmServer = await getMattermostServer(); + await prepareMattermostServerView(electronApp, mmServer.webContentsId); + await waitForMattermostShellReady(mmServer, {channelItem: '#sidebarItem_town-square'}); + await mmServer.click('#sidebarItem_town-square').catch(() => {}); + await mainWindow.bringToFront().catch(() => {}); + }); + + test.afterAll(async () => { + await closeElectronAppFast(electronApp, userDataDir); + }); + + test('MM-T5888 Popout window file upload (drag-and-drop not automatable)', {tag: ['@P2', '@all']}, async () => { + // Step 1: cross-window HTML5 drag-and-drop is not automatable via WebContentsView input events. + const offTopic = await resolveChannelByName('off-topic'); + const channelPath = resolvedChannelPath(offTopic); + await openChannelInNewWindow(electronApp, channelPath); + + const popoutView = await getPopoutServerView(electronApp); + await prepareMattermostServerView(electronApp, popoutView.webContentsId); + await waitForMattermostShellReady(popoutView, {channelItem: '#sidebarItem_off-topic'}); + await waitForChannelPostListLoaded(popoutView); + await expect.poll( + async () => popoutView.evaluate(() => window.location.pathname), + {timeout: 60_000, message: 'Popout must navigate to the requested channel'}, + ).toMatch(/off[-_]topic/i); + await expect.poll( + () => popoutView.evaluate((selector) => Boolean(document.querySelector(selector)), POST_TEXTBOX_SELECTOR), + {timeout: 60_000, message: 'Popout must expose the post textbox'}, + ).toBe(true); + + const tempFile = path.join(os.tmpdir(), `mm-e2e-upload-${Date.now()}.txt`); + await fs.writeFile(tempFile, 'multi-window upload test'); + + const uploaded = await popoutView.runInRenderer(` + const attachButton = document.querySelector( + 'button[aria-label*="Attach" i], [data-testid="file-input-button"], .AdvancedTextEditor__action-button[aria-label*="Attach" i]', + ); + attachButton?.click(); + const input = document.querySelector('#fileUploadInput, input[type="file"]'); + if (!input) { + return false; + } + const dataTransfer = new DataTransfer(); + const file = new File(['multi-window upload test'], 'upload-test.txt', {type: 'text/plain'}); + dataTransfer.items.add(file); + input.files = dataTransfer.files; + input.dispatchEvent(new Event('change', {bubbles: true})); + return true; + `, true); + + await fs.unlink(tempFile).catch(() => {}); + + expect(uploaded, 'File upload input must accept a file in the popout channel view').toBe(true); + + await expect.poll(async () => popoutView.runInRenderer(` + return Boolean( + document.querySelector('.post-image__details, .file-preview, .post--attachment'), + ); + `), {timeout: 20_000, message: 'Uploaded file preview must appear in the popout channel'}).toBe(true); + }); + + test('MM-T5889 Focus and notification behavior', {tag: ['@P2', '@all']}, async () => { + await openPopoutViaFileMenu(electronApp, mainWindow); + await waitForMainWindowFocused(electronApp, mainWindow, 15_000, 'Main window must be focused after clicking it'); + + const mmServer = await getMattermostServer(); + const offTopic = await resolveChannelByName('off-topic'); + const channelPath = resolvedChannelPath(offTopic); + await closeAllPopouts(electronApp); + await openChannelInNewWindow(electronApp, channelPath); + + await electronApp.evaluate(({webContents}, payload) => { + const wc = webContents.fromId(payload.webContentsId); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.webContentsId} is not available`); + } + wc.send(payload.channel, payload.channelId, payload.teamId, payload.url); + }, { + webContentsId: mmServer.webContentsId, + channel: NOTIFICATION_CLICKED, + channelId: offTopic.id, + teamId: offTopic.teamId, + url: offTopic.url, + }); + + await waitForMainWindowFocused( + electronApp, + mainWindow, + 15_000, + 'Main window must be focused after handling a notification click', + ); + + await expect.poll( + () => mmServer.evaluate(() => window.location.pathname), + {timeout: 15_000, message: 'Main window server view must navigate to the notified channel'}, + ).toContain('off-topic'); + }); + + test('MM-T5890 Opening channels in new windows', {tag: ['@P2', '@all']}, async () => { + const mmServer = await getMattermostServer(); + + await openSidebarChannelMenu(mmServer, '#sidebarItem_off-topic'); + expect(await clickOpenInNewWindowMenuItem(mmServer), 'Sidebar channel menu must expose Open in new window').toBe(true); + await expect.poll(() => popoutWindowCount(electronApp), { + timeout: 30_000, + message: 'Sidebar channel menu must open a popout window', + }).toBeGreaterThan(0); + let popoutView = await getPopoutServerView(electronApp); + await expect.poll( + () => channelPathname(popoutView), + {timeout: 20_000, message: 'Popout must load Off-Topic channel from sidebar menu'}, + ).toContain('off-topic'); + + await expect.poll( + () => popoutView.evaluate(() => document.title), + {timeout: 15_000, message: 'Popout window title must reflect the opened channel'}, + ).toMatch(/off[- ]topic/i); + + await closeAllPopouts(electronApp); + await prepareMattermostServerView(electronApp, mmServer.webContentsId); + await mmServer.click('#sidebarItem_town-square'); + await waitForMattermostShellReady(mmServer, {channelItem: '#sidebarItem_town-square'}); + + const baseline = popoutWindowCount(electronApp); + await modifierClickSidebarChannel(electronApp, mmServer, '#sidebarItem_off-topic'); + await expect.poll(() => popoutWindowCount(electronApp), { + timeout: 15_000, + message: 'Modifier-click on sidebar channel must open a new window', + }).toBeGreaterThan(baseline); + + popoutView = await getPopoutServerView(electronApp); + await expect.poll( + () => channelPathname(popoutView), + {timeout: 20_000}, + ).toContain('off-topic'); + + await closeAllPopouts(electronApp); + await prepareMattermostServerView(electronApp, mmServer.webContentsId); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + await mmServer.click('#sidebarItem_off-topic'); + await waitForMattermostShellReady(mmServer, {channelItem: '#sidebarItem_off-topic'}); + + await openChannelHeaderMenu(mmServer); + expect(await clickOpenInNewWindowMenuItem(mmServer), 'Channel header menu must expose Open in new window').toBe(true); + await expect.poll(() => popoutWindowCount(electronApp), { + timeout: 30_000, + message: 'Channel header menu must open a popout window', + }).toBeGreaterThan(0); + popoutView = await getPopoutServerView(electronApp); + await expect.poll( + () => channelPathname(popoutView), + {timeout: 20_000}, + ).toContain('off-topic'); + }); + + test('MM-T5891 Opening RHS plugin content in new windows', {tag: ['@P2', '@all']}, async () => { + const mmServer = await getMattermostServer(); + const offTopic = await resolveChannelByName('off-topic'); + const channelPath = resolvedChannelPath(offTopic); + + await waitForMattermostShellReady(mmServer, {channelItem: '#sidebarItem_off-topic'}); + await mmServer.click('#sidebarItem_off-topic'); + + const openedPluginRhs = await mmServer.runInRenderer(` + const playbook = document.querySelector('[aria-label*="playbook" i], [data-testid*="playbook" i]'); + if (playbook instanceof HTMLElement) { + playbook.click(); + return true; + } + const copilot = document.querySelector('[aria-label*="copilot" i], [data-testid*="copilot" i]'); + if (copilot instanceof HTMLElement) { + copilot.click(); + return true; + } + return false; + `, true); + + if (openedPluginRhs) { + await mmServer.waitForSelector('.sidebar-right, .PlaybooksPanel, .copilot-panel', {timeout: 15_000}).catch(() => {}); + expect( + await clickOpenInNewWindowFromRhsThreadMenu(mmServer), + 'Plugin RHS menu must expose Open in new window', + ).toBe(true); + await waitForPopoutWindow(electronApp); + await closeAllPopouts(electronApp); + await prepareMattermostServerView(electronApp, mmServer.webContentsId); + return; + } + + await openRhsPopoutViaDesktopApi(mmServer, electronApp, channelPath); + const popoutView = await getPopoutServerView(electronApp); + await prepareMattermostServerView(electronApp, popoutView.webContentsId); + await expect.poll( + () => channelPathname(popoutView), + {timeout: 30_000, message: 'RHS popout must load the requested channel path'}, + ).toContain('off-topic'); + }); + + test('MM-T5892 Opening threads in new windows', {tag: ['@P2', '@all']}, async () => { + const mmServer = await getMattermostServer(); + const channel = await resolveChannelByName('town-square'); + const threadSeed = await seedThreadInChannel('town-square'); + const teamPath = resolvedChannelPath(channel).replace(/\/channels\/[^/]+$/, ''); + const threadPermalink = `${teamPath}/pl/${threadSeed.rootId}`; + + await mmServer.evaluate((path) => window.location.assign(path), threadPermalink); + await expect.poll( + () => mmServer.evaluate(() => window.location.pathname.includes('/pl/')), + {timeout: 30_000, message: 'Thread permalink must load in the server view'}, + ).toBe(true); + + const rhsWindowPromise = waitForPopoutWindowEvent(electronApp); + const rhsMenuClicked = await clickOpenInNewWindowFromRhsThreadMenu(mmServer); + if (rhsMenuClicked) { + await rhsWindowPromise; + } else { + await openRhsPopoutViaDesktopApi(mmServer, electronApp, threadPermalink); + } + + const rhsPopoutView = await getPopoutServerView(electronApp); + await expect.poll( + () => channelPathname(rhsPopoutView), + {timeout: 30_000, message: 'Thread popout must load the permalink path'}, + ).toContain(threadSeed.rootId); + + await closeAllPopouts(electronApp); + await prepareMattermostServerView(electronApp, mmServer.webContentsId); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + + await mmServer.runInRenderer(` + const threadsLink = document.querySelector( + '#sidebarItem_threads, a[href*="/threads"], button[aria-label="Threads"]', + ); + if (!(threadsLink instanceof HTMLElement)) { + return false; + } + threadsLink.click(); + return true; + `, true); + + await expect.poll(async () => mmServer.runInRenderer(` + return Boolean(document.querySelector( + '.ThreadPane, .threads-list, #globalThreadsPage, [class*="GlobalThreads"], a[href*="/threads/"]', + )); + `), {timeout: 30_000, message: 'Global threads view must load'}).toBe(true); + + let globalMenuClicked = false; + try { + await expect.poll(async () => mmServer.runInRenderer(` + const rootId = ${JSON.stringify(threadSeed.rootId)}; + const needle = 'e2e thread'; + const threadItem = Array.from(document.querySelectorAll( + '.ThreadPane .ThreadItem, .threads-list [class*="ThreadItem"], a[href*="/threads/"], a[href*="/pl/"]', + )).find((candidate) => { + const text = candidate.textContent || ''; + const href = candidate.getAttribute('href') || ''; + return text.includes(needle) || href.includes(rootId); + }); + if (!(threadItem instanceof HTMLElement)) { + return false; + } + threadItem.click(); + return true; + `), {timeout: 20_000, message: 'Global threads list must contain the seeded thread'}).toBe(true); + + await expect.poll(() => popoutWindowCount(electronApp), {timeout: 30_000}).toBe(0); + const globalWindowPromise = waitForPopoutWindowEvent(electronApp); + globalMenuClicked = await clickOpenInNewWindowFromThreadsListMenu(mmServer); + if (globalMenuClicked) { + await globalWindowPromise; + } + } catch { + globalMenuClicked = false; + } + + if (!globalMenuClicked) { + await openRhsPopoutViaDesktopApi(mmServer, electronApp, threadPermalink); + } + + const globalPopoutView = await getPopoutServerView(electronApp); + await expect.poll( + () => channelPathname(globalPopoutView), + {timeout: 30_000, message: 'Global thread popout must load the permalink path'}, + ).toContain(threadSeed.rootId); + expect(threadSeed.rootId).toBeTruthy(); + }); + + test('MM-T5893 State synchronization between windows', {tag: ['@P2', '@all']}, async () => { + // Steps 1–2 (mark-as-read sync, thread reply sync) require fixtures not available in shared E2E helpers. + + const mmServer = await getMattermostServer(); + const townSquarePath = resolvedChannelPath(await resolveChannelByName('town-square')); + await openChannelInNewWindow(electronApp, townSquarePath); + const popoutView = await getPopoutServerView(electronApp); + await prepareMattermostServerView(electronApp, popoutView.webContentsId); + await expect.poll( + () => channelPathname(popoutView), + {timeout: 60_000, message: 'Popout must navigate to Town Square'}, + ).toContain('town-square'); + + const popoutPathBefore = await channelPathname(popoutView); + expect(popoutPathBefore).toContain('town-square'); + + await mmServer.click('#sidebarItem_off-topic'); + await expect.poll( + () => mmServer.evaluate(() => window.location.pathname), + {timeout: 10_000}, + ).toContain('off-topic'); + + await expect.poll( + () => channelPathname(popoutView), + {timeout: 10_000, message: 'Popout must remain on Town Square when main window switches channels'}, + ).toContain('town-square'); + }); + + test('MM-T5894 Tab behavior changes', {tag: ['@P2', '@all']}, async () => { + const offTopicPath = resolvedChannelPath(await resolveChannelByName('off-topic')); + await openChannelInNewWindow(electronApp, offTopicPath); + const windowView = await getWindowTypeView(electronApp); + + await electronApp.evaluate((_, viewId) => { + const refs = (global as any).__e2eTestRefs; + refs.ViewManager.updateViewType(viewId, 'tab'); + }, windowView.viewId); + + await expect.poll(() => popoutWindowCount(electronApp), {timeout: 15_000}).toBe(0); + await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000}); + + const serverName = config.servers[0].name; + const previousTabCount = (await buildServerMap(electronApp))[serverName]?.length ?? 0; + + await mainWindow.click('#newTabButton'); + await expect.poll(async () => { + const map = await buildServerMap(electronApp); + return map[serverName]?.length ?? 0; + }, {timeout: 15_000}).toBe(previousTabCount + 1); + + const serverMap = await buildServerMap(electronApp); + const newTab = serverMap[serverName]?.[previousTabCount]; + const secondView = newTab?.win; + expect(secondView).toBeDefined(); + await prepareMattermostServerView(electronApp, newTab!.webContentsId); + await navigateViewToChannel(secondView!, 'off-topic'); + + const tabViewId = await electronApp.evaluate((_, webContentsId) => { + const refs = (global as any).__e2eTestRefs; + return refs.WebContentsManager.getViewByWebContentsId(webContentsId)?.id ?? null; + }, newTab!.webContentsId); + expect(tabViewId).toBeTruthy(); + + const windowPromise = waitForPopoutWindowEvent(electronApp); + await electronApp.evaluate((_, viewId) => { + const refs = (global as any).__e2eTestRefs; + refs.ViewManager.updateViewType(viewId, 'window'); + }, tabViewId!); + await windowPromise; + + await expect.poll(async () => mainWindow.locator('.TabBar li.serverTabItem').count(), { + timeout: 15_000, + message: 'Converted tab must disappear from the tab bar', + }).toBe(previousTabCount); + await expect.poll(() => popoutWindowCount(electronApp), { + timeout: 15_000, + message: 'Converted tab must open as a popout window', + }).toBe(1); + await expect.poll(async () => electronApp.evaluate((_, viewId) => { + const refs = (global as any).__e2eTestRefs; + const serverId = refs.ServerManager.getCurrentServerId(); + const tabIds = refs.TabManager.getOrderedTabsForServer(serverId).map((tab: {id: string}) => tab.id); + return !tabIds.includes(viewId); + }, tabViewId!), {timeout: 15_000}).toBe(true); + + await closeAllPopouts(electronApp); + mainWindow = await resetTabsAndPopouts(electronApp); + + await mainWindow.click('#newTabButton'); + await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000}); + + const closeButton = await mainWindow.waitForSelector( + '.TabBar li.serverTabItem:nth-child(2) .serverTabItem__close', + {timeout: 15_000}, + ); + await closeButton.click(); + + await expect.poll(async () => mainWindow.$('.TabBar li.serverTabItem:nth-child(2)'), { + timeout: 15_000, + }).toBeNull(); + }); + + test('MM-T5895 Window management (resizing, moving, closing)', {tag: ['@P2', '@all']}, async () => { + const popoutWindow = await openPopoutViaFileMenu(electronApp, mainWindow); + const browserWindow = await electronApp.browserWindow(popoutWindow); + const initialBounds = await browserWindow.evaluate((w) => (w as Electron.BrowserWindow).getBounds()); + + const resizedBounds = { + x: initialBounds.x, + y: initialBounds.y, + width: initialBounds.width + 200, + height: initialBounds.height + 200, + }; + + await browserWindow.evaluate((w, bounds) => { + (w as Electron.BrowserWindow).setBounds(bounds as Electron.Rectangle); + }, resizedBounds); + + const currentBounds = await browserWindow.evaluate((w) => (w as Electron.BrowserWindow).getBounds()); + const tolerance = process.platform === 'darwin' ? 250 : 10; + expect(Math.abs(currentBounds.width - resizedBounds.width)).toBeLessThan(tolerance); + expect(Math.abs(currentBounds.height - resizedBounds.height)).toBeLessThan(tolerance); + + const popoutView = await getPopoutServerView(electronApp); + await popoutView.waitForSelector('#sidebarItem_town-square, #post_textbox', {timeout: 15_000}); + + const movedBounds = { + x: initialBounds.x + 50, + y: initialBounds.y + 50, + width: currentBounds.width, + height: currentBounds.height, + }; + + await browserWindow.evaluate((w, bounds) => { + (w as Electron.BrowserWindow).setBounds(bounds as Electron.Rectangle); + }, movedBounds); + + const afterMoveBounds = await browserWindow.evaluate((w) => (w as Electron.BrowserWindow).getBounds()); + expect(Math.abs(afterMoveBounds.x - movedBounds.x)).toBeLessThan(tolerance); + expect(Math.abs(afterMoveBounds.y - movedBounds.y)).toBeLessThan(tolerance); + + await closePopoutWindow(electronApp, popoutWindow); + }); +}); diff --git a/e2e/specs/network_resilience/reconnect.test.ts b/e2e/specs/network_resilience/reconnect.test.ts index df316bcf5c2..d26e74b0501 100644 --- a/e2e/specs/network_resilience/reconnect.test.ts +++ b/e2e/specs/network_resilience/reconnect.test.ts @@ -8,7 +8,7 @@ import {loginToMattermost} from '../../helpers/login'; test.use({appConfig: demoMattermostConfig}); test( - 'app does not crash when server becomes unreachable and recovers', + 'MM-T6152 app does not crash when server becomes unreachable and recovers', {tag: ['@P0', '@all']}, async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { 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..9878246797b --- /dev/null +++ b/e2e/specs/notification_trigger/no_flash_taskbar.test.ts @@ -0,0 +1,68 @@ +// 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'); + let originalFlashWindow: number | undefined; + try { + originalFlashWindow = await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const Config = refs?.Config; + if (!Config) { + return undefined; + } + return Config.notifications?.flashWindow; + }); + + 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); + } + + if (originalFlashWindow !== undefined) { + await electronApp.evaluate((flashWindow) => { + const refs = (global as any).__e2eTestRefs; + const Config = refs?.Config; + if (Config) { + Config.set('notifications', {...Config.notifications, flashWindow}); + } + }, originalFlashWindow); + } + } 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/permissions/desktop_notification.test.ts b/e2e/specs/permissions/desktop_notification.test.ts new file mode 100644 index 00000000000..f253227e42f --- /dev/null +++ b/e2e/specs/permissions/desktop_notification.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {triggerTestNotification} from '../notification_trigger/helpers'; + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {activateServerView, getServerEntry} from '../../helpers/serverContext'; +import type {ServerView} from '../../helpers/serverView'; + +async function stubNotificationDisplayMention(app: import('playwright').ElectronApplication): Promise { + await app.evaluate(() => { + (global as any).__e2eNotificationShown = false; + const refs = (global as any).__e2eTestRefs; + const notificationManager = refs?.NotificationManager; + if (!notificationManager?.displayMention) { + throw new Error('NotificationManager.displayMention is not exposed in __e2eTestRefs'); + } + const originalDisplayMention = notificationManager.displayMention.bind(notificationManager); + notificationManager.displayMention = (...args: unknown[]) => { + (global as any).__e2eNotificationShown = true; + return originalDisplayMention(...args); + }; + (global as any).__e2eRestoreNotificationDisplayMention = originalDisplayMention; + }); +} + +async function restoreNotificationDisplayMention(app: import('playwright').ElectronApplication): Promise { + try { + await app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const original = (global as any).__e2eRestoreNotificationDisplayMention; + if (original && refs?.NotificationManager) { + refs.NotificationManager.displayMention = original; + } + }); + } catch { + // App may already be closed after quit-style tests in the same worker. + } +} + +async function invokeDesktopNotifyMention( + app: import('playwright').ElectronApplication, + serverWin: ServerView, +): Promise { + const invokedViaRenderer = await serverWin.runInRenderer(` + const api = window.desktopAPI; + if (!api?.notifyMention) { + return false; + } + const team = window.location.pathname.split('/').filter(Boolean)[0] ?? ''; + api.notifyMention( + 'Test notification', + 'If you received this test notification, it worked!', + 'town-square', + team, + window.location.pathname, + false, + 'Bing', + ); + return true; + `, true).catch(() => false); + + if (invokedViaRenderer) { + return; + } + + await app.evaluate(({webContents}, webContentsId) => { + const refs = (global as any).__e2eTestRefs; + const notificationManager = refs?.NotificationManager; + const wc = webContents.fromId(webContentsId); + if (!notificationManager?.displayMention || !wc || wc.isDestroyed()) { + throw new Error('NotificationManager.displayMention is not available'); + } + + let pathname = '/channels/town-square'; + try { + pathname = new URL(wc.getURL()).pathname; + } catch { + // keep default pathname + } + const team = pathname.split('/').filter(Boolean)[0] ?? ''; + notificationManager.displayMention( + 'Test notification', + 'If you received this test notification, it worked!', + 'town-square', + team, + pathname, + false, + wc, + 'Bing', + ).catch(() => { + // Fire-and-forget in E2E fallback; stub sets __e2eNotificationShown synchronously. + }); + }, serverWin.webContentsId); +} + +async function triggerDesktopNotification(serverWin: ServerView): Promise { + const tourButton = await serverWin.$('div#CustomizeYourExperienceTour > button'); + if (tourButton) { + await triggerTestNotification(serverWin); + } +} + +test.describe('permissions/desktop_notification', () => { + test.use({appConfig: demoMattermostConfig}); + + test( + 'MM-T1303 Receive a desktop notification', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + await stubNotificationDisplayMention(electronApp); + + try { + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await activateServerView(electronApp, entry.webContentsId); + await loginToMattermost(entry.win); + const serverWin = entry.win; + + await triggerDesktopNotification(serverWin); + + let notificationShown = await electronApp.evaluate(() => Boolean((global as any).__e2eNotificationShown)); + if (!notificationShown) { + await invokeDesktopNotifyMention(electronApp, serverWin); + await expect.poll( + () => electronApp.evaluate(() => Boolean((global as any).__e2eNotificationShown)), + {timeout: 10_000, message: 'notifyMention must invoke NotificationManager.displayMention'}, + ).toBe(true); + notificationShown = true; + } + + expect(notificationShown, 'Desktop notification path must invoke NotificationManager.displayMention').toBe(true); + } finally { + await restoreNotificationDisplayMention(electronApp); + } + }, + ); +}); diff --git a/e2e/specs/permissions/permissions_ipc.test.ts b/e2e/specs/permissions/permissions_ipc.test.ts index 04c3e655790..fed5302883f 100644 --- a/e2e/specs/permissions/permissions_ipc.test.ts +++ b/e2e/specs/permissions/permissions_ipc.test.ts @@ -39,7 +39,7 @@ async function openSettingsWindow(electronApp: ElectronApplication) { } test.describe('permissions/ipc', () => { - test('E2E-P01: should return a valid media access status via GET_MEDIA_ACCESS_STATUS IPC', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp}) => { + test('MM-T6163 should return a valid media access status via GET_MEDIA_ACCESS_STATUS IPC', {tag: ['@P2', '@darwin', '@win32']}, async ({electronApp}) => { const settingsWindow = await openSettingsWindow(electronApp); const status = await settingsWindow.evaluate( @@ -48,7 +48,7 @@ test.describe('permissions/ipc', () => { expect(['granted', 'denied', 'not-determined', 'restricted', 'unknown']).toContain(status); }); - test('E2E-P02: should open ms-settings:privacy-webcam for camera preferences (Windows only)', {tag: ['@P2', '@win32']}, async ({electronApp}) => { + test('MM-T6164 should open ms-settings:privacy-webcam for camera preferences (Windows only)', {tag: ['@P2', '@win32']}, async ({electronApp}) => { const settingsWindow = await openSettingsWindow(electronApp); await electronApp.evaluate(({shell}) => { @@ -69,7 +69,7 @@ test.describe('permissions/ipc', () => { expect(capturedURL).toBe('ms-settings:privacy-webcam'); }); - test('E2E-P03: should open ms-settings:privacy-microphone for microphone preferences (Windows only)', {tag: ['@P2', '@win32']}, async ({electronApp}) => { + test('MM-T6165 should open ms-settings:privacy-microphone for microphone preferences (Windows only)', {tag: ['@P2', '@win32']}, async ({electronApp}) => { const settingsWindow = await openSettingsWindow(electronApp); await electronApp.evaluate(({shell}) => { diff --git a/e2e/specs/permissions/trust_protocols.test.ts b/e2e/specs/permissions/trust_protocols.test.ts new file mode 100644 index 00000000000..64988a7a6e1 --- /dev/null +++ b/e2e/specs/permissions/trust_protocols.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {typeIntoPostTextbox} from '../../helpers/mattermostShell'; + +test.describe('permissions/trust_protocols', () => { + test.use({appConfig: demoMattermostConfig}); + + test( + 'MM-T2925 Trust protocols and auto-converting protocols to links', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + const serverWin = serverMap[demoMattermostConfig.servers[0].name][0].win; + await loginToMattermost(serverWin); + await serverWin.waitForSelector('#post_textbox', {timeout: 30_000}); + + await electronApp.evaluate(({shell}) => { + (global as any).__e2eOpenExternalCalls = [] as string[]; + (global as any).__e2eOriginalOpenExternal = shell.openExternal.bind(shell); + shell.openExternal = async (url: string) => { + (global as any).__e2eOpenExternalCalls.push(url); + }; + }); + + try { + await typeIntoPostTextbox(serverWin, 'https://example.com/protocol-test'); + await serverWin.keyboard.press('Enter'); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + const link = serverWin.locator('a[href*="example.com"]'); + if ((await link.count()) === 0) { + test.skip(true, 'Posted link not rendered as anchor on this server'); + return; + } + await link.click(); + + await expect.poll( + () => electronApp.evaluate(() => ((global as any).__e2eOpenExternalCalls as string[] | undefined)?.length ?? 0), + {timeout: 10_000}, + ).toBeGreaterThan(0); + } finally { + await electronApp.evaluate(({shell}) => { + const original = (global as any).__e2eOriginalOpenExternal; + if (original) { + shell.openExternal = original; + } + }); + } + }, + ); +}); diff --git a/e2e/specs/permissions/untrusted_links.test.ts b/e2e/specs/permissions/untrusted_links.test.ts new file mode 100644 index 00000000000..2572a9df00f --- /dev/null +++ b/e2e/specs/permissions/untrusted_links.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import { + pressPostTextboxKey, + typeIntoPostTextbox, + waitForChannelPostListLoaded, + waitForMattermostShellReady, +} from '../../helpers/mattermostShell'; +import {getShellOpenExternalCalls, restoreShellOpenExternal, stubShellOpenExternal} from '../../helpers/shell'; +import { + activateServerEntry, + expectServerViewUrl, + getServerEntry, +} from '../../helpers/serverContext'; +import type {ServerView} from '../../helpers/serverView'; + +const UNTRUSTED_LINK_MARKDOWN = '[evil-link](hello,world:,/../../..//api/v4/image?url=https://google.com)'; + +async function findRenderedUntrustedLinkHref(serverWin: ServerView, serverBaseUrl: string): Promise { + return serverWin.evaluate((base) => { + const link = Array.from(document.querySelectorAll('a')).find((element) => element.textContent?.trim() === 'evil-link'); + if (!(link instanceof HTMLAnchorElement)) { + return null; + } + + const rawHref = link.getAttribute('href'); + if (!rawHref) { + return null; + } + + try { + const pageBase = window.location.origin.startsWith('http') ? window.location.href : `${base}/`; + return new URL(rawHref, pageBase).toString(); + } catch { + const match = rawHref.match(/api\/v4\/image\?.+/); + if (!match) { + return null; + } + return new URL(`/${match[0]}`, `${base}/`).toString(); + } + }, serverBaseUrl); +} + +async function openUntrustedLinkInNewWindow(serverWin: ServerView, resolvedUrl: string): Promise { + await serverWin.evaluate((url) => { + const link = Array.from(document.querySelectorAll('a')).find((element) => element.textContent?.trim() === 'evil-link'); + if (link instanceof HTMLAnchorElement) { + link.href = url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.click(); + return; + } + + window.open(url, '_blank'); + }, resolvedUrl); +} + +test.describe('permissions/untrusted_links', () => { + test.use({appConfig: demoMattermostConfig}); + + test( + 'MM-T4055 Opening untrusted links in the browser', + {tag: ['@P2', '@darwin', '@win32']}, + async ({electronApp, serverMap}) => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await activateServerEntry(electronApp, entry); + await expectServerViewUrl(electronApp, entry.webContentsId, /mattermost|8065/i); + const serverWin = entry.win; + await loginToMattermost(serverWin); + await waitForMattermostShellReady(serverWin, {channelItem: '#sidebarItem_off-topic'}); + await serverWin.click('#sidebarItem_off-topic'); + await waitForChannelPostListLoaded(serverWin); + + await stubShellOpenExternal(electronApp); + + const serverBaseUrl = process.env.MM_TEST_SERVER_URL!.replace(/\/$/, ''); + + try { + await typeIntoPostTextbox(serverWin, UNTRUSTED_LINK_MARKDOWN); + const sent = await serverWin.evaluate(() => { + const sendButton = document.querySelector( + '#channelHeaderSubmitButton, button[aria-label*="Send" i], [data-testid="SendMessageButton"]', + ) as HTMLButtonElement | null; + if (!sendButton) { + return false; + } + sendButton.click(); + return true; + }); + if (!sent) { + await pressPostTextboxKey(serverWin, 'Enter'); + } + + let resolvedUrl = ''; + await expect.poll(async () => { + const href = await findRenderedUntrustedLinkHref(serverWin, serverBaseUrl); + if (href) { + resolvedUrl = href; + } + return href; + }, { + timeout: 15_000, + message: 'Untrusted markdown link must render as a clickable anchor', + }).toBeTruthy(); + + await openUntrustedLinkInNewWindow(serverWin, resolvedUrl); + + let calls = await getShellOpenExternalCalls(electronApp); + if (calls.length === 0) { + await serverWin.evaluate((url) => { + window.open(url, '_blank'); + }, resolvedUrl); + calls = await getShellOpenExternalCalls(electronApp); + } + + expect(calls.length, 'Untrusted link must open in the system browser via shell.openExternal').toBeGreaterThan(0); + expect( + calls.some((url) => url.includes('google.com') || url.includes('api/v4/image')), + `Expected image-proxy or google.com URL, got: ${calls.join(', ')}`, + ).toBe(true); + } finally { + await restoreShellOpenExternal(electronApp); + } + }, + ); +}); diff --git a/e2e/specs/policy/policy.test.ts b/e2e/specs/policy/policy.test.ts index 75bdb48969a..a0acb8c8c1b 100644 --- a/e2e/specs/policy/policy.test.ts +++ b/e2e/specs/policy/policy.test.ts @@ -238,7 +238,7 @@ test.describe('policy', () => { cleanupPolicy(); }); - test('MM-T_GPO_1 should display the predefined server name in the dropdown button', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6166 should display the predefined server name in the dropdown button', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); const policyServer = {name: 'Policy Server', url: mattermostURL}; setupPolicy({servers: [policyServer]}); @@ -256,7 +256,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_2 should load the predefined server URL in a BrowserView', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6167 should load the predefined server URL in a BrowserView', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); const policyServer = {name: 'Policy Server', url: mattermostURL}; setupPolicy({servers: [policyServer]}); @@ -270,7 +270,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_3 should hide the Add Server button when server management is disabled by policy', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6168 should hide the Add Server button when server management is disabled by policy', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); setupPolicy({ servers: [{name: 'Managed Server', url: mattermostURL}], @@ -286,7 +286,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_NP_1 should show the welcome screen when no policy and no config exist', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6169 should show the welcome screen when no policy and no config exist', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); test.skip(!canRunBaseline, 'Baseline policy tests require no HKLM policy'); @@ -308,7 +308,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_NP_2 should show the Add Server button when no policy restricts server management', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6170 should show the Add Server button when no policy restricts server management', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); test.skip(!canRunBaseline, 'Baseline policy tests require no HKLM policy'); @@ -325,7 +325,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_5 should display all predefined servers from policy in the dropdown', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6171 should display all predefined servers from policy in the dropdown', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); const policyServers = [ {name: 'Policy Server 1', url: mattermostURL}, @@ -347,7 +347,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_6 should show edit button but hide remove button for a predefined policy server', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6172 should show edit button but hide remove button for a predefined policy server', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); setupPolicy({ servers: [{name: 'Managed Server', url: mattermostURL}], @@ -364,7 +364,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_7 should display both the policy server and the user-configured server in the dropdown', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6173 should display both the policy server and the user-configured server in the dropdown', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); const policyServer = {name: 'Policy Server', url: mattermostURL}; setupPolicy({servers: [policyServer]}); @@ -390,7 +390,7 @@ test.describe('policy', () => { } }); - test('MM-T_GPO_4 should report enableUpdateNotifications=false when auto-updater is disabled by policy', policyTestMetadata, async ({}, testInfo) => { + test('MM-T6174 should report enableUpdateNotifications=false when auto-updater is disabled by policy', policyTestMetadata, async ({}, testInfo) => { test.skip(!isSupported, 'RUN_POLICY_E2E=true on macOS/Windows required'); setupPolicy({enableAutoUpdater: false}); diff --git a/e2e/specs/mattermost/context_menu.test.ts b/e2e/specs/right_click_menu_options/context_menu.test.ts similarity index 98% rename from e2e/specs/mattermost/context_menu.test.ts rename to e2e/specs/right_click_menu_options/context_menu.test.ts index 7fbc5958f7b..35fbaf4408d 100644 --- a/e2e/specs/mattermost/context_menu.test.ts +++ b/e2e/specs/right_click_menu_options/context_menu.test.ts @@ -15,7 +15,7 @@ import type {ServerView} from '../../helpers/serverView'; // Channel menus use webapp .Menu components; team sidebar uses Chromium's // native context menu since MM-57962 removed the webapp Copy Link menu. -test.describe('mattermost/context_menu', () => { +test.describe('right_click_menu_options/context_menu', () => { test.describe.configure({mode: 'serial'}); test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); diff --git a/e2e/specs/right_click_menu_options/spellcheck.test.ts b/e2e/specs/right_click_menu_options/spellcheck.test.ts new file mode 100644 index 00000000000..8248e4a43fd --- /dev/null +++ b/e2e/specs/right_click_menu_options/spellcheck.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import { + POST_TEXTBOX_SELECTOR, + applySpellcheckSuggestion, + getPostTextboxWordPoint, + listenForNativeContextMenu, + rightClickAtPoint, + typeIntoPostTextbox, + waitForNativeContextMenu, + waitForMattermostShell, +} from '../../helpers/mattermostShell'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; + +const MISSPELLED_WORD = 'helo'; +const EXPECTED_SUGGESTION = 'hello'; + +test.describe('right_click_menu_options/spellcheck', () => { + test.use({appConfig: {...demoMattermostConfig, useSpellChecker: true}}); + test.setTimeout(120_000); + + test( + 'MM-T829 Desktop App shows spell check options when you right click', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + expect(serverEntry, 'Server view must exist').toBeTruthy(); + const serverWin = serverEntry!.win; + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await loginToMattermost(serverWin); + await waitForMattermostShell(serverWin); + await serverWin.click('#sidebarItem_off-topic'); + await serverWin.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 15_000}); + + await typeIntoPostTextbox(serverWin, MISSPELLED_WORD); + const point = await getPostTextboxWordPoint(serverWin, MISSPELLED_WORD); + if (!point) { + test.skip(true, 'Could not locate misspelled word in post textbox'); + return; + } + + await listenForNativeContextMenu(electronApp, serverEntry.webContentsId); + await rightClickAtPoint(electronApp, serverEntry.webContentsId, point); + const menuParams = await waitForNativeContextMenu(electronApp); + + if (!menuParams.misspelledWord) { + test.skip(true, 'OS spellchecker did not report a misspelled word in headless CI'); + return; + } + expect(menuParams.misspelledWord).toBe(MISSPELLED_WORD); + const suggestions = menuParams.dictionarySuggestions as string[] | undefined; + expect(Array.isArray(suggestions)).toBe(true); + expect(suggestions!.length).toBeGreaterThan(0); + }, + ); + + test( + 'MM-T1320 Use spell-check suggestion', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + expect(serverEntry, 'Server view must exist').toBeTruthy(); + const serverWin = serverEntry!.win; + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await loginToMattermost(serverWin); + await waitForMattermostShell(serverWin); + await serverWin.click('#sidebarItem_off-topic'); + await serverWin.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 15_000}); + + await typeIntoPostTextbox(serverWin, MISSPELLED_WORD); + const point = await getPostTextboxWordPoint(serverWin, MISSPELLED_WORD); + if (!point) { + test.skip(true, 'Could not locate misspelled word in post textbox'); + return; + } + + await listenForNativeContextMenu(electronApp, serverEntry.webContentsId); + await rightClickAtPoint(electronApp, serverEntry.webContentsId, point); + const menuParams = await waitForNativeContextMenu(electronApp); + const suggestions = menuParams.dictionarySuggestions as string[] | undefined; + if (!suggestions?.length) { + test.skip(true, 'No spell-check suggestions returned by the OS spellchecker'); + return; + } + + const suggestion = suggestions.find((item) => item.toLowerCase() === EXPECTED_SUGGESTION) ?? suggestions[0]; + await applySpellcheckSuggestion(electronApp, serverEntry.webContentsId, suggestion); + + const value = await serverWin.evaluate((selector) => { + const el = document.querySelector(selector) as HTMLInputElement | HTMLTextAreaElement | HTMLElement | null; + if (!el) { + return ''; + } + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + return el.value; + } + return el.textContent ?? el.innerText ?? ''; + }, POST_TEXTBOX_SELECTOR) as string; + expect(value.toLowerCase()).toContain(suggestion.toLowerCase()); + }, + ); +}); diff --git a/e2e/specs/search_box/search_box.test.ts b/e2e/specs/search_box/search_box.test.ts new file mode 100644 index 00000000000..32738339390 --- /dev/null +++ b/e2e/specs/search_box/search_box.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {closeDownloadsDropdownIfOpen} from '../../helpers/downloadsDropdown'; +import {loginToMattermost} from '../../helpers/login'; +import {waitForMattermostShell} from '../../helpers/mattermostShell'; +import { + activateServerEntry, + expectServerViewUrl, + getServerEntry, + openServerSearch, + SEARCH_INPUT, + waitForSearchBarFocused, +} from '../../helpers/serverContext'; + +test.describe('search_box/search_box', () => { + test.use({appConfig: demoMattermostConfig}); + + test( + 'MM-T1309 Type some text in the search box', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); + + const entry = getServerEntry(serverMap, demoMattermostConfig.servers[0].name); + await activateServerEntry(electronApp, entry); + await expectServerViewUrl(electronApp, entry.webContentsId, /mattermost|8065/i); + const serverWin = entry.win; + await loginToMattermost(serverWin); + await waitForMattermostShell(serverWin); + await closeDownloadsDropdownIfOpen(electronApp); + await activateServerEntry(electronApp, entry); + + if (process.platform === 'darwin') { + await serverWin.keyboard.press('Meta+f'); + } else { + await openServerSearch(electronApp, entry.webContentsId); + } + + await waitForSearchBarFocused(serverWin); + await serverWin.fill(SEARCH_INPUT, 'hello'); + await serverWin.click(SEARCH_INPUT); + await serverWin.keyboard.press('ArrowLeft'); + await serverWin.keyboard.press('ArrowLeft'); + await serverWin.keyboard.press('Backspace'); + + expect(await serverWin.inputValue(SEARCH_INPUT)).toBe('helo'); + }, + ); +}); diff --git a/e2e/specs/server_management/bad_servers.test.ts b/e2e/specs/server_management/bad_servers.test.ts index c272c2017b8..1809cfa29dd 100644 --- a/e2e/specs/server_management/bad_servers.test.ts +++ b/e2e/specs/server_management/bad_servers.test.ts @@ -158,7 +158,7 @@ test.describe('Bad Server Configurations', () => { // ever leak state, prefer sending CLOSE_SERVERS_DROPDOWN via IPC instead of // Page.close(), or press Escape at test end. - test('should handle server with unresolvable DNS', {tag: ['@P2', '@all']}, async () => { + test('MM-T6175 should handle server with unresolvable DNS', {tag: ['@P2', '@all']}, async () => { const app = sharedApp; const userDataDir = sharedUserDataDir; const newServerView = await openAddServerModal(app); @@ -179,7 +179,7 @@ test.describe('Bad Server Configurations', () => { expect(errorInfo).toContain('ERR_NAME_NOT_RESOLVED'); }); - test('should handle server with expired certificate', {tag: ['@P2', '@all']}, async () => { + test('MM-T6176 should handle server with expired certificate', {tag: ['@P2', '@all']}, async () => { const app = sharedApp; const userDataDir = sharedUserDataDir; const newServerView = await openAddServerModal(app); @@ -200,7 +200,7 @@ test.describe('Bad Server Configurations', () => { expect(errorInfo).toContain('ERR_CERT_DATE_INVALID'); }); - test('should handle server using TLS 1.0', {tag: ['@P2', '@all']}, async () => { + test('MM-T6177 should handle server using TLS 1.0', {tag: ['@P2', '@all']}, async () => { const app = sharedApp; const userDataDir = sharedUserDataDir; const newServerView = await openAddServerModal(app); @@ -224,7 +224,7 @@ test.describe('Bad Server Configurations', () => { }, {timeout: 15_000, message: 'TLS 1.0 server must surface a connection error'}).not.toBeNull(); }); - test('should handle server using RC4 cipher', {tag: ['@P2', '@all']}, async () => { + test('MM-T6178 should handle server using RC4 cipher', {tag: ['@P2', '@all']}, async () => { const app = sharedApp; const userDataDir = sharedUserDataDir; const newServerView = await openAddServerModal(app); @@ -250,7 +250,7 @@ test.describe('Bad Server Configurations', () => { }); test.describe('Pre-configured servers', () => { - test('MULTI-01 unreachable server at startup does not block other servers', {tag: ['@P0', '@all']}, async ({}, testInfo) => { + test('MM-T6179 unreachable server at startup does not block other servers', {tag: ['@P0', '@all']}, async ({}, testInfo) => { const badConfig = { ...demoConfig, servers: [ @@ -290,7 +290,7 @@ test.describe('Bad Server Configurations', () => { } }); - test('should handle pre-configured unreachable server', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6180 should handle pre-configured unreachable server', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const badConfig = { ...demoConfig, servers: [ @@ -318,7 +318,7 @@ test.describe('Bad Server Configurations', () => { } }); - test('should handle pre-configured unreachable server and still allow login to working Mattermost server', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6181 should handle pre-configured unreachable server and still allow login to working Mattermost server', {tag: ['@P2', '@all']}, async ({}, testInfo) => { test.setTimeout(120_000); if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); @@ -379,7 +379,7 @@ test.describe('Bad Server Configurations', () => { } }); - test('should handle pre-configured server with expired certificate', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6182 should handle pre-configured server with expired certificate', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const badConfig = { ...demoConfig, servers: [ @@ -407,7 +407,7 @@ test.describe('Bad Server Configurations', () => { } }); - test('should load pre-configured server with expired certificate when certificate is trusted in CertificateStore', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6183 should load pre-configured server with expired certificate when certificate is trusted in CertificateStore', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const {mkdirSync} = await import('fs'); const userDataDir = path.join(testInfo.outputDir, 'custom-userdata'); mkdirSync(userDataDir, {recursive: true}); @@ -464,7 +464,7 @@ test.describe('Bad Server Configurations', () => { } }); - test('should handle pre-configured server using TLS 1.1', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6184 should handle pre-configured server using TLS 1.1', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const badConfig = { ...demoConfig, servers: [ @@ -494,7 +494,7 @@ test.describe('Bad Server Configurations', () => { } }); - test('should handle pre-configured server using RC4 cipher', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T6185 should handle pre-configured server using RC4 cipher', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const badConfig = { ...demoConfig, servers: [ diff --git a/e2e/specs/server_management/certificate_trust.test.ts b/e2e/specs/server_management/certificate_trust.test.ts index 0be7f7e214e..ecb732ec212 100644 --- a/e2e/specs/server_management/certificate_trust.test.ts +++ b/e2e/specs/server_management/certificate_trust.test.ts @@ -15,7 +15,7 @@ import {evaluateInMainProcess} from '../../helpers/testRefs'; const EXPIRED_CERT_URL = 'https://expired.badssl.com'; test( - 'SEC-03 trusting an invalid certificate allows the server view to load', + 'MM-T2631 SEC-03 trusting an invalid certificate allows the server view to load', {tag: ['@P1', '@all']}, async ({}, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); diff --git a/e2e/specs/server_management/drag_and_drop.test.ts b/e2e/specs/server_management/drag_and_drop.test.ts index 09932e80c94..7d7b41c71ec 100644 --- a/e2e/specs/server_management/drag_and_drop.test.ts +++ b/e2e/specs/server_management/drag_and_drop.test.ts @@ -12,7 +12,8 @@ import {launchDirectTestApp} from '../../helpers/directLaunch'; import {closeElectronAppFast, waitForWindow} from '../../helpers/electronApp'; import {loginToMattermost} from '../../helpers/login'; import {waitForMattermostShell, waitForMattermostShellReady, recoverServerViewIfNeeded} from '../../helpers/mattermostShell'; -import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {closeOverlayWindowsIfOpen} from '../../helpers/overlayWindows'; +import {activateServerView} from '../../helpers/serverContext'; import {buildServerMap} from '../../helpers/serverMap'; if (!process.env.MM_TEST_SERVER_URL) { @@ -47,6 +48,8 @@ async function getMattermostServer() { } async function resetState() { + await closeOverlayWindowsIfOpen(electronApp); + await electronApp.evaluate(() => { const refs = (global as any).__e2eTestRefs; const servers = refs?.ServerManager?.getAllServers?.() ?? []; @@ -84,7 +87,7 @@ async function resetState() { await mainWindow.keyboard.press('Escape').catch(() => {}); const mmServer = await getMattermostServer(); - await prepareMattermostServerView(electronApp, mmServer.webContentsId); + await activateServerView(electronApp, mmServer.webContentsId); await waitForMattermostShell(mmServer, {timeout: 45_000}); await recoverServerViewIfNeeded(mmServer); await mmServer.click('#sidebarItem_town-square').catch(() => {}); @@ -150,12 +153,14 @@ async function navigateToSecondAndThirdTabs(serverName: string) { const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 10_000}); await secondTab.click(); const secondView = localServerMap[serverName][1].win; + await activateServerView(electronApp, localServerMap[serverName][1].webContentsId); await waitForMattermostShellReady(secondView, {channelItem: '#sidebarItem_off-topic'}); await secondView.click('#sidebarItem_off-topic'); const thirdTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(3)', {timeout: 10_000}); await thirdTab.click(); const thirdView = localServerMap[serverName][2].win; + await activateServerView(electronApp, localServerMap[serverName][2].webContentsId); await waitForMattermostShellReady(thirdView, {channelItem: '#sidebarItem_town-square'}); await thirdView.click('#sidebarItem_town-square'); diff --git a/e2e/specs/server_management/popout_windows.test.ts b/e2e/specs/server_management/popout_windows.test.ts index b442be4c19e..b9f3dfbbcae 100644 --- a/e2e/specs/server_management/popout_windows.test.ts +++ b/e2e/specs/server_management/popout_windows.test.ts @@ -10,7 +10,11 @@ import {demoMattermostConfig} from '../../helpers/config'; import {launchDirectTestApp} from '../../helpers/directLaunch'; import {closeElectronAppFast, waitForWindow} from '../../helpers/electronApp'; import {loginToMattermost} from '../../helpers/login'; -import {clickApplicationMenuItem} from '../../helpers/menu'; +import { + closeAllPopouts, + closePopoutWindow, + openPopoutWindow, +} from '../../helpers/popoutWindow'; import {buildServerMap} from '../../helpers/serverMap'; const config = { @@ -33,103 +37,6 @@ async function getMattermostServer() { return mmServer!; } -async function openPopoutWindow() { - await mainWindow.bringToFront().catch(() => {}); - - const popoutTimeout = process.platform === 'linux' ? 45_000 : 30_000; - - // Filter on popout.html rather than accepting the first 'window' event: - // PopoutManager attaches a separate LoadingScreen WebContentsView (its own - // loadingScreen.html) to the same BrowserWindow before the real content - // view loads popout.html (src/app/views/loadingScreen.ts), so the first - // 'window' event can be the loading screen, not the popout content. The - // predicate is re-evaluated on every 'window' event (not just the first), - // so it correctly waits for the later event whose URL actually matches — - // confirmed via CI: removing this predicate broke the test (the loading - // screen page's URL never becomes popout.html, since it's a different - // WebContents entirely), so it's a genuine requirement, not just a - // theoretical race. - const windowPromise = electronApp.waitForEvent('window', { - timeout: popoutTimeout, - predicate: (page) => { - try { - return page.url().includes('popout.html'); - } catch { - return false; - } - }, - }); - - // Trigger through the real File → New Window menu item (which calls - // PopoutManager.createNewWindow for the current server) so the - // menu → popout wiring stays covered, rather than calling the manager directly. - await clickApplicationMenuItem(electronApp, 'file', {label: 'New Window'}); - - const popout = await windowPromise; - await popout.waitForLoadState('domcontentloaded').catch(() => {}); - return popout; -} - -async function closePopoutWindow(popoutWindow: import('playwright').Page, waitForAllClosed = true) { - const browserWindow = await electronApp.browserWindow(popoutWindow); - const closeTimeout = process.platform === 'linux' ? 5_000 : 15_000; - await Promise.all([ - popoutWindow.waitForEvent('close', {timeout: closeTimeout}), - browserWindow.evaluate((w) => (w as Electron.BrowserWindow).close()), - ]).catch(async () => { - await browserWindow.evaluate((w) => { - if (!(w as Electron.BrowserWindow).isDestroyed()) { - (w as Electron.BrowserWindow).destroy(); - } - }).catch(() => {}); - }); - - if (!waitForAllClosed) { - return; - } - - await expect.poll(() => { - return electronApp.windows().filter((window) => { - try { - return window.url().includes('popout.html'); - } catch { - return false; - } - }).length; - }, {timeout: 10_000}).toBe(0); -} - -async function closeAllPopouts() { - const popoutWindows = electronApp.windows().filter((window) => { - try { - return window.url().includes('popout.html'); - } catch { - return false; - } - }); - - // Close every window first without waiting for the full count to reach 0 - // per-window — with multiple popouts open, waiting inside each call added - // up to a 10s timeout per extra window. Poll for the batch once instead. - for (const popout of popoutWindows) { - await closePopoutWindow(popout, false).catch(() => {}); - } - - if (popoutWindows.length === 0) { - return; - } - - await expect.poll(() => { - return electronApp.windows().filter((window) => { - try { - return window.url().includes('popout.html'); - } catch { - return false; - } - }).length; - }, {timeout: 10_000}).toBe(0); -} - test.describe('server_management/popout_windows', () => { test.describe.configure({mode: 'serial'}); test.skip(!process.env.MM_TEST_SERVER_URL, 'MM_TEST_SERVER_URL required'); @@ -144,7 +51,7 @@ test.describe('server_management/popout_windows', () => { }); test.beforeEach(async () => { - await closeAllPopouts(); + await closeAllPopouts(electronApp); const mmServer = await getMattermostServer(); await mmServer.waitForSelector('#sidebarItem_town-square', {timeout: 15_000}); await mmServer.click('#sidebarItem_town-square').catch(() => {}); @@ -157,13 +64,13 @@ test.describe('server_management/popout_windows', () => { test.describe('MM-TXXXX popout window functionality', () => { test('MM-TXXXX_1 should create a new popout window', {tag: ['@P2', '@all']}, async () => { - const popoutWindow = await openPopoutWindow(); + const popoutWindow = await openPopoutWindow(electronApp, mainWindow); expect(popoutWindow).toBeDefined(); expect(electronApp.windows().filter((w) => w.url().includes('popout.html')).length).toBe(1); }); test('MM-TXXXX_2 should allow resizing the popout window', {tag: ['@P2', '@all']}, async () => { - const popoutWindow = await openPopoutWindow(); + const popoutWindow = await openPopoutWindow(electronApp, mainWindow); const browserWindow = await electronApp.browserWindow(popoutWindow); const initialBounds = await browserWindow.evaluate((w) => (w as Electron.BrowserWindow).getBounds()); @@ -186,7 +93,7 @@ test.describe('server_management/popout_windows', () => { }); test('MM-TXXXX_3 should allow moving the popout window', {tag: ['@P2', '@all']}, async () => { - const popoutWindow = await openPopoutWindow(); + const popoutWindow = await openPopoutWindow(electronApp, mainWindow); const browserWindow = await electronApp.browserWindow(popoutWindow); const initialBounds = await browserWindow.evaluate((w) => (w as Electron.BrowserWindow).getBounds()); @@ -211,8 +118,8 @@ test.describe('server_management/popout_windows', () => { }); test('MM-TXXXX_4 should close the popout window using close button', {tag: ['@P2', '@all']}, async () => { - const popoutWindow = await openPopoutWindow(); - await closePopoutWindow(popoutWindow); + const popoutWindow = await openPopoutWindow(electronApp, mainWindow); + await closePopoutWindow(electronApp, popoutWindow); }); // NOTE: there is intentionally no "close popout windows when main window is @@ -223,7 +130,7 @@ test.describe('server_management/popout_windows', () => { test.describe('MM-T4411 popout window content functionality', () => { test('MM-T4411_1 should display the same server content in popout window', {tag: ['@P2', '@all']}, async () => { - const popoutWindow = await openPopoutWindow(); + const popoutWindow = await openPopoutWindow(electronApp, mainWindow); const mainWindowTitle = await mainWindow.title(); const popoutWindowTitle = await popoutWindow.title(); @@ -237,7 +144,7 @@ test.describe('server_management/popout_windows', () => { await mainView.waitForSelector('#sidebarItem_off-topic'); await mainView.click('#sidebarItem_off-topic'); - const popoutWindow = await openPopoutWindow(); + const popoutWindow = await openPopoutWindow(electronApp, mainWindow); expect(popoutWindow).toBeDefined(); const mainTabText = await mainWindow.innerText('.TabBar li.serverTabItem.active'); diff --git a/e2e/specs/server_management/remove_server_modal.test.ts b/e2e/specs/server_management/remove_server_modal.test.ts index a0f00d9c5d0..f72e257fcfb 100644 --- a/e2e/specs/server_management/remove_server_modal.test.ts +++ b/e2e/specs/server_management/remove_server_modal.test.ts @@ -44,7 +44,7 @@ async function launchWithRemoveServerModal(testInfo: {outputDir: string}) { } test.describe('RemoveServerModal', () => { - test('MM-T4390_1 should remove existing server on click Remove', {tag: ['@P2', '@all']}, async ({}, testInfo) => { + test('MM-T1286 MM-T4390 Remove existing server on click Remove', {tag: ['@P2', '@all']}, async ({}, testInfo) => { const {app, removeServerView, userDataDir} = await launchWithRemoveServerModal(testInfo); try { await removeServerView.click('button:has-text("Remove")'); diff --git a/e2e/specs/server_management/show_tray_icon.test.ts b/e2e/specs/server_management/show_tray_icon.test.ts new file mode 100644 index 00000000000..4da23eaa892 --- /dev/null +++ b/e2e/specs/server_management/show_tray_icon.test.ts @@ -0,0 +1,46 @@ +// 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 {demoConfig} from '../../helpers/config'; + +const trayIconConfig = { + ...demoConfig, + showTrayIcon: true, +}; + +async function evaluateTrayExists(app: ElectronApplication): Promise { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + return await app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const tray = refs?.TrayIcon?.tray; + return Boolean(tray && !tray.isDestroyed?.()); + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('Execution context was destroyed')) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + return false; +} + +test.describe('server_management/show_tray_icon', () => { + test.use({appConfig: trayIconConfig}); + + test( + 'MM-T1298 Show Mattermost icon in the menu bar (macOS and Linux)', + {tag: ['@P2', '@darwin', '@linux']}, + async ({electronApp}) => { + expect(trayIconConfig.showTrayIcon).toBe(true); + expect(await evaluateTrayExists(electronApp)).toBe(true); + }, + ); +}); diff --git a/e2e/specs/server_management/tab_management.test.ts b/e2e/specs/server_management/tab_management.test.ts index 634dc794ecf..af708b5cf32 100644 --- a/e2e/specs/server_management/tab_management.test.ts +++ b/e2e/specs/server_management/tab_management.test.ts @@ -91,7 +91,7 @@ test.describe('server_management/tab_management', () => { }); test.describe('MM-TXXXX should be able to close server tabs', () => { - test('MM-TXXXX_1 should close a server tab when clicking the x button', {tag: ['@P2', '@all']}, async () => { + test('MM-T6186 should close a server tab when clicking the x button', {tag: ['@P2', '@all']}, async () => { await mainWindow.click('#newTabButton'); await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)'); @@ -127,7 +127,7 @@ test.describe('server_management/tab_management', () => { }); test.describe('MM-TXXXX main tab for a server cannot be closed', () => { - test('MM-TXXXX_2 should not show close button on the main tab when there is only one tab', {tag: ['@P2', '@all']}, async () => { + test('MM-T6187 should not show close button on the main tab when there is only one tab', {tag: ['@P2', '@all']}, async () => { const firstTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(1)'); const secondTab = await mainWindow.$('.TabBar li.serverTabItem:nth-child(2)'); expect(firstTab).toBeDefined(); @@ -137,7 +137,7 @@ test.describe('server_management/tab_management', () => { expect(closeButton).toBeNull(); }); - test('MM-TXXXX_3 should show close button on the main tab when there are multiple tabs', {tag: ['@P2', '@all']}, async () => { + test('MM-T6188 should show close button on the main tab when there are multiple tabs', {tag: ['@P2', '@all']}, async () => { await mainWindow.click('#newTabButton'); const firstTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(1)'); diff --git a/e2e/specs/server_management/unread_badge.test.ts b/e2e/specs/server_management/unread_badge.test.ts new file mode 100644 index 00000000000..4a0f9e9c4e3 --- /dev/null +++ b/e2e/specs/server_management/unread_badge.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import { + clearAllBadgesViaAppState, + readOsBadge, + setUnreadBadgeSetting, + updateServerBadgeViaAppState, + waitForBadgeInfrastructure, +} from '../../helpers/badge'; +import {demoConfig} from '../../helpers/config'; +import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; + +const FIRST_SERVER = demoConfig.servers[0].name; + +test.describe('server_management/unread_badge', () => { + test.beforeEach(async ({electronApp}) => { + if (process.platform === 'linux') { + return; + } + await waitForBadgeInfrastructure(electronApp); + }); + + test.afterEach(async ({electronApp}) => { + if (process.platform === 'linux') { + return; + } + await clearAllBadgesViaAppState(electronApp); + }); + + test( + 'MM-T1291 Show red badge on taskbar for unread messages', + {tag: ['@P2', '@darwin', '@win32']}, + async ({electronApp}) => { + test.skip(process.platform === 'linux', 'Unread dot badge is not supported on Linux Unity path'); + + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await clearAllBadgesViaAppState(electronApp); + await setUnreadBadgeSetting(electronApp, true); + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 0, true); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Badge must show unread state when setting enabled'}, + ).toMatchObject({symbol: 'unread'}); + + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 3, false); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Badge must show mention count'}, + ).toMatchObject({symbol: 'mention', count: 3}); + } finally { + await releaseLock(); + } + }, + ); + + test( + 'MM-T1292 Do not show red badge when setting disabled except mentions', + {tag: ['@P2', '@darwin', '@win32']}, + async ({electronApp}) => { + test.skip(process.platform === 'linux', 'Unread dot badge is not supported on Linux Unity path'); + + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await clearAllBadgesViaAppState(electronApp); + await setUnreadBadgeSetting(electronApp, false); + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 0, true); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Unread badge must stay hidden when setting disabled'}, + ).toMatchObject({symbol: 'none'}); + + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 2, false); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Mention badge must still appear when setting disabled'}, + ).toMatchObject({symbol: 'mention', count: 2}); + } finally { + await releaseLock(); + } + }, + ); +}); diff --git a/e2e/specs/server_management/view_state.test.ts b/e2e/specs/server_management/view_state.test.ts index baf77ce1dec..f86ce9c0161 100644 --- a/e2e/specs/server_management/view_state.test.ts +++ b/e2e/specs/server_management/view_state.test.ts @@ -4,7 +4,7 @@ import {test, expect} from '../../fixtures/index'; test( - 'switching servers preserves view state on return', + 'MM-T6189 switching servers preserves view state on return', {tag: ['@P1', '@all']}, async ({serverMap, electronApp}) => { const serverA = serverMap.example?.[0]?.win; 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..f1199f5940b --- /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 path from 'path'; + +import {test} from '../../fixtures/index'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; +import {waitForConfigValue} from '../../helpers/settingsConfig'; + +test.describe('settings/tray_icon_theme', () => { + test( + 'MM-T4638 Settings - app icon theme (tray icon theme)', + {tag: ['@P2', '@linux']}, + async ({electronApp}, testInfo) => { + const configFilePath = path.join(testInfo.outputDir, 'userdata', 'config.json'); + 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}); + await waitForConfigValue(configFilePath, 'trayIconTheme', 'dark'); + }, + ); +}); diff --git a/e2e/specs/startup/app.test.ts b/e2e/specs/startup/app.test.ts index a46c2b413b8..cf8f1e5288e 100644 --- a/e2e/specs/startup/app.test.ts +++ b/e2e/specs/startup/app.test.ts @@ -5,9 +5,10 @@ import {_electron as electron} from 'playwright'; import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; -import {electronBinaryPath, appDir, demoConfig, emptyConfig, writeConfigFile} from '../../helpers/config'; +import {electronBinaryPath, appDir, demoConfig, writeConfigFile} from '../../helpers/config'; import {closeAppSafely, closeElectronApp} from '../../helpers/electronApp'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; +import {launchEmptyApp} from '../../helpers/emptyApp'; test.describe('startup/app', () => { test.describe.configure({mode: 'serial'}); @@ -64,35 +65,12 @@ test.describe('startup/app', () => { async ({}, testInfo) => { const releaseLock = await acquireExclusiveLock('startup-empty-app'); let emptyApp; + let welcomeScreen; let userDataDir = ''; try { - // This test needs a no-servers config. Override before launch. - // Since electronApp fixture has already launched with demoConfig, - // we test this by launching a fresh app with emptyConfig. - // NOTE: In Phase 3, refactor fixture to accept config override. - // For now, use a nested launch scoped to this test. - userDataDir = testInfo.outputDir + '/empty-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 welcomeModal = emptyApp.windows().find((w) => w.url().includes('welcomeScreen')); - if (!welcomeModal) { - welcomeModal = await emptyApp.waitForEvent('window', { - predicate: (w) => w.url().includes('welcomeScreen'), - timeout: 15_000, - }); - } - await welcomeModal.waitForLoadState('domcontentloaded'); - const text = await welcomeModal.innerText('.WelcomeScreen .WelcomeScreen__button'); + ({app: emptyApp, welcomeScreen, userDataDir} = await launchEmptyApp(testInfo, 'empty-userdata')); + const text = await welcomeScreen.innerText('.WelcomeScreen .WelcomeScreen__button'); expect(text).toBe('Get Started'); } finally { await closeAppSafely(emptyApp, userDataDir); @@ -110,19 +88,7 @@ test.describe('startup/app', () => { let userDataDir = ''; try { - userDataDir = testInfo.outputDir + '/empty-title-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, - }); - - await waitForAppReady(emptyApp); + ({app: emptyApp, userDataDir} = await launchEmptyApp(testInfo, 'empty-title-userdata')); const mainWin = emptyApp.windows().find((w) => w.url().includes('index')); expect(mainWin).toBeDefined(); const runtimeAppName = await emptyApp.evaluate(({app}) => app.getName()); @@ -136,4 +102,53 @@ 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 welcomeScreen; + let userDataDir = ''; + + try { + ({app: emptyApp, welcomeScreen, userDataDir} = await launchEmptyApp(testInfo, 'empty-noservers-userdata')); + 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 welcomeScreen; + let userDataDir = ''; + + try { + ({app: emptyApp, welcomeScreen, userDataDir} = await launchEmptyApp(testInfo, 'empty-modal-lock-userdata')); + 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..6d0602b9024 100644 --- a/e2e/specs/startup/welcome_screen_modal.test.ts +++ b/e2e/specs/startup/welcome_screen_modal.test.ts @@ -1,37 +1,10 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {_electron as electron} from 'playwright'; - import {test, expect} from '../../fixtures/index'; -import {waitForAppReady} from '../../helpers/appReadiness'; -import {electronBinaryPath, appDir, emptyConfig, writeConfigFile} from '../../helpers/config'; import {closeAppSafely} from '../../helpers/electronApp'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; - -// All welcome screen tests need a no-servers app. This helper launches one. -async function launchEmptyApp(testInfo: {outputDir: string; title: string}) { - const {mkdirSync} = await import('fs'); - const userDataDir = testInfo.outputDir + '/empty-userdata'; - mkdirSync(userDataDir, {recursive: true}); - writeConfigFile(userDataDir, emptyConfig); - - const app = await electron.launch({ - executablePath: electronBinaryPath, - args: [appDir, `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu'], - env: {...process.env, NODE_ENV: 'test'}, - timeout: 60_000, - }); - await waitForAppReady(app); - - const modal = app.windows().find((w) => w.url().includes('welcomeScreen')) ?? - await app.waitForEvent('window', { - predicate: (w) => w.url().includes('welcomeScreen'), - timeout: 10_000, - }); - await modal.waitForLoadState('domcontentloaded'); - return {app, modal, userDataDir}; -} +import {launchEmptyApp} from '../../helpers/emptyApp'; async function getCurrentSlideTitle(modal: any) { return modal.locator('.Carousel__slide-current .WelcomeScreenSlide__title').innerText(); @@ -45,7 +18,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'); @@ -53,7 +26,7 @@ test.describe('startup/welcome_screen_modal', () => { let modal; let userDataDir = ''; try { - ({app, modal, userDataDir} = await launchEmptyApp(testInfo)); + ({app, welcomeScreen: modal, userDataDir} = await launchEmptyApp(testInfo)); const titles = [await getCurrentSlideTitle(modal)]; for (let i = 0; i < 3; i++) { @@ -83,7 +56,7 @@ test.describe('startup/welcome_screen_modal', () => { let modal; let userDataDir = ''; try { - ({app, modal, userDataDir} = await launchEmptyApp(testInfo)); + ({app, welcomeScreen: modal, userDataDir} = await launchEmptyApp(testInfo)); const nextBtn = modal.locator('#nextCarouselButton'); const prevBtn = modal.locator('#prevCarouselButton'); await expect(nextBtn).toBeVisible({timeout: 10_000}); @@ -116,7 +89,7 @@ test.describe('startup/welcome_screen_modal', () => { let modal; let userDataDir = ''; try { - ({app, modal, userDataDir} = await launchEmptyApp(testInfo)); + ({app, welcomeScreen: modal, userDataDir} = await launchEmptyApp(testInfo)); await modal.click('#getStartedWelcomeScreen'); await modal.waitForSelector('#input_name', {timeout: 10_000}); await modal.waitForSelector('#input_url', {timeout: 10_000}); @@ -126,4 +99,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, welcomeScreen: 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, welcomeScreen: 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, welcomeScreen: 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, welcomeScreen: 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..0ae9e8aa1b9 --- /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', '@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'}, + ).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/window_close_tray.test.ts b/e2e/specs/system_tray_icon/window_close_tray.test.ts new file mode 100644 index 00000000000..a32caa2a05c --- /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 {evaluateInMainProcess} from '../../helpers/testRefs'; +import {isMainWindowVisible} from '../../helpers/tray'; + +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/user_attributes/user_attributes.test.ts b/e2e/specs/user_attributes/user_attributes.test.ts new file mode 100644 index 00000000000..9aa7add6c28 --- /dev/null +++ b/e2e/specs/user_attributes/user_attributes.test.ts @@ -0,0 +1,607 @@ +// 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 {waitForMattermostShellReady} from '../../helpers/mattermostShell'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import { + activateServerEntry, + expectServerViewUrl, + getServerEntry, +} from '../../helpers/serverContext'; +import {buildServerMap, type ServerEntry, type ServerMap} from '../../helpers/serverMap'; +import type {ServerView} from '../../helpers/serverView'; +import { + TEST_DEPARTMENT, + TEST_INVALID_URL, + TEST_PHONE, + TEST_UPDATED_PHONE, + TEST_UPDATED_URL, + TEST_URL, + TEST_VALID_URL, + cancelCustomAttributeEdit, + closeProfilePopover, + closeProfileSettings, + createCustomProfileAttributeField, + deleteCustomProfileAttributeField, + dismissBlockingOverlays, + editTextCustomAttribute, + getCustomAttributeLabelsInSettings, + getCustomProfileAttributeFields, + isAppResponsive, + isUserAttributesFeatureAvailable, + postAndOpenProfilePopover, + openProfileSettings, + patchCustomProfileAttributeField, + popoverContainsText, + popoverLinkHasHref, + recoverFromProfileSettings, + updateCustomProfileAttributeValues, + type UserPropertyField, +} from '../../helpers/userAttributes'; + +const FIELD_PREFIX = 'E2E_UA_'; + +async function cleanupFields(fieldIds: string[]): Promise { + for (const fieldId of fieldIds) { + try { + await deleteCustomProfileAttributeField(fieldId); + } catch { + // Best-effort cleanup on shared servers. + } + } +} + +async function prepareServer( + electronApp: ElectronApplication, + serverMap?: ServerMap, +): Promise<{entry: ServerEntry; win: ServerView}> { + const map = serverMap ?? await buildServerMap(electronApp); + const entry = getServerEntry(map, demoMattermostConfig.servers[0].name); + await expectServerViewUrl(electronApp, entry.webContentsId, /mattermost|8065/i, { + message: 'Example server view must load the Mattermost URL', + }); + await activateServerEntry(electronApp, entry); + await loginToMattermost(entry.win); + await waitForMattermostShellReady(entry.win); + await dismissBlockingOverlays(entry.win); + return {entry, win: entry.win}; +} + +test.describe('user_attributes/user_attributes', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test.beforeAll(async () => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + if (!process.env.MM_TEST_USER_NAME || !process.env.MM_TEST_PASSWORD) { + test.skip(true, 'MM_TEST_USER_NAME and MM_TEST_PASSWORD required'); + return; + } + + const available = await isUserAttributesFeatureAvailable(); + if (!available) { + test.skip(true, 'User Attributes feature not available on this server'); + } + + const existing = await getCustomProfileAttributeFields(); + await cleanupFields(existing.filter((field) => field.name.startsWith(FIELD_PREFIX)).map((field) => field.id)); + }); + + test.beforeEach(async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + return; + } + + await prepareServer(electronApp, serverMap); + }); + + test('MM-T5747 Attributes are shown in the user profile settings in the same order that they are listed in the System Console', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {win} = await prepareServer(electronApp, serverMap); + const names = [`${FIELD_PREFIX}Alpha`, `${FIELD_PREFIX}Beta`, `${FIELD_PREFIX}Gamma`]; + const created: UserPropertyField[] = []; + + try { + for (let index = 0; index < names.length; index++) { + created.push(await createCustomProfileAttributeField({name: names[index]}, index)); + } + + try { + await openProfileSettings(win); + } catch { + await recoverFromProfileSettings(win); + test.skip(true, 'Profile settings UI is not available on this server'); + return; + } + const labels = await getCustomAttributeLabelsInSettings(win); + await closeProfileSettings(win); + + for (const name of names) { + expect(labels.some((label) => label.includes(name)), `Expected ${name} in profile settings`).toBe(true); + } + + const labelIndexes = names.map((name) => labels.findIndex((label) => label.includes(name))); + expect(labelIndexes.every((index) => index >= 0)).toBe(true); + expect(labelIndexes[0]).toBeLessThan(labelIndexes[1]!); + expect(labelIndexes[1]).toBeLessThan(labelIndexes[2]!); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); + + test('MM-T5748 Long attribute names and descriptions are displayed correctly in a user\'s Profile Settings screen', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {win} = await prepareServer(electronApp, serverMap); + const longName = `${FIELD_PREFIX}${'LongAttributeName'.repeat(4)}`; + const longDescription = 'This is an intentionally long description for verifying profile settings layout in desktop.'; + let created: UserPropertyField | undefined; + + try { + created = await createCustomProfileAttributeField({ + name: longName, + attrs: {description: longDescription}, + }, 0); + + try { + await openProfileSettings(win); + } catch { + await recoverFromProfileSettings(win); + test.skip(true, 'Profile settings UI is not available on this server'); + return; + } + const visible = await win.runInRenderer<{nameVisible: boolean; descriptionVisible: boolean}>(` + const modal = document.querySelector('#accountSettingsModal, .user-settings, #userAccountModal, .AccountModal'); + if (!modal) { + return {nameVisible: false, descriptionVisible: false}; + } + const text = modal.textContent || ''; + return { + nameVisible: text.includes(${JSON.stringify(longName)}), + descriptionVisible: text.includes(${JSON.stringify(longDescription)}), + }; + `); + await closeProfileSettings(win); + + expect(visible.nameVisible, 'Long attribute name should be visible').toBe(true); + } finally { + if (created) { + await cleanupFields([created.id]); + } + } + }, + ); + + test('MM-T5749 Clicking Cancel does not save the edit in user\'s Profile settings screen', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {win} = await prepareServer(electronApp, serverMap); + const fieldName = `${FIELD_PREFIX}CancelTest`; + let created: UserPropertyField | undefined; + + try { + created = await createCustomProfileAttributeField({name: fieldName}, 0); + await updateCustomProfileAttributeValues({[created.id]: TEST_DEPARTMENT}); + + try { + await openProfileSettings(win); + } catch { + await recoverFromProfileSettings(win); + test.skip(true, 'Profile settings UI is not available on this server'); + return; + } + await editTextCustomAttribute(win, created.id, 'Changed Value', false); + await cancelCustomAttributeEdit(win, created.id); + const settingsText = await win.runInRenderer(` + return document.querySelector('.user-settings, #accountSettingsModal')?.textContent || ''; + `); + await closeProfileSettings(win); + + expect(settingsText).toContain(TEST_DEPARTMENT); + expect(settingsText).not.toContain('Changed Value'); + } finally { + if (created) { + await cleanupFields([created.id]); + } + } + }, + ); + + test('MM-T5750 No crash if user is editing a user attribute at the same time as the System Admin is deleting it in the System Console', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {win} = await prepareServer(electronApp, serverMap); + const fieldName = `${FIELD_PREFIX}DeleteWhileEditing`; + let created: UserPropertyField | undefined; + + try { + created = await createCustomProfileAttributeField({name: fieldName}, 0); + await updateCustomProfileAttributeValues({[created.id]: TEST_DEPARTMENT}); + + try { + await openProfileSettings(win); + } catch { + await recoverFromProfileSettings(win); + test.skip(true, 'Profile settings UI is not available on this server'); + return; + } + await win.runInRenderer(` + document.querySelector('#customAttribute_${created!.id}Edit')?.click(); + `); + await win.waitForSelector(`#customAttribute_${created!.id}`, {timeout: 10_000}); + + await deleteCustomProfileAttributeField(created!.id); + created = undefined; + + expect(await isAppResponsive(win), 'App should remain responsive after field deletion').toBe(true); + await closeProfileSettings(win); + expect(await isAppResponsive(win), 'App should remain responsive after closing settings').toBe(true); + } finally { + if (created) { + await cleanupFields([created.id]); + } + } + }, + ); + + test('MM-T5751 Attributes are shown in the user profile pop-over in the same order that they are listed in the System Console', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + const names = [`${FIELD_PREFIX}Pop_A`, `${FIELD_PREFIX}Pop_B`, `${FIELD_PREFIX}Pop_C`]; + const created: UserPropertyField[] = []; + + try { + for (let index = 0; index < names.length; index++) { + const field = await createCustomProfileAttributeField({ + name: names[index], + attrs: {visibility: 'always'}, + }, index); + created.push(field); + await updateCustomProfileAttributeValues({[field.id]: `Value-${index + 1}`}); + } + + const message = 'User attributes popover order test'; + await postAndOpenProfilePopover(electronApp, entry, message); + + await expect.poll(async () => { + const popoverText = await win.runInRenderer(` + const popover = document.querySelector('#user-profile-popover, .user-profile-popover, .profile-popover'); + return popover?.textContent || ''; + `); + return names.every((name) => popoverText.includes(name)); + }, {timeout: 30_000, message: 'Custom profile attribute names must appear in the popover'}).toBe(true); + + const popoverText = await win.runInRenderer(` + const popover = document.querySelector('#user-profile-popover, .user-profile-popover, .profile-popover'); + return popover?.textContent || ''; + `); + + const indexes = names.map((name) => popoverText.indexOf(name)); + expect(indexes.every((index) => index >= 0)).toBe(true); + expect(indexes[0]).toBeLessThan(indexes[1]!); + expect(indexes[1]).toBeLessThan(indexes[2]!); + + await closeProfilePopover(win); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); + + test('MM-T5752 User profile pop-over is scrollable and bottom bar in pop-over is locked in place', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + const created: UserPropertyField[] = []; + + try { + for (let index = 0; index < 6; index++) { + const field = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}Scroll_${index}`, + }, index); + created.push(field); + await updateCustomProfileAttributeValues({[field.id]: `Scroll value ${index}`}); + } + + const scrollMessage = 'User attributes scrollable popover test'; + await postAndOpenProfilePopover(electronApp, entry, scrollMessage); + + const layout = await win.runInRenderer<{scrollable: boolean; bottomLocked: boolean}>(` + const popover = document.querySelector('#user-profile-popover, .user-profile-popover, .profile-popover, [data-testid="userProfilePopover"]'); + if (!popover) { + return {scrollable: false, bottomLocked: false}; + } + const scrollContainer = popover.querySelector( + '.user-profile-popover__wrapper, .popover-content, .user-popover__content, .profile-popover-content, [data-testid="userProfilePopoverBody"]', + ) || popover; + const bottomBarSelectors = [ + '.user-popover__bottom', + '.user-profile-popover__bottom', + '.profile-popover-bottom', + '.popover-footer', + '[data-testid="profilePopoverActions"]', + '[data-testid="userProfilePopoverActions"]', + '.user-profile-popover-actions', + ]; + let bottomBar = null; + for (const selector of bottomBarSelectors) { + bottomBar = popover.querySelector(selector); + if (bottomBar) { + break; + } + } + if (!bottomBar) { + bottomBar = Array.from(popover.querySelectorAll('button, a')).find((element) => { + const label = (element.textContent || element.getAttribute('aria-label') || '').toLowerCase(); + return label.includes('message') || label.includes('call'); + })?.closest('div') || null; + } + const style = window.getComputedStyle(scrollContainer); + const scrollable = scrollContainer.scrollHeight > scrollContainer.clientHeight + || style.overflowY === 'auto' + || style.overflowY === 'scroll'; + const bottomLocked = Boolean(bottomBar); + return {scrollable, bottomLocked}; + `); + + expect(layout.scrollable, 'Popover content should be scrollable when attributes overflow').toBe(true); + expect(layout.bottomLocked, 'Popover bottom bar should be present').toBe(true); + + await closeProfilePopover(win); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); + + test('MM-T5771 Editing Phone and URL Type User Attributes', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + const created: UserPropertyField[] = []; + + try { + const phoneField = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}Phone`, + attrs: {value_type: 'phone'}, + }, 0); + const urlField = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}Website`, + attrs: {value_type: 'url'}, + }, 1); + created.push(phoneField, urlField); + await updateCustomProfileAttributeValues({ + [phoneField.id]: TEST_PHONE, + [urlField.id]: TEST_URL, + }); + + try { + await openProfileSettings(win); + } catch { + await recoverFromProfileSettings(win); + test.skip(true, 'Profile settings UI is not available on this server'); + return; + } + await editTextCustomAttribute(win, phoneField.id, TEST_UPDATED_PHONE); + await editTextCustomAttribute(win, urlField.id, TEST_UPDATED_URL); + await closeProfileSettings(win); + + const phoneUrlMessage = 'Phone and URL attribute edit test'; + await postAndOpenProfilePopover(electronApp, entry, phoneUrlMessage); + expect(await popoverContainsText(win, TEST_UPDATED_PHONE)).toBe(true); + expect(await popoverContainsText(win, TEST_UPDATED_URL)).toBe(true); + await closeProfilePopover(win); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); + + test('MM-T5772 URL Validation in User Attributes', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + let created: UserPropertyField | undefined; + + try { + created = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}WebsiteValidation`, + attrs: {value_type: 'url'}, + }, 0); + await updateCustomProfileAttributeValues({[created.id]: TEST_URL}); + + try { + await openProfileSettings(win); + } catch { + await recoverFromProfileSettings(win); + test.skip(true, 'Profile settings UI is not available on this server'); + return; + } + await editTextCustomAttribute(win, created.id, TEST_INVALID_URL, false); + await win.runInRenderer(` + document.querySelector('#customAttribute_${created!.id}')?.blur(); + `); + + await expect.poll(async () => win.runInRenderer(` + return Boolean(document.querySelector('#error_customAttribute_${created!.id}')); + `), {timeout: 10_000}).toBe(true); + + await editTextCustomAttribute(win, created.id, TEST_VALID_URL); + await closeProfileSettings(win); + + const urlValidationMessage = 'URL validation attribute test'; + await postAndOpenProfilePopover(electronApp, entry, urlValidationMessage); + expect(await popoverContainsText(win, TEST_VALID_URL)).toBe(true); + await closeProfilePopover(win); + } finally { + if (created) { + await cleanupFields([created.id]); + } + } + }, + ); + + test('MM-T5774 Do Not Display User Attributes If None Exist', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + const created: UserPropertyField[] = []; + + try { + const department = await createCustomProfileAttributeField({name: `${FIELD_PREFIX}EmptyDept`}, 0); + const location = await createCustomProfileAttributeField({name: `${FIELD_PREFIX}EmptyLoc`}, 1); + created.push(department, location); + + const emptyMessage = 'No custom attribute values on this user'; + await postAndOpenProfilePopover(electronApp, entry, emptyMessage); + + expect(await popoverContainsText(win, department.name)).toBe(false); + expect(await popoverContainsText(win, location.name)).toBe(false); + + await closeProfilePopover(win); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); + + test('MM-T5776 Hide User Attributes When Visibility Is Set to Hidden', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + const created: UserPropertyField[] = []; + + try { + const hiddenField = await createCustomProfileAttributeField({name: `${FIELD_PREFIX}Hidden`}, 0); + const visibleField = await createCustomProfileAttributeField({name: `${FIELD_PREFIX}Visible`}, 1); + created.push(hiddenField, visibleField); + + await patchCustomProfileAttributeField(hiddenField.id, {attrs: {visibility: 'hidden'}}); + await updateCustomProfileAttributeValues({ + [hiddenField.id]: TEST_DEPARTMENT, + [visibleField.id]: 'Remote', + }); + + const hiddenMessage = 'Hidden attribute visibility test'; + await postAndOpenProfilePopover(electronApp, entry, hiddenMessage); + + expect(await popoverContainsText(win, hiddenField.name)).toBe(false); + expect(await popoverContainsText(win, 'Remote')).toBe(true); + + await closeProfilePopover(win); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); + + test('MM-T5777 Always Display User Attributes With Visibility Set to Always', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {win} = await prepareServer(electronApp, serverMap); + let created: UserPropertyField | undefined; + + try { + created = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}AlwaysShow`, + attrs: {visibility: 'always'}, + }, 0); + + try { + await openProfileSettings(win); + } catch { + await recoverFromProfileSettings(win); + test.skip(true, 'Profile settings UI is not available on this server'); + return; + } + const labels = await getCustomAttributeLabelsInSettings(win); + expect(labels.some((label) => label.includes(created!.name)), 'Always-visible attribute should appear in profile settings').toBe(true); + await closeProfileSettings(win); + } finally { + if (created) { + await cleanupFields([created.id]); + } + } + }, + ); + + test('MM-T5778 Display Phone and URL Type User Attributes Correctly', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + const created: UserPropertyField[] = []; + + try { + const phoneField = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}DisplayPhone`, + attrs: {value_type: 'phone'}, + }, 0); + const urlField = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}DisplayURL`, + attrs: {value_type: 'url'}, + }, 1); + created.push(phoneField, urlField); + await updateCustomProfileAttributeValues({ + [phoneField.id]: TEST_PHONE, + [urlField.id]: TEST_URL, + }); + + const displayMessage = 'Display phone and URL attributes'; + await postAndOpenProfilePopover(electronApp, entry, displayMessage); + + expect(await popoverContainsText(win, TEST_PHONE)).toBe(true); + expect(await popoverContainsText(win, TEST_URL)).toBe(true); + + await closeProfilePopover(win); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); + + test('MM-T5779 Verify Phone and URL Attributes Are Clickable in Profile Popover', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + const {entry, win} = await prepareServer(electronApp, serverMap); + const created: UserPropertyField[] = []; + + try { + const phoneField = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}ClickPhone`, + attrs: {value_type: 'phone'}, + }, 0); + const urlField = await createCustomProfileAttributeField({ + name: `${FIELD_PREFIX}ClickURL`, + attrs: {value_type: 'url'}, + }, 1); + created.push(phoneField, urlField); + await updateCustomProfileAttributeValues({ + [phoneField.id]: TEST_PHONE, + [urlField.id]: TEST_URL, + }); + + const clickableMessage = 'Clickable phone and URL attributes'; + await postAndOpenProfilePopover(electronApp, entry, clickableMessage); + + expect(await popoverLinkHasHref(win, TEST_PHONE, '^tel:')).toBe(true); + expect(await popoverLinkHasHref(win, TEST_URL, '^https:')).toBe(true); + + await closeProfilePopover(win); + } finally { + await cleanupFields(created.map((field) => field.id)); + } + }, + ); +}); 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..f317b5afa8e --- /dev/null +++ b/e2e/specs/windows_and_linux_only/autostart.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import {demoConfig} from '../../helpers/config'; +import { + readConfigValue, + toggleAutostartSetting, + waitForConfigValue, +} from '../../helpers/settingsConfig'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; + +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(readConfigValue(configFilePath, 'autostart')).toBe(false); + await toggleAutostartSetting(settingsWindow, configFilePath); + }, + ); + }); + + 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'); + + if (readConfigValue(configFilePath, 'autostart')) { + await toggleAutostartSetting(settingsWindow, configFilePath); + } + await waitForConfigValue(configFilePath, 'autostart', 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 toggleAutostartSetting(settingsWindow, configFilePath); + await toggleAutostartSetting(settingsWindow, configFilePath); + await waitForConfigValue(configFilePath, 'autostart', 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..e5a76032009 --- /dev/null +++ b/e2e/specs/windows_and_linux_only/startup_after_reboot.test.ts @@ -0,0 +1,100 @@ +// 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 {ensureAutostartEnabled, waitForConfigValue} from '../../helpers/settingsConfig'; +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 configPath = path.join(userDataDir, 'config.json'); + await ensureAutostartEnabled(settingsWindow, configPath); + await waitForConfigValue(configPath, 'autostart', 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); + }, + ); +});