diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index 9aa8e3ea9d0..25dc2583db6 100644 --- a/.github/workflows/e2e-functional-template.yml +++ b/.github/workflows/e2e-functional-template.yml @@ -94,6 +94,15 @@ on: SKIPPED_WINDOWS: description: "Number of skipped tests on Windows" value: ${{ jobs.e2e.outputs.SKIPPED_WINDOWS }} + COLLECTION_FAILED_LINUX: + description: "Whether Playwright collected zero tests on Linux" + value: ${{ jobs.e2e.outputs.COLLECTION_FAILED_LINUX }} + COLLECTION_FAILED_MACOS: + description: "Whether Playwright collected zero tests on macOS" + value: ${{ jobs.e2e.outputs.COLLECTION_FAILED_MACOS }} + COLLECTION_FAILED_WINDOWS: + description: "Whether Playwright collected zero tests on Windows" + value: ${{ jobs.e2e.outputs.COLLECTION_FAILED_WINDOWS }} STATUS_WINDOWS: description: "The status of the windows test" value: ${{ jobs.e2e.outputs.STATUS_WINDOWS }} @@ -182,6 +191,9 @@ jobs: SKIPPED_LINUX: ${{ steps.analyze-flaky-tests.outputs.SKIPPED_LINUX }} SKIPPED_MACOS: ${{ steps.analyze-flaky-tests.outputs.SKIPPED_MACOS }} SKIPPED_WINDOWS: ${{ steps.analyze-flaky-tests.outputs.SKIPPED_WINDOWS }} + COLLECTION_FAILED_LINUX: ${{ steps.analyze-flaky-tests.outputs.COLLECTION_FAILED_LINUX }} + COLLECTION_FAILED_MACOS: ${{ steps.analyze-flaky-tests.outputs.COLLECTION_FAILED_MACOS }} + COLLECTION_FAILED_WINDOWS: ${{ steps.analyze-flaky-tests.outputs.COLLECTION_FAILED_WINDOWS }} steps: - name: e2e/set-required-variables id: variables @@ -393,7 +405,7 @@ jobs: script: | process.chdir('./e2e'); const { analyzeFlakyTests } = require('./utils/analyze-flaky-test.js'); - const { failureCount, passCount, skipCount, totalCount, os, testStatus } = analyzeFlakyTests(); + const { failureCount, passCount, skipCount, totalCount, os, testStatus, collectionFailed } = analyzeFlakyTests(); const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; const reportUrl = process.env.PER_OS_REPORT_URL || runUrl; const setOSOutputs = (suffix) => { @@ -403,6 +415,7 @@ jobs: core.setOutput(`PASSED_${suffix}`, String(passCount)); core.setOutput(`SKIPPED_${suffix}`, String(skipCount)); core.setOutput(`TOTAL_${suffix}`, String(totalCount)); + core.setOutput(`COLLECTION_FAILED_${suffix}`, String(collectionFailed)); }; switch (os) { case 'linux': @@ -417,23 +430,3 @@ jobs: default: throw new Error(`Unsupported OS: ${os}`); } - - - name: Upload Playwright blob report - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: blob-report-${{ env.RUNNER_OS }} - path: | - e2e/blob-report - if-no-files-found: ignore - retention-days: 7 - - - name: Upload test results - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: test-results-${{ env.RUNNER_OS }} - path: | - e2e/test-results - if-no-files-found: ignore - retention-days: 7 diff --git a/.github/workflows/e2e-functional.yml b/.github/workflows/e2e-functional.yml index 7ef15fc2fa9..88c6b6f7f6d 100644 --- a/.github/workflows/e2e-functional.yml +++ b/.github/workflows/e2e-functional.yml @@ -51,8 +51,13 @@ jobs: platforms: ${{ steps.generate.outputs.platforms }} steps: - id: generate + env: + INSTANCE_DETAILS: ${{ inputs.instance_details }} run: | - echo "platforms=$(echo '${{ inputs.instance_details }}' | jq -c)" >> $GITHUB_OUTPUT + # Matterwick still dispatches macos-latest; pin explicitly so the job + # does not drift when GitHub retargets that label to macOS 26. + platforms=$(echo "${INSTANCE_DETAILS}" | jq -c 'map(if .runner == "macos-latest" then .runner = "macos-26" else . end)') + echo "platforms=${platforms}" >> "$GITHUB_OUTPUT" update-initial-status: name: Update initial status @@ -181,12 +186,19 @@ jobs: } if (prNumber) { - await github.rest.issues.removeLabel({ - issue_number: prNumber, - owner: context.repo.owner, - repo: context.repo.repo, - name: 'E2E/Run', - }); + try { + await github.rest.issues.removeLabel({ + issue_number: prNumber, + owner: context.repo.owner, + repo: context.repo.repo, + name: 'E2E/Run', + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + console.log('E2E/Run label already removed'); + } } else { console.log('Label removal skipped - could not find associated PR'); } @@ -359,7 +371,7 @@ jobs: process.chdir('./e2e'); const { analyzeFlakyTests } = require('./utils/analyze-flaky-test.js'); const { formatStatusDescription } = require('./utils/github-actions.js'); - const { newFailedTests, failureCount, passCount, skipCount, totalCount } = analyzeFlakyTests(); + const { newFailedTests, failureCount, passCount, skipCount, totalCount, collectionFailed } = analyzeFlakyTests(); const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; const platform = process.platform === 'darwin' ? 'macos' : 'windows'; @@ -367,8 +379,7 @@ jobs: const description = formatStatusDescription({ passed: passCount, failed: failureCount, - skipped: skipCount, - total: totalCount, + collectionFailed, }); try { diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index 0be785930bd..61519d536ab 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -136,6 +136,7 @@ export const test = base.extend({ await use(app); await closeElectronApp(app, userDataDir, FAST_TEARDOWN); + await fs.rm(userDataDir, {recursive: true, force: true}).catch(() => {}); }, appReady: async ({electronApp}, use) => { diff --git a/e2e/helpers/badge.ts b/e2e/helpers/badge.ts new file mode 100644 index 00000000000..167c33964d6 --- /dev/null +++ b/e2e/helpers/badge.ts @@ -0,0 +1,143 @@ +// 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 {evaluateInMainProcess, evaluateInMainProcessWithArg} from './testRefs'; + +export type OsBadgeState = { + count: number; + symbol: 'mention' | 'unread' | 'expired' | 'none'; + hasOverlay: boolean; +}; + +export async function waitForBadgeInfrastructure(app: ElectronApplication): Promise { + await expect.poll( + async () => app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + return Boolean(refs?.AppState && refs?.ServerManager); + }), + {timeout: 30_000, message: 'AppState and ServerManager must be exposed on __e2eTestRefs'}, + ).toBe(true); +} + +export async function setUnreadBadgeSetting(app: ElectronApplication, enabled: boolean): Promise { + await evaluateInMainProcessWithArg(app, (_electron, showUnreadBadge) => { + const refs = (global as any).__e2eTestRefs; + if (!refs?.setUnreadBadgeSetting) { + throw new Error('setUnreadBadgeSetting missing from __e2eTestRefs'); + } + refs.Config?.set?.('showUnreadBadge', showUnreadBadge); + refs.setUnreadBadgeSetting(showUnreadBadge); + }, enabled); +} + +export async function updateServerBadgeViaAppState( + app: ElectronApplication, + serverName: string, + mentions: number, + unreads: boolean, +): Promise { + await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, mentions: mentionCount, unreads: hasUnreads}) => { + const refs = (global as any).__e2eTestRefs; + const AppState = refs?.AppState; + const ServerManager = refs?.ServerManager; + if (!AppState || !ServerManager) { + throw new Error('AppState or ServerManager missing from __e2eTestRefs'); + } + const server = ServerManager.getAllServers().find((s: {name: string}) => s.name === name); + if (!server) { + throw new Error(`Server not found: ${name}`); + } + AppState.updateUnreadsAndMentionsPerServer(server.id, mentionCount, hasUnreads); + }, {serverName, mentions, unreads}); +} + +export async function setServerExpiredViaAppState( + app: ElectronApplication, + serverName: string, + expired: boolean, +): Promise { + await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, expired: isExpired}) => { + const refs = (global as any).__e2eTestRefs; + const AppState = refs?.AppState; + const ServerManager = refs?.ServerManager; + if (!AppState || !ServerManager) { + throw new Error('AppState or ServerManager missing from __e2eTestRefs'); + } + const server = ServerManager.getAllServers().find((s: {name: string}) => s.name === name); + if (!server) { + throw new Error(`Server not found: ${name}`); + } + AppState.updateExpired(server.id, isExpired); + }, {serverName, expired}); +} + +export async function clearAllBadgesViaAppState(app: ElectronApplication): Promise { + await evaluateInMainProcess(app, () => { + const refs = (global as any).__e2eTestRefs; + const AppState = refs?.AppState; + const ServerManager = refs?.ServerManager; + if (!AppState || !ServerManager) { + throw new Error('AppState or ServerManager missing from __e2eTestRefs'); + } + for (const server of ServerManager.getAllServers()) { + AppState.updateUnreadsAndMentionsPerServer(server.id, 0, false); + AppState.updateExpired(server.id, false); + } + refs.Config?.set?.('showUnreadBadge', false); + refs.setUnreadBadgeSetting?.(false); + }); +} + +export async function readOsBadge(electronApp: ElectronApplication): Promise { + return evaluateInMainProcess(electronApp, ({app}) => { + const testState = (global as any).__testBadgeState; + + if (process.platform === 'darwin') { + const badge = app.dock?.getBadge() ?? ''; + if (badge === '•') { + return {count: 0, symbol: 'unread' as const, hasOverlay: false}; + } + if (badge === '!') { + return {count: 0, symbol: 'expired' as const, hasOverlay: false}; + } + if (badge === '') { + return {count: 0, symbol: 'none' as const, hasOverlay: false}; + } + return {count: parseInt(badge, 10), symbol: 'mention' as const, hasOverlay: false}; + } + + if (process.platform === 'linux') { + // app.getBadgeCount()/setBadgeCount() are no-ops without a running Unity + // desktop (true in headless CI), so fall back to re-deriving the count + // from the same inputs showBadgeLinux() would have passed to + // setBadgeCount() — mentionCount plus 1 for an expired session. + let count: number; + if (app.isUnityRunning()) { + count = app.getBadgeCount(); + } else if (testState) { + count = testState.mentionCount + (testState.sessionExpired ? 1 : 0); + } else { + count = 0; + } + const symbol = testState?.resolvedType ?? (count > 0 ? 'mention' : 'none'); + return {count, symbol, hasOverlay: false}; + } + + // Electron exposes setOverlayIcon() but no getOverlayIcon(); __testBadgeState + // records what showBadgeWindows() decided to pass to setOverlayIcon(). + const symbol = testState?.resolvedType ?? 'none'; + return { + count: testState?.mentionCount ?? 0, + symbol, + hasOverlay: testState?.hasOverlay ?? false, + }; + }); +} + +export async function readBadgeCount(app: ElectronApplication): Promise { + const state = await readOsBadge(app); + return state.count; +} diff --git a/e2e/helpers/callsWidget.ts b/e2e/helpers/callsWidget.ts new file mode 100644 index 00000000000..d8d68e2ecd9 --- /dev/null +++ b/e2e/helpers/callsWidget.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +export function findCallsWidgetWindow(electronApp: ElectronApplication): Page | null { + return electronApp.windows().find((w) => { + try { + const url = w.url(); + return url.includes('/plugins/com.mattermost.calls/standalone/widget.html'); + } catch { + return false; + } + }) ?? null; +} + +export async function waitForCallsWidgetWindow( + electronApp: ElectronApplication, + timeoutMs = 20_000, +): Promise { + const existing = findCallsWidgetWindow(electronApp); + if (existing) { + return existing; + } + + return electronApp.waitForEvent('window', { + predicate: (w) => { + try { + return w.url().includes('/plugins/com.mattermost.calls/standalone/widget.html'); + } catch { + return false; + } + }, + timeout: timeoutMs, + }).catch(() => null); +} diff --git a/e2e/helpers/downloadsDropdown.ts b/e2e/helpers/downloadsDropdown.ts new file mode 100644 index 00000000000..3a65bce9a8b --- /dev/null +++ b/e2e/helpers/downloadsDropdown.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {CLOSE_DOWNLOADS_DROPDOWN, CLOSE_DOWNLOADS_DROPDOWN_MENU} from './ipcChannels'; + +import {evaluateInMainProcessWithArg, isTransientNavigationError} from './testRefs'; + +/** + * Close the downloads dropdown WebContentsView if it is open. + * Parallel download specs can leave this overlay focused and block other UI flows. + */ +export async function closeDownloadsDropdownIfOpen(app: ElectronApplication): Promise { + await evaluateInMainProcessWithArg(app, ({ipcMain}, channels) => { + ipcMain.emit(channels.menu); + ipcMain.emit(channels.dropdown); + }, {dropdown: CLOSE_DOWNLOADS_DROPDOWN, menu: CLOSE_DOWNLOADS_DROPDOWN_MENU}, { + timeoutMs: 15_000, + isRetryable: isTransientNavigationError, + }); +} diff --git a/e2e/helpers/errorView.ts b/e2e/helpers/errorView.ts index 052d44b20e4..ed41936e6fa 100644 --- a/e2e/helpers/errorView.ts +++ b/e2e/helpers/errorView.ts @@ -2,14 +2,18 @@ // See LICENSE.txt for license information. import {expect} from '@playwright/test'; -import type {ElectronApplication} from 'playwright'; +import type {ElectronApplication, Page} from 'playwright'; +import {waitForRendererReady} from './badServer'; import {clearCertificateErrorCallbacks} from './dialog'; -import {evaluateInMainProcessWithArg, isTransientEvaluateError} from './testRefs'; +import {evaluateInMainProcessWithArg, findMainIndexWindow, resolveMainIndexWindow} from './testRefs'; type WaitForErrorViewOptions = { serverName?: string; timeout?: number; + + /** Set after add-server modal confirm; skip for pre-configured startup configs. */ + waitForActiveServer?: boolean; }; type ServerReloadAction = 'checkRegistered' | 'reload' | 'checkLoading'; @@ -72,50 +76,19 @@ async function evaluateServerReloadState( }, {action, targetServerName}); } -/** - * Wait for the renderer to mount, then reload server views so load failures - * that fired before IPC listeners were registered are surfaced in ErrorView. - */ -export async function waitForRendererThenReload( +async function reloadTargetServerViews( app: ElectronApplication, serverName?: string, ): Promise { - const mainWindow = app.windows().find((window) => window.url().includes('index')); - if (!mainWindow) { - return; - } - - await mainWindow.waitForSelector('.ServerDropdownButton', {timeout: 15_000}).catch(() => {}); - - if (serverName) { - await expect.poll(() => { - return !app.windows().some((window) => { - try { - return window.url().includes('newServer'); - } catch { - return false; - } - }); - }, {timeout: 10_000, message: 'Add server modal should close after confirm'}).toBe(true); - - await expect.poll(async () => { - return mainWindow.innerText('.ServerDropdownButton'); - }, {timeout: 10_000, message: `Active server should switch to ${serverName}`}).toContain(serverName); - } - - // Wait until ServerManager knows about the target server. We intentionally do NOT - // require a WebContentsManager entry to exist here: on platforms where the initial - // load fails very fast (e.g. expired cert at startup), the view may never be added - // to WebContentsManager before this poll's deadline. If no view exists, the reload - // step below becomes a no-op and the existing load failure already surfaces in - // `.ErrorView`, which is what the caller is polling for. + // On platforms where the initial load fails very fast, the view may never be + // added to WebContentsManager before this poll's deadline. If no view exists, + // reload is a no-op and the existing load failure may already be in ErrorView. await expect.poll( () => evaluateServerReloadState(app, 'checkRegistered', serverName), {timeout: 15_000, message: 'Target server should be registered before reload'}, ).toBe(true); await clearCertificateErrorCallbacks(app).catch(() => {}); - await evaluateServerReloadState(app, 'reload', serverName); await expect.poll( @@ -124,52 +97,63 @@ export async function waitForRendererThenReload( ).toBe(true); } -/** - * Only DOM-not-ready-yet failures (missing window, missing selector, transient - * evaluate errors) should be retried here. A real programming error — a bad ref, - * a renamed method, a typo — should fail immediately instead of being retried - * away for up to a minute and reported as a generic "ErrorView did not appear". - */ -function isRetryableErrorViewFailure(error: unknown): boolean { - if (isTransientEvaluateError(error)) { - return true; - } - if (error instanceof TypeError || error instanceof ReferenceError) { - return false; - } - if (error instanceof Error && error.message.startsWith('__e2eTestRefs.')) { - return false; - } - return true; +function resolveErrorViewHost(app: ElectronApplication, fallback: Page): Page { + return findMainIndexWindow(app) ?? fallback; } +/** + * Wait for MainPage to surface a load failure in `.ErrorView` on index.html. + * + * ErrorView is rendered in the main BrowserWindow (MainPage → BasePage), not in + * server WebContentsViews. If LOAD_FAILED fired before MainPage registered IPC + * listeners, reload the server view so the failure is captured in React state. + */ export async function waitForErrorView( app: ElectronApplication, options: WaitForErrorViewOptions = {}, -): Promise { +): Promise { const timeout = options.timeout ?? (process.env.CI ? 60_000 : 45_000); - const deadline = Date.now() + timeout; - let lastError: unknown; - while (Date.now() < deadline) { - try { - const mainWindow = app.windows().find((window) => window.url().includes('index')); - if (!mainWindow) { - throw new Error('Main index window is not available yet'); - } - await waitForRendererThenReload(app, options.serverName); - await mainWindow.waitForSelector('.ErrorView', { - timeout: Math.min(10_000, deadline - Date.now()), + const {serverName, waitForActiveServer = false} = options; + + const mainWindow = await resolveMainIndexWindow(app, Math.min(timeout, 30_000)); + await waitForRendererReady(mainWindow); + + if (waitForActiveServer && serverName) { + await expect.poll(() => { + return !app.windows().some((window) => { + try { + return window.url().includes('newServer'); + } catch { + return false; + } }); - return; - } catch (error) { - if (!isRetryableErrorViewFailure(error)) { - throw error; - } - lastError = error; - await clearCertificateErrorCallbacks(app).catch(() => {}); - await new Promise((resolve) => setTimeout(resolve, 250)); - } + }, {timeout: 10_000, message: 'Add server modal should close after confirm'}).toBe(true); + + await expect.poll(async () => { + const window = resolveErrorViewHost(app, mainWindow); + return window.innerText('.ServerDropdownButton').catch(() => ''); + }, { + timeout: 15_000, + message: `Active server should switch to ${serverName}`, + }).toContain(serverName); + } + + const host = resolveErrorViewHost(app, mainWindow); + if (!(await host.isVisible('.ErrorView').catch(() => false))) { + await reloadTargetServerViews(app, serverName); } - throw lastError instanceof Error ? lastError : new Error('ErrorView did not appear before timeout'); + await expect.poll(async () => { + const window = resolveErrorViewHost(app, mainWindow); + try { + return await window.isVisible('.ErrorView'); + } catch { + return false; + } + }, { + timeout, + message: 'ErrorView did not appear before timeout', + }).toBe(true); + + return resolveErrorViewHost(app, mainWindow); } diff --git a/e2e/helpers/ipcChannels.ts b/e2e/helpers/ipcChannels.ts new file mode 100644 index 00000000000..bab9c9199e4 --- /dev/null +++ b/e2e/helpers/ipcChannels.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// IPC channel strings used by E2E helpers — must match src/common/communication.ts. +export const SHOW_SETTINGS_WINDOW = 'show-settings-window'; +export const CLOSE_DOWNLOADS_DROPDOWN = 'close-downloads-dropdown'; +export const CLOSE_DOWNLOADS_DROPDOWN_MENU = 'close-downloads-dropdown-menu'; +export const CALLS_LEAVE_CALL = 'calls-leave-call'; +export const EMIT_CONFIGURATION = 'emit-configuration'; +export const SHOW_NEW_SERVER_MODAL = 'show_new_server_modal'; diff --git a/e2e/helpers/mattermostShell.ts b/e2e/helpers/mattermostShell.ts index 0084294b00d..d38b6f77a94 100644 --- a/e2e/helpers/mattermostShell.ts +++ b/e2e/helpers/mattermostShell.ts @@ -3,6 +3,7 @@ import {expect} from '@playwright/test'; +import {isTransientEvaluateError} from './testRefs'; import type {ServerView} from './serverView'; export const POST_TEXTBOX_CANDIDATES = [ @@ -43,7 +44,16 @@ const POST_TEXTBOX_RESOLVER_JS = ` }; const __mmResolvePostTextboxRoot = () => { - const candidates = ${POST_TEXTBOX_CANDIDATES_JSON}.map((selector) => document.querySelector(selector)).filter(Boolean); + 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; @@ -88,7 +98,7 @@ export async function recoverServerViewIfNeeded( options?: {channelItem?: string}, ) { const channelItem = options?.channelItem ?? '#sidebarItem_town-square'; - const healthy = await win.runInRenderer(` + const healthy = await win.runInRenderer(` return Boolean( document.querySelector('#channelHeaderTitle') && document.querySelector(${JSON.stringify(channelItem)}), @@ -99,7 +109,14 @@ export async function recoverServerViewIfNeeded( return; } - await win.runInRenderer('window.location.reload(); return true;', true); + 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}); } @@ -128,7 +145,7 @@ export async function waitForChannelPostListLoaded( /** 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(); @@ -146,7 +163,7 @@ export async function getPostTextboxValue(win: ServerView): Promise { /** 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(); @@ -171,7 +188,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} @@ -218,7 +235,7 @@ export async function getPostTextboxWordPoint( win: ServerView, word: string, ): Promise<{x: number; y: number} | null> { - return win.runInRenderer(` + return win.runInRenderer<{x: number; y: number} | null>(` const target = ${JSON.stringify(word)}; ${POST_TEXTBOX_RESOLVER_JS} diff --git a/e2e/helpers/menu.ts b/e2e/helpers/menu.ts index fb75cd664b1..a6c83df81e6 100644 --- a/e2e/helpers/menu.ts +++ b/e2e/helpers/menu.ts @@ -3,6 +3,8 @@ import type {ElectronApplication} from 'playwright'; +import {isTransientEvaluateError} from './testRefs'; + type MenuItemMatcher = { id?: string; label?: string; @@ -120,8 +122,7 @@ export async function clickApplicationMenuItem( }); return; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes('Execution context was destroyed')) { + if (!isTransientEvaluateError(error)) { throw error; } await new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/e2e/helpers/methodSpy.ts b/e2e/helpers/methodSpy.ts new file mode 100644 index 00000000000..0e314e0120a --- /dev/null +++ b/e2e/helpers/methodSpy.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {evaluateInMainProcess, evaluateInMainProcessWithArg} from './testRefs'; + +// These spies install/restore around navigation-heavy flows (e.g. tests that +// hide the main window, trigger notifications, then re-show it), during which +// Electron's evaluate context can be transiently destroyed. Routing through +// evaluateInMainProcess[WithArg] gives us the same "retry on transient +// context-destroyed errors" behavior used by the tray helpers. + +export async function installDockBounceSpy(app: ElectronApplication): Promise { + await evaluateInMainProcessWithArg(app, ({app: electronApp}) => { + (electronApp as any).__e2eDockBounceCalls = []; + const dock = electronApp.dock; + if (!dock) { + return; + } + const originalBounce = dock.bounce.bind(dock); + (dock as any).__e2eOriginalBounce = originalBounce; + dock.bounce = ((type?: 'informational' | 'critical') => { + (electronApp as any).__e2eDockBounceCalls.push(type ?? 'informational'); + return originalBounce(type); + }) as typeof dock.bounce; + }, null); +} + +export async function restoreDockBounceSpy(app: ElectronApplication): Promise { + await evaluateInMainProcessWithArg(app, ({app: electronApp}) => { + const dock = electronApp.dock; + if (dock && (dock as any).__e2eOriginalBounce) { + dock.bounce = (dock as any).__e2eOriginalBounce; + delete (dock as any).__e2eOriginalBounce; + } + delete (electronApp as any).__e2eDockBounceCalls; + }, null); +} + +export async function installFlashFrameSpy(app: ElectronApplication): Promise { + await evaluateInMainProcess(app, () => { + (global as any).__e2eFlashFrameCalls = []; + const refs = (global as any).__e2eTestRefs; + const mainWin = refs?.MainWindow?.get?.(); + if (!mainWin) { + throw new Error('Main window not available for flashFrame spy'); + } + const originalFlashFrame = mainWin.flashFrame.bind(mainWin); + (mainWin as any).__e2eOriginalFlashFrame = originalFlashFrame; + mainWin.flashFrame = (flash: boolean) => { + (global as any).__e2eFlashFrameCalls.push(flash); + originalFlashFrame(flash); + }; + }); +} + +export async function restoreFlashFrameSpy(app: ElectronApplication): Promise { + await evaluateInMainProcess(app, () => { + const refs = (global as any).__e2eTestRefs; + const mainWin = refs?.MainWindow?.get?.(); + if (mainWin && (mainWin as any).__e2eOriginalFlashFrame) { + mainWin.flashFrame = (mainWin as any).__e2eOriginalFlashFrame; + delete (mainWin as any).__e2eOriginalFlashFrame; + } + delete (global as any).__e2eFlashFrameCalls; + }); +} diff --git a/e2e/helpers/notificationClick.ts b/e2e/helpers/notificationClick.ts new file mode 100644 index 00000000000..f1bb0f5c646 --- /dev/null +++ b/e2e/helpers/notificationClick.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {evaluateInMainProcessWithArg} from './testRefs'; + +export type NotificationClickPayload = { + webContentsId: number; + channelId: string; + teamId: string; + url: string; +}; + +/** + * Invoke the production mention-click handler (NOTIFICATION_CLICKED + focus-on-nav). + * Does not create an OS notification — use for webapp↔desktop integration smoke tests. + */ +export async function simulateNotificationClick( + app: ElectronApplication, + payload: NotificationClickPayload, +): Promise { + await evaluateInMainProcessWithArg(app, (_electron, p) => { + const simulate = (global as any).__e2eSimulateNotificationClick as + ((value: typeof p) => void) | undefined; + if (!simulate) { + throw new Error('__e2eSimulateNotificationClick not exposed (NODE_ENV must be test)'); + } + simulate(p); + }, payload); +} diff --git a/e2e/helpers/notificationEffects.ts b/e2e/helpers/notificationEffects.ts index d1cbd7be99f..1d0665cdc74 100644 --- a/e2e/helpers/notificationEffects.ts +++ b/e2e/helpers/notificationEffects.ts @@ -4,7 +4,7 @@ import type {ElectronApplication} from 'playwright'; /** - * Invoke the production flashFrame() helper from src/main/notifications/index.ts. + * Invoke the E2E mirror of notifications/index.ts flashFrame(). * * OS notification delivery is unreliable in headless CI (Electron's Notification * often emits `failed` without `show`), so flash_taskbar and dock_bounce tests diff --git a/e2e/helpers/prepareServerView.ts b/e2e/helpers/prepareServerView.ts index 9d94d777692..442e0b7d964 100644 --- a/e2e/helpers/prepareServerView.ts +++ b/e2e/helpers/prepareServerView.ts @@ -4,6 +4,7 @@ import type {ElectronApplication} from 'playwright'; import {closeOverlayWindowsIfOpen} from './overlayWindows'; +import {evaluateInMainProcessWithArg} from './testRefs'; /** * Close overlay windows and focus a Mattermost server WebContentsView so @@ -14,7 +15,7 @@ export async function prepareMattermostServerView( webContentsId: number, ): Promise { await closeOverlayWindowsIfOpen(app); - await app.evaluate(({webContents}, id) => { + await evaluateInMainProcessWithArg(app, ({webContents}, id) => { const wc = webContents.fromId(id); if (!wc || wc.isDestroyed()) { throw new Error(`webContents ${id} is not available`); diff --git a/e2e/helpers/serverView.ts b/e2e/helpers/serverView.ts index 36c7aee93c5..7a7ad6b8daf 100644 --- a/e2e/helpers/serverView.ts +++ b/e2e/helpers/serverView.ts @@ -3,6 +3,8 @@ import type {ElectronApplication} from 'playwright'; +import {isTransientEvaluateError} from './testRefs'; + type WaitForSelectorOptions = { timeout?: number; state?: 'attached' | 'detached' | 'visible' | 'hidden'; @@ -145,10 +147,12 @@ function keyCodeFor(key: string): string { return key; } -function parseKeyPress(shortcut: string) { +type KeyboardModifier = 'meta' | 'control' | 'alt' | 'shift'; + +function parseKeyPress(shortcut: string): {key: string; keyCode: string; modifiers: KeyboardModifier[]} { const parts = shortcut.split('+'); const key = parts.pop() ?? shortcut; - const modifiers = parts.map((part) => { + const modifiers = parts.map((part): KeyboardModifier => { if (part === 'Meta' || part === 'Command' || part === 'Cmd') { return 'meta'; } @@ -161,7 +165,7 @@ function parseKeyPress(shortcut: string) { if (part === 'Shift') { return 'shift'; } - return part.toLowerCase(); + throw new Error(`Unsupported keyboard modifier: ${part}`); }); return { @@ -249,8 +253,8 @@ export class ServerLocator { ); } - async count() { - return this.view.runInRenderer( + async count(): Promise { + return this.view.runInRenderer( ` ${DOM_UTILS} const descriptor = ${JSON.stringify(this.descriptor)}; @@ -502,35 +506,48 @@ export class ServerView { await this.keyboard.press(shortcut); } - runInRenderer(body: string, userGesture = false) { - return this.app.evaluate(async ({webContents}, payload) => { - const wc = webContents.fromId(payload.id); - if (!wc || wc.isDestroyed()) { - throw new Error(`webContents ${payload.id} is not available`); - } - const result = await wc.executeJavaScript(` - (() => { - try { - return {__e2eResult: (() => {${payload.body}})()}; - } catch (error) { - return { - __e2eError: error instanceof Error ? error.message : String(error), - __e2eStack: error instanceof Error ? error.stack : '', - }; + async runInRenderer(body: string, userGesture = false): Promise { + const maxAttempts = 15; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + return await this.app.evaluate(async ({webContents}, payload) => { + const wc = webContents.fromId(payload.id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.id} is not available`); + } + const result = await wc.executeJavaScript(` + (() => { + try { + return {__e2eResult: (() => {${payload.body}})()}; + } catch (error) { + return { + __e2eError: error instanceof Error ? error.message : String(error), + __e2eStack: error instanceof Error ? error.stack : '', + }; + } + })() + `, payload.userGesture); + + if (result && typeof result === 'object' && '__e2eError' in result) { + throw new Error(`${result.__e2eError}${result.__e2eStack ? `\n${result.__e2eStack}` : ''}`); } - })() - `, payload.userGesture); - if (result && typeof result === 'object' && '__e2eError' in result) { - throw new Error(`${result.__e2eError}${result.__e2eStack ? `\n${result.__e2eStack}` : ''}`); + let value = result?.__e2eResult; + if (value && typeof (value as Promise).then === 'function') { + value = await value; + } + return value; + }, {id: this.webContentsId, body, userGesture}) as Promise; + } catch (error) { + if (!isTransientEvaluateError(error) || attempt === maxAttempts - 1) { + throw error; + } + await sleep(100); } + } - let value = result?.__e2eResult; - if (value && typeof (value as Promise).then === 'function') { - value = await value; - } - return value; - }, {id: this.webContentsId, body, userGesture}) as Promise; + throw new Error('Timed out waiting for server renderer evaluate'); } async type(selector: string, text: string) { diff --git a/e2e/helpers/settingsWindow.ts b/e2e/helpers/settingsWindow.ts index 74e3e33844e..625e2e839ad 100644 --- a/e2e/helpers/settingsWindow.ts +++ b/e2e/helpers/settingsWindow.ts @@ -3,7 +3,8 @@ import type {ElectronApplication, Page} from 'playwright'; -import {SHOW_SETTINGS_WINDOW} from '../../src/common/communication'; +import {SHOW_SETTINGS_WINDOW} from './ipcChannels'; +import {evaluateInMainProcessWithArg} from './testRefs'; export async function openSettingsWindow(electronApp: ElectronApplication): Promise { for (let attempt = 0; attempt < 5; attempt++) { @@ -21,16 +22,12 @@ export async function openSettingsWindow(electronApp: ElectronApplication): Prom } } - try { - await electronApp.evaluate(({ipcMain}, showWindow) => { - ipcMain.emit(showWindow); - }, SHOW_SETTINGS_WINDOW); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes('Execution context was destroyed') || attempt === 4) { - throw error; - } - } + // Route through evaluateInMainProcessWithArg to reuse its transient + // "Execution context was destroyed" retry behavior instead of + // duplicating the try/catch loop here. + await evaluateInMainProcessWithArg(electronApp, ({ipcMain}, showWindow) => { + ipcMain.emit(showWindow); + }, SHOW_SETTINGS_WINDOW); try { const settingsWindow = electronApp.windows().find((window) => window.url().includes('settings')) ?? diff --git a/e2e/helpers/testRefs.ts b/e2e/helpers/testRefs.ts index 0e576e44d7f..1c1eb530ea0 100644 --- a/e2e/helpers/testRefs.ts +++ b/e2e/helpers/testRefs.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {expect} from '@playwright/test'; -import type {ElectronApplication} from 'playwright'; +import type {ElectronApplication, Page} from 'playwright'; const TRANSIENT_EVALUATE_ERRORS = [ 'Execution context was destroyed', @@ -15,20 +15,38 @@ export function isTransientEvaluateError(error: unknown): boolean { return TRANSIENT_EVALUATE_ERRORS.some((part) => message.includes(part)); } +export function isTransientNavigationError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return isTransientEvaluateError(error) || + message.includes('Target closed') || + message.includes('Protocol error'); +} + +type EvaluateRetryOptions = { + timeoutMs?: number; + retryDelayMs?: number; + isRetryable?: (error: unknown) => boolean; +}; + +type MainProcessEvaluator = ( + _electron: typeof import('electron'), +) => T | Promise; + export async function evaluateInMainProcess( app: ElectronApplication, - pageFunction: () => T, - options: {timeoutMs?: number; retryDelayMs?: number} = {}, + pageFunction: MainProcessEvaluator, + options: EvaluateRetryOptions = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 15_000; const retryDelayMs = options.retryDelayMs ?? 100; + const isRetryable = options.isRetryable ?? isTransientEvaluateError; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { - return await app.evaluate(pageFunction); + return await (app.evaluate as (fn: MainProcessEvaluator) => Promise).call(app, pageFunction); } catch (error) { - if (!isTransientEvaluateError(error)) { + if (!isRetryable(error)) { throw error; } await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); @@ -47,10 +65,11 @@ export async function evaluateInMainProcessWithArg( app: ElectronApplication, pageFunction: MainProcessEvaluatorWithArg, arg: A, - options: {timeoutMs?: number; retryDelayMs?: number} = {}, + options: EvaluateRetryOptions = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 15_000; const retryDelayMs = options.retryDelayMs ?? 100; + const isRetryable = options.isRetryable ?? isTransientEvaluateError; const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -61,7 +80,7 @@ export async function evaluateInMainProcessWithArg( value: A, ) => Promise).call(app, pageFunction, arg); } catch (error) { - if (!isTransientEvaluateError(error)) { + if (!isRetryable(error)) { throw error; } await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); @@ -71,6 +90,101 @@ export async function evaluateInMainProcessWithArg( throw new Error('Timed out waiting for electron main-process evaluate'); } +function isMainIndexUrl(url: string): boolean { + return url.includes('mattermost-desktop://renderer/index'); +} + +export function findMainIndexWindow(app: ElectronApplication): Page | undefined { + return app.windows().find((window) => { + try { + return isMainIndexUrl(window.url()); + } catch { + return false; + } + }); +} + +async function findMainIndexWindowByBrowserId( + app: ElectronApplication, + browserWindowId: number, +): Promise { + for (const window of app.windows()) { + try { + const browserWin = await app.browserWindow(window); + const id = await browserWin.evaluate((win: {id: number}) => win.id); + if (id === browserWindowId) { + return window; + } + } catch { + // Window may still be attaching. + } + } + return undefined; +} + +async function ensureMainWindowVisible(app: ElectronApplication): Promise { + return evaluateInMainProcess(app, () => { + const refs = (global as any).__e2eTestRefs; + const win = refs?.MainWindow?.get?.(); + if (win && !win.isDestroyed()) { + if (!win.isVisible()) { + win.show(); + } + win.focus(); + return win.id; + } + return null; + }).catch(() => null); +} + +/** + * Resolve the main wrapper window (index.html). On macOS CI the BrowserWindow can + * exist before Playwright attaches it to app.windows(), especially when startup + * load fails fast — show/focus from main process and match by BrowserWindow id. + */ +export async function resolveMainIndexWindow( + app: ElectronApplication, + timeout = 30_000, +): Promise { + const deadline = Date.now() + timeout; + let mainWindow: Page | undefined; + + while (Date.now() < deadline) { + const mainWindowId = await ensureMainWindowVisible(app); + + mainWindow = findMainIndexWindow(app); + if (!mainWindow && mainWindowId != null) { + mainWindow = await findMainIndexWindowByBrowserId(app, mainWindowId); + } + + if (mainWindow) { + return mainWindow; + } + + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + + try { + return await app.waitForEvent('window', { + predicate: (window) => { + try { + return isMainIndexUrl(window.url()); + } catch { + return false; + } + }, + timeout: Math.min(2_000, remaining), + }); + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + + throw new Error('Main index window should be available'); +} + export async function getMainWindowId(app: ElectronApplication): Promise { let mainWindowId: number | null = null; await expect.poll(async () => { diff --git a/e2e/helpers/tray.ts b/e2e/helpers/tray.ts index 6fba6985e79..6df92c6550c 100644 --- a/e2e/helpers/tray.ts +++ b/e2e/helpers/tray.ts @@ -53,3 +53,14 @@ export async function isMainWindowVisible(app: ElectronApplication): Promise { + await evaluateInMainProcess(app, () => { + const refs = (global as any).__e2eTestRefs; + const mainWindow = refs?.MainWindow?.get?.(); + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { + refs?.MainWindow?.show?.(); + } + }).catch(() => {}); +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index addfa1bf7f3..5e7b449a5ee 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -115,9 +115,13 @@ export default defineConfig({ reporter: reporters, use: { - trace: 'retain-on-failure', + + // Video/trace land in test-results/ and bloat CI artifacts (Electron + // userdata + webm/zip per test). Failures are debugged via the merged + // HTML report on S3, which includes screenshots and traces from blob. + trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', screenshot: 'only-on-failure', - video: 'retain-on-failure', + video: process.env.CI ? 'off' : 'retain-on-failure', }, projects: buildPlatformProjects(), diff --git a/e2e/specs/calls/calls_functionality.test.ts b/e2e/specs/calls/calls_functionality.test.ts new file mode 100644 index 00000000000..22d96095dbf --- /dev/null +++ b/e2e/specs/calls/calls_functionality.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {test, expect} from '../../fixtures/index'; +import {findCallsWidgetWindow, waitForCallsWidgetWindow} from '../../helpers/callsWidget'; +import {demoMattermostConfig} from '../../helpers/config'; +import {CALLS_LEAVE_CALL} from '../../helpers/ipcChannels'; +import {loginToMattermost} from '../../helpers/login'; +import type {ServerView} from '../../helpers/serverView'; + +type CallStartOutcome = {kind: 'widget'} | {kind: 'post'}; + +async function pollCallStartOutcome( + electronApp: ElectronApplication, + serverWin: ServerView, + postIdBefore: string | null, +): Promise { + let outcome: CallStartOutcome | null = null; + + await expect.poll(async (): Promise => { + if (findCallsWidgetWindow(electronApp)) { + outcome = {kind: 'widget'}; + return true; + } + + const newPostMentionsCall = await serverWin.evaluate((idBefore: string | null) => { + const items = Array.from(document.querySelectorAll('[data-testid="postView"]')) as HTMLElement[]; + const last = items[items.length - 1]; + if (!last || last.id === idBefore) { + return false; + } + const text = last.querySelector('.post-message__text')?.textContent ?? ''; + return text.toLowerCase().includes('call'); + }, postIdBefore); + if (newPostMentionsCall) { + outcome = {kind: 'post'}; + return true; + } + return false; + }, { + timeout: 20_000, + message: '/call start produced neither a Calls widget window nor a new ephemeral response.', + }).toBe(true); + + return outcome!; +} + +test.describe('calls/calls_functionality', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + let serverWin: ServerView; + + test.beforeEach(async ({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, 'Mattermost server view should exist').toBeTruthy(); + serverWin = serverEntry!.win; + + await loginToMattermost(serverWin); + await serverWin.click('#sidebarItem_town-square'); + await serverWin.waitForSelector('#channelHeaderTitle', {timeout: 10_000}); + }); + + test('MM-T4841 Calls UI Functionality - Self-managed', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + await serverWin.waitForSelector('#post_textbox', {timeout: 10_000}); + await serverWin.fill('#post_textbox', '/call start'); + await serverWin.press('#post_textbox', 'Enter'); + + const widgetWindow = await waitForCallsWidgetWindow(electronApp); + if (!widgetWindow) { + test.skip(true, 'Calls plugin/widget not available on this test server'); + return; + } + + expect(widgetWindow.url(), 'Widget URL must point to Calls plugin').toContain( + '/plugins/com.mattermost.calls/standalone/widget.html', + ); + + await widgetWindow.waitForLoadState('domcontentloaded'); + const hasControls = await widgetWindow.evaluate(() => document.querySelectorAll('button').length > 0); + expect(hasControls, 'Calls widget must have interactive controls').toBe(true); + + const muteButton = await widgetWindow.waitForSelector( + 'button[aria-label*="Mute"], button[aria-label*="mute"]', + {timeout: 10_000}, + ); + expect(muteButton, 'Mute button must exist in Calls widget').toBeTruthy(); + + const initialPressed = await widgetWindow.evaluate(() => { + const btn = document.querySelector('button[aria-label*="Mute"], button[aria-label*="mute"]'); + return btn?.getAttribute('aria-pressed') ?? null; + }); + + await muteButton.click(); + + await expect.poll( + () => widgetWindow.evaluate(() => { + const btn = document.querySelector('button[aria-label*="Mute"], button[aria-label*="mute"]'); + return btn?.getAttribute('aria-pressed') ?? null; + }), + {timeout: 5_000, message: 'Mute button aria-pressed must change after click'}, + ).not.toBe(initialPressed); + + await closeCallsWidget(electronApp, widgetWindow); + }, + ); + + test('MM-T5587 Calls - Slash Commands', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + await serverWin.waitForSelector('#post_textbox', {timeout: 10_000}); + const postIdBefore = await serverWin.evaluate(() => { + const items = document.querySelectorAll('[data-testid="postView"]'); + const last = items[items.length - 1] as HTMLElement | undefined; + return last?.id ?? null; + }) as string | null; + + await serverWin.fill('#post_textbox', '/call start'); + await serverWin.press('#post_textbox', 'Enter'); + + let outcome: CallStartOutcome; + try { + outcome = await pollCallStartOutcome(electronApp, serverWin, postIdBefore); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('/call start produced neither')) { + test.skip(true, 'Calls plugin/widget not available on this test server'); + return; + } + throw error; + } + + if (outcome.kind === 'widget') { + const widgetWindow = findCallsWidgetWindow(electronApp); + expect(widgetWindow, '/call start must open Calls widget').toBeTruthy(); + expect(widgetWindow!.url(), '/call start must open Calls widget').toContain( + '/plugins/com.mattermost.calls/standalone/widget.html', + ); + await closeCallsWidget(electronApp, widgetWindow!); + } + }, + ); + + test('MM-T5411 Calls - Keyboard Shortcuts (self-managed)', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + await serverWin.waitForSelector('#post_textbox', {timeout: 10_000}); + await serverWin.fill('#post_textbox', '/call start'); + await serverWin.press('#post_textbox', 'Enter'); + + const widgetWindow = await waitForCallsWidgetWindow(electronApp, 30_000); + if (!widgetWindow) { + test.skip(true, 'Calls plugin/widget not available on this test server'); + return; + } + + await widgetWindow.waitForLoadState('domcontentloaded'); + await widgetWindow.waitForSelector('button[aria-label*="Mute"], button[aria-label*="mute"]', {timeout: 10_000}); + await widgetWindow.bringToFront(); + + const initialPressed = await widgetWindow.evaluate(() => { + const btn = document.querySelector('button[aria-label*="Mute"], button[aria-label*="mute"]'); + return btn?.getAttribute('aria-pressed') ?? null; + }); + + await widgetWindow.keyboard.press('m'); + + await expect.poll( + () => widgetWindow.evaluate(() => { + const btn = document.querySelector('button[aria-label*="Mute"], button[aria-label*="mute"]'); + return btn?.getAttribute('aria-pressed') ?? null; + }), + {timeout: 5_000, message: 'Mute button aria-pressed must change after pressing the "m" keyboard shortcut'}, + ).not.toBe(initialPressed); + + await closeCallsWidget(electronApp, widgetWindow); + }, + ); +}); + +async function closeCallsWidget( + electronApp: ElectronApplication, + widgetWindow: Page, +): Promise { + const leaveClicked = await widgetWindow.evaluate(() => { + const leaveBtn = document.querySelector( + 'button[aria-label*="Leave"], button[aria-label*="leave"], button[aria-label*="End"], button[aria-label*="end"]', + ) as HTMLButtonElement; + if (leaveBtn) { + leaveBtn.click(); + return true; + } + return false; + }); + + if (!leaveClicked) { + await electronApp.evaluate(({ipcMain}, channel) => { + ipcMain.emit(channel); + }, CALLS_LEAVE_CALL); + } + + await expect.poll( + () => findCallsWidgetWindow(electronApp), + {timeout: 10_000, message: 'Calls widget window must close after leave'}, + ).toBeNull(); +} diff --git a/e2e/specs/focus.test.ts b/e2e/specs/focus.test.ts index dea7bb17e0b..d45f3ea3c4d 100644 --- a/e2e/specs/focus.test.ts +++ b/e2e/specs/focus.test.ts @@ -8,13 +8,11 @@ import * as path from 'path'; import {test, expect} from '../fixtures/index'; import {waitForAppReady} from '../helpers/appReadiness'; import {electronBinaryPath, appDir, demoMattermostConfig, writeConfigFile} from '../helpers/config'; -import {waitForLockFileRelease} from '../helpers/cleanup'; +import {closeElectronAppFast} from '../helpers/electronApp'; +import {SHOW_NEW_SERVER_MODAL, SHOW_SETTINGS_WINDOW} from '../helpers/ipcChannels'; import {loginToMattermost} from '../helpers/login'; import {buildServerMap, type ServerMap} from '../helpers/serverMap'; -const SHOW_SETTINGS_WINDOW = 'show-settings-window'; -const SHOW_NEW_SERVER_MODAL = 'show_new_server_modal'; - const config = { ...demoMattermostConfig, servers: [ @@ -158,9 +156,10 @@ test.describe('focus', () => { }); test.afterAll(async () => { - await electronApp?.close().catch(() => {}); - if (userDataDir) { - await waitForLockFileRelease(userDataDir).catch(() => {}); + if (electronApp && userDataDir) { + await closeElectronAppFast(electronApp, userDataDir); + } else if (electronApp) { + await electronApp.close().catch(() => {}); } }); diff --git a/e2e/specs/focus/app_switch_focus.test.ts b/e2e/specs/focus/app_switch_focus.test.ts new file mode 100644 index 00000000000..1bd333c1b84 --- /dev/null +++ b/e2e/specs/focus/app_switch_focus.test.ts @@ -0,0 +1,86 @@ +// 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'; + +// ── MM-T1311: Switch applications: Text input is focused ────────────── +// When the user switches away from the Desktop App (Cmd+Tab on macOS, +// Alt+Tab on Windows) and returns, the text input in the server view +// must retain focus. This is a desktop-specific focus management concern. +// +// Related: focus.test.ts (MM-T1315, MM-T1316, MM-T1317) tests focus +// after closing modals and switching servers. This test covers the +// application-switch scenario. + +test.describe('focus/app_switch', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test('MM-T1311 Switch applications: Text input is focused within server view (webview)', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + // Login + readiness — must run inside the test body because the + // `serverMap` fixture is test-scoped and not available in beforeAll. + const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; + expect(firstServer, 'Server view must exist').toBeTruthy(); + await loginToMattermost(firstServer!); + await firstServer!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + + // Focus the post textbox + await firstServer!.waitForSelector('#post_textbox', {timeout: 10_000}); + await firstServer!.focus('#post_textbox'); + + const initiallyFocused = await firstServer!.evaluate(() => { + const textbox = document.querySelector('#post_textbox'); + return textbox === document.activeElement; + }); + expect(initiallyFocused, 'Post textbox must be focused initially').toBe(true); + + // Resolve the main window through the same registry the rest of + // the suite uses, so we don't blindly hide/show the wrong window + // once a second BrowserWindow exists (e.g. Calls widget, popout). + const mainWindowId = await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const win = refs?.MainWindow?.get?.(); + return win?.id ?? null; + }); + expect(mainWindowId, 'MainWindow must be resolvable via __e2eTestRefs').not.toBeNull(); + + // Simulate switching away + await electronApp.evaluate(({BrowserWindow}, id: number) => { + BrowserWindow.fromId(id)?.hide(); + }, mainWindowId as number); + + // Simulate switching back + await electronApp.evaluate(({BrowserWindow}, id: number) => { + const win = BrowserWindow.fromId(id); + if (win) { + win.show(); + win.focus(); + } + }, mainWindowId as number); + + await expect.poll( + () => electronApp.evaluate(({BrowserWindow}, id: number) => + Boolean(BrowserWindow.fromId(id)?.isVisible()), + mainWindowId as number), + {timeout: 10_000, message: 'Main window must be visible after switching back'}, + ).toBe(true); + + await expect.poll( + () => firstServer!.evaluate(() => { + const textbox = document.querySelector('#post_textbox'); + return textbox === document.activeElement; + }), + {timeout: 10_000, message: 'Post textbox must retain focus after app switch'}, + ).toBe(true); + }, + ); +}); diff --git a/e2e/specs/linux_dark_mode.test.ts b/e2e/specs/linux_dark_mode.test.ts index c773ed7a358..6d890ffe867 100644 --- a/e2e/specs/linux_dark_mode.test.ts +++ b/e2e/specs/linux_dark_mode.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {test, expect} from '../fixtures/index'; +import {EMIT_CONFIGURATION} from '../helpers/ipcChannels'; async function toggleDarkModeLinux(electronApp: import('playwright').ElectronApplication) { await electronApp.evaluate(({Menu}) => { @@ -17,15 +18,15 @@ async function toggleDarkModeLinux(electronApp: import('playwright').ElectronApp } async function setDarkModeConfig(electronApp: import('playwright').ElectronApplication, enabled: boolean) { - await electronApp.evaluate(({ipcMain}, darkMode: boolean) => { + await electronApp.evaluate(({ipcMain}, {darkMode, channel}) => { const refs = (global as any).__e2eTestRefs; const Config = refs?.Config; if (!Config) { throw new Error('__e2eTestRefs.Config is unavailable'); } Config.set('darkMode', darkMode); - ipcMain.emit('emit-configuration', null, Config.data); - }, enabled); + ipcMain.emit(channel, null, Config.data); + }, {darkMode: enabled, channel: EMIT_CONFIGURATION}); } test.describe('dark_mode', () => { diff --git a/e2e/specs/menu_bar/clear_all_data.test.ts b/e2e/specs/menu_bar/clear_all_data.test.ts new file mode 100644 index 00000000000..f6280b03f4f --- /dev/null +++ b/e2e/specs/menu_bar/clear_all_data.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {restoreMessageBox, stubMessageBoxResponses} from '../../helpers/dialog'; +import {clickApplicationMenuItem} from '../../helpers/menu'; + +test( + 'clear all data menu item can be cancelled without restarting the app', + {tag: ['@P1', '@all']}, + async ({electronApp, mainWindow}) => { + expect(mainWindow).toBeDefined(); + + const serverButtonText = await mainWindow!.innerText('.ServerDropdownButton'); + + await stubMessageBoxResponses(electronApp, [{response: 1}]); + try { + await clickApplicationMenuItem(electronApp, 'view', {labelIncludes: 'Clear All Data'}); + await expect.poll( + () => mainWindow!.innerText('.ServerDropdownButton'), + {timeout: 10_000, message: 'Canceling Clear All Data should leave the active server unchanged'}, + ).toBe(serverButtonText); + } finally { + await restoreMessageBox(electronApp); + } + }, +); diff --git a/e2e/specs/menu_bar/devtools_current_server.test.ts b/e2e/specs/menu_bar/devtools_current_server.test.ts new file mode 100644 index 00000000000..fbbc219d931 --- /dev/null +++ b/e2e/specs/menu_bar/devtools_current_server.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// ── MM-T821: Toggle Developer Tools for Current Server ──────────────── +// Tests opening DevTools for the active server's WebContentsView (the +// embedded view that renders the Mattermost webapp). +// +// Sibling test: specs/menu_bar/view_menu.test.ts :: MM-T820 tests +// DevTools for the Application Wrapper (the main BrowserWindow). +// These are distinct: MM-T820 targets the chrome window, MM-T821 +// targets the server content view. + +import {test, expect} from '../../fixtures/index'; +import {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import {clickApplicationMenuItem} from '../../helpers/menu'; +import {closeOverlayWindowsIfOpen} from '../../helpers/overlayWindows'; +import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {evaluateInMainProcessWithArg, getActiveServerWebContentsId} from '../../helpers/testRefs'; + +test.describe('menu_bar/devtools_current_server', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test('MM-T821 Toggle Developer Tools for Current Server in the Menu Bar', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + } + + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + const firstServer = serverEntry?.win; + expect(firstServer, 'Mattermost server view should exist').toBeTruthy(); + + await closeOverlayWindowsIfOpen(electronApp); + await prepareMattermostServerView(electronApp, serverEntry!.webContentsId); + await loginToMattermost(firstServer!); + await firstServer!.waitForSelector('#sidebarItem_town-square', {timeout: 30_000}); + + const webContentsId = serverEntry!.webContentsId ?? await getActiveServerWebContentsId(electronApp); + + const webContentsExists = await evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { + const wc = webContents.fromId(id); + return wc !== undefined && !wc.isDestroyed(); + }, webContentsId); + expect(webContentsExists, 'Server webContents should exist').toBe(true); + + await clickApplicationMenuItem( + electronApp, + 'view', + {label: 'Developer Tools for Current Tab'}, + {webContentsId}, + ); + await expect.poll( + () => evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { + const wc = webContents.fromId(id); + return Boolean(wc && !wc.isDestroyed() && wc.isDevToolsOpened()); + }, webContentsId), + {timeout: 15_000, message: 'DevTools must open for the current server webContents after menu click'}, + ).toBe(true); + + // MattermostWebContentsView.openDevTools() runs a 500ms macOS reset and documents + // that isDevToolsOpened() may not reflect close — use closeDevTools() and assert + // the server view is usable instead of polling isDevToolsOpened() on darwin. + if (process.platform === 'darwin') { + await new Promise((resolve) => setTimeout(resolve, 750)); + await evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { + const wc = webContents.fromId(id); + if (wc && !wc.isDestroyed() && wc.isDevToolsOpened()) { + wc.closeDevTools(); + } + }, webContentsId); + } else { + await evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { + try { + const wc = webContents.fromId(id); + if (wc && !wc.isDestroyed() && wc.isDevToolsOpened()) { + wc.toggleDevTools(); + } + } catch { + // DevTools may already be detaching. + } + }, webContentsId).catch(() => {}); + await expect.poll( + () => evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { + const wc = webContents.fromId(id); + return wc && !wc.isDestroyed() ? !wc.isDevToolsOpened() : true; + }, webContentsId).catch(() => true), + {timeout: 15_000, message: 'DevTools must close after toggle'}, + ).toBe(true); + } + + // DevTools attach/detach can briefly invalidate Playwright's Electron context on macOS. + await prepareMattermostServerView(electronApp, webContentsId); + await firstServer!.waitForSelector('#post_textbox', {timeout: 15_000}); + }, + ); +}); diff --git a/e2e/specs/menu_bar/diagnostics.test.ts b/e2e/specs/menu_bar/diagnostics.test.ts new file mode 100644 index 00000000000..d531546d617 --- /dev/null +++ b/e2e/specs/menu_bar/diagnostics.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 {clickApplicationMenuItem} from '../../helpers/menu'; + +test( + 'DIAG-01 Run diagnostics completes from the Help menu', + {tag: ['@P1', '@all']}, + async ({electronApp}) => { + await clickApplicationMenuItem(electronApp, 'help', {id: 'diagnostics'}); + + await expect.poll(async () => { + return electronApp.evaluate(() => { + const diagnostics = (global as any).__e2eTestRefs?.Diagnostics; + return diagnostics?.isRunning?.() ?? false; + }); + }, { + timeout: 30_000, + message: 'Diagnostics.run should start after choosing Help → Run diagnostics', + }).toBe(true); + + await expect.poll(async () => { + return electronApp.evaluate(() => { + const diagnostics = (global as any).__e2eTestRefs?.Diagnostics; + return diagnostics?.isRunning?.() ?? true; + }); + }, { + timeout: 60_000, + message: 'Diagnostics.run should finish without staying in the running state', + }).toBe(false); + }, +); diff --git a/e2e/specs/menu_bar/edit_menu.test.ts b/e2e/specs/menu_bar/edit_menu.test.ts index 976a46aa78f..d5becd36ada 100644 --- a/e2e/specs/menu_bar/edit_menu.test.ts +++ b/e2e/specs/menu_bar/edit_menu.test.ts @@ -6,9 +6,9 @@ import * as os from 'os'; import * as path from 'path'; import {test, expect} from '../../fixtures/index'; -import {waitForAppReady} from '../../helpers/appReadiness'; -import {appDir, demoMattermostConfig, electronBinaryPath, writeConfigFile} from '../../helpers/config'; -import {waitForWindow, closeElectronApp} from '../../helpers/electronApp'; +import {demoMattermostConfig} from '../../helpers/config'; +import {launchDirectTestApp} from '../../helpers/directLaunch'; +import {waitForWindow, closeElectronAppFast} from '../../helpers/electronApp'; import {loginToMattermost} from '../../helpers/login'; import {buildServerMap} from '../../helpers/serverMap'; import type {ServerView} from '../../helpers/serverView'; @@ -16,7 +16,7 @@ import type {ServerView} from '../../helpers/serverView'; type ElectronApplication = Awaited>; type ElectronPage = import('playwright').Page; -let electronApp: ElectronApplication; +let electronApp: ElectronApplication | undefined; let mainWindow: ElectronPage; let firstServer: ServerView; let firstServerId: number; @@ -86,39 +86,8 @@ test.describe('edit_menu', () => { test.beforeAll(async () => { userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mm-edit-menu-e2e-')); - writeConfigFile(userDataDir, demoMattermostConfig); - - const {_electron: electron} = await import('playwright'); - electronApp = await electron.launch({ - executablePath: electronBinaryPath, - args: [ - appDir, - `--user-data-dir=${userDataDir}`, - '--no-sandbox', - '--disable-gpu', - '--disable-gpu-sandbox', - '--disable-dev-shm-usage', - '--no-zygote', - '--disable-software-rasterizer', - '--disable-breakpad', - '--disable-features=SpareRendererForSitePerProcess', - '--disable-features=CrossOriginOpenerPolicy', - '--disable-renderer-backgrounding', - '--force-color-profile=srgb', - '--mute-audio', - ], - env: { - ...process.env, - NODE_ENV: 'test', - RESOURCES_PATH: appDir, - ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', - ELECTRON_NO_ATTACH_CONSOLE: 'true', - NODE_OPTIONS: '--no-warnings', - }, - timeout: 90_000, - }); - - await waitForAppReady(electronApp); + electronApp = await launchDirectTestApp(userDataDir, demoMattermostConfig); + mainWindow = await waitForWindow(electronApp, 'index'); const serverMap = await buildServerMap(electronApp); firstServer = serverMap[demoMattermostConfig.servers[0].name][0].win; @@ -134,7 +103,11 @@ test.describe('edit_menu', () => { }); test.afterAll(async () => { - await closeElectronApp(electronApp, userDataDir); + if (electronApp && userDataDir) { + await closeElectronAppFast(electronApp, userDataDir); + } else if (electronApp) { + await electronApp.close().catch(() => {}); + } }); test('MM-T807 Undo in the post textbox', {tag: ['@P2', '@all']}, async () => { diff --git a/e2e/specs/menu_bar/file_menu.test.ts b/e2e/specs/menu_bar/file_menu.test.ts index 523318baeeb..fa16f8ff822 100644 --- a/e2e/specs/menu_bar/file_menu.test.ts +++ b/e2e/specs/menu_bar/file_menu.test.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {test, expect} from '../../fixtures/index'; +import {clickApplicationMenuItem} from '../../helpers/menu'; async function openPreferencesFromAppMenu(electronApp: Awaited>) { await electronApp.evaluate(async ({app}) => { @@ -63,24 +64,10 @@ test.describe('file_menu/dropdown', () => { expect(settingsWindow).toBeDefined(); }); - test('MM-T805 Sign in to Another Server Window opens using menu item', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows-only test'); - return; - } - - // Invoke the File menu item directly — keyboard presses sent via Playwright - // do not reliably reach popup menus in headless CI on Windows. - await electronApp.evaluate(({app}) => { - const fileMenu = (app as any).applicationMenu?.getMenuItemById('file'); - const signInItem = fileMenu?.submenu?.items?.find( - (item: any) => typeof item.label === 'string' && item.label.includes('Sign in'), - ); - if (!signInItem) { - throw new Error('Sign in to Another Server menu item not found'); - } - signInItem.click(); - }); + // appReady ensures the application menu is built before clicking File items. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test('MM-T805 Sign in to Another Server Window opens using menu item', {tag: ['@P2', '@win32']}, async ({electronApp, appReady: _appReady}) => { + await clickApplicationMenuItem(electronApp, 'file', {labelIncludes: 'Sign in'}); const signInToAnotherServerWindow = await electronApp.waitForEvent('window', { predicate: (window) => window.url().includes('newServer'), timeout: 15_000, @@ -89,11 +76,6 @@ test.describe('file_menu/dropdown', () => { }); test('MM-T804 Preferences in Menu Bar open the Settings page', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows-only test'); - return; - } - // Reuse the existing direct-invocation helper instead of keyboard navigation. await openPreferencesFromAppMenu(electronApp); const settingsWindow = await waitForSettingsWindow(electronApp); @@ -101,11 +83,6 @@ test.describe('file_menu/dropdown', () => { }); test('MM-T806 Exit in the Menu Bar', {tag: ['@P2', '@darwin']}, async ({electronApp, mainWindow}) => { - if (process.platform !== 'darwin') { - test.skip(true, 'macOS-only test'); - return; - } - expect(mainWindow).toBeDefined(); await mainWindow.waitForLoadState(); await mainWindow.bringToFront(); diff --git a/e2e/specs/menu_bar/full_screen.test.ts b/e2e/specs/menu_bar/full_screen.test.ts index aff14c7af0a..fb66b69aa6f 100644 --- a/e2e/specs/menu_bar/full_screen.test.ts +++ b/e2e/specs/menu_bar/full_screen.test.ts @@ -10,10 +10,6 @@ test.describe('menu/view', () => { test.use({appConfig: demoMattermostConfig}); test('MM-T816 Toggle Full Screen in the Menu Bar', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); return; diff --git a/e2e/specs/menu_bar/help_menu.test.ts b/e2e/specs/menu_bar/help_menu.test.ts new file mode 100644 index 00000000000..83283ad7e5b --- /dev/null +++ b/e2e/specs/menu_bar/help_menu.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {clickApplicationMenuItem} from '../../helpers/menu'; + +test.describe('menu_bar/help_menu', () => { + test( + 'HELP-01 Check for Updates menu item invokes the update manager', + {tag: ['@P1', '@all']}, + async ({electronApp}) => { + const canUpgrade = await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + return Boolean(refs?.Config?.canUpgrade); + }); + + if (!canUpgrade) { + test.skip(true, 'Config.canUpgrade is false in this build'); + return; + } + + await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const updateNotifier = refs?.updateNotifier; + if (!updateNotifier) { + throw new Error('updateNotifier is not exposed in __e2eTestRefs'); + } + updateNotifier.__e2eCheckForUpdatesCalls = 0; + updateNotifier.__e2eOriginalCheckForUpdates = updateNotifier.checkForUpdates; + updateNotifier.checkForUpdates = () => { + updateNotifier.__e2eCheckForUpdatesCalls += 1; + }; + }); + + try { + await clickApplicationMenuItem(electronApp, 'help', {labelIncludes: 'Check for Updates'}); + + await expect.poll(async () => { + return electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + return refs?.updateNotifier?.__e2eCheckForUpdatesCalls ?? 0; + }); + }, {timeout: 10_000}).toBeGreaterThan(0); + } finally { + await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + if (refs?.updateNotifier?.__e2eOriginalCheckForUpdates) { + refs.updateNotifier.checkForUpdates = refs.updateNotifier.__e2eOriginalCheckForUpdates; + delete refs.updateNotifier.__e2eOriginalCheckForUpdates; + } + }); + } + }, + ); + + test( + 'HELP-02 Show logs menu item opens the log file location', + {tag: ['@P1', '@all']}, + async ({electronApp}) => { + await electronApp.evaluate(({shell}) => { + (global as any).__e2eShownInFolder = [] as string[]; + (global as any).__e2eOriginalShowItemInFolder = shell.showItemInFolder.bind(shell); + shell.showItemInFolder = (fullPath: string) => { + (global as any).__e2eShownInFolder.push(fullPath); + return (global as any).__e2eOriginalShowItemInFolder(fullPath); + }; + }); + + try { + await clickApplicationMenuItem(electronApp, 'help', {id: 'Show logs'}); + + await expect.poll(async () => { + return electronApp.evaluate(() => ((global as any).__e2eShownInFolder as string[] | undefined)?.length ?? 0); + }, {timeout: 10_000}).toBeGreaterThan(0); + } finally { + await electronApp.evaluate(({shell}) => { + const original = (global as any).__e2eOriginalShowItemInFolder; + if (original) { + shell.showItemInFolder = original; + delete (global as any).__e2eOriginalShowItemInFolder; + } + delete (global as any).__e2eShownInFolder; + }); + } + }, + ); +}); diff --git a/e2e/specs/menu_bar/menu.test.ts b/e2e/specs/menu_bar/menu.test.ts index 8378ebd1279..8835872e848 100644 --- a/e2e/specs/menu_bar/menu.test.ts +++ b/e2e/specs/menu_bar/menu.test.ts @@ -5,11 +5,6 @@ import {test, expect} from '../../fixtures/index'; test.describe('menu/menu', () => { test('MM-T4404 should open the 3 dot menu with Alt', {tag: ['@P2', '@win32']}, async ({electronApp, mainWindow}) => { - if (process.platform === 'darwin') { - test.skip(true, 'No keyboard shortcut for macOS'); - return; - } - expect(mainWindow).toBeDefined(); await mainWindow.waitForSelector('button.three-dot-menu'); diff --git a/e2e/specs/menu_bar/view_menu.test.ts b/e2e/specs/menu_bar/view_menu.test.ts index 3f183ca5e15..c96058d571e 100644 --- a/e2e/specs/menu_bar/view_menu.test.ts +++ b/e2e/specs/menu_bar/view_menu.test.ts @@ -6,9 +6,9 @@ import * as os from 'os'; import * as path from 'path'; import {test, expect} from '../../fixtures/index'; -import {waitForAppReady} from '../../helpers/appReadiness'; -import {appDir, demoMattermostConfig, electronBinaryPath, writeConfigFile} from '../../helpers/config'; -import {waitForWindow, closeElectronApp} from '../../helpers/electronApp'; +import {demoMattermostConfig} from '../../helpers/config'; +import {launchDirectTestApp} from '../../helpers/directLaunch'; +import {waitForWindow, closeElectronAppFast} from '../../helpers/electronApp'; import {loginToMattermost} from '../../helpers/login'; import {clickApplicationMenuItem} from '../../helpers/menu'; import {buildServerMap} from '../../helpers/serverMap'; @@ -16,7 +16,7 @@ import {buildServerMap} from '../../helpers/serverMap'; type ElectronApplication = Awaited>; type ElectronPage = import('playwright').Page; -let electronApp: ElectronApplication; +let electronApp: ElectronApplication | undefined; let mainWindow: ElectronPage; let userDataDir: string; @@ -117,39 +117,8 @@ test.describe('menu/view', () => { test.beforeAll(async () => { userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mm-view-menu-e2e-')); - writeConfigFile(userDataDir, demoMattermostConfig); - - const {_electron: electron} = await import('playwright'); - electronApp = await electron.launch({ - executablePath: electronBinaryPath, - args: [ - appDir, - `--user-data-dir=${userDataDir}`, - '--no-sandbox', - '--disable-gpu', - '--disable-gpu-sandbox', - '--disable-dev-shm-usage', - '--no-zygote', - '--disable-software-rasterizer', - '--disable-breakpad', - '--disable-features=SpareRendererForSitePerProcess', - '--disable-features=CrossOriginOpenerPolicy', - '--disable-renderer-backgrounding', - '--force-color-profile=srgb', - '--mute-audio', - ], - env: { - ...process.env, - NODE_ENV: 'test', - RESOURCES_PATH: appDir, - ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', - ELECTRON_NO_ATTACH_CONSOLE: 'true', - NODE_OPTIONS: '--no-warnings', - }, - timeout: 90_000, - }); + electronApp = await launchDirectTestApp(userDataDir, demoMattermostConfig); - await waitForAppReady(electronApp); mainWindow = await waitForWindow(electronApp, 'index'); const serverMap = await buildServerMap(electronApp); const firstServer = serverMap[demoMattermostConfig.servers[0].name][0].win; @@ -163,7 +132,11 @@ test.describe('menu/view', () => { }); test.afterAll(async () => { - await closeElectronApp(electronApp, userDataDir); + if (electronApp && userDataDir) { + await closeElectronAppFast(electronApp, userDataDir); + } else if (electronApp) { + await electronApp.close().catch(() => {}); + } }); test('MM-T813 Control+F should focus the search bar in Mattermost', {tag: ['@P2', '@all']}, async () => { @@ -264,11 +237,6 @@ test.describe('menu/view', () => { }); test('MM-T820 should open Developer Tools For Application Wrapper for main window', {tag: ['@P2', '@darwin', '@win32']}, async () => { - if (process.platform === 'linux') { - test.skip(true, 'Linux not supported'); - return; - } - const browserWindow = await electronApp.browserWindow(mainWindow); let isDevToolsOpen = await browserWindow.evaluate((window) => { diff --git a/e2e/specs/menu_bar/window_menu.test.ts b/e2e/specs/menu_bar/window_menu.test.ts index 40022653ebb..a23e21a454d 100644 --- a/e2e/specs/menu_bar/window_menu.test.ts +++ b/e2e/specs/menu_bar/window_menu.test.ts @@ -7,12 +7,13 @@ import * as path from 'path'; import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; -import {waitForLockFileRelease} from '../../helpers/cleanup'; -import {buildServerMap} from '../../helpers/serverMap'; import {appDir, demoMattermostConfig, electronBinaryPath, writeConfigFile} from '../../helpers/config'; +import {closeDownloadsDropdownIfOpen} from '../../helpers/downloadsDropdown'; +import {closeElectronAppFast, registerElectronMainProcess, waitForWindow} from '../../helpers/electronApp'; import {loginToMattermost} from '../../helpers/login'; import {waitForMattermostShellReady} from '../../helpers/mattermostShell'; import {prepareMattermostServerView} from '../../helpers/prepareServerView'; +import {buildServerMap} from '../../helpers/serverMap'; import type {ServerView} from '../../helpers/serverView'; const windowMenuConfig = { @@ -38,56 +39,6 @@ let mainWindow: ElectronPage; let serverMap: Awaited>; let userDataDir: string; -async function waitForWindow(app: ElectronApplication, pattern: string, timeout = 30_000) { - const timeoutAt = Date.now() + timeout; - while (Date.now() < timeoutAt) { - const win = app.windows().find((window) => { - try { - return window.url().includes(pattern); - } catch { - return false; - } - }); - - if (win) { - await win.waitForLoadState().catch(() => {}); - return win; - } - - await new Promise((resolve) => setTimeout(resolve, 200)); - } - - throw new Error(`Timed out waiting for window matching "${pattern}"`); -} - -async function closeElectronApp(app: ElectronApplication, dataDir: string) { - let pid: number | undefined; - try { - pid = app.process()?.pid; - } catch { - pid = undefined; - } - - let cleanClosed = false; - await Promise.race([ - app.close().catch(() => {}).then(() => { - cleanClosed = true; - }), - new Promise((resolve) => setTimeout(resolve, 10_000)), - ]); - - if (!cleanClosed && pid) { - try { - process.kill(pid, 'SIGTERM'); - } catch { - // already exited - } - return; - } - - await waitForLockFileRelease(dataDir).catch(() => {}); -} - async function clickWindowMenuItem( app: ElectronApplication, matcher: {label?: string; labelIncludes?: string; accelerator?: string; role?: string}, @@ -106,7 +57,7 @@ async function clickWindowMenuItem( replace(/\s+/g, ''); }; - const windowMenu = app.applicationMenu.getMenuItemById('window'); + const windowMenu = app.applicationMenu?.getMenuItemById('window'); const items = windowMenu?.submenu?.items ?? []; const item = items.find((candidate: any) => { if (expected.role && candidate.role !== expected.role) { @@ -245,6 +196,7 @@ async function focusMainWindow() { } async function resetWindowMenuState() { + await closeDownloadsDropdownIfOpen(electronApp); await focusMainWindow(); const resetResult = await evaluateWithRetry(electronApp, () => { const refs = (global as any).__e2eTestRefs; @@ -280,14 +232,14 @@ async function createExtraTabs() { await mainWindow.click('#newTabButton'); await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(3)', {timeout: 15_000}); - // Wait until WebContentsManager has registered all 3 views const serverName = windowMenuConfig.servers[0].name; let map = await buildServerMap(electronApp); - const deadline = Date.now() + 15_000; + const deadline = Date.now() + 30_000; while ((map[serverName]?.length ?? 0) < 3 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 200)); map = await buildServerMap(electronApp); } + expect(map[serverName]?.length, 'Three Mattermost tabs should be registered').toBeGreaterThanOrEqual(3); return map; } @@ -296,30 +248,46 @@ async function prepareTabView(app: ElectronApplication, view: ServerView) { await loginToMattermost(view); } -async function navigateToSecondAndThirdTabs(serverName: string) { - let localServerMap = await buildServerMap(electronApp); +async function switchToTabAndOpenChannel( + serverName: string, + tabIndex: number, + channelItem: string, + initialServerMap?: Awaited>, +) { + let localServerMap = initialServerMap ?? await buildServerMap(electronApp); await expect.poll(async () => { localServerMap = await buildServerMap(electronApp); return localServerMap[serverName]?.length ?? 0; - }, {timeout: 30_000}).toBeGreaterThanOrEqual(3); - - const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000}); - await secondTab.click(); - const secondView = localServerMap[serverName][1].win; - await prepareTabView(electronApp, secondView); - 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: 15_000}); - await thirdTab.click(); - const thirdView = localServerMap[serverName][2].win; - await prepareTabView(electronApp, thirdView); - await waitForMattermostShellReady(thirdView, {channelItem: '#sidebarItem_town-square'}); - await thirdView.click('#sidebarItem_town-square'); + }, {timeout: 30_000}).toBeGreaterThanOrEqual(tabIndex); + + const tab = await mainWindow.waitForSelector( + `.TabBar li.serverTabItem:nth-child(${tabIndex})`, + {timeout: 15_000}, + ); + await tab.click(); + const view = localServerMap[serverName][tabIndex - 1].win; + await prepareTabView(electronApp, view); + await waitForMattermostShellReady(view, {channelItem}); + await view.click(channelItem); return localServerMap; } +async function navigateToSecondAndThirdTabs(serverName: string) { + let localServerMap = await switchToTabAndOpenChannel( + serverName, + 2, + '#sidebarItem_off-topic', + ); + localServerMap = await switchToTabAndOpenChannel( + serverName, + 3, + '#sidebarItem_town-square', + localServerMap, + ); + return localServerMap; +} + test.describe('Menu/window_menu', () => { test.beforeAll(async () => { if (!process.env.MM_TEST_SERVER_URL) { @@ -360,6 +328,8 @@ test.describe('Menu/window_menu', () => { timeout: 90_000, }); + registerElectronMainProcess(electronApp.process()?.pid); + await waitForAppReady(electronApp); mainWindow = await waitForWindow(electronApp, 'index'); serverMap = await buildServerMap(electronApp); @@ -373,7 +343,10 @@ test.describe('Menu/window_menu', () => { }); test.afterAll(async () => { - await closeElectronApp(electronApp, userDataDir); + if (!electronApp) { + return; + } + await closeElectronAppFast(electronApp, userDataDir); }); test.describe('MM-T826 should switch to servers when keyboard shortcuts are pressed', () => { @@ -434,18 +407,7 @@ test.describe('Menu/window_menu', () => { await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000}); const serverName = windowMenuConfig.servers[0].name; - let localServerMap = await buildServerMap(electronApp); - await expect.poll(async () => { - localServerMap = await buildServerMap(electronApp); - return localServerMap[serverName]?.length ?? 0; - }, {timeout: 30_000}).toBeGreaterThanOrEqual(2); - - const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)'); - await secondTab.click(); - const secondView = localServerMap[serverName][1].win; - await prepareTabView(electronApp, secondView); - await waitForMattermostShellReady(secondView, {channelItem: '#sidebarItem_off-topic'}); - await secondView.click('#sidebarItem_off-topic'); + await switchToTabAndOpenChannel(serverName, 2, '#sidebarItem_off-topic'); await expect.poll(() => getActiveTabTitle(electronApp), {timeout: 15_000}).toContain('Off-Topic'); @@ -457,10 +419,6 @@ test.describe('Menu/window_menu', () => { }); test('MM-T824 should be minimized when keyboard shortcuts are pressed', {tag: ['@P2', '@darwin', '@win32']}, async () => { - if (process.platform === 'linux') { - test.skip(true, 'Linux not supported'); - return; - } const browserWindow = await electronApp.browserWindow(mainWindow); // Both macOS and Windows: invoke minimize() directly on the BrowserWindow. @@ -481,10 +439,6 @@ test.describe('Menu/window_menu', () => { // Ctrl+Shift+W closes the window, and closing the main window with // minimizeToTray=false shows a quit confirmation dialog rather than hiding it. // So this behavior is only meaningful (and only passes) on macOS. - if (process.platform !== 'darwin') { - test.skip(true, 'App hide is macOS-only'); - return; - } const browserWindow = await electronApp.browserWindow(mainWindow); // macOS: app.hide() hides all windows without closing (Cmd+H behavior) diff --git a/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts new file mode 100644 index 00000000000..2d59748fa59 --- /dev/null +++ b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {test, expect} from '../../fixtures/index'; +import {readBadgeCount} from '../../helpers/badge'; +import {demoMattermostConfig} from '../../helpers/config'; +import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; +import {loginToMattermost} from '../../helpers/login'; + +import {triggerTestNotification, verifyNotificationReceivedInDM} from './helpers'; + +test.describe('notification_trigger/desktop_notification_delivery', () => { + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + test('MM-T1661 Desktop notifications', + {tag: ['@P2', '@all']}, + async ({electronApp, serverMap}) => { + if (!process.env.MM_TEST_SERVER_URL) { + test.skip(true, 'MM_TEST_SERVER_URL required'); + return; + } + + const releaseLock = await acquireExclusiveLock('notification-state'); + try { + const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; + expect(firstServer, 'Server view must exist').toBeTruthy(); + + await loginToMattermost(firstServer!); + + try { + await firstServer!.waitForSelector('div#CustomizeYourExperienceTour > button', {timeout: 15_000}); + } catch { + test.skip(true, 'CustomizeYourExperienceTour not available in this server version'); + return; + } + + const unityRunning = process.platform === 'linux' ? + await electronApp.evaluate(({app}) => app.isUnityRunning()) : + true; + + const beforeBadge = unityRunning ? await readBadgeCount(electronApp) : 0; + + await triggerTestNotification(firstServer!); + + if (unityRunning && process.platform !== 'win32') { + await expect.poll( + () => readBadgeCount(electronApp), + {timeout: 10_000, message: 'Badge count must increment after notification'}, + ).toBeGreaterThan(beforeBadge); + } + + await verifyNotificationReceivedInDM(firstServer!); + } finally { + await releaseLock(); + } + }, + ); +}); diff --git a/e2e/specs/notification_trigger/dock_bounce.test.ts b/e2e/specs/notification_trigger/dock_bounce.test.ts new file mode 100644 index 00000000000..4b79201b80e --- /dev/null +++ b/e2e/specs/notification_trigger/dock_bounce.test.ts @@ -0,0 +1,120 @@ +// 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 {waitForAppReady} from '../../helpers/appReadiness'; +import {demoConfig} from '../../helpers/config'; +import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; +import {installDockBounceSpy, restoreDockBounceSpy} from '../../helpers/methodSpy'; +import {triggerNotificationEffects} from '../../helpers/notificationEffects'; + +type BounceConfigArgs = {bounceIcon: boolean; bounceIconType: 'informational' | 'critical' | null}; + +async function setBounceConfig( + electronApp: ElectronApplication, + bounceIcon: boolean, + bounceIconType?: 'informational' | 'critical', +): Promise { + const args: BounceConfigArgs = {bounceIcon, bounceIconType: bounceIconType ?? null}; + await electronApp.evaluate((_, payload: BounceConfigArgs) => { + const refs = (global as any).__e2eTestRefs; + const Config = refs?.Config; + if (!Config) { + return; + } + const notifications = {...Config.notifications, bounceIcon: payload.bounceIcon}; + if (payload.bounceIconType) { + notifications.bounceIconType = payload.bounceIconType; + } + Config.set('notifications', notifications); + }, args); +} + +test.describe('notification_trigger/dock_bounce', () => { + test.use({appConfig: demoConfig}); + test.setTimeout(120_000); + + test('MM-T1295 Do not bounce the dock icon — macOS ONLY', + {tag: ['@P2', '@darwin']}, + async ({electronApp}) => { + await waitForAppReady(electronApp); + + const releaseLock = await acquireExclusiveLock('dock-bounce-state'); + try { + await setBounceConfig(electronApp, false); + await installDockBounceSpy(electronApp); + try { + await triggerNotificationEffects(electronApp, true); + + const bounceCalls: string[] = await electronApp.evaluate( + ({app}) => (app as any).__e2eDockBounceCalls ?? [], + ); + expect( + bounceCalls, + 'dock.bounce() must NOT be called when bounceIcon is false', + ).toHaveLength(0); + } finally { + await restoreDockBounceSpy(electronApp); + } + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T1296 Bounce the dock icon — macOS ONLY', + {tag: ['@P2', '@darwin']}, + async ({electronApp}) => { + await waitForAppReady(electronApp); + + const releaseLock = await acquireExclusiveLock('dock-bounce-state'); + try { + await setBounceConfig(electronApp, true, 'informational'); + await installDockBounceSpy(electronApp); + try { + await triggerNotificationEffects(electronApp, true); + + await expect.poll( + () => electronApp.evaluate( + ({app}) => (app as any).__e2eDockBounceCalls ?? [], + ), + {timeout: 10_000, message: 'dock.bounce("informational") must be called'}, + ).toContain('informational'); + } finally { + await restoreDockBounceSpy(electronApp); + } + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T1297 Bounce the dock until I open the app — macOS ONLY', + {tag: ['@P2', '@darwin']}, + async ({electronApp}) => { + await waitForAppReady(electronApp); + + const releaseLock = await acquireExclusiveLock('dock-bounce-state'); + try { + await setBounceConfig(electronApp, true, 'critical'); + await installDockBounceSpy(electronApp); + try { + await triggerNotificationEffects(electronApp, true); + + await expect.poll( + () => electronApp.evaluate( + ({app}) => (app as any).__e2eDockBounceCalls ?? [], + ), + {timeout: 10_000, message: 'dock.bounce("critical") must be called'}, + ).toContain('critical'); + } finally { + await restoreDockBounceSpy(electronApp); + } + } finally { + await releaseLock(); + } + }, + ); +}); diff --git a/e2e/specs/notification_trigger/flash_taskbar.test.ts b/e2e/specs/notification_trigger/flash_taskbar.test.ts new file mode 100644 index 00000000000..1e059500978 --- /dev/null +++ b/e2e/specs/notification_trigger/flash_taskbar.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 {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/flash_taskbar', () => { + test.use({appConfig: demoConfig}); + test.setTimeout(120_000); + + test('MM-T1293 Flash taskbar icon — Windows & Linux ONLY', + {tag: ['@P2', '@win32', '@linux']}, + async ({electronApp}) => { + await waitForAppReady(electronApp); + + const releaseLock = await acquireExclusiveLock('flash-taskbar-state'); + try { + await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const Config = refs?.Config; + if (Config) { + Config.set('notifications', {...Config.notifications, flashWindow: 2}); + } + }); + + await installFlashFrameSpy(electronApp); + + try { + await triggerNotificationEffects(electronApp, true); + + await expect.poll( + () => electronApp.evaluate(() => (global as any).__e2eFlashFrameCalls ?? []), + {timeout: 10_000, message: 'flashFrame(true) must be called when flashWindow is enabled'}, + ).toContain(true); + } finally { + await restoreFlashFrameSpy(electronApp); + } + } finally { + await releaseLock(); + } + }, + ); +}); diff --git a/e2e/specs/notification_trigger/helpers.ts b/e2e/specs/notification_trigger/helpers.ts index 417d30e7e94..c39a0e1a63f 100644 --- a/e2e/specs/notification_trigger/helpers.ts +++ b/e2e/specs/notification_trigger/helpers.ts @@ -23,10 +23,17 @@ export async function triggerTestNotification(firstServer: ServerView) { export async function verifyNotificationReceivedInDM(firstServer: ServerView) { await firstServer.click('div.modal-header button[aria-label="Close"]'); - const sidebarLink = await firstServer.locator('a.SidebarLink:has-text("system-bot")'); - const badgeElement = await sidebarLink.locator('span.badge'); - const badgeCount = await badgeElement.textContent(); - expect(parseInt(badgeCount!, 10)).toBeGreaterThan(0); + const sidebarLink = firstServer.locator('a.SidebarLink:has-text("system-bot")'); + const badgeElement = sidebarLink.locator('span.badge'); + + await expect.poll(async () => { + if (await badgeElement.count() === 0) { + return 0; + } + const text = (await badgeElement.textContent())?.trim() ?? ''; + const parsed = parseInt(text, 10); + return Number.isFinite(parsed) ? parsed : 0; + }, {timeout: 15_000, message: 'system-bot sidebar badge must show unread count'}).toBeGreaterThan(0); await sidebarLink.click(); await firstServer.waitForSelector('div.post__body'); diff --git a/e2e/specs/notification_trigger/notification_badge.test.ts b/e2e/specs/notification_trigger/notification_badge.test.ts new file mode 100644 index 00000000000..46c15805839 --- /dev/null +++ b/e2e/specs/notification_trigger/notification_badge.test.ts @@ -0,0 +1,203 @@ +// 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 { + clearAllBadgesViaAppState, + readOsBadge, + setServerExpiredViaAppState, + 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('notification_trigger/notification_badge', () => { + test.use({appConfig: demoConfig}); + test.setTimeout(120_000); + + test.beforeEach(async ({electronApp}) => { + await waitForAppReady(electronApp); + await waitForBadgeInfrastructure(electronApp); + }); + + // These three run unconditionally rather than skipping without a running Unity + // desktop (true on every headless CI runner): app.getBadgeCount()/setBadgeCount() + // are Unity-only no-ops there, so readOsBadge() falls back to re-deriving the + // count from __testBadgeState using the same arithmetic showBadgeLinux() uses. + // 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', + {tag: ['@P2', '@linux']}, + async ({electronApp}) => { + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await clearAllBadgesViaAppState(electronApp); + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 5, false); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Linux badge count must reflect AppState mention total'}, + ).toMatchObject({count: 5, symbol: 'mention'}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_LNX session expired via AppState', + {tag: ['@P2', '@linux']}, + async ({electronApp}) => { + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await clearAllBadgesViaAppState(electronApp); + await setServerExpiredViaAppState(electronApp, FIRST_SERVER, true); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Linux badge must show session expired'}, + ).toMatchObject({count: 1, symbol: 'expired'}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_LNX mentions beat session expired', + {tag: ['@P2', '@linux']}, + async ({electronApp}) => { + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await clearAllBadgesViaAppState(electronApp); + await setServerExpiredViaAppState(electronApp, FIRST_SERVER, true); + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 3, false); + + // showBadgeLinux() adds 1 for the still-expired session on top of the + // mention count (it doesn't clear `expired` when mentions arrive), so + // the real badge total is 3 + 1 = 4. The symbol still resolves to + // 'mention' since resolvedType only tracks priority, not the sum. + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Mention count must win priority over session expired on Linux'}, + ).toMatchObject({count: 4, symbol: 'mention'}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_OSX dock badge via AppState', + {tag: ['@P2', '@darwin']}, + async ({electronApp}) => { + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await clearAllBadgesViaAppState(electronApp); + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 7, false); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'macOS dock badge must reflect AppState mention total'}, + ).toMatchObject({count: 7, symbol: 'mention'}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_OSX unread dot via AppState', + {tag: ['@P2', '@darwin']}, + async ({electronApp}) => { + 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: 'macOS dock must show unread dot when setting enabled'}, + ).toMatchObject({symbol: 'unread'}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_OSX clear badge via AppState', + {tag: ['@P2', '@darwin']}, + async ({electronApp}) => { + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 4, false); + await clearAllBadgesViaAppState(electronApp); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'macOS dock badge must clear when AppState totals reset'}, + ).toMatchObject({count: 0, symbol: 'none'}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_WIN overlay via AppState', + {tag: ['@P2', '@win32']}, + async ({electronApp}) => { + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await clearAllBadgesViaAppState(electronApp); + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 5, false); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Windows taskbar overlay must appear for mentions'}, + ).toMatchObject({hasOverlay: true, symbol: 'mention', count: 5}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_WIN unread overlay via AppState', + {tag: ['@P2', '@win32']}, + async ({electronApp}) => { + 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: 'Windows taskbar overlay must appear for unreads when enabled'}, + ).toMatchObject({hasOverlay: true, symbol: 'unread'}); + } finally { + await releaseLock(); + } + }, + ); + + test('MM-T_BADGE_WIN clear overlay via AppState', + {tag: ['@P2', '@win32']}, + async ({electronApp}) => { + const releaseLock = await acquireExclusiveLock('notification-badge-state'); + try { + await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 2, false); + await clearAllBadgesViaAppState(electronApp); + + await expect.poll( + () => readOsBadge(electronApp), + {timeout: 10_000, message: 'Windows taskbar overlay must clear when AppState totals reset'}, + ).toMatchObject({hasOverlay: false, symbol: 'none', count: 0}); + } finally { + await releaseLock(); + } + }, + ); +}); diff --git a/e2e/specs/notification_trigger/notification_badge_in_dock.test.ts b/e2e/specs/notification_trigger/notification_badge_in_dock.test.ts deleted file mode 100644 index ff513b810a1..00000000000 --- a/e2e/specs/notification_trigger/notification_badge_in_dock.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {triggerTestNotification, verifyNotificationReceivedInDM} from './helpers'; - -import {test, expect} from '../../fixtures/index'; -import {demoMattermostConfig} from '../../helpers/config'; -import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; -import {loginToMattermost} from '../../helpers/login'; - -test.describe('Trigger Notification From desktop', () => { - test.use({appConfig: demoMattermostConfig}); - test.setTimeout(120_000); - - test('should receive a notification on macOS', {tag: ['@P2', '@darwin']}, async ({electronApp, serverMap}) => { - if (process.platform !== 'darwin') { - test.skip(true, 'This test is only for macOS'); - return; - } - if (!process.env.MM_TEST_SERVER_URL) { - test.skip(true, 'MM_TEST_SERVER_URL required'); - return; - } - - const releaseLock = await acquireExclusiveLock('notification-state'); - try { - const firstServer = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; - if (!firstServer) { - test.skip(true, 'No server view available'); - return; - } - - await loginToMattermost(firstServer); - const textbox = await firstServer.waitForSelector('#post_textbox'); - await textbox.focus(); - - // The notification trigger depends on the Customize Your Experience tour button. - // Skip if it's not available in this server version. - const tourButton = await firstServer.$('div#CustomizeYourExperienceTour > button'); - if (!tourButton) { - test.skip(true, 'CustomizeYourExperienceTour not available in this server version'); - return; - } - - const beforeBadgeValue = await electronApp.evaluate(async ({app}) => { - const badge = (app as any).dock.getBadge(); - return badge === '' || isNaN(badge) ? 0 : parseInt(badge, 10); - }); - - await triggerTestNotification(firstServer); - - await expect.poll(async () => { - const badge = await electronApp.evaluate(async ({app}) => { - const current = (app as any).dock.getBadge(); - return current === '' || isNaN(current) ? 0 : parseInt(current, 10); - }); - return badge; - }, {timeout: 10_000}).toBeGreaterThanOrEqual(beforeBadgeValue + 1); - - await verifyNotificationReceivedInDM(firstServer); - } finally { - await releaseLock(); - } - }); -}); diff --git a/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts b/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts deleted file mode 100644 index d178dcd82f7..00000000000 --- a/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts +++ /dev/null @@ -1,577 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {test, expect} from '../../fixtures/index'; - -type BadgeState = { - mentionCount: number; - sessionExpired: boolean; - showUnreadBadge: boolean; - resolvedType?: string; -} | null; - -async function triggerBadge( - app: import('playwright').ElectronApplication, - sessionExpired: boolean, - mentionCount: number, - showUnreadBadge: boolean, -) { - // Wait for setupBadge() to have registered the test hook (it runs after app - // is ready but the fixture's waitForAppReady may return slightly before it). - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - try { - const isReady = await app.evaluate(() => typeof (global as any).__testTriggerBadge === 'function'); - if (isReady) { - break; - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (!msg.includes('Execution context was destroyed') && !msg.includes('Unable to find context')) { - throw err; - } - - // transient context error during app initialisation — retry - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - - await app.evaluate((_, args: {sessionExpired: boolean; mentionCount: number; showUnreadBadge: boolean}) => { - const trigger = (global as any).__testTriggerBadge; - if (typeof trigger !== 'function') { - throw new Error('__testTriggerBadge is not registered — setupBadge() may not have run yet'); - } - trigger(args.sessionExpired, args.mentionCount, args.showUnreadBadge); - }, {sessionExpired, mentionCount, showUnreadBadge}); - - // Windows canvas drawing in setOverlayIcon is async — give it time to settle - if (process.platform === 'win32') { - await new Promise((resolve) => setTimeout(resolve, 500)); - } else { - await new Promise((resolve) => setTimeout(resolve, 100)); - } -} - -async function getBadgeState(app: import('playwright').ElectronApplication): Promise { - return app.evaluate(() => (global as any).__testBadgeState || null); -} - -async function resetBadgeState(app: import('playwright').ElectronApplication) { - await app.evaluate(() => { - (global as any).__testBadgeState = null; - }); -} - -test.describe('notification_badge/windows_and_linux', () => { - // Reset showUnreadBadgeSetting to false before each test to prevent state bleed - // when a test sets the setting to true but fails before resetting it. - // Retry on "Execution context was destroyed" which can occur when the Electron - // main process is still completing initialisation at the start of the suite. - test.beforeEach(async ({electronApp}) => { - // Poll for the badge-setting hook to be registered before calling it — - // using optional chaining (?.) would silently succeed (no-op) before - // setup completes and leave the setting unreset between tests. - const started = Date.now(); - const deadline = started + 10_000; - let resetDone = false; - while (Date.now() < deadline) { - try { - const isReady = await electronApp.evaluate( - () => typeof (global as any).__testTriggerSetUnreadBadgeSetting === 'function', - ); - if (!isReady) { - await new Promise((resolve) => setTimeout(resolve, 200)); - continue; - } - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(false); - }); - await electronApp.evaluate(() => { - (global as any).__testBadgeState = null; - }); - resetDone = true; - break; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (!msg.includes('Execution context was destroyed') && !msg.includes('Unable to find context')) { - throw err; - } - await new Promise((resolve) => setTimeout(resolve, 200)); - } - } - if (!resetDone) { - throw new Error( - `badge reset hook did not complete before deadline (elapsed ${Date.now() - started}ms, limit 10000ms)`, - ); - } - }); - - // --- Windows: overlay icon badge --- - - test('MM-T_BADGE_WIN_01 - should show a mention count badge on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 5, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(5); - expect(state!.sessionExpired).toBe(false); - expect(state!.showUnreadBadge).toBe(false); - }); - - test('MM-T_BADGE_WIN_02 - should show an unread badge on Windows when showUnreadBadge is true', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(true); - }); - await triggerBadge(electronApp, false, 0, true); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(0); - expect(state!.showUnreadBadge).toBe(true); - expect(state!.sessionExpired).toBe(false); - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(false); - }); - }); - - test('MM-T_BADGE_WIN_03 - should show a session-expired badge on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, true, 0, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.sessionExpired).toBe(true); - expect(state!.mentionCount).toBe(0); - }); - - test('MM-T_BADGE_WIN_04 - should clear the badge on Windows when all counts are zero', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 3, false); - let state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(3); - - await resetBadgeState(electronApp); - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(0); - expect(state!.sessionExpired).toBe(false); - expect(state!.showUnreadBadge).toBe(false); - }); - - test('MM-T_BADGE_WIN_05 - should handle mention counts above 99 on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 150, false); - const state = await getBadgeState(electronApp); - - // Raw inputs are faithfully recorded; the "99+" cap is applied inside - // showBadgeWindows() before setOverlayIcon() — a platform rendering detail - expect(state!.mentionCount).toBe(150); - expect(state!.sessionExpired).toBe(false); - }); - - // --- Linux: setBadgeCount badge --- - - test('MM-T_BADGE_LNX_01 - should show a mention count badge on Linux', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - await triggerBadge(electronApp, false, 3, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(3); - expect(state!.sessionExpired).toBe(false); - }); - - test('MM-T_BADGE_LNX_02 - should account for session expiry in Linux badge count', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - - // showBadgeLinux passes mentionCount + 1 to setBadgeCount when sessionExpired - await triggerBadge(electronApp, true, 2, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.sessionExpired).toBe(true); - expect(state!.mentionCount).toBe(2); - }); - - test('MM-T_BADGE_LNX_03 - should clear the badge on Linux when all counts are zero', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - await triggerBadge(electronApp, false, 5, false); - let state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(5); - - await resetBadgeState(electronApp); - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(0); - expect(state!.sessionExpired).toBe(false); - }); - - // --- Group 1: Badge Type Priority --- - - test.describe('badge type priority', () => { - test('MM-T_BADGE_WIN_06 - mention count wins over session-expired on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, true, 5, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(5); - expect(state!.sessionExpired).toBe(true); - expect(state!.showUnreadBadge).toBe(false); - expect(state!.resolvedType).toBe('mention'); - }); - - test('MM-T_BADGE_WIN_07 - mention count wins over unread dot on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 5, true); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(5); - expect(state!.showUnreadBadge).toBe(true); - expect(state!.sessionExpired).toBe(false); - expect(state!.resolvedType).toBe('mention'); - }); - - test('MM-T_BADGE_WIN_08 - unread dot wins over session-expired on Windows when setting enabled', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(true); - }); - await triggerBadge(electronApp, true, 0, true); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.showUnreadBadge).toBe(true); - expect(state!.sessionExpired).toBe(true); - expect(state!.mentionCount).toBe(0); - expect(state!.resolvedType).toBe('unread'); - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(false); - }); - }); - - test('MM-T_BADGE_LNX_04 - Linux passes both mentionCount and sessionExpired through', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - await triggerBadge(electronApp, true, 5, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(5); - expect(state!.sessionExpired).toBe(true); - expect(state!.resolvedType).toBe('mention'); - }); - }); - - // --- Group 2: Unread Setting Toggle (Windows only) --- - - test.describe('unread setting toggle', () => { - test('MM-T_BADGE_WIN_09 - unread dot not shown when showUnreadBadgeSetting is false', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - - // setting defaults to falsy — do not enable it - await triggerBadge(electronApp, false, 0, true); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(0); - expect(state!.showUnreadBadge).toBe(true); - expect(state!.sessionExpired).toBe(false); - expect(state!.resolvedType).toBe('none'); - }); - - test('MM-T_BADGE_WIN_10 - unread dot shown when showUnreadBadgeSetting is true', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(true); - }); - await triggerBadge(electronApp, false, 0, true); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(0); - expect(state!.showUnreadBadge).toBe(true); - expect(state!.sessionExpired).toBe(false); - expect(state!.resolvedType).toBe('unread'); - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(false); - }); - }); - }); - - // --- Group 3: Badge Clearing / Ghost-Badge Regression --- - - test.describe('badge clearing', () => { - test('MM-T_BADGE_WIN_11 - ghost mention badge clears on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 3, false); - let state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(3); - - await resetBadgeState(electronApp); - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(0); - expect(state!.showUnreadBadge).toBe(false); - expect(state!.sessionExpired).toBe(false); - }); - - test('MM-T_BADGE_WIN_12 - ghost unread dot clears on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(true); - }); - await triggerBadge(electronApp, false, 0, true); - let state = await getBadgeState(electronApp); - expect(state!.showUnreadBadge).toBe(true); - - await resetBadgeState(electronApp); - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.showUnreadBadge).toBe(false); - expect(state!.mentionCount).toBe(0); - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(false); - }); - }); - - test('MM-T_BADGE_WIN_13 - ghost session-expired badge clears on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, true, 0, false); - let state = await getBadgeState(electronApp); - expect(state!.sessionExpired).toBe(true); - - await resetBadgeState(electronApp); - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.sessionExpired).toBe(false); - }); - - test('MM-T_BADGE_LNX_05 - Linux counter resets to zero', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - await triggerBadge(electronApp, false, 5, false); - let state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(5); - - await resetBadgeState(electronApp); - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(0); - }); - }); - - // --- Group 4: State Transitions (Windows) --- - - test.describe('state transitions', () => { - test('MM-T_BADGE_WIN_14 - mention count decrements correctly on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 5, false); - let state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(5); - - await triggerBadge(electronApp, false, 3, false); - state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(3); - - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(0); - }); - - test('MM-T_BADGE_WIN_15 - transitions from mention to unread dot on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(true); - }); - await triggerBadge(electronApp, false, 3, false); - let state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(3); - - await triggerBadge(electronApp, false, 0, true); - state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(0); - expect(state!.showUnreadBadge).toBe(true); - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(false); - }); - }); - - test('MM-T_BADGE_WIN_16 - transitions from unread dot to mention on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(true); - }); - await triggerBadge(electronApp, false, 0, true); - let state = await getBadgeState(electronApp); - expect(state!.showUnreadBadge).toBe(true); - expect(state!.mentionCount).toBe(0); - - await triggerBadge(electronApp, false, 2, false); - state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(2); - await electronApp.evaluate(() => { - (global as any).__testTriggerSetUnreadBadgeSetting(false); - }); - }); - - test('MM-T_BADGE_WIN_17 - session-restore with pending mentions on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, true, 0, false); - let state = await getBadgeState(electronApp); - expect(state!.sessionExpired).toBe(true); - - await triggerBadge(electronApp, false, 3, false); - state = await getBadgeState(electronApp); - expect(state!.sessionExpired).toBe(false); - expect(state!.mentionCount).toBe(3); - }); - }); - - // --- Group 5: Windows-specific Edge Cases --- - - test.describe('windows edge cases', () => { - test('MM-T_BADGE_WIN_18 - mention count exactly at 99 on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 99, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(99); - expect(state!.sessionExpired).toBe(false); - }); - - test('MM-T_BADGE_WIN_19 - mention count over 99 cap on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 100, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(100); - expect(state!.sessionExpired).toBe(false); - }); - - test('MM-T_BADGE_WIN_20 - explicit no-badge state recorded on Windows', {tag: ['@P2', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - await triggerBadge(electronApp, false, 0, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.sessionExpired).toBe(false); - expect(state!.mentionCount).toBe(0); - expect(state!.showUnreadBadge).toBe(false); - }); - }); - - // --- Group 6: Linux-specific Edge Cases --- - - test.describe('linux edge cases', () => { - test('MM-T_BADGE_LNX_06 - no cap on mention count on Linux', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - await triggerBadge(electronApp, false, 100, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.mentionCount).toBe(100); - }); - - test('MM-T_BADGE_LNX_07 - session-expired with zero mentions on Linux', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - await triggerBadge(electronApp, true, 0, false); - const state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.sessionExpired).toBe(true); - expect(state!.mentionCount).toBe(0); - }); - - test('MM-T_BADGE_LNX_08 - Linux clears correctly with all false/zero', {tag: ['@P2', '@linux']}, async ({electronApp}) => { - if (process.platform !== 'linux') { - test.skip(true, 'Linux only'); - return; - } - await triggerBadge(electronApp, false, 3, false); - let state = await getBadgeState(electronApp); - expect(state!.mentionCount).toBe(3); - - await resetBadgeState(electronApp); - await triggerBadge(electronApp, false, 0, false); - state = await getBadgeState(electronApp); - expect(state).not.toBeNull(); - expect(state!.sessionExpired).toBe(false); - expect(state!.mentionCount).toBe(0); - }); - }); -}); diff --git a/e2e/specs/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index bb337721394..1c723551c5c 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -5,8 +5,10 @@ import {test, expect} from '../../fixtures/index'; import {demoMattermostConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; +import {simulateNotificationClick} from '../../helpers/notificationClick'; import {resolveChannelByName} from '../../helpers/server_api/channel'; -import {NOTIFICATION_CLICKED} from '../../../src/common/communication'; +import {getActiveServerWebContentsId} from '../../helpers/testRefs'; +import {hideMainWindow, isMainWindowVisible, showMainWindowIfHidden} from '../../helpers/tray'; test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); @@ -32,16 +34,15 @@ test( const targetChannel = await resolveChannelByName('off-topic'); const targetPathname = new URL(targetChannel.url).pathname; - await electronApp.evaluate(({webContents}, payload) => { - const wc = webContents.fromId(payload.webContentsId); - if (!wc || wc.isDestroyed()) { - throw new Error(`webContents ${payload.webContentsId} is not available`); - } + await hideMainWindow(electronApp); - wc.send(payload.channel, payload.channelId, payload.teamId, payload.url); - }, { - webContentsId: serverEntry!.webContentsId, - channel: NOTIFICATION_CLICKED, + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 5_000, message: 'Main window should be hidden before notification click'}, + ).toBe(false); + + await simulateNotificationClick(electronApp, { + webContentsId: await getActiveServerWebContentsId(electronApp), channelId: targetChannel.id, teamId: targetChannel.teamId, url: targetChannel.url, @@ -51,7 +52,13 @@ test( () => serverWin!.evaluate(() => window.location.pathname), {timeout: 10_000, message: 'View should navigate to the clicked channel path'}, ).toBe(targetPathname); + + await expect.poll( + () => isMainWindowVisible(electronApp), + {timeout: 10_000, message: 'Main window should be visible after notification click navigation'}, + ).toBe(true); } finally { + await showMainWindowIfHidden(electronApp); await releaseLock(); } }, diff --git a/e2e/specs/permissions/permissions_ipc.test.ts b/e2e/specs/permissions/permissions_ipc.test.ts index 5ce9e7b92cf..04c3e655790 100644 --- a/e2e/specs/permissions/permissions_ipc.test.ts +++ b/e2e/specs/permissions/permissions_ipc.test.ts @@ -2,8 +2,8 @@ // See LICENSE.txt for license information. import {test, expect} from '../../fixtures/index'; +import {SHOW_SETTINGS_WINDOW} from '../../helpers/ipcChannels'; -const SHOW_SETTINGS_WINDOW = 'show-settings-window'; type ElectronApplication = Awaited>; async function openSettingsWindow(electronApp: ElectronApplication) { @@ -39,12 +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', '@all']}, async ({electronApp}) => { - if (process.platform === 'linux') { - test.skip(true, 'systemPreferences.getMediaAccessStatus is not available on Linux'); - return; - } - + test('E2E-P01: 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( @@ -53,12 +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', '@all', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - + test('E2E-P02: 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}) => { @@ -79,12 +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', '@all', '@win32']}, async ({electronApp}) => { - if (process.platform !== 'win32') { - test.skip(true, 'Windows only'); - return; - } - + test('E2E-P03: 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/server_management/bad_servers.test.ts b/e2e/specs/server_management/bad_servers.test.ts index 36a1fc2a155..c272c2017b8 100644 --- a/e2e/specs/server_management/bad_servers.test.ts +++ b/e2e/specs/server_management/bad_servers.test.ts @@ -174,7 +174,7 @@ test.describe('Bad Server Configurations', () => { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Unreachable Server'}); + await waitForErrorView(app, {serverName: 'Unreachable Server', waitForActiveServer: true}); const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); expect(errorInfo).toContain('ERR_NAME_NOT_RESOLVED'); }); @@ -195,7 +195,7 @@ test.describe('Bad Server Configurations', () => { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Expired Cert Server'}); + await waitForErrorView(app, {serverName: 'Expired Cert Server', waitForActiveServer: true}); const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); expect(errorInfo).toContain('ERR_CERT_DATE_INVALID'); }); @@ -216,7 +216,7 @@ test.describe('Bad Server Configurations', () => { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'TLS 1.0 Server'}); + await waitForErrorView(app, {serverName: 'TLS 1.0 Server', waitForActiveServer: true}); await expect.poll(async () => { const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); @@ -240,7 +240,7 @@ test.describe('Bad Server Configurations', () => { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'RC4 Cipher Server'}); + await waitForErrorView(app, {serverName: 'RC4 Cipher Server', waitForActiveServer: true}); await expect.poll(async () => { const errorInfo = await mainWindow!.innerText('.ErrorView-techInfo'); @@ -267,7 +267,7 @@ test.describe('Bad Server Configurations', () => { try { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Pre-configured Unreachable'}); + await waitForErrorView(app, {serverName: 'Pre-configured Unreachable', waitForActiveServer: false}); const start = Date.now(); const dropdownView = await openServerDropdown(app); @@ -307,7 +307,7 @@ test.describe('Bad Server Configurations', () => { try { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Pre-configured Unreachable'}); + await waitForErrorView(app, {serverName: 'Pre-configured Unreachable', waitForActiveServer: false}); const errorView = await mainWindow!.$('.ErrorView'); expect(errorView).toBeDefined(); @@ -341,7 +341,7 @@ test.describe('Bad Server Configurations', () => { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Pre-configured Unreachable'}); + await waitForErrorView(app, {serverName: 'Pre-configured Unreachable', waitForActiveServer: false}); const errorView = await mainWindow!.$('.ErrorView'); expect(errorView).toBeDefined(); @@ -369,7 +369,7 @@ test.describe('Bad Server Configurations', () => { {timeout: 45_000, message: 'Working cloud server should load after switching away from unreachable server'}, ).toContain(cloudHost); - await prepareMattermostServerView(app, mmEntry.webContentsId); + await prepareMattermostServerView(app, mmEntry!.webContentsId); await loginToMattermost(mmServer); const postTextbox = await mmServer.$('#post_textbox'); @@ -396,7 +396,7 @@ test.describe('Bad Server Configurations', () => { try { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Pre-configured Expired Cert'}); + await waitForErrorView(app, {serverName: 'Pre-configured Expired Cert', waitForActiveServer: false}); const errorView = await mainWindow!.$('.ErrorView'); expect(errorView).toBeDefined(); @@ -481,7 +481,7 @@ test.describe('Bad Server Configurations', () => { try { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Pre-configured TLS 1.1'}); + await waitForErrorView(app, {serverName: 'Pre-configured TLS 1.1', waitForActiveServer: false}); const errorView = await mainWindow!.$('.ErrorView'); expect(errorView).toBeDefined(); @@ -511,7 +511,7 @@ test.describe('Bad Server Configurations', () => { try { const mainWindow = app.windows().find((w) => w.url().includes('index')); expect(mainWindow).toBeDefined(); - await waitForErrorView(app, {serverName: 'Pre-configured RC4'}); + await waitForErrorView(app, {serverName: 'Pre-configured RC4', waitForActiveServer: false}); const errorView = await mainWindow!.$('.ErrorView'); expect(errorView).toBeDefined(); diff --git a/e2e/specs/settings/keyboard_shortcuts.test.ts b/e2e/specs/settings/keyboard_shortcuts.test.ts index 13af780a709..a9c4a66db37 100644 --- a/e2e/specs/settings/keyboard_shortcuts.test.ts +++ b/e2e/specs/settings/keyboard_shortcuts.test.ts @@ -3,8 +3,7 @@ import {test, expect} from '../../fixtures/index'; import {cmdOrCtrl} from '../../helpers/config'; - -const SHOW_SETTINGS_WINDOW = 'show-settings-window'; +import {SHOW_SETTINGS_WINDOW} from '../../helpers/ipcChannels'; type ElectronApplication = Awaited>; diff --git a/e2e/utils/analyze-flaky-test.js b/e2e/utils/analyze-flaky-test.js index d32ecde26f3..095cf1900fb 100644 --- a/e2e/utils/analyze-flaky-test.js +++ b/e2e/utils/analyze-flaky-test.js @@ -209,8 +209,38 @@ function getOutcomeCounts(report) { return {passed, failed, skipped, total: passed + failed + skipped}; } +function buildAnalysisResult({failureCount, passCount, skipCount, totalCount}) { + const collectedCount = passCount + failureCount + skipCount; + + // Playwright can exit 0 when test collection finds nothing (e.g. a broken + // import aborts discovery). Treat that as an infrastructure failure so the + // PR status check does not go green with "No tests ran". + if (collectedCount === 0) { + return { + failureCount: 1, + passCount: 0, + skipCount, + totalCount, + newFailedTests: ['no-tests-collected'], + os: process.platform, + testStatus: 'failure', + collectionFailed: true, + }; + } + + return { + failureCount, + passCount, + skipCount, + totalCount, + newFailedTests: new Array(failureCount).fill('failed'), + os: process.platform, + testStatus: failureCount > 0 ? 'failure' : 'success', + collectionFailed: false, + }; +} + function analyzeFlakyTests() { - const exitCode = toNumber(process.env.PLAYWRIGHT_EXIT_CODE || '0'); const hasJunit = fs.existsSync(JUNIT_REPORT_PATH); if (!hasJunit) { @@ -223,19 +253,16 @@ function analyzeFlakyTests() { newFailedTests: [], os: process.platform, testStatus: 'error', + collectionFailed: false, }; } - const failureCount = exitCode === 0 ? 0 : 1; - return { - failureCount, + return buildAnalysisResult({ + failureCount: 0, passCount: 0, skipCount: 0, - totalCount: failureCount, - newFailedTests: new Array(failureCount).fill('unknown'), - os: process.platform, - testStatus: failureCount > 0 ? 'failure' : 'success', - }; + totalCount: 0, + }); } const XMLParser = getXMLParserClass(); @@ -254,19 +281,16 @@ function analyzeFlakyTests() { // `failureCount` and reconcile the rest. const reconciledFailed = failureCount; const reconciledPassed = Math.max(0, outcomes.total - reconciledFailed - outcomes.skipped); - const testStatus = reconciledFailed > 0 ? 'failure' : 'success'; - return { - failureCount, + return buildAnalysisResult({ + failureCount: reconciledFailed, passCount: reconciledPassed, skipCount: outcomes.skipped, totalCount: reconciledFailed + reconciledPassed + outcomes.skipped, - newFailedTests: new Array(failureCount).fill('failed'), - os: process.platform, - testStatus, - }; + }); } module.exports = { analyzeFlakyTests, + buildAnalysisResult, }; diff --git a/e2e/utils/github-actions.js b/e2e/utils/github-actions.js index 62646ac51c9..f399f5cd41b 100644 --- a/e2e/utils/github-actions.js +++ b/e2e/utils/github-actions.js @@ -46,7 +46,11 @@ async function updateInitialStatus({github, context, platforms}) { * - all pass: "All 161 ran, 161 passed" * - any failure: "161 ran, 157 passed, 4 failed" */ -function formatStatusDescription({passed, failed}) { +function formatStatusDescription({passed, failed, collectionFailed}) { + if (collectionFailed) { + return 'No tests ran (collection failed)'; + } + const ran = passed + failed; if (ran === 0) { return failed > 0 ? `0 ran, ${failed} failed` : 'No tests ran'; @@ -58,6 +62,13 @@ function formatStatusDescription({passed, failed}) { } async function resolveStatusSha({github, context, prNumber}) { + // Commit statuses must target the SHA this workflow run was dispatched for. + // PR HEAD moves when a new push cancels an in-flight run; using it would mark + // the new commit cancelled instead of the superseded one. + if (context.sha) { + return context.sha; + } + if (prNumber) { const {data: pr} = await github.rest.pulls.get({ owner: context.repo.owner, @@ -67,7 +78,7 @@ async function resolveStatusSha({github, context, prNumber}) { return pr.head.sha; } - return context.payload.pull_request?.head?.sha || context.sha; + return context.payload.pull_request?.head?.sha; } /** @@ -97,6 +108,7 @@ async function updateFinalStatus({github, context, platforms, outputs, e2eTestsR const failed = Number(outputs[`NEW_FAILURES_${osKey}`] || 0); const passed = Number(outputs[`PASSED_${osKey}`] || 0); + const collectionFailed = outputs[`COLLECTION_FAILED_${osKey}`] === 'true'; const platformStatus = outputs[`STATUS_${osKey}`] || ''; const reportLink = outputs[`REPORT_LINK_${osKey}`] || workflowUrl; const ran = passed + failed; @@ -112,10 +124,10 @@ async function updateFinalStatus({github, context, platforms, outputs, e2eTestsR description = workflowCancelled ? CANCELLED_STATUS_DESCRIPTION : 'E2E incomplete — no tests ran'; } else if (failed > 0 || platformStatus === 'failure') { state = 'failure'; - description = formatStatusDescription({passed, failed}); + description = formatStatusDescription({passed, failed, collectionFailed}); } else { state = 'success'; - description = formatStatusDescription({passed, failed}); + description = formatStatusDescription({passed, failed, collectionFailed}); } return github.rest.repos.createCommitStatus({ @@ -136,6 +148,7 @@ async function updateFinalStatus({github, context, platforms, outputs, e2eTestsR */ async function markE2EStatusesCancelled({github, context, sha, reason = CANCELLED_STATUS_DESCRIPTION}) { const description = String(reason).substring(0, 140); + const targetUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await Promise.all(E2E_STATUS_CONTEXTS.map((statusContext) => github.rest.repos.createCommitStatus({ @@ -145,6 +158,7 @@ async function markE2EStatusesCancelled({github, context, sha, reason = CANCELLE state: 'error', context: statusContext, description, + target_url: targetUrl, }).catch((error) => { console.log(`Could not update ${statusContext} on ${sha}: ${error.message}`); }), diff --git a/src/app/system/badge.ts b/src/app/system/badge.ts index 3f0c75c3ec5..4d3061e1360 100644 --- a/src/app/system/badge.ts +++ b/src/app/system/badge.ts @@ -7,7 +7,6 @@ import {app, nativeImage} from 'electron'; import AppState from 'common/appState'; import {UPDATE_APPSTATE_TOTALS} from 'common/communication'; import {Logger} from 'common/log'; -import {setTestField} from 'common/utils/util'; import {localizeMessage} from 'main/i18nManager'; import MainWindow from '../mainWindow/mainWindow'; @@ -17,6 +16,14 @@ const MAX_WIN_COUNT = 99; let showUnreadBadgeSetting: boolean; +type BadgeTestRecorder = (sessionExpired: boolean, mentionCount: number, showUnreadBadge: boolean, showUnreadBadgeSetting: boolean) => void; +let badgeTestRecorder: BadgeTestRecorder | undefined; + +/** Lets E2E wire up test-state recording without badge.ts depending on main/e2e. */ +export function setBadgeTestRecorder(recorder: BadgeTestRecorder | undefined) { + badgeTestRecorder = recorder; +} + /** * Badge generation for Windows */ @@ -125,27 +132,7 @@ function showBadge(sessionExpired: boolean, mentionCount: number, showUnreadBadg break; } - if (process.env.NODE_ENV === 'test') { - let resolvedType: 'mention' | 'unread' | 'expired' | 'none'; - if (process.platform === 'linux') { - if (mentionCount > 0) { - resolvedType = 'mention'; - } else if (sessionExpired) { - resolvedType = 'expired'; - } else { - resolvedType = 'none'; - } - } else if (mentionCount > 0) { - resolvedType = 'mention'; - } else if (showUnreadBadge && showUnreadBadgeSetting) { - resolvedType = 'unread'; - } else if (sessionExpired) { - resolvedType = 'expired'; - } else { - resolvedType = 'none'; - } - setTestField('__testBadgeState', {sessionExpired, mentionCount, showUnreadBadge, resolvedType}); - } + badgeTestRecorder?.(sessionExpired, mentionCount, showUnreadBadge, showUnreadBadgeSetting); } export function setUnreadBadgeSetting(showUnreadBadge: boolean) { @@ -155,6 +142,4 @@ export function setUnreadBadgeSetting(showUnreadBadge: boolean) { export function setupBadge() { AppState.on(UPDATE_APPSTATE_TOTALS, showBadge); - setTestField('__testTriggerBadge', showBadge); - setTestField('__testTriggerSetUnreadBadgeSetting', setUnreadBadgeSetting); } diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index b582da9f2c4..047dc06f2bb 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -148,6 +148,8 @@ jest.mock('main/AutoLauncher', () => ({ jest.mock('main/updateNotifier', () => ({})); jest.mock('app/system/badge', () => ({ setupBadge: jest.fn(), + setBadgeTestRecorder: jest.fn(), + setUnreadBadgeSetting: jest.fn(), })); jest.mock('main/CriticalErrorHandler', () => ({ init: jest.fn(), diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 3d74554d1b7..7ac09e3a0b8 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -12,13 +12,10 @@ import Joi from 'joi'; import MainWindow from 'app/mainWindow/mainWindow'; import MenuManager from 'app/menus'; -import createTrayMenu from 'app/menus/tray'; import NavigationManager from 'app/navigationManager'; import {setupBadge} from 'app/system/badge'; import Tray from 'app/system/tray/tray'; -import TabManager from 'app/tabs/tabManager'; import WebContentsManager from 'app/views/webContentsManager'; -import PopoutManager from 'app/windows/popoutManager'; import { QUIT, NOTIFY_MENTION, @@ -34,7 +31,6 @@ import { DOUBLE_CLICK_ON_WINDOW, TOGGLE_SECURE_INPUT, GET_APP_INFO, - SHOW_SETTINGS_WINDOW, DEVELOPER_MODE_UPDATED, SERVER_ADDED, GET_FULL_SCREEN_STATUS, @@ -46,16 +42,14 @@ import {MATTERMOST_PROTOCOL} from 'common/constants'; import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import {parseURL} from 'common/utils/url'; -import {setTestField} from 'common/utils/util'; import {ipcValidate} from 'common/Validator'; -import ViewManager from 'common/views/viewManager'; import AppVersionManager from 'main/AppVersionManager'; import AutoLauncher from 'main/AutoLauncher'; import {configPath, updatePaths} from 'main/constants'; import CriticalErrorHandler from 'main/CriticalErrorHandler'; import DeveloperMode from 'main/developerMode'; -import Diagnostics from 'main/diagnostics'; import downloadsManager from 'main/downloadsManager'; +import {maybeRegisterE2eHooks} from 'main/e2e/register'; import i18nManager from 'main/i18nManager'; import NonceManager from 'main/nonceManager'; import {getDoNotDisturb} from 'main/notifications'; @@ -67,7 +61,6 @@ import PermissionsManager from 'main/security/permissionsManager'; import PreAuthManager from 'main/security/preAuthManager'; import sentryHandler from 'main/sentryHandler'; import SessionAttributesManager from 'main/sessionAttributes/sessionAttributesManager'; -import {installMessageBoxStub, restoreMessageBoxStub} from 'main/testMessageBoxStub'; import updateNotifier from 'main/updateNotifier'; import UserActivityMonitor from 'main/UserActivityMonitor'; @@ -79,7 +72,6 @@ import { handleAppWillFinishLaunching, handleAppWindowAllClosed, handleChildProcessGone, - certificateErrorCallbacks, } from './app'; import { handleConfigUpdate, @@ -98,12 +90,10 @@ import { handleQuit, handlePingDomain, handleToggleSecureInput, - handleShowSettingsModal, } from './intercom'; import { clearAppCache, getDeeplinkingURL, - openDeepLink, shouldShowTrayIcon, updateSpellCheckerLocales, wasUpdated, @@ -284,60 +274,13 @@ function initializeInterCommunicationEventListeners() { ipcMain.on(TOGGLE_SECURE_INPUT, handleToggleSecureInput); - if (process.env.NODE_ENV === 'test') { - ipcMain.on(SHOW_SETTINGS_WINDOW, handleShowSettingsModal); - } - ipcMain.handle(GET_FULL_SCREEN_STATUS, (event: IpcMainInvokeEvent) => { return BrowserWindow.fromWebContents(event.sender)?.isFullScreen(); }); } async function initializeAfterAppReady() { - const e2eTestRefs = { - MainWindow, - ServerManager, - TabManager, - ViewManager, - WebContentsManager, - Config, - TrayIcon: Tray, - Diagnostics, - PopoutManager, - }; - - setTestField('__e2eTestRefs', e2eTestRefs); - - setTestField('__e2eOpenDeepLink', (url: string) => { - openDeepLink(url); - }); - - setTestField('__e2eClickTrayMenuItem', (label: string) => { - const truncated = label.length > 50 ? `${label.slice(0, 50)}...` : label; - - function clickItem(items: Electron.MenuItem[]): boolean { - for (const item of items) { - const itemLabel = typeof item.label === 'string' ? item.label : ''; - if ( - (itemLabel === label || itemLabel === truncated) && - item.enabled !== false && - item.visible !== false && - typeof item.click === 'function' - ) { - item.click(); - return true; - } - if (item.submenu?.items && clickItem(item.submenu.items)) { - return true; - } - } - return false; - } - - if (!clickItem(createTrayMenu().items)) { - throw new Error(`Tray menu item not found: ${label}`); - } - }); + maybeRegisterE2eHooks(); // Block all NTLM/Negotiate requests by default session.defaultSession.allowNTLMCredentialsForDomains(''); @@ -382,17 +325,6 @@ async function initializeAfterAppReady() { ServerManager.on(SERVER_URL_CHANGED, updateServerInfo); ServerManager.on(SERVER_PRE_AUTH_SECRET_CHANGED, updateServerInfo); - setTestField('__e2eStubMessageBoxResponses', installMessageBoxStub); - setTestField('__e2eRestoreMessageBox', restoreMessageBoxStub); - setTestField('__e2eClearCertificateErrorCallbacks', () => certificateErrorCallbacks.clear()); - if (process.env.NODE_ENV === 'test') { - if (process.env.MM_E2E_STUB_MESSAGE_BOX === 'cancel') { - installMessageBoxStub([{response: 1}]); - } else if (process.env.MM_E2E_STUB_MESSAGE_BOX === 'trust') { - installMessageBoxStub([{response: 0}, {response: 0}]); - } - } - ServerManager.on(SERVER_ADDED, PreAuthManager.loadPreAuthSecretForServer); ServerManager.init(); ServerManager.off(SERVER_ADDED, PreAuthManager.loadPreAuthSecretForServer); diff --git a/src/main/app/intercom.ts b/src/main/app/intercom.ts index cf08661af29..079fc03cbf3 100644 --- a/src/main/app/intercom.ts +++ b/src/main/app/intercom.ts @@ -7,13 +7,13 @@ import {app, BrowserWindow, Menu} from 'electron'; import MainWindow from 'app/mainWindow/mainWindow'; import ModalManager from 'app/mainWindow/modals/modalManager'; import ServerViewState from 'app/serverHub'; -import {APP_MENU_WILL_CLOSE, MAIN_WINDOW_CREATED} from 'common/communication'; +import {APP_MENU_WILL_CLOSE} from 'common/communication'; import {ModalConstants} from 'common/constants'; import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import {ping} from 'common/utils/requests'; import {parseURL} from 'common/utils/url'; -import {setTestField} from 'common/utils/util'; +import {signalE2EAppReadyWhenShown} from 'main/e2e/appReady'; import NotificationManager from 'main/notifications'; import {getLocalPreload} from 'main/utils'; @@ -91,38 +91,6 @@ export function handleMainWindowIsShown() { signalE2EAppReadyWhenShown(); } -// E2E only: signals `__e2eAppReady` once the main window is visible so Playwright/Detox can wait -// on app readiness. Gated on NODE_ENV==='test' (the same gate setTestField uses), so it adds no -// listeners and is completely inert in normal app usage. Listener-based (no polling); also covers -// the case where the main window has not been constructed yet. -function signalE2EAppReadyWhenShown() { - if (process.env.NODE_ENV !== 'test') { - return; - } - - const markReady = () => setTestField('__e2eAppReady', true); - const whenVisible = (win: BrowserWindow) => { - if (win.isVisible()) { - markReady(); - } else { - win.once('show', markReady); - } - }; - - const win = MainWindow.get(); - if (win) { - whenVisible(win); - return; - } - - MainWindow.once(MAIN_WINDOW_CREATED, () => { - const created = MainWindow.get(); - if (created) { - whenVisible(created); - } - }); -} - export function handleWelcomeScreenModal(prefillURL?: string) { log.debug('handleWelcomeScreenModal'); diff --git a/src/main/e2e/appReady.ts b/src/main/e2e/appReady.ts new file mode 100644 index 00000000000..b0c2608570d --- /dev/null +++ b/src/main/e2e/appReady.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {BrowserWindow} from 'electron'; + +import MainWindow from 'app/mainWindow/mainWindow'; +import {MAIN_WINDOW_CREATED} from 'common/communication'; +import {setTestField} from 'common/utils/util'; + +/** + * Signals `__e2eAppReady` once the main window is visible so Playwright can wait + * on app readiness. No-op outside NODE_ENV=test. + */ +export function signalE2EAppReadyWhenShown(): void { + if (process.env.NODE_ENV !== 'test') { + return; + } + + const markReady = () => setTestField('__e2eAppReady', true); + const whenVisible = (win: BrowserWindow) => { + if (win.isVisible()) { + markReady(); + } else { + win.once('show', markReady); + } + }; + + const win = MainWindow.get(); + if (win) { + whenVisible(win); + return; + } + + MainWindow.once(MAIN_WINDOW_CREATED, () => { + const created = MainWindow.get(); + if (created) { + whenVisible(created); + } + }); +} diff --git a/src/main/e2e/badgeState.ts b/src/main/e2e/badgeState.ts new file mode 100644 index 00000000000..e0cc9f33ae2 --- /dev/null +++ b/src/main/e2e/badgeState.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {setTestField} from 'common/utils/util'; + +export type BadgeTestState = { + sessionExpired: boolean; + mentionCount: number; + showUnreadBadge: boolean; + resolvedType: 'mention' | 'unread' | 'expired' | 'none'; + hasOverlay: boolean; +}; + +/** Records badge resolution for E2E assertions. No-op outside NODE_ENV=test. */ +export function recordBadgeTestState( + sessionExpired: boolean, + mentionCount: number, + showUnreadBadge: boolean, + showUnreadBadgeSetting: boolean, +): void { + if (process.env.NODE_ENV !== 'test') { + return; + } + + let resolvedType: BadgeTestState['resolvedType']; + if (process.platform === 'linux') { + if (mentionCount > 0) { + resolvedType = 'mention'; + } else if (sessionExpired) { + resolvedType = 'expired'; + } else { + resolvedType = 'none'; + } + } else if (mentionCount > 0) { + resolvedType = 'mention'; + } else if (showUnreadBadge && showUnreadBadgeSetting) { + resolvedType = 'unread'; + } else if (sessionExpired) { + resolvedType = 'expired'; + } else { + resolvedType = 'none'; + } + + const hasOverlay = process.platform === 'win32' && resolvedType !== 'none'; + setTestField('__testBadgeState', {sessionExpired, mentionCount, showUnreadBadge, resolvedType, hasOverlay}); +} diff --git a/src/main/e2e/hooks.ts b/src/main/e2e/hooks.ts new file mode 100644 index 00000000000..02de2e7d031 --- /dev/null +++ b/src/main/e2e/hooks.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {setTestField} from 'common/utils/util'; + +import type {SimulateNotificationClickPayload} from './notificationClick'; + +type MessageBoxResponse = {response: number}; + +/** + * Shape of `global.__e2eTestRefs`, set by registerE2eHooks() below. Kept in sync + * manually with the object built in `register.ts` — there's no way to derive this + * from the call site without a circular type dependency. + */ +export type E2eGlobalRefs = { + AppState: typeof import('common/appState').default; + MainWindow: typeof import('app/mainWindow/mainWindow').default; + NotificationManager: typeof import('main/notifications').default; + ServerManager: typeof import('common/servers/serverManager').default; + TabManager: typeof import('app/tabs/tabManager').default; + ViewManager: typeof import('common/views/viewManager').default; + WebContentsManager: typeof import('app/views/webContentsManager').default; + Config: typeof import('common/config').default; + TrayIcon: typeof import('app/system/tray/tray').default; + Diagnostics: typeof import('main/diagnostics').default; + PopoutManager: typeof import('app/windows/popoutManager').default; + updateNotifier: typeof import('main/updateNotifier').default; + setUnreadBadgeSetting: (showUnreadBadge: boolean) => void; +}; + +type RegisterE2eHooksOptions = { + e2eTestRefs: E2eGlobalRefs; + openDeepLink: (url: string) => void; + clickTrayMenuItem: (label: string) => void; + triggerNotificationFrameEffects: (flash: boolean) => void; + simulateNotificationClick: (payload: SimulateNotificationClickPayload) => void; + installMessageBoxStub: (responses: MessageBoxResponse[]) => void; + restoreMessageBoxStub: () => void; + clearCertificateErrorCallbacks: () => void; +}; + +/** + * Register Playwright/Detox globals on `global` for E2E. Each assignment is a + * no-op in production because setTestField() gates on NODE_ENV === 'test'. + */ +export function registerE2eHooks(options: RegisterE2eHooksOptions): void { + setTestField('__e2eTestRefs', options.e2eTestRefs); + setTestField('__e2eOpenDeepLink', options.openDeepLink); + setTestField('__e2eClickTrayMenuItem', options.clickTrayMenuItem); + setTestField('__e2eNotificationEffects', options.triggerNotificationFrameEffects); + setTestField('__e2eSimulateNotificationClick', options.simulateNotificationClick); + setTestField('__e2eStubMessageBoxResponses', options.installMessageBoxStub); + setTestField('__e2eRestoreMessageBox', options.restoreMessageBoxStub); + setTestField('__e2eClearCertificateErrorCallbacks', options.clearCertificateErrorCallbacks); +} diff --git a/src/main/e2e/notificationClick.ts b/src/main/e2e/notificationClick.ts new file mode 100644 index 00000000000..2f92f8ccd51 --- /dev/null +++ b/src/main/e2e/notificationClick.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ipcMain, webContents} from 'electron'; + +import MainWindow from 'app/mainWindow/mainWindow'; +import TabManager from 'app/tabs/tabManager'; +import WebContentsManager from 'app/views/webContentsManager'; +import {BROWSER_HISTORY_PUSH, NOTIFICATION_CLICKED} from 'common/communication'; + +export type SimulateNotificationClickPayload = { + webContentsId: number; + channelId: string; + teamId: string; + url: string; +}; + +/** Mirrors notifications/index.ts mention click handler — E2E only. */ +function dispatchMentionClick( + view: {id: string}, + wc: Electron.WebContents, + channelId: string, + teamId: string, + url: string, +) { + const focus = () => { + MainWindow.show(); + TabManager.switchToTab(view.id); + ipcMain.off(BROWSER_HISTORY_PUSH, focus); + }; + ipcMain.on(BROWSER_HISTORY_PUSH, focus); + wc.send(NOTIFICATION_CLICKED, channelId, teamId, url); +} + +export function simulateNotificationClick(payload: SimulateNotificationClickPayload) { + const wc = webContents.fromId(payload.webContentsId); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.webContentsId} is not available`); + } + + const view = WebContentsManager.getViewByWebContentsId(wc.id); + if (!view) { + throw new Error(`No view for webContents ${payload.webContentsId}`); + } + + dispatchMentionClick(view, wc, payload.channelId, payload.teamId, payload.url); +} diff --git a/src/main/e2e/notificationFrameEffects.ts b/src/main/e2e/notificationFrameEffects.ts new file mode 100644 index 00000000000..04980194b37 --- /dev/null +++ b/src/main/e2e/notificationFrameEffects.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app} from 'electron'; + +import MainWindow from 'app/mainWindow/mainWindow'; +import Config from 'common/config'; + +/** Mirrors notifications/index.ts flashFrame — E2E only. */ +export function triggerNotificationFrameEffects(flash: boolean) { + if (process.platform === 'linux' || process.platform === 'win32') { + if (Config.notifications.flashWindow) { + MainWindow.get()?.flashFrame(flash); + } + } + if (process.platform === 'darwin' && Config.notifications.bounceIcon && Config.notifications.bounceIconType) { + app.dock?.bounce(Config.notifications.bounceIconType); + } +} diff --git a/src/main/e2e/register.ts b/src/main/e2e/register.ts new file mode 100644 index 00000000000..641828bbbee --- /dev/null +++ b/src/main/e2e/register.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ipcMain} from 'electron'; + +import MainWindow from 'app/mainWindow/mainWindow'; +import createTrayMenu from 'app/menus/tray'; +import {setBadgeTestRecorder, setUnreadBadgeSetting} from 'app/system/badge'; +import Tray from 'app/system/tray/tray'; +import TabManager from 'app/tabs/tabManager'; +import WebContentsManager from 'app/views/webContentsManager'; +import PopoutManager from 'app/windows/popoutManager'; +import AppState from 'common/appState'; +import {SHOW_SETTINGS_WINDOW} from 'common/communication'; +import Config from 'common/config'; +import ServerManager from 'common/servers/serverManager'; +import ViewManager from 'common/views/viewManager'; +import {certificateErrorCallbacks} from 'main/app/app'; +import {handleShowSettingsModal} from 'main/app/intercom'; +import {openDeepLink} from 'main/app/utils'; +import Diagnostics from 'main/diagnostics'; +import notificationManager from 'main/notifications'; +import {installMessageBoxStub, restoreMessageBoxStub} from 'main/testMessageBoxStub'; +import updateNotifier from 'main/updateNotifier'; + +import {recordBadgeTestState} from './badgeState'; +import {registerE2eHooks} from './hooks'; +import {simulateNotificationClick} from './notificationClick'; +import {triggerNotificationFrameEffects} from './notificationFrameEffects'; +import {createClickTrayMenuItem} from './trayMenu'; + +/** + * Register Playwright globals and test-only IPC handlers. + * No-op outside NODE_ENV=test. + */ +export function maybeRegisterE2eHooks(): void { + if (process.env.NODE_ENV !== 'test') { + return; + } + + setBadgeTestRecorder(recordBadgeTestState); + ipcMain.on(SHOW_SETTINGS_WINDOW, handleShowSettingsModal); + + registerE2eHooks({ + e2eTestRefs: { + AppState, + MainWindow, + NotificationManager: notificationManager, + ServerManager, + TabManager, + ViewManager, + WebContentsManager, + Config, + TrayIcon: Tray, + Diagnostics, + PopoutManager, + updateNotifier, + setUnreadBadgeSetting, + }, + openDeepLink, + clickTrayMenuItem: createClickTrayMenuItem(createTrayMenu), + triggerNotificationFrameEffects, + simulateNotificationClick, + installMessageBoxStub, + restoreMessageBoxStub, + clearCertificateErrorCallbacks: () => certificateErrorCallbacks.clear(), + }); + + if (process.env.MM_E2E_STUB_MESSAGE_BOX === 'cancel') { + installMessageBoxStub([{response: 1}]); + } else if (process.env.MM_E2E_STUB_MESSAGE_BOX === 'trust') { + installMessageBoxStub([{response: 0}, {response: 0}]); + } +} diff --git a/src/main/e2e/trayMenu.ts b/src/main/e2e/trayMenu.ts new file mode 100644 index 00000000000..2b26b0c3ff5 --- /dev/null +++ b/src/main/e2e/trayMenu.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Menu} from 'electron'; + +export function createClickTrayMenuItem(getTrayMenu: () => Menu) { + return (label: string) => { + const truncated = label.length > 50 ? `${label.slice(0, 50)}...` : label; + + function clickItem(items: Electron.MenuItem[]): boolean { + for (const item of items) { + const itemLabel = typeof item.label === 'string' ? item.label : ''; + if ( + (itemLabel === label || itemLabel === truncated) && + item.enabled !== false && + item.visible !== false && + typeof item.click === 'function' + ) { + item.click(); + return true; + } + if (item.submenu?.items && clickItem(item.submenu.items)) { + return true; + } + } + return false; + } + + if (!clickItem(getTrayMenu().items)) { + throw new Error(`Tray menu item not found: ${label}`); + } + }; +} diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 4045241612f..c0fc46e5466 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -12,7 +12,6 @@ import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH, OPEN_NOTIFICATIO import Config from 'common/config'; import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; -import {setTestField} from 'common/utils/util'; import viewManager from 'common/views/viewManager'; import DeveloperMode from 'main/developerMode'; import PermissionsManager from 'main/security/permissionsManager'; @@ -279,9 +278,5 @@ function flashFrame(flash: boolean) { } } -if (process.env.NODE_ENV === 'test') { - setTestField('__e2eNotificationEffects', flashFrame); -} - const notificationManager = new NotificationManager(); export default notificationManager;