From db20b560eadb4559225b817b92462b4aec4d82a5 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 19 Jun 2026 15:32:02 +0530 Subject: [PATCH 01/22] E2E: Playwright harness, fixtures, and CI wiring (1/10). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 of splitting #3847 — core Playwright config, fixtures, electronApp teardown, and GitHub Actions E2E workflow updates. No new specs yet. --- .../install-os-dependencies/action.yaml | 3 + .github/workflows/e2e-functional-template.yml | 62 +++- .github/workflows/e2e-functional.yml | 49 ++- e2e/AGENTS.md | 22 ++ e2e/fixtures/index.ts | 90 ++--- e2e/global-setup.ts | 39 +-- e2e/global-teardown.ts | 88 +---- e2e/helpers/appReadiness.ts | 32 +- e2e/helpers/cleanup.ts | 31 +- e2e/helpers/config.ts | 1 + e2e/helpers/electronApp.ts | 322 +++++++++++++++++- e2e/package-lock.json | 53 +-- e2e/package.json | 2 +- e2e/playwright.config.ts | 88 ++++- e2e/utils/analyze-flaky-test.js | 28 +- 15 files changed, 624 insertions(+), 286 deletions(-) diff --git a/.github/actions/install-os-dependencies/action.yaml b/.github/actions/install-os-dependencies/action.yaml index 789e0250fa0..1dffc04aa73 100644 --- a/.github/actions/install-os-dependencies/action.yaml +++ b/.github/actions/install-os-dependencies/action.yaml @@ -62,6 +62,9 @@ runs: if: inputs.os == 'macOS' shell: bash run: | + # GitHub-hosted macOS runners ship aws/tap and azure/bicep pre-tapped. + # Trust them so Homebrew does not warn on every brew invocation. + brew trust aws/tap azure/bicep 2>/dev/null || true brew install nss - name: Install Windows Dependencies diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index 40c9efa4cfe..8a7746f3ff3 100644 --- a/.github/workflows/e2e-functional-template.yml +++ b/.github/workflows/e2e-functional-template.yml @@ -188,7 +188,9 @@ jobs: run: | RUNNER_OS=$(echo "${{ runner.os }}" | tr '[:upper:]' '[:lower:]') echo "RUNNER_OS=${RUNNER_OS}" >> $GITHUB_ENV - echo "CI_ENVIRONMENT_NAME=@${RUNNER_OS}" >> $GITHUB_ENV + # Report-only tag for Playwright blob/HTML output (@ci-* avoids colliding with + # platform selection grep tokens @linux/@darwin/@win32 in playwright.config.ts). + echo "CI_ENVIRONMENT_NAME=@ci-${RUNNER_OS}" >> $GITHUB_ENV # Define build type and suffix # inputs.TYPE takes precedence when provided by the caller (e.g. RELEASE, MASTER, PR). @@ -235,8 +237,19 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22.x' - cache: "npm" - cache-dependency-path: package-lock.json + package-manager-cache: false + # node_modules is cached in e2e/cache-node-modules; disable setup-node's + # npm cache so actions/cache v5 does not hit legacy entries. + + - name: e2e/use-gnu-tar-macos + if: runner.os == 'macOS' + run: | + # BSD tar on macOS can corrupt actions/cache v5 archives; prefer GNU tar when present. + if [ -x /opt/homebrew/opt/gnu-tar/libexec/gnubin/gtar ]; then + echo "/opt/homebrew/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH + elif [ -x /usr/local/opt/gnu-tar/libexec/gnubin/gtar ]; then + echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH + fi - name: e2e/cache-node-modules id: cache-node-modules @@ -244,17 +257,41 @@ jobs: with: path: | node_modules - C:\Users\runneradmin\.electron-gyp - key: ${{ runner.os }}-build-node-modules-${{ hashFiles('**/package-lock.json') }} + e2e/node_modules + enableCrossOsArchive: false + # v7: exact restore only — avoids probing legacy v6 blobs that warn on macOS + key: ${{ runner.os }}-build-node-modules-v7-${{ hashFiles('**/package-lock.json') }} + + - name: e2e/cache-electron-gyp-windows + if: runner.os == 'Windows' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: C:\Users\runneradmin\.electron-gyp + enableCrossOsArchive: false + key: ${{ runner.os }}-electron-gyp-v7-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-node-modules - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-electron-gyp-v7-${{ hashFiles('**/package-lock.json') }} + + # Clear pip's HTTP cache on macOS-26 runners before setup-python. The pre-baked + # image cache contains entries serialized by an older pip that the newer pip + # shipped with Python 3.10 cannot deserialize, producing the noisy + # "WARNING: Cache entry deserialization failed, entry ignored" lines in CI. + # See actions/setup-python#1317. + - name: e2e/clear-pip-cache-macos + if: runner.os == 'macOS' + run: | + rm -rf "${HOME}/Library/Caches/pip" || true + python3 -m pip cache purge >/dev/null 2>&1 || true - name: e2e/setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + env: + # Bypass pip's on-disk HTTP cache entirely so a stale/corrupt entry on the + # runner image cannot trigger deserialization warnings. + PIP_NO_CACHE_DIR: "1" with: python-version: "3.10" + # Omit `cache` — setup-python only accepts pip/pipenv/poetry, not `false`. - name: e2e/install-os-dependencies uses: ./.github/actions/install-os-dependencies @@ -287,7 +324,7 @@ jobs: cd e2e export PW_CHROMIUM_ARGS="--disable-gpu --no-sandbox --disable-dev-shm-usage" set +e - xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' npm test + xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' npm test -- --project=linux echo "PLAYWRIGHT_EXIT_CODE=$?" >> $GITHUB_ENV env: SERVER_VERSION: ${{ inputs.MM_SERVER_VERSION }} @@ -303,7 +340,7 @@ jobs: echo "Running E2E tests…" cd e2e set +e - npm test + npm test -- --project=darwin echo "PLAYWRIGHT_EXIT_CODE=$?" >> $GITHUB_ENV env: SERVER_VERSION: ${{ inputs.MM_SERVER_VERSION }} @@ -316,7 +353,7 @@ jobs: npm run build-test cd e2e set +e - npm test + npm test -- --project=win32 echo "PLAYWRIGHT_EXIT_CODE=$?" >> $GITHUB_ENV env: SERVER_VERSION: ${{ inputs.MM_SERVER_VERSION }} @@ -368,10 +405,9 @@ jobs: script: | process.chdir('./e2e'); const { analyzeFlakyTests } = require('./utils/analyze-flaky-test.js'); - const { failureCount, passCount, skipCount, totalCount, os } = analyzeFlakyTests(); + const { failureCount, passCount, skipCount, totalCount, os, testStatus } = 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 testStatus = failureCount > 0 || Number(process.env.PLAYWRIGHT_EXIT_CODE || 0) !== 0 ? 'failure' : 'success'; const setOSOutputs = (suffix) => { core.setOutput(`NEW_FAILURES_${suffix}`, String(failureCount)); core.setOutput(`REPORT_LINK_${suffix}`, reportUrl); diff --git a/.github/workflows/e2e-functional.yml b/.github/workflows/e2e-functional.yml index 09b2dbab4c4..d0a9e616450 100644 --- a/.github/workflows/e2e-functional.yml +++ b/.github/workflows/e2e-functional.yml @@ -239,8 +239,19 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22.x' - cache: "npm" - cache-dependency-path: package-lock.json + package-manager-cache: false + # node_modules is cached in e2e/cache-node-modules; disable setup-node's + # npm cache so actions/cache v5 does not hit legacy entries. + + - name: e2e/use-gnu-tar-macos + if: runner.os == 'macOS' + run: | + # BSD tar on macOS can corrupt actions/cache v5 archives; prefer GNU tar when present. + if [ -x /opt/homebrew/opt/gnu-tar/libexec/gnubin/gtar ]; then + echo "/opt/homebrew/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH + elif [ -x /usr/local/opt/gnu-tar/libexec/gnubin/gtar ]; then + echo "/usr/local/opt/gnu-tar/libexec/gnubin" >> $GITHUB_PATH + fi - name: e2e/cache-node-modules id: cache-node-modules @@ -248,17 +259,41 @@ jobs: with: path: | node_modules - C:\Users\runneradmin\.electron-gyp - key: ${{ runner.os }}-build-node-modules-${{ hashFiles('**/package-lock.json') }} + e2e/node_modules + enableCrossOsArchive: false + # v7: exact restore only — avoids probing legacy v6 blobs that warn on macOS + key: ${{ runner.os }}-build-node-modules-v7-${{ hashFiles('**/package-lock.json') }} + + - name: e2e/cache-electron-gyp-windows + if: runner.os == 'Windows' + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: C:\Users\runneradmin\.electron-gyp + enableCrossOsArchive: false + key: ${{ runner.os }}-electron-gyp-v7-${{ hashFiles('**/package-lock.json') }} restore-keys: | - ${{ runner.os }}-build-node-modules - ${{ runner.os }}-build- - ${{ runner.os }}- + ${{ runner.os }}-electron-gyp-v7-${{ hashFiles('**/package-lock.json') }} + + # Clear pip's HTTP cache on macOS-26 runners before setup-python. The pre-baked + # image cache contains entries serialized by an older pip that the newer pip + # shipped with Python 3.10 cannot deserialize, producing the noisy + # "WARNING: Cache entry deserialization failed, entry ignored" lines in CI. + # See actions/setup-python#1317. + - name: e2e/clear-pip-cache-macos + if: runner.os == 'macOS' + run: | + rm -rf "${HOME}/Library/Caches/pip" || true + python3 -m pip cache purge >/dev/null 2>&1 || true - name: e2e/setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + env: + # Bypass pip's on-disk HTTP cache entirely so a stale/corrupt entry on the + # runner image cannot trigger deserialization warnings. + PIP_NO_CACHE_DIR: "1" with: python-version: "3.10" + # Omit `cache` — setup-python only accepts pip/pipenv/poetry, not `false`. - name: e2e/install-os-dependencies uses: ./.github/actions/install-os-dependencies diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 2a6328a48d8..defaf6fb795 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -44,6 +44,26 @@ await exampleServer.waitForSelector('#sidebarItem_town-square'); - Reuse helpers from `e2e/helpers` before creating new launch, login, or server-discovery logic. - Do not add new `electron-mocha`, `robotjs`, or Mochawesome-based code. +## Declarative platform tags + +Platform selection is handled by Playwright **projects** in `e2e/playwright.config.ts`, not runtime `test.skip()` guards. + +Every test must declare: + +- **Priority:** `@P0`, `@P1`, or `@P2` +- **Platform:** `@all` (runs on every OS) or one or more of `@linux`, `@darwin`, `@win32` + +Special tags: + +- `@wayland` — Wayland-only coverage (`linux/wayland_launch.test.ts`). Runs only when `E2E_WAYLAND=true` on Linux via the `wayland` project; excluded from normal Linux CI. +- Policy specs under `specs/policy/` — excluded from the main CI run unless `RUN_POLICY_E2E=true`. + +Do **not** add `test.skip()` for platform gating when tags already express the intended OS. Keep runtime skips only for conditional cases (missing server URL, license/feature unavailable on the test instance). + +CI report labels (`@ci-linux`, `@ci-macos`, `@ci-windows`) come from `CI_ENVIRONMENT_NAME` and must not be used as test tags — they would collide with platform grep. + +Local/CI runs use `--project=` to match the host OS (set automatically in GitHub Actions). + ## Version Compatibility Treat dependency upgrades as infrastructure changes, not routine edits. @@ -138,6 +158,8 @@ Many server-backed specs require: Do not remove skips or platform guards unless the test can actually run in the current environment. +Platform guards belong in test **tags** (`@linux`, `@darwin`, `@win32`, `@all`, `@wayland`), not `test.skip()` — see [Declarative platform tags](#declarative-platform-tags). + Examples: - Tests tagged for Windows or Linux are not truthfully verified on macOS. - Tests requiring a live Mattermost server should not be treated as fixed unless those env vars are set and the spec was rerun. diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index ccd012a1adf..000278e398e 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -2,8 +2,6 @@ // See LICENSE.txt for license information. import * as fs from 'fs/promises'; -import * as fsSync from 'fs'; -import * as os from 'os'; import * as path from 'path'; import {test as base, type Page} from '@playwright/test'; @@ -11,8 +9,14 @@ import type {ElectronApplication} from 'playwright'; import {_electron as electron} from 'playwright'; import {waitForAppReady} from '../helpers/appReadiness'; -import {waitForLockFileRelease} from '../helpers/cleanup'; import {electronBinaryPath, appDir, demoConfig, writeConfigFile, type AppConfig} from '../helpers/config'; +import { + closeElectronApp, + FAST_TEARDOWN, + registerElectronMainProcess, + cleanupRegisteredElectronProcesses, +} from '../helpers/electronApp'; +import {closeOverlayWindowsIfOpen} from '../helpers/overlayWindows'; import {buildServerMap, type ServerMap} from '../helpers/serverMap'; export type {ServerMap, ServerEntry} from '../helpers/serverMap'; @@ -48,17 +52,31 @@ type Fixtures = { mainWindow: Page; }; -export const test = base.extend({ +type WorkerFixtures = { + + /** Worker-scoped cleanup for orphaned Electron main processes. */ + workerElectronCleanup: void; +}; + +export const test = base.extend({ + workerElectronCleanup: [async ({}, use) => { + await use(); + await Promise.race([ + cleanupRegisteredElectronProcesses(), + new Promise((resolve) => setTimeout(resolve, 20_000)), + ]); + }, {scope: 'worker'}], + appConfig: async ({}, use) => { await use(demoConfig); }, - electronApp: async ({appConfig}, use, testInfo) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + electronApp: async ({appConfig, workerElectronCleanup: _workerElectronCleanup}, use, testInfo) => { const userDataDir = path.join(testInfo.outputDir, 'userdata'); await fs.rm(userDataDir, {recursive: true, force: true}); await fs.mkdir(userDataDir, {recursive: true}); - // writeConfigFile is SYNCHRONOUS — must complete before electron.launch() writeConfigFile(userDataDir, appConfig); let launchTimeout: number; @@ -70,35 +88,25 @@ export const test = base.extend({ launchTimeout = 60_000; } - const E2E_PROCESS_REGISTRY = path.join(os.tmpdir(), 'mattermost-desktop-e2e-main-pids.txt'); - const app = await electron.launch({ executablePath: electronBinaryPath, args: [ - appDir, // test build directory (e2e/dist) + appDir, `--user-data-dir=${userDataDir}`, - - // CI compatibility — required for Linux sandbox, GPU stability '--no-sandbox', '--disable-gpu', '--disable-gpu-sandbox', '--disable-dev-shm-usage', '--no-zygote', '--disable-software-rasterizer', - - // Stability '--disable-breakpad', '--disable-features=SpareRendererForSitePerProcess', '--disable-features=CrossOriginOpenerPolicy', '--disable-renderer-backgrounding', - - // Dialogs & first-run '--no-first-run', '--no-default-browser-check', '--disable-default-apps', '--disable-crash-reporter', - - // Consistency '--force-color-profile=srgb', '--mute-audio', ], @@ -113,54 +121,20 @@ export const test = base.extend({ timeout: launchTimeout, }); - // Register PID for global teardown orphan cleanup. - // electronApp.process().pid is available here from Playwright at runtime, - // so we write it from the test side rather than from inside the app. - const launchPid = app.process()?.pid; - if (launchPid) { - try { - fsSync.appendFileSync(E2E_PROCESS_REGISTRY, `${launchPid}\n`, 'utf8'); - } catch { /* non-fatal */ } - } + registerElectronMainProcess(app.process()?.pid); await use(app); - // Teardown strategy: - // 1. Try app.close() (clean Playwright shutdown) with a 10s cap. - // 2. If it hangs, send SIGTERM and return immediately — do NOT SIGKILL. - // SIGKILL triggers macOS "Electron quit unexpectedly" crash dialogs. - // SIGTERM does not. Global teardown (pkill targeting main process only) - // will reap any lingering orphans after the full suite completes. - let pid: number | undefined; - try { - pid = app.process()?.pid; - } catch { /* app already disconnected */ } - - 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 gone */ } - // Return immediately — don't wait for the process to exit. - // Lock-file cleanup is not needed: each test has a unique userDataDir - // so a lingering lock never blocks the next test. - return; - } - - await waitForLockFileRelease(userDataDir).catch(() => {}); + await closeElectronApp(app, userDataDir, FAST_TEARDOWN); }, - // Deduplicated readiness gate. Both serverMap and mainWindow declare this - // as a dependency — Playwright runs it exactly once and tears it down once. appReady: async ({electronApp}, use) => { await waitForAppReady(electronApp); + + // Setup path: the main process is freshly launched and responsive, so + // the default 3s bound is ample for sub-100ms dropdown closes and still + // fails fast if app.evaluate hangs. No larger setup timeout is needed. + await closeOverlayWindowsIfOpen(electronApp); await use(); }, diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 4938374cc6d..9caf264a63c 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -6,7 +6,8 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -const E2E_PROCESS_REGISTRY = path.join(os.tmpdir(), 'mattermost-desktop-e2e-main-pids.txt'); +import {clearAllRegistryFiles} from './helpers/electronApp'; + const MACOS_DEFAULTS_SNAPSHOT = path.join(os.tmpdir(), 'mattermost-desktop-e2e-macos-defaults-snapshot.json'); function readMacOsDefault(domain: string, key: string): string | null { @@ -17,45 +18,32 @@ function readMacOsDefault(domain: string, key: string): string | null { } } -/** - * Disable macOS window-restoration (Resume) for the Electron binary used in tests. - * - * When Electron is killed by a signal (SIGTERM from fixture teardown or SIGKILL from - * Playwright's worker timeout), macOS marks the process as having "quit unexpectedly" - * and shows a "Do you want to reopen its windows?" dialog on the next launch. - * That dialog blocks the app UI, so __e2eAppReady is never set, waitForAppReady times - * out, and fixture teardown hangs — causing more kills, more dialogs, and so on. - * - * NSQuitAlwaysKeepsWindows = false — don't offer to restore windows after unexpected quit - * ApplePersistenceIgnoreState = YES — skip saved-state restoration on every launch - */ export default async function globalSetup() { + // Clear stale per-worker PID shards (and any legacy shared file) from a + // prior crashed run. We only delete files here, never signal pids, because + // pids may have been reused by unrelated processes since that run. try { - fs.rmSync(E2E_PROCESS_REGISTRY, {force: true}); + clearAllRegistryFiles(); } catch { // ignore stale registry cleanup failures } if (process.platform === 'darwin') { - // Multiple bundle IDs may be involved: com.github.Electron (Electron binary - // launched directly) and the app's own bundle ID (when running signed builds). const bundleIDs = ['com.github.Electron']; for (const bundleID of bundleIDs) { try { execFileSync('defaults', ['write', bundleID, 'NSQuitAlwaysKeepsWindows', '-bool', 'false'], {stdio: 'pipe'}); } catch { - // Non-fatal — tests still run, just potentially with the Resume dialog + // non-fatal } try { execFileSync('defaults', ['write', bundleID, 'ApplePersistenceIgnoreState', '-bool', 'YES'], {stdio: 'pipe'}); } catch { - // Non-fatal + // non-fatal } } - // Snapshot system defaults we are about to override so global-teardown - // can restore them (or delete keys that did not exist before). try { const snapshot = { LSQuarantine: readMacOsDefault('com.apple.LaunchServices', 'LSQuarantine'), @@ -63,24 +51,19 @@ export default async function globalSetup() { }; fs.writeFileSync(MACOS_DEFAULTS_SNAPSHOT, JSON.stringify(snapshot), 'utf8'); } catch { - // Non-fatal — teardown will skip restore if file missing + // non-fatal } - // Apply system-level settings to suppress macOS dialogs that block - // Electron startup. These target system domains (LaunchServices, - // CrashReporter) rather than per-app bundle IDs. try { execFileSync('defaults', ['write', 'com.apple.LaunchServices', 'LSQuarantine', '-bool', 'false'], {stdio: 'pipe'}); } catch { - // Non-fatal + // non-fatal } - // Suppress the macOS crash dialog ("Electron quit unexpectedly") that - // appears when a process exits via SIGTERM or other unexpected quits. try { execFileSync('defaults', ['write', 'com.apple.CrashReporter', 'DialogType', 'none'], {stdio: 'pipe'}); } catch { - // Non-fatal + // non-fatal } } } diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts index 37c163f13a5..838fb9d3bd8 100644 --- a/e2e/global-teardown.ts +++ b/e2e/global-teardown.ts @@ -6,7 +6,8 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -const E2E_PROCESS_REGISTRY = path.join(os.tmpdir(), 'mattermost-desktop-e2e-main-pids.txt'); +import {cleanupAllRegisteredElectronProcesses} from './helpers/electronApp'; + const MACOS_DEFAULTS_SNAPSHOT = path.join(os.tmpdir(), 'mattermost-desktop-e2e-macos-defaults-snapshot.json'); function restoreMacOsDefaultsSnapshot() { @@ -44,87 +45,12 @@ function restoreMacOsDefaultsSnapshot() { } } -/** - * Kill any main Electron processes still running from this test suite. - * - * Main test processes append their PID to a temp registry during startup. - * Teardown kills only those registered main-process PIDs, avoiding broad shell - * matching across unrelated Electron helper processes. - */ export default async function globalTeardown() { restoreMacOsDefaultsSnapshot(); - let pids: number[] = []; - try { - if (fs.existsSync(E2E_PROCESS_REGISTRY)) { - pids = Array.from(new Set( - fs.readFileSync(E2E_PROCESS_REGISTRY, 'utf8'). - split(/\s+/). - map((value) => Number.parseInt(value, 10)). - filter((value) => Number.isInteger(value) && value > 0), - )); - } - fs.rmSync(E2E_PROCESS_REGISTRY, {force: true}); - } catch { - pids = []; - } - - for (const pid of pids) { - if (process.platform === 'win32') { - try { - execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], {stdio: 'ignore'}); - } catch { - // already exited - } - continue; - } - - if (!isProcessAlive(pid)) { - continue; - } - - try { - process.kill(pid, 'SIGTERM'); - } catch { - continue; - } - - // Give the process time to exit gracefully. - // On macOS, use a longer wait since Electron shutdown can be slow. - const waitMs = process.platform === 'darwin' ? 10_000 : 5_000; - const deadline = Date.now() + waitMs; - while (Date.now() < deadline) { - if (!isProcessAlive(pid)) { - break; - } - await sleep(200); - } - - if (isProcessAlive(pid)) { - // On macOS, SIGKILL triggers the "quit unexpectedly" crash dialog - // which blocks subsequent Electron launches. Skip SIGKILL and let - // the process linger — global-setup clears the registry, and each - // test uses a unique userDataDir so orphans never block new tests. - if (process.platform !== 'darwin') { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // already exited - } - } - } - } -} - -function isProcessAlive(pid: number) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); + // Reap any Electron main processes left registered across all workers (e.g. + // from workers that crashed or skipped their worker teardown), then remove + // every registry shard. Shared with the worker-scoped cleanup so the kill + // strategy lives in one place and stays consistent across platforms. + await cleanupAllRegisteredElectronProcesses(); } diff --git a/e2e/helpers/appReadiness.ts b/e2e/helpers/appReadiness.ts index a41fd923804..28cf23c0258 100644 --- a/e2e/helpers/appReadiness.ts +++ b/e2e/helpers/appReadiness.ts @@ -4,30 +4,8 @@ import {expect} from '@playwright/test'; import type {ElectronApplication} from 'playwright'; -/** - * Wait until the main process has set global.__e2eAppReady = true. - * - * This flag is set in src/main/app/initialize.ts after handleMainWindowIsShown() - * when NODE_ENV === 'test'. It fires once per app launch, after all views are - * initialized and the main window is shown. - * - * IMPORTANT: app.evaluate() runs in the MAIN process context. - * ipcRenderer does NOT exist there — only main-process Electron APIs do. - * We read the global directly, not via IPC. - */ export async function waitForAppReady(app: ElectronApplication): Promise { - // macOS CI runners are slower and may show Resume dialogs that delay startup. - // Windows GitHub-hosted runners are similarly slow (cold-start Electron + - // Visual Studio environment); 30s consistently timed out on `windows-2022`. - // Linux (xvfb) is fastest. 60s on Windows matches the macOS budget. - let timeout: number; - if (process.platform === 'darwin') { - timeout = 60_000; - } else if (process.platform === 'win32') { - timeout = 60_000; - } else { - timeout = 30_000; - } + const timeout = process.platform === 'linux' ? 30_000 : 60_000; await expect.poll( async () => { @@ -45,13 +23,7 @@ export async function waitForAppReady(app: ElectronApplication): Promise { } }, { - message: [ - 'Timed out waiting for __e2eAppReady.', - `Timeout: ${timeout}ms.`, - 'Check that initialize.ts sets __e2eAppReady after handleMainWindowIsShown().', - 'On macOS, verify that global-setup.ts successfully wrote NSQuitAlwaysKeepsWindows=false', - 'to prevent the "Reopen windows" dialog from blocking startup.', - ].join(' '), + message: `Timed out waiting for __e2eAppReady (${timeout}ms)`, timeout, intervals: [200, 500, 1000, 2000], }, diff --git a/e2e/helpers/cleanup.ts b/e2e/helpers/cleanup.ts index fc9455ace80..cdd506443e5 100644 --- a/e2e/helpers/cleanup.ts +++ b/e2e/helpers/cleanup.ts @@ -19,12 +19,27 @@ import {expect} from '@playwright/test'; */ export async function waitForLockFileRelease(userDataDir: string): Promise { const lockFile = path.join(userDataDir, 'SingletonLock'); - await expect.poll( - () => !fs.existsSync(lockFile), - { - message: `SingletonLock not released at ${lockFile}`, - timeout: process.platform === 'win32' ? 10_000 : 5_000, - intervals: [100, 200, 500, 1000], - }, - ).toBe(true); + const timeout = process.platform === 'win32' ? 15_000 : 5_000; + + try { + await expect.poll( + () => !fs.existsSync(lockFile), + { + message: `SingletonLock not released at ${lockFile}`, + timeout, + intervals: [100, 200, 500, 1000], + }, + ).toBe(true); + } catch { + if (fs.existsSync(lockFile)) { + try { + fs.unlinkSync(lockFile); + } catch { + // Best-effort cleanup when a child process kept the lock open. + } + } + if (fs.existsSync(lockFile)) { + throw new Error(`SingletonLock still present after cleanup attempt: ${lockFile}`); + } + } } diff --git a/e2e/helpers/config.ts b/e2e/helpers/config.ts index 871d4225822..383fcd9aeb1 100644 --- a/e2e/helpers/config.ts +++ b/e2e/helpers/config.ts @@ -34,6 +34,7 @@ export type AppConfig = { showTrayIcon: boolean; trayIconTheme: string; minimizeToTray: boolean; + alwaysClose?: boolean; notifications: {flashWindow: number; bounceIcon: boolean; bounceIconType: string}; showUnreadBadge: boolean; useSpellChecker: boolean; diff --git a/e2e/helpers/electronApp.ts b/e2e/helpers/electronApp.ts index d1bb5fcbe04..b8aef5abd9f 100644 --- a/e2e/helpers/electronApp.ts +++ b/e2e/helpers/electronApp.ts @@ -1,10 +1,279 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {execFileSync} from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + import {waitForLockFileRelease} from './cleanup'; type ElectronApplication = Awaited>; +/** + * PID registry for orphaned Electron main processes. + * + * Each Playwright worker is a separate Node process, so the registry is sharded + * per worker (`...-.txt`). This avoids the read-modify-write races a + * single shared file had under fullyParallel: a worker only ever touches its + * own shard, and tests run serially within a worker, so register/unregister + * never contend. Worker teardown reaps this worker's shard; global teardown + * enumerates every shard (plus a legacy shared file from older runs) as the + * final backstop. + */ +const REGISTRY_DIR = os.tmpdir(); +const REGISTRY_PREFIX = 'mattermost-desktop-e2e-main-pids'; +const LEGACY_REGISTRY = path.join(REGISTRY_DIR, 'mattermost-desktop-e2e-main-pids.txt'); + +export type CloseElectronAppOptions = { + skipLockWaitUnlessCleanClose?: boolean; +}; + +/** Unique per-test userDataDir (fixture path): abandon fast, reap via worker cleanup. */ +export const FAST_TEARDOWN: CloseElectronAppOptions = { + skipLockWaitUnlessCleanClose: true, +}; + +/** Convenience wrapper for {@link FAST_TEARDOWN}. */ +export async function closeElectronAppFast( + app: ElectronApplication, + dataDir?: string, +): Promise { + return closeElectronApp(app, dataDir, FAST_TEARDOWN); +} + +function workerRegistryPath(workerPid: number = process.pid): string { + return path.join(REGISTRY_DIR, `${REGISTRY_PREFIX}-${workerPid}.txt`); +} + +function listRegistryFiles(): string[] { + const files: string[] = []; + try { + for (const entry of fs.readdirSync(REGISTRY_DIR)) { + if (entry.startsWith(`${REGISTRY_PREFIX}-`) && entry.endsWith('.txt')) { + files.push(path.join(REGISTRY_DIR, entry)); + } + } + } catch { + // tmpdir unreadable; best-effort + } + if (fs.existsSync(LEGACY_REGISTRY)) { + files.push(LEGACY_REGISTRY); + } + return files; +} + +function readPidsFromFile(file: string): number[] { + try { + if (!fs.existsSync(file)) { + return []; + } + return Array.from(new Set( + fs.readFileSync(file, 'utf8'). + split(/\s+/). + map((value) => Number.parseInt(value, 10)). + filter((value) => Number.isInteger(value) && value > 0), + )); + } catch { + return []; + } +} + +export function registerElectronMainProcess(pid: number | undefined) { + if (!pid) { + return; + } + try { + fs.appendFileSync(workerRegistryPath(), `${pid}\n`, 'utf8'); + } catch { + // non-fatal + } +} + +export function unregisterElectronMainProcess(pid: number | undefined) { + if (!pid) { + return; + } + + // Only the current worker touches its own shard (tests are serial within a + // worker), so a plain read-modify-write here is race-free. + const file = workerRegistryPath(); + try { + if (!fs.existsSync(file)) { + return; + } + const remaining = fs.readFileSync(file, 'utf8'). + split(/\n/). + filter((line) => { + const value = Number.parseInt(line.trim(), 10); + return Number.isInteger(value) && value > 0 && value !== pid; + }); + if (remaining.length > 0) { + fs.writeFileSync(file, `${remaining.join('\n')}\n`, 'utf8'); + } else { + fs.rmSync(file, {force: true}); + } + } catch { + // non-fatal + } +} + +/** + * Reap this worker's registered Electron main processes. Called from the + * worker-scoped fixture teardown, so it only touches this worker's shard. + */ +export async function cleanupRegisteredElectronProcesses(): Promise { + const file = workerRegistryPath(); + const pids = readPidsFromFile(file); + fs.rmSync(file, {force: true}); + await reapPids(pids); +} + +/** + * Reap every worker's registered Electron main processes. Called from global + * teardown as the final backstop for processes left by workers that crashed or + * skipped their worker-scoped teardown. + */ +export async function cleanupAllRegisteredElectronProcesses(): Promise { + const files = listRegistryFiles(); + const pids = Array.from(new Set(files.flatMap(readPidsFromFile))); + for (const file of files) { + fs.rmSync(file, {force: true}); + } + await reapPids(pids); +} + +/** + * Remove every registry shard without reaping. Used at global setup to clear + * stale files from a prior crashed run; we deliberately do not signal any pids + * here because they may have been reused by unrelated processes since that run. + */ +export function clearAllRegistryFiles(): void { + for (const file of listRegistryFiles()) { + fs.rmSync(file, {force: true}); + } +} + +async function reapPids(pids: number[]): Promise { + if (pids.length === 0) { + return; + } + + const alive = pids.filter(isProcessAlive); + if (alive.length === 0) { + return; + } + + for (const pid of alive) { + if (process.platform === 'linux') { + signalProcessGroup(pid, 'SIGKILL'); + forceKillLinuxProcessTree(pid); + } else { + signalShutdownAndReturn(pid); + } + } + + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (alive.every((pid) => !isProcessAlive(pid))) { + return; + } + await sleep(200); + } +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) { + return true; + } + await sleep(200); + } + return !isProcessAlive(pid); +} + +function signalProcessGroup(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(-pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + // already exited + } + } +} + +function forceKillLinuxProcessTree(pid: number): void { + try { + execFileSync('pkill', ['-KILL', '-P', String(pid)], {stdio: 'ignore'}); + } catch { + // no child processes + } + signalProcessGroup(pid, 'SIGKILL'); +} + +async function drainPlaywrightClose(closePromise: Promise): Promise { + // Playwright keeps a gracefullyClose entry until app.close() settles (#29431). + // Cap the wait so worker teardown does not hang for 90s when close never settles. + await Promise.race([closePromise, sleep(3_000)]); +} + +async function forceShutdownLinux(pid: number): Promise { + if (!isProcessAlive(pid)) { + return; + } + + signalProcessGroup(pid, 'SIGTERM'); + if (await waitForProcessExit(pid, 3_000)) { + return; + } + + forceKillLinuxProcessTree(pid); + await waitForProcessExit(pid, 10_000); +} + +function signalShutdownAndReturn(pid: number): void { + if (process.platform === 'win32') { + try { + execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], {stdio: 'ignore'}); + } catch { + // already exited + } + return; + } + + try { + process.kill(pid, 'SIGTERM'); + } catch { + // already exited + } +} + +async function attemptClose(app: ElectronApplication, timeoutMs: number): Promise { + let closed = false; + const closePromise = app.close().catch(() => {}).then(() => { + closed = true; + }); + await Promise.race([closePromise, sleep(timeoutMs)]); + await drainPlaywrightClose(closePromise); + return closed; +} + export async function waitForWindow(app: ElectronApplication, pattern: string, timeout = 30_000) { const timeoutAt = Date.now() + timeout; while (Date.now() < timeoutAt) { @@ -21,13 +290,17 @@ export async function waitForWindow(app: ElectronApplication, pattern: string, t return win; } - await new Promise((resolve) => setTimeout(resolve, 200)); + await sleep(200); } throw new Error(`Timed out waiting for window matching "${pattern}"`); } -export async function closeElectronApp(app: ElectronApplication, dataDir: string) { +export async function closeElectronApp( + app: ElectronApplication, + dataDir?: string, + options: CloseElectronAppOptions = {}, +) { let pid: number | undefined; try { pid = app.process()?.pid; @@ -35,21 +308,42 @@ export async function closeElectronApp(app: ElectronApplication, dataDir: string pid = undefined; } - let cleanClosed = false; - await Promise.race([ - app.close().catch(() => {}).then(() => { - cleanClosed = true; - }), - new Promise((resolve) => setTimeout(resolve, 10_000)), - ]); + // `skipLockWaitUnlessCleanClose` marks a unique per-test userDataDir (the + // fixture path): teardown abandons fast and lets worker/global cleanup reap + // orphans, matching master's model. Without it (direct-launch specs), the + // same userDataDir may be relaunched, so we force-kill on failure (Linux) + // and always wait for the SingletonLock to release. + const fastTeardown = Boolean(options.skipLockWaitUnlessCleanClose); + + const cleanClosed = await attemptClose(app, 10_000); if (!cleanClosed && pid) { - try { - process.kill(pid, 'SIGTERM'); - } catch { - // already exited + if (process.platform === 'linux') { + // Always SIGKILL stuck trees on Linux so worker teardown does not sit + // in Playwright's 90s gracefullyClose wait with live Electron PIDs. + await forceShutdownLinux(pid); + } else { + signalShutdownAndReturn(pid); + } + } + + // Fast path on a failed close: return immediately (master-style). The lock + // lives in an abandoned dir and worker/global cleanup reaps any live PID. + if (fastTeardown && !cleanClosed) { + if (pid && !isProcessAlive(pid)) { + unregisterElectronMainProcess(pid); } + return; } - await waitForLockFileRelease(dataDir).catch(() => {}); + // Full path always waits for the lock; fast path only on a clean close. + if (dataDir && (cleanClosed || !fastTeardown)) { + await waitForLockFileRelease(dataDir).catch(() => {}); + } + + // Drop the PID from the registry only once it's actually gone; a live + // leftover stays for worker/global cleanup to reap (matches master). + if (!pid || !isProcessAlive(pid)) { + unregisterElectronMainProcess(pid); + } } diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 2c40fcec021..da10cb6ca9a 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -14,7 +14,7 @@ "ps-node": "0.1.6" }, "devDependencies": { - "@playwright/test": "1.58.0", + "@playwright/test": "1.61.0", "cross-env": "^10.1.0" } }, @@ -26,13 +26,13 @@ "license": "MIT" }, "node_modules/@playwright/test": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", - "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.0" + "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -106,6 +106,21 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -124,13 +139,13 @@ } }, "node_modules/playwright": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", - "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.0" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -143,9 +158,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -155,20 +170,6 @@ "node": ">=18" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/ps-node": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/ps-node/-/ps-node-0.1.6.tgz", diff --git a/e2e/package.json b/e2e/package.json index 50c431c3ea0..50f45408b33 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -24,7 +24,7 @@ "ps-node": "0.1.6" }, "devDependencies": { - "@playwright/test": "1.58.0", + "@playwright/test": "1.61.0", "cross-env": "^10.1.0" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index e6bd95d5ace..b73f260e57d 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,17 +3,26 @@ import * as os from 'os'; -import {defineConfig} from '@playwright/test'; - -let platformGrep: RegExp; -if (process.platform === 'darwin') { - platformGrep = /@all|@darwin/; -} else if (process.platform === 'win32') { - platformGrep = /@all|@win32/; -} else { - platformGrep = /@all|@linux/; +import {defineConfig, type Project} from '@playwright/test'; + +type Platform = 'linux' | 'darwin' | 'win32'; + +function getActivePlatform(): Platform { + if (process.platform === 'darwin') { + return 'darwin'; + } + if (process.platform === 'win32') { + return 'win32'; + } + return 'linux'; } +const PLATFORM_GREP: Record = { + linux: /@all|@linux/, + darwin: /@all|@darwin/, + win32: /@all|@win32/, +}; + // Each test gets its own isolated userDataDir (testInfo.outputDir/userdata), so each // Electron instance has its own SingletonLock — parallel workers never conflict. // Electron processes are heavy (~300MB each), so cap at 2 in CI and half the CPU @@ -21,7 +30,51 @@ if (process.platform === 'darwin') { const cpuCount = os.cpus().length; const defaultWorkers = process.env.CI ? 2 : Math.min(4, Math.max(1, Math.floor(cpuCount / 2))); const workers = process.env.E2E_WORKERS ? parseInt(process.env.E2E_WORKERS, 10) : defaultWorkers; -const ciEnvironmentTag = process.env.CI_ENVIRONMENT_NAME; + +// Prepended to each test in blob/HTML reports so multi-environment runs are distinguishable +// when merging. Must NOT reuse platform grep tokens (@linux, @darwin, @win32, @all) — +// Playwright inherits config tags onto file suites, which would make Linux grep match +// every test when CI used to set CI_ENVIRONMENT_NAME=@linux. +function getReportTag(): string | undefined { + const raw = process.env.CI_ENVIRONMENT_NAME; + if (!raw) { + return undefined; + } + + const legacyReportTags: Record = { + '@linux': '@ci-linux', + '@macos': '@ci-macos', + '@windows': '@ci-windows', + }; + + return legacyReportTags[raw] ?? raw; +} + +const reportTag = getReportTag(); +const excludePolicyFromMainRun = Boolean(process.env.CI) && process.env.RUN_POLICY_E2E !== 'true'; +const activePlatform = getActivePlatform(); + +function buildPlatformProjects(): Project[] { + const policyFilter = excludePolicyFromMainRun ? {grepInvert: /[/\\]policy[/\\]/} : {}; + + const projects: Project[] = [ + { + name: activePlatform, + grep: PLATFORM_GREP[activePlatform], + ...policyFilter, + }, + ]; + + if (process.env.E2E_WAYLAND === 'true' && activePlatform === 'linux') { + projects.push({ + name: 'wayland', + grep: /@wayland/, + }); + } + + return projects; +} + const reporters = process.env.CI ? [ ['blob', {outputDir: 'blob-report'}], ['line'], @@ -49,7 +102,13 @@ export default defineConfig({ // while still failing reasonably fast on a genuinely-stuck test. timeout: 90_000, - ...(ciEnvironmentTag ? {tag: ciEnvironmentTag} : {}), + // Worker teardown reaps this worker's orphaned Electron main processes. The + // per-worker PID registry + concurrent SIGKILL reap keep this to ~2s even + // with several leftovers, so 90s is ample headroom. If teardown ever + // approaches this it signals a reaping gap to fix, not a timeout to raise. + workerTeardownTimeout: 90_000, + + ...(reportTag ? {tag: reportTag} : {}), reporter: reporters, @@ -59,10 +118,5 @@ export default defineConfig({ video: 'retain-on-failure', }, - projects: [ - { - name: process.platform, - grep: platformGrep, - }, - ], + projects: buildPlatformProjects(), }); diff --git a/e2e/utils/analyze-flaky-test.js b/e2e/utils/analyze-flaky-test.js index 1f59053ccb0..356a733d49b 100644 --- a/e2e/utils/analyze-flaky-test.js +++ b/e2e/utils/analyze-flaky-test.js @@ -3,11 +3,28 @@ const fs = require('fs'); const path = require('path'); - -const {XMLParser} = require('fast-xml-parser'); +const {createRequire} = require('module'); const JUNIT_REPORT_PATH = path.join(__dirname, '..', 'test-results', 'e2e-junit.xml'); +function getXMLParserClass() { + const packageCandidates = [ + path.join(__dirname, '..', 'package.json'), + path.join(__dirname, '..', '..', 'package.json'), + ]; + + for (const packageJson of packageCandidates) { + try { + const {XMLParser} = createRequire(packageJson)('fast-xml-parser'); + return XMLParser; + } catch { + // try the other package root (e2e/ vs repo root) + } + } + + throw new Error('fast-xml-parser is not installed. Run npm ci in the repo root and e2e/.'); +} + function toNumber(value) { const parsed = parseInt(value, 10); return Number.isNaN(parsed) ? 0 : parsed; @@ -187,8 +204,9 @@ function getOutcomeCounts(report) { function analyzeFlakyTests() { const exitCode = toNumber(process.env.PLAYWRIGHT_EXIT_CODE || '0'); + const hasJunit = fs.existsSync(JUNIT_REPORT_PATH); - if (!fs.existsSync(JUNIT_REPORT_PATH)) { + if (!hasJunit) { const failureCount = exitCode === 0 ? 0 : 1; return { failureCount, @@ -197,9 +215,11 @@ function analyzeFlakyTests() { totalCount: failureCount, newFailedTests: new Array(failureCount).fill('unknown'), os: process.platform, + testStatus: failureCount > 0 ? 'failure' : 'success', }; } + const XMLParser = getXMLParserClass(); const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', @@ -215,6 +235,7 @@ 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, @@ -223,6 +244,7 @@ function analyzeFlakyTests() { totalCount: reconciledFailed + reconciledPassed + outcomes.skipped, newFailedTests: new Array(failureCount).fill('failed'), os: process.platform, + testStatus, }; } From f46ebbcff1e1c2a4cca756eb0c41ead71288ec30 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 19 Jun 2026 15:32:09 +0530 Subject: [PATCH 02/22] E2E: Main-process test hooks and shared launch helpers (2/10). Exposes __e2eTestRefs, message-box stub, tray/deep-link hooks (NODE_ENV=test only) plus directLaunch, testRefs, and shared helper updates. --- e2e/helpers/dialog.ts | 42 +++++++++++ e2e/helpers/directLaunch.ts | 72 ++++++++++++++++++ e2e/helpers/login.ts | 79 +++++++++++++------- e2e/helpers/notificationEffects.ts | 21 ++++++ e2e/helpers/overlayWindows.ts | 30 ++++++++ e2e/helpers/prepareServerView.ts | 24 ++++++ e2e/helpers/serverMap.ts | 13 ++-- e2e/helpers/serverView.ts | 25 +++++-- e2e/helpers/testRefs.ts | 116 +++++++++++++++++++++++++++++ e2e/helpers/tray.ts | 35 +++++++++ package-lock.json | 110 ++++++++++++++++++++++++++- package.json | 1 + src/main/app/initialize.test.js | 9 +++ src/main/app/initialize.ts | 49 +++++++++++- src/main/notifications/index.ts | 5 ++ src/main/testMessageBoxStub.ts | 38 ++++++++++ 16 files changed, 630 insertions(+), 39 deletions(-) create mode 100644 e2e/helpers/dialog.ts create mode 100644 e2e/helpers/directLaunch.ts create mode 100644 e2e/helpers/notificationEffects.ts create mode 100644 e2e/helpers/overlayWindows.ts create mode 100644 e2e/helpers/prepareServerView.ts create mode 100644 e2e/helpers/testRefs.ts create mode 100644 e2e/helpers/tray.ts create mode 100644 src/main/testMessageBoxStub.ts diff --git a/e2e/helpers/dialog.ts b/e2e/helpers/dialog.ts new file mode 100644 index 00000000000..675f1249971 --- /dev/null +++ b/e2e/helpers/dialog.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +type MessageBoxResponse = { + response: number; + checkboxChecked?: boolean; +}; + +export async function stubMessageBoxResponses( + app: ElectronApplication, + responses: MessageBoxResponse[], +): Promise { + if (responses.length === 0) { + throw new Error('stubMessageBoxResponses requires at least one response'); + } + + await app.evaluate((_electron, value) => { + const stub = (global as any).__e2eStubMessageBoxResponses as ((responses: MessageBoxResponse[]) => void) | undefined; + if (!stub) { + throw new Error('__e2eStubMessageBoxResponses is not available'); + } + stub(value); + }, responses); +} + +export async function restoreMessageBox(app: ElectronApplication): Promise { + await app.evaluate(() => { + const restore = (global as any).__e2eRestoreMessageBox as (() => void) | undefined; + if (restore) { + restore(); + } + }); +} + +export async function clearCertificateErrorCallbacks(app: ElectronApplication): Promise { + await app.evaluate(() => { + const clear = (global as any).__e2eClearCertificateErrorCallbacks as (() => void) | undefined; + clear?.(); + }); +} diff --git a/e2e/helpers/directLaunch.ts b/e2e/helpers/directLaunch.ts new file mode 100644 index 00000000000..08f02d442da --- /dev/null +++ b/e2e/helpers/directLaunch.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; +import {_electron as electron} from 'playwright'; + +import {waitForAppReady} from './appReadiness'; +import {electronBinaryPath, appDir, writeConfigFile, type AppConfig} from './config'; +import {registerElectronMainProcess} from './electronApp'; + +export const DIRECT_LAUNCH_ARGS = [ + '--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', + '--no-first-run', + '--disable-default-apps', + '--disable-crash-reporter', + '--force-color-profile=srgb', + '--mute-audio', +]; + +export type LaunchDirectTestAppOptions = { + extraEnv?: Record; + writeConfig?: boolean; +}; + +function resolveLaunchOptions( + extraEnvOrOptions: Record | LaunchDirectTestAppOptions, +): LaunchDirectTestAppOptions { + if ('writeConfig' in extraEnvOrOptions || 'extraEnv' in extraEnvOrOptions) { + return extraEnvOrOptions; + } + return {extraEnv: extraEnvOrOptions}; +} + +export async function launchDirectTestApp( + userDataDir: string, + config: AppConfig | object, + extraEnvOrOptions: Record | LaunchDirectTestAppOptions = {}, +): Promise { + const {extraEnv = {}, writeConfig = true} = resolveLaunchOptions(extraEnvOrOptions); + + if (writeConfig) { + writeConfigFile(userDataDir, config as AppConfig); + } + + const app = await electron.launch({ + executablePath: electronBinaryPath, + args: [appDir, `--user-data-dir=${userDataDir}`, ...DIRECT_LAUNCH_ARGS], + env: { + ...process.env, + NODE_ENV: 'test', + RESOURCES_PATH: appDir, + ELECTRON_DISABLE_SECURITY_WARNINGS: 'true', + ELECTRON_NO_ATTACH_CONSOLE: 'true', + NODE_OPTIONS: '--no-warnings', + ...extraEnv, + }, + timeout: process.platform === 'win32' ? 120_000 : 90_000, + }); + + registerElectronMainProcess(app.process()?.pid); + await waitForAppReady(app); + return app; +} diff --git a/e2e/helpers/login.ts b/e2e/helpers/login.ts index c41d3d6bc30..7090d189b71 100644 --- a/e2e/helpers/login.ts +++ b/e2e/helpers/login.ts @@ -1,22 +1,43 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {expect} from '@playwright/test'; + import type {ServerView} from './serverView'; -async function waitForAppShell(win: ServerView, timeout: number) { - const results = await Promise.allSettled([ - win.waitForSelector('#post_textbox', {timeout}), - win.waitForSelector('#channelHeaderTitle', {timeout}), - win.waitForSelector('input.search-bar.form-control', {timeout}), - ]); +async function isMattermostServerUrl(win: ServerView): Promise { + const url = await win.url().catch(() => ''); + return url.startsWith('http://') || url.startsWith('https://'); +} + +async function hasAppShell(win: ServerView): Promise { + if (!(await isMattermostServerUrl(win))) { + return false; + } - return results.some((result) => result.status === 'fulfilled'); + return win.runInRenderer(` + return Boolean( + document.querySelector('#post_textbox') + || document.querySelector('#channelHeaderTitle') + || document.querySelector('input.search-bar.form-control'), + ); + `).catch(() => false); +} + +async function hasLoginForm(win: ServerView): Promise { + if (!(await isMattermostServerUrl(win))) { + return false; + } + + return win.runInRenderer(` + return Boolean(document.querySelector('#input_loginId')); + `).catch(() => false); } /** * Log in to a Mattermost server in the given window/page. - * Requires MM_TEST_USER_NAME and MM_TEST_PASSWORD env vars. - * Requires MM_TEST_SERVER_URL to be set in the app config (use demoMattermostConfig). + * Callers must ensure the server WebContentsView is loaded first + * (switch server, prepareMattermostServerView, waitForMattermostShell). */ export async function loginToMattermost(win: ServerView): Promise { const username = process.env.MM_TEST_USER_NAME; @@ -26,33 +47,37 @@ export async function loginToMattermost(win: ServerView): Promise { throw new Error('MM_TEST_USER_NAME and MM_TEST_PASSWORD must be set for tests requiring login'); } - const timeout = process.platform === 'win32' ? 60_000 : 30_000; - + const timeout = process.platform === 'win32' ? 60_000 : 45_000; const loginSelector = '#input_loginId'; const passwordSelector = '#input_password-input, input[type="password"]'; - const submitSelector = 'button[type="submit"]'; - - let onLoginPage = false; - try { - await win.waitForSelector(loginSelector, {timeout}); - onLoginPage = true; - } catch { - if (await waitForAppShell(win, 5_000)) { - return; + const submitSelector = '#saveSetting, button[type="submit"]'; + + await expect.poll(async () => { + if (await hasAppShell(win)) { + return 'logged-in'; } - } + if (await hasLoginForm(win)) { + return 'login-form'; + } + return 'loading'; + }, { + timeout, + intervals: [500, 1000, 2000], + message: `Mattermost login form or app shell must appear (URL: ${await win.url()})`, + }).not.toBe('loading'); - if (!onLoginPage) { - throw new Error(`loginToMattermost: login form was not found and the app shell never appeared. Current URL: ${await win.url()}`); + if (await hasAppShell(win)) { + return; } await win.fill(loginSelector, username); await win.fill(passwordSelector, password); await win.click(submitSelector); - // Wait for login to complete: URL leaves /login await win.waitForURL((url) => !url.pathname.startsWith('/login'), {timeout}); - if (!await waitForAppShell(win, timeout)) { - throw new Error(`loginToMattermost: login succeeded but the app shell never became ready. Current URL: ${await win.url()}`); - } + await expect.poll(async () => hasAppShell(win), { + timeout, + intervals: [500, 1000, 2000], + message: `Mattermost app shell must appear after login (URL: ${await win.url()})`, + }).toBe(true); } diff --git a/e2e/helpers/notificationEffects.ts b/e2e/helpers/notificationEffects.ts new file mode 100644 index 00000000000..ef1d6bb68ba --- /dev/null +++ b/e2e/helpers/notificationEffects.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +/** + * Invoke the production flashFrame() helper from src/main/notifications/index.ts. + * + * OS notification delivery is unreliable in headless CI (Electron's Notification + * often emits `failed` without `show`), so flash_taskbar and dock_bounce tests + * exercise the same flashFrame() gate the notification `show` handler calls. + */ +export async function triggerFlashEffects(app: ElectronApplication, flash = true): Promise { + await app.evaluate((_, shouldFlash: boolean) => { + const trigger = (global as any).__e2eFlashEffects as ((value: boolean) => void) | undefined; + if (!trigger) { + throw new Error('__e2eFlashEffects not exposed (NODE_ENV must be test)'); + } + trigger(shouldFlash); + }, flash); +} diff --git a/e2e/helpers/overlayWindows.ts b/e2e/helpers/overlayWindows.ts new file mode 100644 index 00000000000..358d67e8fb0 --- /dev/null +++ b/e2e/helpers/overlayWindows.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export async function closeOverlayWindowsIfOpen(app: ElectronApplication, timeoutMs = 3_000): Promise { + // app.evaluate can hang if the Electron main process is blocked or unresponsive + // (e.g. during teardown). Cap the wait so setup/teardown never deadlock. + const closePromise = app.evaluate(({BrowserWindow}) => { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) { + continue; + } + try { + const url = win.webContents.getURL(); + if (url.includes('dropdown') || url.includes('downloadsDropdown.html')) { + win.close(); + } + } catch { + // Ignore windows that disappear while iterating. + } + } + }).catch(() => { + // Ignore evaluation failures (e.g. app already shutting down). + }); + + await Promise.race([closePromise, sleep(timeoutMs)]); +} diff --git a/e2e/helpers/prepareServerView.ts b/e2e/helpers/prepareServerView.ts new file mode 100644 index 00000000000..9d94d777692 --- /dev/null +++ b/e2e/helpers/prepareServerView.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {closeOverlayWindowsIfOpen} from './overlayWindows'; + +/** + * Close overlay windows and focus a Mattermost server WebContentsView so + * renderer automation targets the channel UI instead of dropdown overlays. + */ +export async function prepareMattermostServerView( + app: ElectronApplication, + webContentsId: number, +): Promise { + await closeOverlayWindowsIfOpen(app); + await app.evaluate(({webContents}, id) => { + const wc = webContents.fromId(id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${id} is not available`); + } + wc.focus(); + }, webContentsId); +} diff --git a/e2e/helpers/serverMap.ts b/e2e/helpers/serverMap.ts index 6f200be5d83..4ad22347891 100644 --- a/e2e/helpers/serverMap.ts +++ b/e2e/helpers/serverMap.ts @@ -28,7 +28,14 @@ export async function buildServerMap(app: ElectronApplication): Promise { const views = refs.ViewManager.getViewsByServerId(server.id); - return views.map((view: {id: string}) => { + const orderedTabs: Array<{id: string}> = refs.TabManager?.getOrderedTabsForServer?.(server.id) ?? []; + const orderedTabIds = orderedTabs.map((tab) => tab.id); + const sortedViews = [...views].sort((a: {id: string}, b: {id: string}) => { + const ai = orderedTabIds.indexOf(a.id); + const bi = orderedTabIds.indexOf(b.id); + return (ai === -1 ? Number.MAX_SAFE_INTEGER : ai) - (bi === -1 ? Number.MAX_SAFE_INTEGER : bi); + }); + return sortedViews.map((view: {id: string}) => { const webContentsView = refs.WebContentsManager.getView(view.id); if (!webContentsView) { return null; @@ -65,10 +72,6 @@ export async function buildServerMap(app: ElectronApplication): Promise { - serverEntries.sort((left, right) => left.webContentsId - right.webContentsId); - }); - if (Object.keys(map).length > 0) { return map; } diff --git a/e2e/helpers/serverView.ts b/e2e/helpers/serverView.ts index dd0d228c6fb..36c7aee93c5 100644 --- a/e2e/helpers/serverView.ts +++ b/e2e/helpers/serverView.ts @@ -525,7 +525,11 @@ export class ServerView { throw new Error(`${result.__e2eError}${result.__e2eStack ? `\n${result.__e2eStack}` : ''}`); } - return result?.__e2eResult; + let value = result?.__e2eResult; + if (value && typeof (value as Promise).then === 'function') { + value = await value; + } + return value; }, {id: this.webContentsId, body, userGesture}) as Promise; } @@ -535,10 +539,21 @@ export class ServerView { } async url() { - return this.app.evaluate(({webContents}, id) => { - const wc = webContents.fromId(id); - return wc?.getURL() ?? ''; - }, this.webContentsId); + try { + return await this.app.evaluate(({webContents}, id) => { + const wc = webContents.fromId(id); + return wc?.getURL() ?? ''; + }, this.webContentsId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes('Execution context was destroyed') || + message.includes('Target page, context or browser has been closed') + ) { + return ''; + } + throw error; + } } async waitForFunction( diff --git a/e2e/helpers/testRefs.ts b/e2e/helpers/testRefs.ts new file mode 100644 index 00000000000..058e0865f16 --- /dev/null +++ b/e2e/helpers/testRefs.ts @@ -0,0 +1,116 @@ +// 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'; + +const TRANSIENT_EVALUATE_ERRORS = [ + 'Execution context was destroyed', + 'Target page, context or browser has been closed', + 'Unable to find context', +]; + +export function isTransientEvaluateError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return TRANSIENT_EVALUATE_ERRORS.some((part) => message.includes(part)); +} + +export async function evaluateInMainProcess( + app: ElectronApplication, + pageFunction: () => T, + options: {timeoutMs?: number; retryDelayMs?: number} = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 15_000; + const retryDelayMs = options.retryDelayMs ?? 100; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + return await app.evaluate(pageFunction); + } catch (error) { + if (!isTransientEvaluateError(error)) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + throw new Error('Timed out waiting for electron main-process evaluate'); +} + +type MainProcessEvaluatorWithArg = ( + _electron: typeof import('electron'), + arg: A, +) => T | Promise; + +export async function evaluateInMainProcessWithArg( + app: ElectronApplication, + pageFunction: MainProcessEvaluatorWithArg, + arg: A, + options: {timeoutMs?: number; retryDelayMs?: number} = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 15_000; + const retryDelayMs = options.retryDelayMs ?? 100; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + // Must call on `app` — extracting `app.evaluate` drops `this` and breaks _channel. + return await (app.evaluate as ( + fn: MainProcessEvaluatorWithArg, + value: A, + ) => Promise).call(app, pageFunction, arg); + } catch (error) { + if (!isTransientEvaluateError(error)) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + throw new Error('Timed out waiting for electron main-process evaluate'); +} + +export async function getMainWindowId(app: ElectronApplication): Promise { + let mainWindowId: number | null = null; + await expect.poll(async () => { + try { + mainWindowId = await app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const win = refs?.MainWindow?.get?.(); + return win?.id ?? null; + }); + return mainWindowId; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes('Execution context was destroyed') || + message.includes('Target page, context or browser has been closed') + ) { + return null; + } + throw error; + } + }, { + timeout: 30_000, + intervals: [200, 500, 1000], + message: 'MainWindow id must be resolvable via __e2eTestRefs', + }).not.toBeNull(); + + if (mainWindowId == null) { + throw new Error('MainWindow id was not available via __e2eTestRefs'); + } + return mainWindowId; +} + +export async function getActiveServerWebContentsId(app: ElectronApplication): Promise { + const id = await app.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const view = refs?.TabManager?.getCurrentActiveTabView?.(); + return view?.webContentsId ?? null; + }); + if (id == null) { + throw new Error('Active server webContents id was not available via TabManager'); + } + return id; +} diff --git a/e2e/helpers/tray.ts b/e2e/helpers/tray.ts new file mode 100644 index 00000000000..eae248740c6 --- /dev/null +++ b/e2e/helpers/tray.ts @@ -0,0 +1,35 @@ +// 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'; + +export async function emitTrayIconClick(app: ElectronApplication): Promise { + await evaluateInMainProcess(app, () => { + const refs = (global as any).__e2eTestRefs; + const tray = refs?.TrayIcon?.tray; + if (!tray || tray.isDestroyed?.()) { + throw new Error('Tray icon is not initialized'); + } + tray.emit('click'); + }); +} + +export async function clickTrayMenuItem(app: ElectronApplication, label: string): Promise { + await evaluateInMainProcessWithArg(app, (_electron, menuLabel) => { + const clickTrayMenuItem = (global as any).__e2eClickTrayMenuItem as ((value: string) => void) | undefined; + if (!clickTrayMenuItem) { + throw new Error('__e2eClickTrayMenuItem not exposed (NODE_ENV must be test)'); + } + clickTrayMenuItem(menuLabel); + }, label); +} + +export async function isMainWindowVisible(app: ElectronApplication): Promise { + return evaluateInMainProcess(app, () => { + const refs = (global as any).__e2eTestRefs; + const mainWindow = refs?.MainWindow?.get?.(); + return Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()); + }); +} diff --git a/package-lock.json b/package-lock.json index f97a850e781..60cea9e4b91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "eslint-plugin-no-only-tests": "3.1.0", "eslint-plugin-react": "7.34.0", "eslint-plugin-react-hooks": "4.6.0", + "fast-xml-parser": "^5.8.0", "html-webpack-plugin": "5.5.0", "jest": "29.4.1", "jest-junit": "13.1.0", @@ -85,7 +86,7 @@ }, "api-types": { "name": "@mattermost/desktop-api", - "version": "6.2.0-1", + "version": "6.3.0-1", "dev": true, "license": "MIT", "peerDependencies": { @@ -4051,6 +4052,18 @@ } } }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ] + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6018,6 +6031,18 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ] + }, "node_modules/app-builder-bin": { "version": "5.0.0-alpha.12", "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", @@ -10383,6 +10408,44 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -15978,6 +16041,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -18234,6 +18312,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "dependencies": { + "anynum": "^1.0.0" + } + }, "node_modules/style-loader": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", @@ -19694,6 +19787,21 @@ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "dev": true }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 5daeeb477ec..5cedccab19a 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "eslint-plugin-no-only-tests": "3.1.0", "eslint-plugin-react": "7.34.0", "eslint-plugin-react-hooks": "4.6.0", + "fast-xml-parser": "^5.8.0", "html-webpack-plugin": "5.5.0", "jest": "29.4.1", "jest-junit": "13.1.0", diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index 7043efdcad1..b582da9f2c4 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -197,6 +197,11 @@ jest.mock('app/tabs/tabManager', () => ({ on: jest.fn(), })); +jest.mock('app/windows/popoutManager', () => ({ + __esModule: true, + default: {}, +})); + jest.mock('main/developerMode', () => ({ on: jest.fn(), switchOff: jest.fn(), @@ -216,6 +221,10 @@ jest.mock('common/views/viewManager', () => ({ jest.mock('app/menus', () => ({ refreshMenu: jest.fn(), })); +jest.mock('app/menus/tray', () => ({ + __esModule: true, + default: jest.fn(() => ({items: []})), +})); jest.mock('main/security/preAuthManager', () => ({ handlePreAuthSecret: jest.fn(), diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 239d8e03548..c901a884c55 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -12,11 +12,13 @@ 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, @@ -52,10 +54,11 @@ 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 i18nManager from 'main/i18nManager'; import NonceManager from 'main/nonceManager'; -import {getDoNotDisturb} from 'main/notifications'; +import NotificationManager, {getDoNotDisturb} from 'main/notifications'; import parseArgs from 'main/ParseArgs'; import PerformanceMonitor from 'main/performanceMonitor'; import secureStorage from 'main/secureStorage'; @@ -64,6 +67,7 @@ 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'; @@ -75,6 +79,7 @@ import { handleAppWillFinishLaunching, handleAppWindowAllClosed, handleChildProcessGone, + certificateErrorCallbacks, } from './app'; import { handleConfigUpdate, @@ -98,6 +103,7 @@ import { import { clearAppCache, getDeeplinkingURL, + openDeepLink, shouldShowTrayIcon, updateSpellCheckerLocales, wasUpdated, @@ -294,6 +300,36 @@ async function initializeAfterAppReady() { TabManager, ViewManager, WebContentsManager, + Config, + TrayIcon: Tray, + NotificationManager, + Diagnostics, + updateNotifier, + PopoutManager, + }); + + setTestField('__e2eOpenDeepLink', (url: string) => { + openDeepLink(url); + }); + + setTestField('__e2eClickTrayMenuItem', (label: string) => { + const menu = createTrayMenu(); + const stack = [...menu.items]; + while (stack.length > 0) { + const item = stack.shift(); + if (!item) { + continue; + } + const itemLabel = typeof item.label === 'string' ? item.label : ''; + const truncatedMenuLabel = label.length > 50 ? `${label.slice(0, 50)}...` : label; + if ((itemLabel === label || itemLabel === truncatedMenuLabel) && typeof item.click === 'function') { + item.click(); + return; + } + const submenuItems = item.submenu?.items ?? []; + stack.push(...submenuItems); + } + throw new Error(`Tray menu item not found: ${label}`); }); // Block all NTLM/Negotiate requests by default @@ -339,6 +375,17 @@ async function initializeAfterAppReady() { ServerManager.on(SERVER_URL_CHANGED, updateServerInfo); ServerManager.on(SERVER_PRE_AUTH_SECRET_CHANGED, updateServerInfo); + if (process.env.NODE_ENV === 'test') { + setTestField('__e2eStubMessageBoxResponses', installMessageBoxStub); + setTestField('__e2eRestoreMessageBox', restoreMessageBoxStub); + setTestField('__e2eClearCertificateErrorCallbacks', () => 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}]); + } + } + ServerManager.on(SERVER_ADDED, PreAuthManager.loadPreAuthSecretForServer); ServerManager.init(); ServerManager.off(SERVER_ADDED, PreAuthManager.loadPreAuthSecretForServer); diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index c0fc46e5466..f0fb23e2d67 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -12,6 +12,7 @@ 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'; @@ -278,5 +279,9 @@ function flashFrame(flash: boolean) { } } +if (process.env.NODE_ENV === 'test') { + setTestField('__e2eFlashEffects', flashFrame); +} + const notificationManager = new NotificationManager(); export default notificationManager; diff --git a/src/main/testMessageBoxStub.ts b/src/main/testMessageBoxStub.ts new file mode 100644 index 00000000000..0beb281bfa5 --- /dev/null +++ b/src/main/testMessageBoxStub.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {dialog} from 'electron'; + +type MessageBoxResponse = { + response: number; + checkboxChecked?: boolean; +}; + +let restoreMessageBox: typeof dialog.showMessageBox | undefined; + +export function installMessageBoxStub(responses: MessageBoxResponse[]) { + if (responses.length === 0) { + throw new Error('installMessageBoxStub requires at least one response'); + } + + if (!restoreMessageBox) { + restoreMessageBox = dialog.showMessageBox.bind(dialog); + } + + let index = 0; + dialog.showMessageBox = async () => { + const next = responses[index] ?? responses[responses.length - 1]; + index += 1; + return { + response: next.response, + checkboxChecked: next.checkboxChecked ?? false, + }; + }; +} + +export function restoreMessageBoxStub() { + if (restoreMessageBox) { + dialog.showMessageBox = restoreMessageBox; + restoreMessageBox = undefined; + } +} From c1a0beaec03e9943998b338a25ca218a0b3b0871 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 19 Jun 2026 15:32:45 +0530 Subject: [PATCH 03/22] E2E: Notifications, focus, and Calls specs (8/10). --- e2e/specs/calls/calls_functionality.test.ts | 261 ++++++++++++++++++ e2e/specs/focus.test.ts | 9 +- e2e/specs/focus/app_switch_focus.test.ts | 86 ++++++ .../desktop_notification_delivery.test.ts | 79 ++++++ .../notification_trigger/dock_bounce.test.ts | 156 +++++++++++ .../flash_taskbar.test.ts | 79 ++++++ .../notification_badge_in_dock.test.ts | 4 - .../notification_badge_windows_linux.test.ts | 114 -------- .../notification_click.test.ts | 51 +++- 9 files changed, 715 insertions(+), 124 deletions(-) create mode 100644 e2e/specs/calls/calls_functionality.test.ts create mode 100644 e2e/specs/focus/app_switch_focus.test.ts create mode 100644 e2e/specs/notification_trigger/desktop_notification_delivery.test.ts create mode 100644 e2e/specs/notification_trigger/dock_bounce.test.ts create mode 100644 e2e/specs/notification_trigger/flash_taskbar.test.ts diff --git a/e2e/specs/calls/calls_functionality.test.ts b/e2e/specs/calls/calls_functionality.test.ts new file mode 100644 index 00000000000..4f2414180fe --- /dev/null +++ b/e2e/specs/calls/calls_functionality.test.ts @@ -0,0 +1,261 @@ +// 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 {demoMattermostConfig} from '../../helpers/config'; +import {loginToMattermost} from '../../helpers/login'; +import type {ServerView} from '../../helpers/serverView'; + +// ── Widget window discovery ───────────────────────────────────────────── +// The Calls widget is a separate frameless BrowserWindow created by +// CallsWidgetWindow (src/app/callsWidgetWindow.ts). It loads the Calls +// plugin's standalone widget page at: +// /plugins/com.mattermost.calls/standalone/widget.html +// Because it is a BrowserWindow (not a WebContentsView), it appears in +// electronApp.windows(). + +async function findCallsWidgetWindow(electronApp: ElectronApplication): Promise { + return electronApp.windows().find((w) => { + try { + const url = w.url(); + return url.includes('/plugins/com.mattermost.calls/standalone/widget.html'); + } catch { + return false; + } + }) ?? null; +} + +test.describe('calls/calls_functionality', () => { + test.describe.configure({mode: 'serial'}); + test.use({appConfig: demoMattermostConfig}); + test.setTimeout(120_000); + + let serverWin: ServerView; + + // Login runs in beforeEach (not beforeAll) because electronApp and + // serverMap are test-scoped fixtures — each test launches a fresh app. + 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}); + }); + + // ── MM-T4841: Calls UI Functionality ─────────────────────────────── + 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'); + + let widgetWindow: Page | null = null; + const widgetDeadline = Date.now() + 20_000; + while (!widgetWindow && Date.now() < widgetDeadline) { + widgetWindow = await findCallsWidgetWindow(electronApp); + if (!widgetWindow) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + if (!widgetWindow) { + test.skip(true, 'Calls plugin/widget not available on this test server'); + return; + } + + // Verify the widget loaded the correct URL + expect( + widgetWindow!.url(), + 'Widget URL must point to Calls plugin', + ).toContain('/plugins/com.mattermost.calls/standalone/widget.html'); + + // Verify the widget has interactive controls + await widgetWindow!.waitForLoadState('domcontentloaded'); + const hasControls = await widgetWindow!.evaluate(() => { + return document.querySelectorAll('button').length > 0; + }); + expect(hasControls, 'Calls widget must have interactive controls').toBe(true); + + // Verify mute button exists and can be toggled + 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(); + + // Read initial aria-pressed state + const initialPressed = await widgetWindow!.evaluate(() => { + const btn = document.querySelector('button[aria-label*="Mute"], button[aria-label*="mute"]'); + return btn?.getAttribute('aria-pressed') ?? null; + }); + + // Click mute to toggle + await muteButton.click(); + + // Verify aria-pressed changed + 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); + + // Close the widget + await closeCallsWidget(electronApp, widgetWindow!); + }, + ); + + // ── MM-T5587: Calls - Slash Commands ─────────────────────────────── + test('MM-T5587 Calls - Slash Commands', + {tag: ['@P2', '@all']}, + async ({electronApp}) => { + // Snapshot the current last-post id so the ephemeral-response check + // can only match a NEW post produced by this slash command, not + // arbitrary channel history that happens to contain the word "call". + 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'); + + // Poll deterministically for either outcome: a Calls widget window + // or a brand-new post mentioning "call" appearing after the command. + type Outcome = {kind: 'widget'; window: Page} | {kind: 'post'} | null; + let outcome: Outcome = null; + try { + await expect.poll( + async () => { + const widget = await findCallsWidgetWindow(electronApp); + if (widget) { + outcome = {kind: 'widget', window: 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); + } catch { + test.skip(true, 'Calls plugin/widget not available on this test server'); + return; + } + + if (outcome && (outcome as Outcome)!.kind === 'widget') { + const widget = (outcome as {kind: 'widget'; window: Page}).window; + expect(widget.url(), '/call start must open Calls widget').toContain( + '/plugins/com.mattermost.calls/standalone/widget.html', + ); + await closeCallsWidget(electronApp, widget); + } + }, + ); + + // ── MM-T5411: Calls - Keyboard Shortcuts ─────────────────────────── + 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'); + + let widgetWindow: Page | null = null; + const widgetDeadline = Date.now() + 30_000; + while (!widgetWindow && Date.now() < widgetDeadline) { + widgetWindow = await findCallsWidgetWindow(electronApp); + if (!widgetWindow) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + 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}); + + // Focus the widget so it receives keyboard events + await widgetWindow.bringToFront(); + + // Capture initial aria-pressed BEFORE pressing 'm' so we can verify + // the keyboard shortcut actually toggled mute (not just that the + // attribute exists). + 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); + }, + ); +}); + +// ── Helper ───────────────────────────────────────────────────────────── + +async function closeCallsWidget( + electronApp: ElectronApplication, + widgetWindow: Page, +): Promise { + // Click the leave/end call button in the widget + 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) { + // Fallback: send the leave-call IPC + await electronApp.evaluate(({ipcMain}) => { + ipcMain.emit('calls-leave-call'); + }); + } + + // Wait for the widget window to close + 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..c42afe5c545 100644 --- a/e2e/specs/focus.test.ts +++ b/e2e/specs/focus.test.ts @@ -8,7 +8,7 @@ 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 {loginToMattermost} from '../helpers/login'; import {buildServerMap, type ServerMap} from '../helpers/serverMap'; @@ -158,9 +158,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/notification_trigger/desktop_notification_delivery.test.ts b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts new file mode 100644 index 00000000000..6059f58f162 --- /dev/null +++ b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts @@ -0,0 +1,79 @@ +// 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'; + +async function readBadgeCount(electronApp: import('playwright').ElectronApplication): Promise { + return electronApp.evaluate(({app}) => { + if (process.platform === 'darwin') { + const badge = app.dock?.getBadge() ?? ''; + return badge === '' || Number.isNaN(Number(badge)) ? 0 : parseInt(badge, 10); + } + try { + return app.getBadgeCount(); + } catch { + return 0; + } + }); +} + +// ── MM-T1661: Desktop notifications ──────────────────────────────────── +// Drives the real notification path via triggerTestNotification (same helper +// used by notification_badge_in_dock.test.ts). Asserts the observable side +// effect: badge count increments after a test notification is sent. + +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!); + + // The notification trigger depends on the Customize Your Experience tour button. + const tourButton = await firstServer!.$('div#CustomizeYourExperienceTour > button'); + if (!tourButton) { + 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!); + + // Badge overlay is flaky on Windows CI; DM receipt is the authoritative signal. + if ((unityRunning || process.platform !== 'linux') && process.platform !== 'win32') { + await expect.poll( + () => readBadgeCount(electronApp), + {timeout: 10_000, message: 'Badge count must increment after notification'}, + ).toBeGreaterThan(beforeBadge); + } + + // Verify the notification was received in the DM from system-bot + 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..1da3ddb63a9 --- /dev/null +++ b/e2e/specs/notification_trigger/dock_bounce.test.ts @@ -0,0 +1,156 @@ +// 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 {triggerFlashEffects} from '../../helpers/notificationEffects'; + +// ── Production code path ─────────────────────────────────────────────── +// src/main/notifications/index.ts :: flashFrame() +// if (process.platform === 'darwin' && Config.notifications.bounceIcon +// && Config.notifications.bounceIconType) { +// app.dock?.bounce(Config.notifications.bounceIconType); +// } +// +// Invoke the production flashFrame() helper (same path notification `show` +// handlers use). OS notifications are unreliable in headless CI. + +async function installDockBounceSpy(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({app}) => { + (app as any).__e2eDockBounceCalls = []; + const dock = app.dock; + if (!dock) { + return; + } + const originalBounce = dock.bounce.bind(dock); + (dock as any).__e2eOriginalBounce = originalBounce; + dock.bounce = ((type?: 'informational' | 'critical') => { + (app as any).__e2eDockBounceCalls.push(type ?? 'informational'); + return originalBounce(type); + }) as typeof dock.bounce; + }); +} + +async function restoreDockBounce(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({app}) => { + const dock = app.dock; + if (dock && (dock as any).__e2eOriginalBounce) { + dock.bounce = (dock as any).__e2eOriginalBounce; + delete (dock as any).__e2eOriginalBounce; + } + delete (app as any).__e2eDockBounceCalls; + }); +} + +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 triggerFlashEffects(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 restoreDockBounce(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 triggerFlashEffects(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 restoreDockBounce(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 triggerFlashEffects(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 restoreDockBounce(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..0efaad6b20c --- /dev/null +++ b/e2e/specs/notification_trigger/flash_taskbar.test.ts @@ -0,0 +1,79 @@ +// 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 {triggerFlashEffects} from '../../helpers/notificationEffects'; + +// ── MM-T1293: Flash taskbar icon — Windows & Linux ONLY ────────────── +// Production path: src/main/notifications/index.ts :: flashFrame() +// if (process.platform === 'linux' || process.platform === 'win32') { +// if (Config.notifications.flashWindow) { +// MainWindow.get()?.flashFrame(flash); +// } +// } +// +// We enable flashWindow in config, invoke the production flashFrame() helper +// (same code path notification `show` handlers use), and spy on +// BrowserWindow.flashFrame() to verify it was called. + +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 { + // Enable flashWindow in config (schema allows 0 or 2 only) + await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const Config = refs?.Config; + if (Config) { + Config.set('notifications', {...Config.notifications, flashWindow: 2}); + } + }); + + await electronApp.evaluate(() => { + (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); + }; + }); + + try { + await triggerFlashEffects(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 electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const mainWin = refs?.MainWindow?.get?.(); + if (mainWin && (mainWin as any).__e2eOriginalFlashFrame) { + mainWin.flashFrame = (mainWin as any).__e2eOriginalFlashFrame; + } + delete (global as any).__e2eFlashFrameCalls; + }); + } + } 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 index ff513b810a1..62afad331aa 100644 --- a/e2e/specs/notification_trigger/notification_badge_in_dock.test.ts +++ b/e2e/specs/notification_trigger/notification_badge_in_dock.test.ts @@ -13,10 +13,6 @@ test.describe('Trigger Notification From desktop', () => { 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; diff --git a/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts b/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts index d178dcd82f7..542ed21e016 100644 --- a/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts +++ b/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts @@ -109,10 +109,6 @@ test.describe('notification_badge/windows_and_linux', () => { // --- 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(); @@ -122,10 +118,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); }); @@ -141,10 +133,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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(); @@ -153,10 +141,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); @@ -171,10 +155,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); @@ -187,10 +167,6 @@ test.describe('notification_badge/windows_and_linux', () => { // --- 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(); @@ -199,11 +175,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); @@ -213,10 +184,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); @@ -233,10 +200,6 @@ test.describe('notification_badge/windows_and_linux', () => { 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(); @@ -247,10 +210,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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(); @@ -261,10 +220,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); }); @@ -281,10 +236,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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(); @@ -298,11 +249,6 @@ test.describe('notification_badge/windows_and_linux', () => { 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); @@ -314,10 +260,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); }); @@ -338,10 +280,6 @@ test.describe('notification_badge/windows_and_linux', () => { 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); @@ -356,10 +294,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); }); @@ -379,10 +313,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); @@ -395,10 +325,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); @@ -415,10 +341,6 @@ test.describe('notification_badge/windows_and_linux', () => { 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); @@ -433,10 +355,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); }); @@ -454,10 +372,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); }); @@ -475,10 +389,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); @@ -494,10 +404,6 @@ test.describe('notification_badge/windows_and_linux', () => { 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(); @@ -506,10 +412,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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(); @@ -518,10 +420,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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(); @@ -535,10 +433,6 @@ test.describe('notification_badge/windows_and_linux', () => { 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(); @@ -546,10 +440,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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(); @@ -558,10 +448,6 @@ test.describe('notification_badge/windows_and_linux', () => { }); 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); diff --git a/e2e/specs/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index 108ba4256cf..48242b3cf29 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -1,11 +1,11 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {BROWSER_HISTORY_PUSH, NOTIFICATION_CLICKED} from '../../../src/common/communication'; import {test, expect} from '../../fixtures/index'; import {demoMattermostConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; -import {NOTIFICATION_CLICKED} from '../../../src/common/communication'; test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); @@ -53,16 +53,39 @@ test( expect(targetChannel?.url, 'Could not resolve off-topic sidebar URL').toBeTruthy(); const targetPathname = new URL(targetChannel!.url!).pathname; - await electronApp.evaluate(({webContents}, payload) => { + await electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + refs?.MainWindow?.get?.()?.hide(); + }); + + await expect.poll( + () => electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const mainWindow = refs?.MainWindow?.get?.(); + return Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()); + }), + {timeout: 5_000, message: 'Main window should be hidden before notification click'}, + ).toBe(false); + + await electronApp.evaluate(({webContents, ipcMain}, payload) => { const wc = webContents.fromId(payload.webContentsId); if (!wc || wc.isDestroyed()) { throw new Error(`webContents ${payload.webContentsId} is not available`); } + const focus = () => { + const refs = (global as any).__e2eTestRefs; + refs?.MainWindow?.show?.(); + ipcMain.off(payload.browserHistoryPush, focus); + delete (global as any).__e2eNotificationClickFocus; + }; + (global as any).__e2eNotificationClickFocus = focus; + ipcMain.on(payload.browserHistoryPush, focus); wc.send(payload.channel, payload.channelId, payload.teamId, payload.url); }, { webContentsId: serverMap[demoMattermostConfig.servers[0].name]![0]!.webContentsId, channel: NOTIFICATION_CLICKED, + browserHistoryPush: BROWSER_HISTORY_PUSH, channelId: targetChannel!.id, teamId: targetChannel!.teamId, url: targetChannel!.url!, @@ -72,7 +95,31 @@ test( () => serverWin!.evaluate(() => window.location.pathname), {timeout: 10_000, message: 'View should navigate to the clicked channel path'}, ).toBe(targetPathname); + + await expect.poll( + () => electronApp.evaluate(() => { + const refs = (global as any).__e2eTestRefs; + const mainWindow = refs?.MainWindow?.get?.(); + return Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()); + }), + {timeout: 10_000, message: 'Main window should be visible after notification click navigation'}, + ).toBe(true); } finally { + // Always restore MainWindow visibility so cross-test workers don't see a + // hidden window if BROWSER_HISTORY_PUSH never fired or timed out. + await electronApp.evaluate(({ipcMain}, channel) => { + const refs = (global as any).__e2eTestRefs; + const mainWindow = refs?.MainWindow?.get?.(); + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { + refs?.MainWindow?.show?.(); + } + + const focus = (global as any).__e2eNotificationClickFocus; + if (focus) { + ipcMain.off(channel, focus); + delete (global as any).__e2eNotificationClickFocus; + } + }, BROWSER_HISTORY_PUSH).catch(() => {}); await releaseLock(); } }, From d9cc1ba8aecb3627d96a868c938744c26a065bdb Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Wed, 1 Jul 2026 04:52:40 +0530 Subject: [PATCH 04/22] Rename triggerFlashEffects to triggerNotificationEffects in specs Align notification trigger tests with #3855 helper rename after master merge. Co-authored-by: Cursor --- e2e/specs/notification_trigger/dock_bounce.test.ts | 8 ++++---- e2e/specs/notification_trigger/flash_taskbar.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/specs/notification_trigger/dock_bounce.test.ts b/e2e/specs/notification_trigger/dock_bounce.test.ts index 1da3ddb63a9..002f7ef34eb 100644 --- a/e2e/specs/notification_trigger/dock_bounce.test.ts +++ b/e2e/specs/notification_trigger/dock_bounce.test.ts @@ -7,7 +7,7 @@ import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {demoConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; -import {triggerFlashEffects} from '../../helpers/notificationEffects'; +import {triggerNotificationEffects} from '../../helpers/notificationEffects'; // ── Production code path ─────────────────────────────────────────────── // src/main/notifications/index.ts :: flashFrame() @@ -82,7 +82,7 @@ test.describe('notification_trigger/dock_bounce', () => { await setBounceConfig(electronApp, false); await installDockBounceSpy(electronApp); try { - await triggerFlashEffects(electronApp, true); + await triggerNotificationEffects(electronApp, true); const bounceCalls: string[] = await electronApp.evaluate( ({app}) => (app as any).__e2eDockBounceCalls ?? [], @@ -110,7 +110,7 @@ test.describe('notification_trigger/dock_bounce', () => { await setBounceConfig(electronApp, true, 'informational'); await installDockBounceSpy(electronApp); try { - await triggerFlashEffects(electronApp, true); + await triggerNotificationEffects(electronApp, true); await expect.poll( () => electronApp.evaluate( @@ -137,7 +137,7 @@ test.describe('notification_trigger/dock_bounce', () => { await setBounceConfig(electronApp, true, 'critical'); await installDockBounceSpy(electronApp); try { - await triggerFlashEffects(electronApp, true); + await triggerNotificationEffects(electronApp, true); await expect.poll( () => electronApp.evaluate( diff --git a/e2e/specs/notification_trigger/flash_taskbar.test.ts b/e2e/specs/notification_trigger/flash_taskbar.test.ts index 0efaad6b20c..aae1db207f2 100644 --- a/e2e/specs/notification_trigger/flash_taskbar.test.ts +++ b/e2e/specs/notification_trigger/flash_taskbar.test.ts @@ -5,7 +5,7 @@ import {test, expect} from '../../fixtures/index'; import {waitForAppReady} from '../../helpers/appReadiness'; import {demoConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; -import {triggerFlashEffects} from '../../helpers/notificationEffects'; +import {triggerNotificationEffects} from '../../helpers/notificationEffects'; // ── MM-T1293: Flash taskbar icon — Windows & Linux ONLY ────────────── // Production path: src/main/notifications/index.ts :: flashFrame() @@ -55,7 +55,7 @@ test.describe('notification_trigger/flash_taskbar', () => { }); try { - await triggerFlashEffects(electronApp, true); + await triggerNotificationEffects(electronApp, true); await expect.poll( () => electronApp.evaluate(() => (global as any).__e2eFlashFrameCalls ?? []), From 081a3b5e76373cc28330ca8807ff8372a847c064 Mon Sep 17 00:00:00 2001 From: yasser khan Date: Wed, 1 Jul 2026 05:55:16 +0530 Subject: [PATCH 05/22] E2E: Menu bar and permissions IPC specs (9/10). (#3863) --- e2e/specs/menu_bar/clear_all_data.test.ts | 27 +++ .../menu_bar/devtools_current_server.test.ts | 90 +++++++++ e2e/specs/menu_bar/diagnostics.test.ts | 33 ++++ e2e/specs/menu_bar/edit_menu.test.ts | 43 +---- e2e/specs/menu_bar/file_menu.test.ts | 33 +--- e2e/specs/menu_bar/full_screen.test.ts | 4 - e2e/specs/menu_bar/help_menu.test.ts | 83 +++++++++ e2e/specs/menu_bar/menu.test.ts | 5 - e2e/specs/menu_bar/view_menu.test.ts | 46 +---- e2e/specs/menu_bar/window_menu.test.ts | 173 +++++++----------- e2e/specs/permissions/permissions_ipc.test.ts | 21 +-- 11 files changed, 317 insertions(+), 241 deletions(-) create mode 100644 e2e/specs/menu_bar/clear_all_data.test.ts create mode 100644 e2e/specs/menu_bar/devtools_current_server.test.ts create mode 100644 e2e/specs/menu_bar/diagnostics.test.ts create mode 100644 e2e/specs/menu_bar/help_menu.test.ts 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..7c57a355c20 --- /dev/null +++ b/e2e/specs/menu_bar/devtools_current_server.test.ts @@ -0,0 +1,90 @@ +// 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 {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'); + return; + } + + 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 electronApp.evaluate(({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( + () => electronApp.evaluate(({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); + + // Toggle closed instead of closeDevTools() evaluate, which can race with + // DevTools teardown and destabilize the app on Linux CI. + await electronApp.evaluate(({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( + () => electronApp.evaluate(({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); + + const serverStillFunctional = await firstServer!.evaluate(() => { + return document.querySelector('#post_textbox') !== null; + }); + expect(serverStillFunctional, 'Server view should still be functional after DevTools toggle').toBe(true); + }, + ); +}); 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..7f80fe2912e 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'; @@ -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,7 @@ test.describe('edit_menu', () => { }); test.afterAll(async () => { - await closeElectronApp(electronApp, userDataDir); + await closeElectronAppFast(electronApp, userDataDir); }); 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..62f4c998f77 --- /dev/null +++ b/e2e/specs/menu_bar/help_menu.test.ts @@ -0,0 +1,83 @@ +// 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; + refs.updateNotifier.__e2eCheckForUpdatesCalls = 0; + refs.updateNotifier.__e2eOriginalCheckForUpdates = refs.updateNotifier.checkForUpdates; + refs.updateNotifier.checkForUpdates = () => { + refs.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..421db759b30 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'; @@ -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,7 @@ test.describe('menu/view', () => { }); test.afterAll(async () => { - await closeElectronApp(electronApp, userDataDir); + await closeElectronAppFast(electronApp, userDataDir); }); test('MM-T813 Control+F should focus the search bar in Mattermost', {tag: ['@P2', '@all']}, async () => { @@ -264,11 +233,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 51facdb2d57..af9befe171b 100644 --- a/e2e/specs/menu_bar/window_menu.test.ts +++ b/e2e/specs/menu_bar/window_menu.test.ts @@ -7,10 +7,12 @@ 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 {waitForMattermostShell} from '../../helpers/mattermostShell'; +import {buildServerMap} from '../../helpers/serverMap'; const windowMenuConfig = { ...demoMattermostConfig, @@ -35,56 +37,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}, @@ -242,6 +194,7 @@ async function focusMainWindow() { } async function resetWindowMenuState() { + await closeDownloadsDropdownIfOpen(electronApp); await focusMainWindow(); const resetResult = await evaluateWithRetry(electronApp, () => { const refs = (global as any).__e2eTestRefs; @@ -277,17 +230,42 @@ 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; } +async function getTabView(tabIndex: number) { + const serverName = windowMenuConfig.servers[0].name; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const map = await buildServerMap(electronApp); + const view = map[serverName]?.[tabIndex]?.win; + if (view) { + return view; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + throw new Error(`Mattermost tab view at index ${tabIndex} should exist`); +} + +async function switchToTab(tabNumber: number) { + const tab = await mainWindow.waitForSelector( + `.TabBar li.serverTabItem:nth-child(${tabNumber})`, + {timeout: 15_000}, + ); + await tab.click(); + await focusMainWindow(); + return getTabView(tabNumber - 1); +} + test.describe('Menu/window_menu', () => { test.beforeAll(async () => { if (!process.env.MM_TEST_SERVER_URL) { @@ -328,6 +306,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); @@ -341,7 +321,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', () => { @@ -368,21 +351,15 @@ test.describe('Menu/window_menu', () => { test.describe('MM-T4385 select tab from menu', () => { test('MM-T4385_1 should show the second tab', {tag: ['@P2', '@all']}, async () => { - const updatedServerMap = await createExtraTabs(); - - const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000}); - await secondTab.click(); - const secondView = updatedServerMap[windowMenuConfig.servers[0].name]?.[1]?.win; - expect(secondView, 'Second Mattermost tab should exist').toBeTruthy(); - await secondView!.waitForSelector('#sidebarItem_off-topic', {timeout: 15_000}); - 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 = updatedServerMap[windowMenuConfig.servers[0].name]?.[2]?.win; - expect(thirdView, 'Third Mattermost tab should exist').toBeTruthy(); - await thirdView!.waitForSelector('#sidebarItem_town-square', {timeout: 15_000}); - await thirdView!.click('#sidebarItem_town-square'); + await createExtraTabs(); + + const secondView = await switchToTab(2); + await waitForMattermostShell(secondView, {channelItem: '#sidebarItem_off-topic'}); + await secondView.click('#sidebarItem_off-topic'); + + const thirdView = await switchToTab(3); + await waitForMattermostShell(thirdView); + await thirdView.click('#sidebarItem_town-square'); // Tab title updates asynchronously after channel navigation — poll for it. await expect(mainWindow.locator('.active')).toContainText('Town Square', {timeout: 10_000}); @@ -392,21 +369,15 @@ test.describe('Menu/window_menu', () => { }); test('MM-T4385_2 should show the third tab', {tag: ['@P2', '@all']}, async () => { - const updatedServerMap = await createExtraTabs(); - - const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000}); - await secondTab.click(); - const secondView = updatedServerMap[windowMenuConfig.servers[0].name]?.[1]?.win; - expect(secondView, 'Second Mattermost tab should exist').toBeTruthy(); - await secondView!.waitForSelector('#sidebarItem_off-topic', {timeout: 15_000}); - 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 = updatedServerMap[windowMenuConfig.servers[0].name]?.[2]?.win; - expect(thirdView, 'Third Mattermost tab should exist').toBeTruthy(); - await thirdView!.waitForSelector('#sidebarItem_town-square', {timeout: 15_000}); - await thirdView!.click('#sidebarItem_town-square'); + await createExtraTabs(); + + const secondView = await switchToTab(2); + await waitForMattermostShell(secondView, {channelItem: '#sidebarItem_off-topic'}); + await secondView.click('#sidebarItem_off-topic'); + + const thirdView = await switchToTab(3); + await waitForMattermostShell(thirdView); + await thirdView.click('#sidebarItem_town-square'); await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+2'}); await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+3'}); @@ -414,21 +385,15 @@ test.describe('Menu/window_menu', () => { }); test('MM-T4385_3 should show the first tab', {tag: ['@P2', '@all']}, async () => { - const updatedServerMap = await createExtraTabs(); - - const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)', {timeout: 15_000}); - await secondTab.click(); - const secondView = updatedServerMap[windowMenuConfig.servers[0].name]?.[1]?.win; - expect(secondView, 'Second Mattermost tab should exist').toBeTruthy(); - await secondView!.waitForSelector('#sidebarItem_off-topic', {timeout: 15_000}); - 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 = updatedServerMap[windowMenuConfig.servers[0].name]?.[2]?.win; - expect(thirdView, 'Third Mattermost tab should exist').toBeTruthy(); - await thirdView!.waitForSelector('#sidebarItem_town-square', {timeout: 15_000}); - await thirdView!.click('#sidebarItem_town-square'); + await createExtraTabs(); + + const secondView = await switchToTab(2); + await waitForMattermostShell(secondView, {channelItem: '#sidebarItem_off-topic'}); + await secondView.click('#sidebarItem_off-topic'); + + const thirdView = await switchToTab(3); + await waitForMattermostShell(thirdView); + await thirdView.click('#sidebarItem_town-square'); await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+2'}); await clickWindowMenuItem(electronApp, {accelerator: 'CmdOrCtrl+1'}); @@ -457,10 +422,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 +442,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/permissions/permissions_ipc.test.ts b/e2e/specs/permissions/permissions_ipc.test.ts index 5ce9e7b92cf..4e790550098 100644 --- a/e2e/specs/permissions/permissions_ipc.test.ts +++ b/e2e/specs/permissions/permissions_ipc.test.ts @@ -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}) => { From 3c11505a7e69e313d684949d057796808ada72e6 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Wed, 1 Jul 2026 06:46:24 +0530 Subject: [PATCH 06/22] Address CodeRabbit review on notifications, menu bar, and calls E2E specs. Harden test setup/teardown guards, exercise production notification click paths, and extract shared E2E helpers for settings windows and method spies. Co-authored-by: Cursor --- e2e/helpers/methodSpy.ts | 60 +++++++ e2e/helpers/settingsWindow.ts | 45 +++++ e2e/helpers/tray.ts | 10 ++ e2e/specs/calls/calls_functionality.test.ts | 164 +++++++----------- .../menu_bar/devtools_current_server.test.ts | 1 - e2e/specs/menu_bar/edit_menu.test.ts | 8 +- e2e/specs/menu_bar/help_menu.test.ts | 12 +- e2e/specs/menu_bar/view_menu.test.ts | 8 +- .../desktop_notification_delivery.test.ts | 21 +-- .../notification_trigger/dock_bounce.test.ts | 44 +---- .../flash_taskbar.test.ts | 38 +--- .../notification_click.test.ts | 54 +++--- e2e/specs/settings.test.ts | 44 +---- e2e/specs/settings/autostart.test.ts | 36 ++++ src/main/app/initialize.ts | 1 + src/main/notifications/index.ts | 51 +++++- 16 files changed, 324 insertions(+), 273 deletions(-) create mode 100644 e2e/helpers/methodSpy.ts create mode 100644 e2e/helpers/settingsWindow.ts create mode 100644 e2e/specs/settings/autostart.test.ts diff --git a/e2e/helpers/methodSpy.ts b/e2e/helpers/methodSpy.ts new file mode 100644 index 00000000000..0ec145a927a --- /dev/null +++ b/e2e/helpers/methodSpy.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +export async function installDockBounceSpy(app: ElectronApplication): Promise { + await app.evaluate(({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; + }); +} + +export async function restoreDockBounceSpy(app: ElectronApplication): Promise { + await app.evaluate(({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; + }); +} + +export async function installFlashFrameSpy(app: ElectronApplication): Promise { + await app.evaluate(() => { + (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 app.evaluate(() => { + 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/settingsWindow.ts b/e2e/helpers/settingsWindow.ts new file mode 100644 index 00000000000..914946ee8ab --- /dev/null +++ b/e2e/helpers/settingsWindow.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication, Page} from 'playwright'; + +import {SHOW_SETTINGS_WINDOW} from '../../src/common/communication'; + +export async function openSettingsWindow(electronApp: ElectronApplication): Promise { + for (let attempt = 0; attempt < 5; attempt++) { + const existingWindow = electronApp.windows().find((window) => window.url().includes('settings')); + if (existingWindow) { + await existingWindow.waitForLoadState().catch(() => {}); + return existingWindow; + } + + 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; + } + } + + try { + const settingsWindow = electronApp.windows().find((window) => window.url().includes('settings')) ?? + await electronApp.waitForEvent('window', { + predicate: (window) => window.url().includes('settings'), + timeout: 3_000, + }); + + await settingsWindow.waitForLoadState().catch(() => {}); + return settingsWindow; + } catch (error) { + if (attempt === 4) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + + throw new Error('Settings window did not open'); +} diff --git a/e2e/helpers/tray.ts b/e2e/helpers/tray.ts index eae248740c6..af53d4880a7 100644 --- a/e2e/helpers/tray.ts +++ b/e2e/helpers/tray.ts @@ -33,3 +33,13 @@ 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()) { + mainWindow.hide(); + } + }); +} diff --git a/e2e/specs/calls/calls_functionality.test.ts b/e2e/specs/calls/calls_functionality.test.ts index 4f2414180fe..450e8aeddd7 100644 --- a/e2e/specs/calls/calls_functionality.test.ts +++ b/e2e/specs/calls/calls_functionality.test.ts @@ -9,14 +9,6 @@ import {demoMattermostConfig} from '../../helpers/config'; import {loginToMattermost} from '../../helpers/login'; import type {ServerView} from '../../helpers/serverView'; -// ── Widget window discovery ───────────────────────────────────────────── -// The Calls widget is a separate frameless BrowserWindow created by -// CallsWidgetWindow (src/app/callsWidgetWindow.ts). It loads the Calls -// plugin's standalone widget page at: -// /plugins/com.mattermost.calls/standalone/widget.html -// Because it is a BrowserWindow (not a WebContentsView), it appears in -// electronApp.windows(). - async function findCallsWidgetWindow(electronApp: ElectronApplication): Promise { return electronApp.windows().find((w) => { try { @@ -28,6 +20,21 @@ async function findCallsWidgetWindow(electronApp: ElectronApplication): Promise< }) ?? null; } +async function waitForCallsWidgetWindow( + electronApp: ElectronApplication, + timeoutMs = 20_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const widget = await findCallsWidgetWindow(electronApp); + if (widget) { + return widget; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return null; +} + test.describe('calls/calls_functionality', () => { test.describe.configure({mode: 'serial'}); test.use({appConfig: demoMattermostConfig}); @@ -35,8 +42,6 @@ test.describe('calls/calls_functionality', () => { let serverWin: ServerView; - // Login runs in beforeEach (not beforeAll) because electronApp and - // serverMap are test-scoped fixtures — each test launches a fresh app. test.beforeEach(async ({serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); @@ -52,7 +57,6 @@ test.describe('calls/calls_functionality', () => { await serverWin.waitForSelector('#channelHeaderTitle', {timeout: 10_000}); }); - // ── MM-T4841: Calls UI Functionality ─────────────────────────────── test('MM-T4841 Calls UI Functionality - Self-managed', {tag: ['@P2', '@all']}, async ({electronApp}) => { @@ -60,66 +64,48 @@ test.describe('calls/calls_functionality', () => { await serverWin.fill('#post_textbox', '/call start'); await serverWin.press('#post_textbox', 'Enter'); - let widgetWindow: Page | null = null; - const widgetDeadline = Date.now() + 20_000; - while (!widgetWindow && Date.now() < widgetDeadline) { - widgetWindow = await findCallsWidgetWindow(electronApp); - if (!widgetWindow) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } + const widgetWindow = await waitForCallsWidgetWindow(electronApp); if (!widgetWindow) { test.skip(true, 'Calls plugin/widget not available on this test server'); return; } - // Verify the widget loaded the correct URL - expect( - widgetWindow!.url(), - 'Widget URL must point to Calls plugin', - ).toContain('/plugins/com.mattermost.calls/standalone/widget.html'); + expect(widgetWindow.url(), 'Widget URL must point to Calls plugin').toContain( + '/plugins/com.mattermost.calls/standalone/widget.html', + ); - // Verify the widget has interactive controls - await widgetWindow!.waitForLoadState('domcontentloaded'); - const hasControls = await widgetWindow!.evaluate(() => { - return document.querySelectorAll('button').length > 0; - }); + await widgetWindow.waitForLoadState('domcontentloaded'); + const hasControls = await widgetWindow.evaluate(() => document.querySelectorAll('button').length > 0); expect(hasControls, 'Calls widget must have interactive controls').toBe(true); - // Verify mute button exists and can be toggled - const muteButton = await widgetWindow!.waitForSelector('button[aria-label*="Mute"], button[aria-label*="mute"]', {timeout: 10_000}); + 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(); - // Read initial aria-pressed state - const initialPressed = await widgetWindow!.evaluate(() => { + const initialPressed = await widgetWindow.evaluate(() => { const btn = document.querySelector('button[aria-label*="Mute"], button[aria-label*="mute"]'); return btn?.getAttribute('aria-pressed') ?? null; }); - // Click mute to toggle await muteButton.click(); - // Verify aria-pressed changed await expect.poll( - () => widgetWindow!.evaluate(() => { + () => 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); - // Close the widget - await closeCallsWidget(electronApp, widgetWindow!); + await closeCallsWidget(electronApp, widgetWindow); }, ); - // ── MM-T5587: Calls - Slash Commands ─────────────────────────────── test('MM-T5587 Calls - Slash Commands', {tag: ['@P2', '@all']}, async ({electronApp}) => { - // Snapshot the current last-post id so the ephemeral-response check - // can only match a NEW post produced by this slash command, not - // arbitrary channel history that happens to contain the word "call". await serverWin.waitForSelector('#post_textbox', {timeout: 10_000}); const postIdBefore = await serverWin.evaluate(() => { const items = document.querySelectorAll('[data-testid="postView"]'); @@ -130,55 +116,52 @@ test.describe('calls/calls_functionality', () => { await serverWin.fill('#post_textbox', '/call start'); await serverWin.press('#post_textbox', 'Enter'); - // Poll deterministically for either outcome: a Calls widget window - // or a brand-new post mentioning "call" appearing after the command. - type Outcome = {kind: 'widget'; window: Page} | {kind: 'post'} | null; - let outcome: Outcome = null; + let detectedKind: 'widget' | 'post' | null = null; try { - await expect.poll( - async () => { - const widget = await findCallsWidgetWindow(electronApp); - if (widget) { - outcome = {kind: 'widget', window: 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; + await expect.poll(async (): Promise => { + if (await findCallsWidgetWindow(electronApp)) { + detectedKind = '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; } - return false; - }, - { - timeout: 20_000, - message: - '/call start produced neither a Calls widget window nor a new ephemeral response.', - }, - ).toBe(true); - } catch { - test.skip(true, 'Calls plugin/widget not available on this test server'); - return; + const text = last.querySelector('.post-message__text')?.textContent ?? ''; + return text.toLowerCase().includes('call'); + }, postIdBefore); + if (newPostMentionsCall) { + detectedKind = 'post'; + return true; + } + return false; + }, { + timeout: 20_000, + message: '/call start produced neither a Calls widget window nor a new ephemeral response.', + }).toBe(true); + } 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 && (outcome as Outcome)!.kind === 'widget') { - const widget = (outcome as {kind: 'widget'; window: Page}).window; - expect(widget.url(), '/call start must open Calls widget').toContain( + if (detectedKind === 'widget') { + const widgetWindow = await 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, widget); + await closeCallsWidget(electronApp, widgetWindow!); } }, ); - // ── MM-T5411: Calls - Keyboard Shortcuts ─────────────────────────── test('MM-T5411 Calls - Keyboard Shortcuts (self-managed)', {tag: ['@P2', '@all']}, async ({electronApp}) => { @@ -186,14 +169,7 @@ test.describe('calls/calls_functionality', () => { await serverWin.fill('#post_textbox', '/call start'); await serverWin.press('#post_textbox', 'Enter'); - let widgetWindow: Page | null = null; - const widgetDeadline = Date.now() + 30_000; - while (!widgetWindow && Date.now() < widgetDeadline) { - widgetWindow = await findCallsWidgetWindow(electronApp); - if (!widgetWindow) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } + const widgetWindow = await waitForCallsWidgetWindow(electronApp, 30_000); if (!widgetWindow) { test.skip(true, 'Calls plugin/widget not available on this test server'); return; @@ -201,13 +177,8 @@ test.describe('calls/calls_functionality', () => { await widgetWindow.waitForLoadState('domcontentloaded'); await widgetWindow.waitForSelector('button[aria-label*="Mute"], button[aria-label*="mute"]', {timeout: 10_000}); - - // Focus the widget so it receives keyboard events await widgetWindow.bringToFront(); - // Capture initial aria-pressed BEFORE pressing 'm' so we can verify - // the keyboard shortcut actually toggled mute (not just that the - // attribute exists). const initialPressed = await widgetWindow.evaluate(() => { const btn = document.querySelector('button[aria-label*="Mute"], button[aria-label*="mute"]'); return btn?.getAttribute('aria-pressed') ?? null; @@ -228,13 +199,10 @@ test.describe('calls/calls_functionality', () => { ); }); -// ── Helper ───────────────────────────────────────────────────────────── - async function closeCallsWidget( electronApp: ElectronApplication, widgetWindow: Page, ): Promise { - // Click the leave/end call button in the widget 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"]', @@ -247,13 +215,11 @@ async function closeCallsWidget( }); if (!leaveClicked) { - // Fallback: send the leave-call IPC await electronApp.evaluate(({ipcMain}) => { ipcMain.emit('calls-leave-call'); }); } - // Wait for the widget window to close await expect.poll( () => findCallsWidgetWindow(electronApp), {timeout: 10_000, message: 'Calls widget window must close after leave'}, diff --git a/e2e/specs/menu_bar/devtools_current_server.test.ts b/e2e/specs/menu_bar/devtools_current_server.test.ts index 7c57a355c20..816b148ea1e 100644 --- a/e2e/specs/menu_bar/devtools_current_server.test.ts +++ b/e2e/specs/menu_bar/devtools_current_server.test.ts @@ -27,7 +27,6 @@ test.describe('menu_bar/devtools_current_server', () => { async ({electronApp, serverMap}) => { if (!process.env.MM_TEST_SERVER_URL) { test.skip(true, 'MM_TEST_SERVER_URL required'); - return; } const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; diff --git a/e2e/specs/menu_bar/edit_menu.test.ts b/e2e/specs/menu_bar/edit_menu.test.ts index 7f80fe2912e..d5becd36ada 100644 --- a/e2e/specs/menu_bar/edit_menu.test.ts +++ b/e2e/specs/menu_bar/edit_menu.test.ts @@ -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; @@ -103,7 +103,11 @@ test.describe('edit_menu', () => { }); test.afterAll(async () => { - await closeElectronAppFast(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/help_menu.test.ts b/e2e/specs/menu_bar/help_menu.test.ts index 62f4c998f77..83283ad7e5b 100644 --- a/e2e/specs/menu_bar/help_menu.test.ts +++ b/e2e/specs/menu_bar/help_menu.test.ts @@ -21,10 +21,14 @@ test.describe('menu_bar/help_menu', () => { await electronApp.evaluate(() => { const refs = (global as any).__e2eTestRefs; - refs.updateNotifier.__e2eCheckForUpdatesCalls = 0; - refs.updateNotifier.__e2eOriginalCheckForUpdates = refs.updateNotifier.checkForUpdates; - refs.updateNotifier.checkForUpdates = () => { - refs.updateNotifier.__e2eCheckForUpdatesCalls += 1; + 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; }; }); diff --git a/e2e/specs/menu_bar/view_menu.test.ts b/e2e/specs/menu_bar/view_menu.test.ts index 421db759b30..c96058d571e 100644 --- a/e2e/specs/menu_bar/view_menu.test.ts +++ b/e2e/specs/menu_bar/view_menu.test.ts @@ -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; @@ -132,7 +132,11 @@ test.describe('menu/view', () => { }); test.afterAll(async () => { - await closeElectronAppFast(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 () => { diff --git a/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts index 6059f58f162..1fe34a40548 100644 --- a/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts +++ b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts @@ -1,14 +1,16 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {triggerTestNotification, verifyNotificationReceivedInDM} from './helpers'; +import type {ElectronApplication} from 'playwright'; import {test, expect} from '../../fixtures/index'; import {demoMattermostConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; -async function readBadgeCount(electronApp: import('playwright').ElectronApplication): Promise { +import {triggerTestNotification, verifyNotificationReceivedInDM} from './helpers'; + +async function readBadgeCount(electronApp: ElectronApplication): Promise { return electronApp.evaluate(({app}) => { if (process.platform === 'darwin') { const badge = app.dock?.getBadge() ?? ''; @@ -22,11 +24,6 @@ async function readBadgeCount(electronApp: import('playwright').ElectronApplicat }); } -// ── MM-T1661: Desktop notifications ──────────────────────────────────── -// Drives the real notification path via triggerTestNotification (same helper -// used by notification_badge_in_dock.test.ts). Asserts the observable side -// effect: badge count increments after a test notification is sent. - test.describe('notification_trigger/desktop_notification_delivery', () => { test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); @@ -46,9 +43,9 @@ test.describe('notification_trigger/desktop_notification_delivery', () => { await loginToMattermost(firstServer!); - // The notification trigger depends on the Customize Your Experience tour button. - const tourButton = await firstServer!.$('div#CustomizeYourExperienceTour > button'); - if (!tourButton) { + try { + await firstServer!.waitForSelector('div#CustomizeYourExperienceTour > button', {timeout: 15_000}); + } catch { test.skip(true, 'CustomizeYourExperienceTour not available in this server version'); return; } @@ -61,15 +58,13 @@ test.describe('notification_trigger/desktop_notification_delivery', () => { await triggerTestNotification(firstServer!); - // Badge overlay is flaky on Windows CI; DM receipt is the authoritative signal. - if ((unityRunning || process.platform !== 'linux') && process.platform !== 'win32') { + if (unityRunning && process.platform !== 'win32') { await expect.poll( () => readBadgeCount(electronApp), {timeout: 10_000, message: 'Badge count must increment after notification'}, ).toBeGreaterThan(beforeBadge); } - // Verify the notification was received in the DM from system-bot 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 index 002f7ef34eb..4b79201b80e 100644 --- a/e2e/specs/notification_trigger/dock_bounce.test.ts +++ b/e2e/specs/notification_trigger/dock_bounce.test.ts @@ -7,45 +7,9 @@ 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'; -// ── Production code path ─────────────────────────────────────────────── -// src/main/notifications/index.ts :: flashFrame() -// if (process.platform === 'darwin' && Config.notifications.bounceIcon -// && Config.notifications.bounceIconType) { -// app.dock?.bounce(Config.notifications.bounceIconType); -// } -// -// Invoke the production flashFrame() helper (same path notification `show` -// handlers use). OS notifications are unreliable in headless CI. - -async function installDockBounceSpy(electronApp: ElectronApplication): Promise { - await electronApp.evaluate(({app}) => { - (app as any).__e2eDockBounceCalls = []; - const dock = app.dock; - if (!dock) { - return; - } - const originalBounce = dock.bounce.bind(dock); - (dock as any).__e2eOriginalBounce = originalBounce; - dock.bounce = ((type?: 'informational' | 'critical') => { - (app as any).__e2eDockBounceCalls.push(type ?? 'informational'); - return originalBounce(type); - }) as typeof dock.bounce; - }); -} - -async function restoreDockBounce(electronApp: ElectronApplication): Promise { - await electronApp.evaluate(({app}) => { - const dock = app.dock; - if (dock && (dock as any).__e2eOriginalBounce) { - dock.bounce = (dock as any).__e2eOriginalBounce; - delete (dock as any).__e2eOriginalBounce; - } - delete (app as any).__e2eDockBounceCalls; - }); -} - type BounceConfigArgs = {bounceIcon: boolean; bounceIconType: 'informational' | 'critical' | null}; async function setBounceConfig( @@ -92,7 +56,7 @@ test.describe('notification_trigger/dock_bounce', () => { 'dock.bounce() must NOT be called when bounceIcon is false', ).toHaveLength(0); } finally { - await restoreDockBounce(electronApp); + await restoreDockBounceSpy(electronApp); } } finally { await releaseLock(); @@ -119,7 +83,7 @@ test.describe('notification_trigger/dock_bounce', () => { {timeout: 10_000, message: 'dock.bounce("informational") must be called'}, ).toContain('informational'); } finally { - await restoreDockBounce(electronApp); + await restoreDockBounceSpy(electronApp); } } finally { await releaseLock(); @@ -146,7 +110,7 @@ test.describe('notification_trigger/dock_bounce', () => { {timeout: 10_000, message: 'dock.bounce("critical") must be called'}, ).toContain('critical'); } finally { - await restoreDockBounce(electronApp); + 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 index aae1db207f2..1e059500978 100644 --- a/e2e/specs/notification_trigger/flash_taskbar.test.ts +++ b/e2e/specs/notification_trigger/flash_taskbar.test.ts @@ -5,20 +5,9 @@ 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'; -// ── MM-T1293: Flash taskbar icon — Windows & Linux ONLY ────────────── -// Production path: src/main/notifications/index.ts :: flashFrame() -// if (process.platform === 'linux' || process.platform === 'win32') { -// if (Config.notifications.flashWindow) { -// MainWindow.get()?.flashFrame(flash); -// } -// } -// -// We enable flashWindow in config, invoke the production flashFrame() helper -// (same code path notification `show` handlers use), and spy on -// BrowserWindow.flashFrame() to verify it was called. - test.describe('notification_trigger/flash_taskbar', () => { test.use({appConfig: demoConfig}); test.setTimeout(120_000); @@ -30,7 +19,6 @@ test.describe('notification_trigger/flash_taskbar', () => { const releaseLock = await acquireExclusiveLock('flash-taskbar-state'); try { - // Enable flashWindow in config (schema allows 0 or 2 only) await electronApp.evaluate(() => { const refs = (global as any).__e2eTestRefs; const Config = refs?.Config; @@ -39,20 +27,7 @@ test.describe('notification_trigger/flash_taskbar', () => { } }); - await electronApp.evaluate(() => { - (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); - }; - }); + await installFlashFrameSpy(electronApp); try { await triggerNotificationEffects(electronApp, true); @@ -62,14 +37,7 @@ test.describe('notification_trigger/flash_taskbar', () => { {timeout: 10_000, message: 'flashFrame(true) must be called when flashWindow is enabled'}, ).toContain(true); } finally { - await electronApp.evaluate(() => { - const refs = (global as any).__e2eTestRefs; - const mainWin = refs?.MainWindow?.get?.(); - if (mainWin && (mainWin as any).__e2eOriginalFlashFrame) { - mainWin.flashFrame = (mainWin as any).__e2eOriginalFlashFrame; - } - delete (global as any).__e2eFlashFrameCalls; - }); + await restoreFlashFrameSpy(electronApp); } } finally { await releaseLock(); diff --git a/e2e/specs/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index 48242b3cf29..e7aee7494b0 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -1,11 +1,11 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BROWSER_HISTORY_PUSH, NOTIFICATION_CLICKED} from '../../../src/common/communication'; import {test, expect} from '../../fixtures/index'; import {demoMattermostConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; +import {hideMainWindow} from '../../helpers/tray'; test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); @@ -21,7 +21,8 @@ test( const releaseLock = await acquireExclusiveLock('notification-state'); try { - const serverWin = serverMap[demoMattermostConfig.servers[0].name]?.[0]?.win; + const serverEntry = serverMap[demoMattermostConfig.servers[0].name]?.[0]; + const serverWin = serverEntry?.win; expect(serverWin, 'No server view available').toBeTruthy(); await loginToMattermost(serverWin!); @@ -53,10 +54,7 @@ test( expect(targetChannel?.url, 'Could not resolve off-topic sidebar URL').toBeTruthy(); const targetPathname = new URL(targetChannel!.url!).pathname; - await electronApp.evaluate(() => { - const refs = (global as any).__e2eTestRefs; - refs?.MainWindow?.get?.()?.hide(); - }); + await hideMainWindow(electronApp); await expect.poll( () => electronApp.evaluate(() => { @@ -67,25 +65,23 @@ test( {timeout: 5_000, message: 'Main window should be hidden before notification click'}, ).toBe(false); - await electronApp.evaluate(({webContents, ipcMain}, payload) => { - const wc = webContents.fromId(payload.webContentsId); - if (!wc || wc.isDestroyed()) { - throw new Error(`webContents ${payload.webContentsId} is not available`); + await electronApp.evaluate((payload) => { + const displayAndClick = (global as any).__e2eDisplayAndClickMention as + | ((value: typeof payload) => Promise) + | undefined; + if (!displayAndClick) { + throw new Error('__e2eDisplayAndClickMention not exposed (NODE_ENV must be test)'); } - - const focus = () => { - const refs = (global as any).__e2eTestRefs; - refs?.MainWindow?.show?.(); - ipcMain.off(payload.browserHistoryPush, focus); - delete (global as any).__e2eNotificationClickFocus; - }; - (global as any).__e2eNotificationClickFocus = focus; - ipcMain.on(payload.browserHistoryPush, focus); - wc.send(payload.channel, payload.channelId, payload.teamId, payload.url); + return displayAndClick({ + webContentsId: payload.webContentsId, + title: 'E2E mention', + body: 'Notification click test', + channelId: payload.channelId, + teamId: payload.teamId, + url: payload.url, + }); }, { - webContentsId: serverMap[demoMattermostConfig.servers[0].name]![0]!.webContentsId, - channel: NOTIFICATION_CLICKED, - browserHistoryPush: BROWSER_HISTORY_PUSH, + webContentsId: serverEntry!.webContentsId, channelId: targetChannel!.id, teamId: targetChannel!.teamId, url: targetChannel!.url!, @@ -105,21 +101,13 @@ test( {timeout: 10_000, message: 'Main window should be visible after notification click navigation'}, ).toBe(true); } finally { - // Always restore MainWindow visibility so cross-test workers don't see a - // hidden window if BROWSER_HISTORY_PUSH never fired or timed out. - await electronApp.evaluate(({ipcMain}, channel) => { + await electronApp.evaluate(() => { const refs = (global as any).__e2eTestRefs; const mainWindow = refs?.MainWindow?.get?.(); if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { refs?.MainWindow?.show?.(); } - - const focus = (global as any).__e2eNotificationClickFocus; - if (focus) { - ipcMain.off(channel, focus); - delete (global as any).__e2eNotificationClickFocus; - } - }, BROWSER_HISTORY_PUSH).catch(() => {}); + }).catch(() => {}); await releaseLock(); } }, diff --git a/e2e/specs/settings.test.ts b/e2e/specs/settings.test.ts index a44dfda6868..f739e9878a5 100644 --- a/e2e/specs/settings.test.ts +++ b/e2e/specs/settings.test.ts @@ -4,49 +4,7 @@ import * as fs from 'fs'; import {test, expect} from '../fixtures/index'; - -const SHOW_SETTINGS_WINDOW = 'show-settings-window'; - -type ElectronApplication = Awaited>; - -async function openSettingsWindow(electronApp: ElectronApplication) { - for (let attempt = 0; attempt < 5; attempt++) { - const existingWindow = electronApp.windows().find((window) => window.url().includes('settings')); - if (existingWindow) { - await existingWindow.waitForLoadState().catch(() => {}); - return existingWindow; - } - - 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; - } - } - - try { - const settingsWindow = electronApp.windows().find((window) => window.url().includes('settings')) ?? - await electronApp.waitForEvent('window', { - predicate: (window) => window.url().includes('settings'), - timeout: 3_000, - }); - - await settingsWindow.waitForLoadState().catch(() => {}); - return settingsWindow; - } catch (error) { - if (attempt === 4) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - } - - throw new Error('Settings window did not open'); -} +import {openSettingsWindow} from '../helpers/settingsWindow'; test.describe('Settings', () => { test.describe('Options', () => { diff --git a/e2e/specs/settings/autostart.test.ts b/e2e/specs/settings/autostart.test.ts new file mode 100644 index 00000000000..fd1da3dd940 --- /dev/null +++ b/e2e/specs/settings/autostart.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as fs from 'fs'; +import * as path from 'path'; + +import {test, expect} from '../../fixtures/index'; +import {openSettingsWindow} from '../../helpers/settingsWindow'; + +test( + 'SET-01 toggling autostart updates config.json', + {tag: ['@P1', '@win32', '@linux']}, + async ({electronApp}, testInfo) => { + const configFilePath = path.join(testInfo.outputDir, 'userdata', 'config.json'); + const settingsWindow = await openSettingsWindow(electronApp); + await settingsWindow.click('#settingCategoryButton-general'); + + const autostartToggle = settingsWindow.locator('#CheckSetting_autostart button'); + await autostartToggle.waitFor({state: 'visible', timeout: 10_000}); + + const initialConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) as {autostart: boolean}; + try { + await autostartToggle.click(); + await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); + + const updatedConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) as {autostart: boolean}; + expect(updatedConfig.autostart).toBe(!initialConfig.autostart); + } finally { + const currentConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) as {autostart: boolean}; + if (currentConfig.autostart !== initialConfig.autostart) { + await autostartToggle.click(); + await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); + } + } + }, +); diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 4c3308eb399..64c9dcb5e4e 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -304,6 +304,7 @@ async function initializeAfterAppReady() { TrayIcon: Tray, Diagnostics, PopoutManager, + updateNotifier, }); setTestField('__e2eOpenDeepLink', (url: string) => { diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 4045241612f..3457143729e 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {app, shell, Notification, ipcMain} from 'electron'; +import {app, shell, Notification, ipcMain, webContents} from 'electron'; import isDev from 'electron-is-dev'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; @@ -281,6 +281,55 @@ function flashFrame(flash: boolean) { if (process.env.NODE_ENV === 'test') { setTestField('__e2eNotificationEffects', flashFrame); + setTestField('__e2eDisplayAndClickMention', async (payload: { + webContentsId: number; + title: string; + body: string; + channelId: string; + teamId: string; + url: string; + }) => { + const wc = webContents.fromId(payload.webContentsId); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.webContentsId} is not available`); + } + + const displayPromise = notificationManager.displayMention( + payload.title, + payload.body, + payload.channelId, + payload.teamId, + payload.url, + true, + wc, + '', + ); + + let mention: Mention | undefined; + const deadline = Date.now() + 5_000; + while (!mention && Date.now() < deadline) { + const activeNotifications = (notificationManager as unknown as { + allActiveNotifications?: Map; + }).allActiveNotifications; + for (const notification of activeNotifications?.values() ?? []) { + if (notification instanceof Mention && notification.channelId === payload.channelId) { + mention = notification; + break; + } + } + if (!mention) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + + if (!mention) { + throw new Error('Mention notification was not created'); + } + + mention.emit('click'); + await displayPromise.catch(() => {}); + }); } const notificationManager = new NotificationManager(); From dd9ae3af6de722f2e598c56101c9f0a39ce80896 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Thu, 2 Jul 2026 00:35:08 +0530 Subject: [PATCH 07/22] fix(e2e): fix duplicate cross-PR bugs, unsafe private-field cast, bare catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settingsWindow.ts / autostart.test.ts: same bugs already found and fixed in the sibling PR #3860 (both branches independently introduced this file from the same base) — waitForLoadState() failures were being swallowed and returned as a possibly-broken Page outside the retry loop, and the autostart restore step could fail silently, leaving the real OS autostart entry toggled for subsequent CI runs with no warning. src/main/notifications/index.ts: the new __e2eDisplayAndClickMention test hook reached into NotificationManager's private allActiveNotifications field via `as unknown as {...}`, defeating type-checking — a future rename of that field would silently break the hook instead of failing to compile. Added a small, type-safe findActiveMentionByChannelId() method on the class instead, used by the test hook. Verified: `tsc` (full check-types) clean, all 66 existing notification unit tests still pass. desktop_notification_delivery.test.ts: readBadgeCount's bare `catch { return 0 }` masked any real failure as "no badge" — but the non-darwin call sites are already gated behind the unityRunning check at the call site, so app.getBadgeCount() isn't expected to throw there; removed the redundant catch so a real failure surfaces instead of being silently scored as a passing badge-count of 0. Co-Authored-By: Claude Sonnet 5 --- e2e/helpers/settingsWindow.ts | 14 +++++++--- .../desktop_notification_delivery.test.ts | 10 +++---- e2e/specs/settings/autostart.test.ts | 27 ++++++++++++++++--- src/main/notifications/index.ts | 21 ++++++++------- 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/e2e/helpers/settingsWindow.ts b/e2e/helpers/settingsWindow.ts index 914946ee8ab..74e3e33844e 100644 --- a/e2e/helpers/settingsWindow.ts +++ b/e2e/helpers/settingsWindow.ts @@ -9,8 +9,16 @@ export async function openSettingsWindow(electronApp: ElectronApplication): Prom for (let attempt = 0; attempt < 5; attempt++) { const existingWindow = electronApp.windows().find((window) => window.url().includes('settings')); if (existingWindow) { - await existingWindow.waitForLoadState().catch(() => {}); - return existingWindow; + try { + await existingWindow.waitForLoadState(); + return existingWindow; + } catch (error) { + if (attempt === 4) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } } try { @@ -31,7 +39,7 @@ export async function openSettingsWindow(electronApp: ElectronApplication): Prom timeout: 3_000, }); - await settingsWindow.waitForLoadState().catch(() => {}); + await settingsWindow.waitForLoadState(); return settingsWindow; } catch (error) { if (attempt === 4) { diff --git a/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts index 1fe34a40548..aae8ac395ac 100644 --- a/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts +++ b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts @@ -10,17 +10,17 @@ import {loginToMattermost} from '../../helpers/login'; import {triggerTestNotification, verifyNotificationReceivedInDM} from './helpers'; +// Only called when the platform actually supports a badge count (darwin, or +// linux with Unity running — see the unityRunning check at the call site), so +// app.getBadgeCount() isn't expected to throw here; let a real failure surface +// instead of masking it as "0 badge". async function readBadgeCount(electronApp: ElectronApplication): Promise { return electronApp.evaluate(({app}) => { if (process.platform === 'darwin') { const badge = app.dock?.getBadge() ?? ''; return badge === '' || Number.isNaN(Number(badge)) ? 0 : parseInt(badge, 10); } - try { - return app.getBadgeCount(); - } catch { - return 0; - } + return app.getBadgeCount(); }); } diff --git a/e2e/specs/settings/autostart.test.ts b/e2e/specs/settings/autostart.test.ts index fd1da3dd940..4499f88b1a6 100644 --- a/e2e/specs/settings/autostart.test.ts +++ b/e2e/specs/settings/autostart.test.ts @@ -19,18 +19,37 @@ test( await autostartToggle.waitFor({state: 'visible', timeout: 10_000}); const initialConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) as {autostart: boolean}; + let testError: unknown; try { await autostartToggle.click(); await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); const updatedConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) as {autostart: boolean}; expect(updatedConfig.autostart).toBe(!initialConfig.autostart); + } catch (error) { + testError = error; } finally { - const currentConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) as {autostart: boolean}; - if (currentConfig.autostart !== initialConfig.autostart) { - await autostartToggle.click(); - await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); + // Restoring failure here would leave the real OS autostart entry toggled + // for subsequent CI runs, so surface it loudly instead of swallowing it — + // but don't let a restore failure mask an earlier test failure. + try { + const currentConfig = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')) as {autostart: boolean}; + if (currentConfig.autostart !== initialConfig.autostart) { + await autostartToggle.click(); + await settingsWindow.waitForSelector('.SettingsModal__saving :text("Changes saved")', {timeout: 15_000}); + } + } catch (restoreError) { + // eslint-disable-next-line no-console + console.error( + 'SET-01: failed to restore autostart to its original value — ' + + 'the real OS autostart entry may be left toggled for subsequent runs.', + restoreError, + ); + testError = testError ?? restoreError; } } + if (testError) { + throw testError; + } }, ); diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 3457143729e..4f0dfa7807b 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -243,6 +243,17 @@ class NotificationManager { break; } } + + /** Test-only accessor: find an active Mention by channel. Used to simulate clicking a + * just-displayed notification in E2E tests without reaching into private fields via a cast. */ + public findActiveMentionByChannelId(channelId: string): Mention | undefined { + for (const notification of this.allActiveNotifications?.values() ?? []) { + if (notification instanceof Mention && notification.channelId === channelId) { + return notification; + } + } + return undefined; + } } export async function getDoNotDisturb() { @@ -308,15 +319,7 @@ if (process.env.NODE_ENV === 'test') { let mention: Mention | undefined; const deadline = Date.now() + 5_000; while (!mention && Date.now() < deadline) { - const activeNotifications = (notificationManager as unknown as { - allActiveNotifications?: Map; - }).allActiveNotifications; - for (const notification of activeNotifications?.values() ?? []) { - if (notification instanceof Mention && notification.channelId === payload.channelId) { - mention = notification; - break; - } - } + mention = notificationManager.findActiveMentionByChannelId(payload.channelId); if (!mention) { // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => setTimeout(resolve, 50)); From bad91be104116654da3a35e11f1e03b8fffc9e3d Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Thu, 2 Jul 2026 01:41:06 +0530 Subject: [PATCH 08/22] fix(e2e): restore downloadsDropdown helper and address CodeRabbit nitpicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAIN FIX (the "10 did not run" tests): e2e/specs/menu_bar/window_menu.test.ts imports closeDownloadsDropdownIfOpen from e2e/helpers/downloadsDropdown, but that module wasn't on this branch — I deleted it in a fix commit on sibling PR #3857 without checking whether the stacked PRs above it referenced it. That broke this PR's build: Playwright couldn't load window_menu.test.ts, so its ~10 tests never ran (showed up as "did not run" instead of "failed" or "skipped", which is why it was easy to miss until the module-not-found error was surfaced). Restored the helper on this branch verbatim from git history so PR #3862 is self-contained regardless of sibling merge order. CodeRabbit nitpicks addressed: - methodSpy.ts: routed the dock/flash-frame spy install/restore through evaluateInMainProcess[WithArg] so they get the shared transient-context retry behavior used by the tray helpers — these spies wrap install/restore around notification triggers that navigate windows, exactly the case where transient evaluate failures happen. - settingsWindow.ts: dropped the local try/catch retry loop around ipcMain.emit(SHOW_SETTINGS_WINDOW) in favor of evaluateInMainProcessWithArg, centralizing the "Execution context was destroyed" retry logic instead of duplicating it. - notification_click.test.ts: replaced two inline duplicated MainWindow visibility polls with the existing isMainWindowVisible helper in tray.ts (identical predicate — pure dedup, no behavior change). - notifications/index.ts findActiveMentionByChannelId: now iterates through ALL matches and returns the most-recently-inserted one, in case older Mention notifications for the same channel haven't dismissed yet — Map preserves insertion order, so scanning to the end reliably gives us the notification the test just displayed. All 66 existing notification unit tests still pass. Skipped one CodeRabbit nitpick: the suggestion to extend evaluateInMainProcess itself to expose SHOW_SETTINGS_WINDOW as a param — the current usage via evaluateInMainProcessWithArg already achieves the goal with no helper change. Co-Authored-By: Claude Sonnet 5 --- e2e/helpers/downloadsDropdown.ts | 37 +++++++++++++++++++ e2e/helpers/methodSpy.ts | 20 +++++++--- e2e/helpers/settingsWindow.ts | 17 ++++----- .../notification_click.test.ts | 14 ++----- src/main/notifications/index.ts | 11 ++++-- 5 files changed, 68 insertions(+), 31 deletions(-) create mode 100644 e2e/helpers/downloadsDropdown.ts diff --git a/e2e/helpers/downloadsDropdown.ts b/e2e/helpers/downloadsDropdown.ts new file mode 100644 index 00000000000..1695cdcf7a5 --- /dev/null +++ b/e2e/helpers/downloadsDropdown.ts @@ -0,0 +1,37 @@ +// 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 '../../src/common/communication'; + +function isTransientNavigationError(message: string): boolean { + return message.includes('Execution context was destroyed') || + message.includes('Target closed') || + message.includes('Protocol error'); +} + +/** + * 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 { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + await app.evaluate(({ipcMain}, channels) => { + ipcMain.emit(channels.menu); + ipcMain.emit(channels.dropdown); + }, {dropdown: CLOSE_DOWNLOADS_DROPDOWN, menu: CLOSE_DOWNLOADS_DROPDOWN_MENU}); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!isTransientNavigationError(message)) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + throw new Error('Timed out closing downloads dropdown after navigation'); +} diff --git a/e2e/helpers/methodSpy.ts b/e2e/helpers/methodSpy.ts index 0ec145a927a..0e314e0120a 100644 --- a/e2e/helpers/methodSpy.ts +++ b/e2e/helpers/methodSpy.ts @@ -3,8 +3,16 @@ 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 app.evaluate(({app: electronApp}) => { + await evaluateInMainProcessWithArg(app, ({app: electronApp}) => { (electronApp as any).__e2eDockBounceCalls = []; const dock = electronApp.dock; if (!dock) { @@ -16,22 +24,22 @@ export async function installDockBounceSpy(app: ElectronApplication): Promise { - await app.evaluate(({app: electronApp}) => { + 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 app.evaluate(() => { + await evaluateInMainProcess(app, () => { (global as any).__e2eFlashFrameCalls = []; const refs = (global as any).__e2eTestRefs; const mainWin = refs?.MainWindow?.get?.(); @@ -48,7 +56,7 @@ export async function installFlashFrameSpy(app: ElectronApplication): Promise { - await app.evaluate(() => { + await evaluateInMainProcess(app, () => { const refs = (global as any).__e2eTestRefs; const mainWin = refs?.MainWindow?.get?.(); if (mainWin && (mainWin as any).__e2eOriginalFlashFrame) { diff --git a/e2e/helpers/settingsWindow.ts b/e2e/helpers/settingsWindow.ts index 74e3e33844e..0895dea01bf 100644 --- a/e2e/helpers/settingsWindow.ts +++ b/e2e/helpers/settingsWindow.ts @@ -4,6 +4,7 @@ import type {ElectronApplication, Page} from 'playwright'; import {SHOW_SETTINGS_WINDOW} from '../../src/common/communication'; +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/specs/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index e7aee7494b0..42f16a0e926 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -5,7 +5,7 @@ import {test, expect} from '../../fixtures/index'; import {demoMattermostConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; -import {hideMainWindow} from '../../helpers/tray'; +import {hideMainWindow, isMainWindowVisible} from '../../helpers/tray'; test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); @@ -57,11 +57,7 @@ test( await hideMainWindow(electronApp); await expect.poll( - () => electronApp.evaluate(() => { - const refs = (global as any).__e2eTestRefs; - const mainWindow = refs?.MainWindow?.get?.(); - return Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()); - }), + () => isMainWindowVisible(electronApp), {timeout: 5_000, message: 'Main window should be hidden before notification click'}, ).toBe(false); @@ -93,11 +89,7 @@ test( ).toBe(targetPathname); await expect.poll( - () => electronApp.evaluate(() => { - const refs = (global as any).__e2eTestRefs; - const mainWindow = refs?.MainWindow?.get?.(); - return Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()); - }), + () => isMainWindowVisible(electronApp), {timeout: 10_000, message: 'Main window should be visible after notification click navigation'}, ).toBe(true); } finally { diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 4f0dfa7807b..161b6127c2b 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -244,15 +244,18 @@ class NotificationManager { } } - /** Test-only accessor: find an active Mention by channel. Used to simulate clicking a - * just-displayed notification in E2E tests without reaching into private fields via a cast. */ + /** Test-only accessor: find the MOST-RECENTLY-INSERTED active Mention for a channel. + * Used by __e2eDisplayAndClickMention to target the notification the test just displayed + * (there can be older ones for the same channel that haven't dismissed yet, and + * Map preserves insertion order so scanning to the end always gives us the newest). */ public findActiveMentionByChannelId(channelId: string): Mention | undefined { + let latest: Mention | undefined; for (const notification of this.allActiveNotifications?.values() ?? []) { if (notification instanceof Mention && notification.channelId === channelId) { - return notification; + latest = notification; } } - return undefined; + return latest; } } From 19a1f77cda46204c97e0049f5f159824e71adf46 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Thu, 2 Jul 2026 03:42:11 +0530 Subject: [PATCH 09/22] fix(e2e): restore missing mattermostShell helper for window_menu tests PR #3863 imported waitForMattermostShell without adding the helper file, which blocked Playwright test collection and caused CI to report no tests ran. Co-authored-by: Cursor --- e2e/helpers/mattermostShell.ts | 343 +++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 e2e/helpers/mattermostShell.ts diff --git a/e2e/helpers/mattermostShell.ts b/e2e/helpers/mattermostShell.ts new file mode 100644 index 00000000000..4af3096a1d1 --- /dev/null +++ b/e2e/helpers/mattermostShell.ts @@ -0,0 +1,343 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; + +import type {ServerView} from './serverView'; + +export const POST_TEXTBOX_CANDIDATES = [ + '[data-slate-editor="true"]', + '#post_textbox[contenteditable="true"]', + '[data-testid="post_textbox"][contenteditable="true"]', + '#post_textbox', + '[data-testid="post_textbox"]', + '.post-create__input [contenteditable="true"]', + '.post-create__input [role="textbox"]', + '.AdvancedTextEditor [contenteditable="true"]', + '[role="textbox"][contenteditable="true"]', + 'textarea#post_textbox', +] as const; + +export const POST_TEXTBOX_SELECTOR = POST_TEXTBOX_CANDIDATES.join(', '); + +const POST_TEXTBOX_CANDIDATES_JSON = JSON.stringify(POST_TEXTBOX_CANDIDATES); + +/** + * Shared renderer-side JS, inlined into each `runInRenderer` string below. + * Defines `__mmIsVisible` and `__mmResolvePostTextboxRoot` once so the four + * functions in this file don't each carry their own copy of the candidate + * resolution logic — `runInRenderer` evaluates a raw string in the renderer + * process, so this has to be textually interpolated rather than imported. + */ +const POST_TEXTBOX_RESOLVER_JS = ` + const __mmIsVisible = (element) => { + if (!element || !element.isConnected) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const __mmResolvePostTextboxRoot = () => { + const seen = new Set(); + const candidates = []; + for (const selector of ${POST_TEXTBOX_CANDIDATES_JSON}) { + for (const element of document.querySelectorAll(selector)) { + if (!seen.has(element)) { + seen.add(element); + candidates.push(element); + } + } + } + for (const candidate of candidates) { + if (!__mmIsVisible(candidate)) { + continue; + } + if (candidate.matches('[contenteditable="true"], textarea, input')) { + return candidate; + } + const nested = candidate.querySelector('[contenteditable="true"], textarea, input'); + if (nested && __mmIsVisible(nested)) { + return nested; + } + } + return null; + }; +`; + +/** + * Wait until the Mattermost webapp shell is interactive in a server view. + */ +export async function waitForMattermostShell( + win: ServerView, + options?: {channelItem?: string; timeout?: number}, +) { + const channelItem = options?.channelItem ?? '#sidebarItem_town-square'; + const timeout = options?.timeout ?? 60_000; + + await expect.poll(async () => { + try { + await win.waitForSelector(channelItem, {timeout: 2_000}); + return true; + } catch { + return false; + } + }, {timeout, message: `Mattermost shell must expose ${channelItem}`}).toBe(true); +} + +/** + * Reload the server view when the channel shell failed to mount (blank hex background). + */ +export async function recoverServerViewIfNeeded( + win: ServerView, + options?: {channelItem?: string}, +) { + const channelItem = options?.channelItem ?? '#sidebarItem_town-square'; + const healthy = await win.runInRenderer(` + return Boolean( + document.querySelector('#channelHeaderTitle') + && document.querySelector(${JSON.stringify(channelItem)}), + ); + `).catch(() => false); + + if (healthy) { + return; + } + + await win.runInRenderer('window.location.reload(); return true;', true); + await waitForMattermostShell(win, {channelItem}); +} + +/** Wait for the shell to mount, then recover it if the channel content failed to render. */ +export async function waitForMattermostShellReady( + win: ServerView, + options?: {channelItem?: string; timeout?: number}, +): Promise { + await waitForMattermostShell(win, options); + await recoverServerViewIfNeeded(win, options); +} + +/** Wait until the channel post list finishes its initial load. */ +export async function waitForChannelPostListLoaded( + win: ServerView, + options?: {timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 15_000; + await expect.poll( + async () => win.evaluate(() => !document.querySelector( + '.post-list__loading, .post-list__dynamic-loading, .loading-screen', + )), + {timeout, message: 'Channel post list must finish loading'}, + ).toBe(true); +} + +/** Read the current post textbox contents (textarea value or contenteditable text). */ +export async function getPostTextboxValue(win: ServerView): Promise { + const value = await win.runInRenderer(` + ${POST_TEXTBOX_RESOLVER_JS} + + const root = __mmResolvePostTextboxRoot(); + if (!root) { + return ''; + } + if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) { + return root.value ?? ''; + } + return root.innerText || root.textContent || ''; + `, true); + + return value ?? ''; +} + +/** Press a keyboard shortcut on the post textbox. */ +export async function pressPostTextboxKey(win: ServerView, key: string): Promise { + const focused = await win.runInRenderer(` + ${POST_TEXTBOX_RESOLVER_JS} + + const root = __mmResolvePostTextboxRoot(); + if (!root) { + return false; + } + root.focus?.(); + return true; + `, true); + + if (!focused) { + throw new Error('Post textbox not found'); + } + + await win.keyboard.press(key); +} + +/** + * Type into the post textbox, preferring DOM insertion so Slate keeps text nodes. + */ +export async function typeIntoPostTextbox(win: ServerView, text: string): Promise { + await win.waitForSelector(POST_TEXTBOX_SELECTOR, {timeout: 10_000}); + await win.click(POST_TEXTBOX_SELECTOR); + + const inserted = await win.runInRenderer(` + const value = ${JSON.stringify(text)}; + + ${POST_TEXTBOX_RESOLVER_JS} + + const root = __mmResolvePostTextboxRoot(); + if (!root) { + return false; + } + + root.focus?.(); + root.setAttribute('spellcheck', 'true'); + + if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) { + const descriptor = Object.getOwnPropertyDescriptor(root.constructor.prototype, 'value'); + descriptor?.set?.call(root, value); + root.dispatchEvent(new Event('input', {bubbles: true})); + root.dispatchEvent(new Event('change', {bubbles: true})); + return root.value.includes(value.slice(0, 8)); + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + document.execCommand('selectAll', false); + document.execCommand('delete', false); + const insertedText = document.execCommand('insertText', false, value); + root.dispatchEvent(new InputEvent('input', {bubbles: true, data: value, inputType: 'insertText'})); + const content = root.innerText || root.textContent || ''; + return insertedText && content.includes(value.slice(0, 8)); + `, true); + + if (!inserted) { + const mod = process.platform === 'darwin' ? 'Meta' : 'Control'; + await win.keyboard.press(`${mod}+A`); + await win.keyboard.press('Backspace'); + await win.keyboard.type(text); + } +} + +/** + * Select a word in the post textbox and return viewport coordinates for it. + * Native spell-check menus require the right-click to land on misspelled text. + */ +export async function getPostTextboxWordPoint( + win: ServerView, + word: string, +): Promise<{x: number; y: number} | null> { + return win.runInRenderer(` + const target = ${JSON.stringify(word)}; + + ${POST_TEXTBOX_RESOLVER_JS} + + const getTextareaWordPoint = (textarea, needle) => { + const text = textarea.value || ''; + const index = text.indexOf(needle); + if (index < 0) { + return null; + } + + textarea.focus(); + textarea.setSelectionRange(index, index + needle.length); + + const mirror = document.createElement('div'); + const properties = [ + 'direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', + 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', + 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', + 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', + 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing', 'whiteSpace', + ]; + const computed = window.getComputedStyle(textarea); + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.top = '0'; + mirror.style.left = '0'; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.wordWrap = 'break-word'; + for (const property of properties) { + mirror.style[property] = computed[property]; + } + mirror.style.width = computed.width; + mirror.textContent = text.slice(0, index); + const marker = document.createElement('span'); + marker.textContent = text.slice(index, index + needle.length) || '.'; + mirror.appendChild(marker); + document.body.appendChild(mirror); + const mirrorRect = mirror.getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + const textareaRect = textarea.getBoundingClientRect(); + document.body.removeChild(mirror); + + return { + x: Math.round( + textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft + (markerRect.width / 2), + ), + y: Math.round( + textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop + (markerRect.height / 2), + ), + }; + }; + + const findRangeInRoot = (root, needle) => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + const text = node.textContent || ''; + const index = text.indexOf(needle); + if (index >= 0) { + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + needle.length); + const rect = range.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return {range, rect}; + } + } + node = walker.nextNode(); + } + return null; + }; + + const root = __mmResolvePostTextboxRoot(); + if (!root) { + return null; + } + + root.setAttribute('spellcheck', 'true'); + + if (root instanceof HTMLTextAreaElement || root instanceof HTMLInputElement) { + return getTextareaWordPoint(root, target); + } + + const match = findRangeInRoot(root, target); + if (!match) { + const fullText = root.innerText || root.textContent || ''; + const index = fullText.indexOf(target); + if (index < 0) { + return null; + } + + const rect = root.getBoundingClientRect(); + const ratio = (index + (target.length / 2)) / Math.max(fullText.length, 1); + root.focus?.(); + return { + x: Math.round(rect.left + Math.min(rect.width * ratio, Math.max(rect.width - 8, 8))), + y: Math.round(rect.top + Math.max(rect.height * 0.7, 20)), + }; + } + + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(match.range); + root.focus?.(); + + return { + x: Math.round(match.rect.left + (match.rect.width / 2)), + y: Math.round(match.rect.top + (match.rect.height / 2)), + }; + `, true); +} From cdba90083d0e5213a797a152806f3d1d7fb147be Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Thu, 2 Jul 2026 03:42:16 +0530 Subject: [PATCH 10/22] fix(e2e): fail CI status when Playwright collects zero tests Treat missing or empty JUnit output as a collection failure so PR checks no longer go green with "No tests ran". Address CodeRabbit review to use collectedCount (including skips) instead of executed pass/fail only. Co-authored-by: Cursor --- e2e/utils/analyze-flaky-test.js | 55 +++++++++++++++++++++++---------- e2e/utils/github-actions.js | 6 +++- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/e2e/utils/analyze-flaky-test.js b/e2e/utils/analyze-flaky-test.js index 16c5ae2bcc6..5e47f2585d6 100644 --- a/e2e/utils/analyze-flaky-test.js +++ b/e2e/utils/analyze-flaky-test.js @@ -209,21 +209,47 @@ 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) { - 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(); @@ -242,19 +268,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 a1551547e4c..8b8d5ea4a91 100644 --- a/e2e/utils/github-actions.js +++ b/e2e/utils/github-actions.js @@ -42,7 +42,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 || (passed === 0 && failed > 0 && passed + failed === failed)) { + return 'No tests ran (collection failed)'; + } + const ran = passed + failed; if (ran === 0) { return failed > 0 ? `0 ran, ${failed} failed` : 'No tests ran'; From a52c70234ebb152ee4464ab81488553d47a7a556 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 02:11:24 +0530 Subject: [PATCH 11/22] clean up --- .github/workflows/e2e-functional-template.yml | 15 +- .github/workflows/e2e-functional.yml | 5 +- e2e/helpers/badge.ts | 140 ++++++ e2e/helpers/mattermostShell.ts | 20 +- e2e/helpers/notificationClick.ts | 93 ++++ .../desktop_notification_delivery.test.ts | 17 +- .../notification_badge.test.ts | 203 ++++++++ .../notification_badge_in_dock.test.ts | 61 --- .../notification_badge_windows_linux.test.ts | 463 ------------------ .../notification_click.test.ts | 18 +- e2e/utils/github-actions.js | 7 +- src/main/app/initialize.ts | 5 +- src/main/notifications/index.ts | 105 ++-- 13 files changed, 531 insertions(+), 621 deletions(-) create mode 100644 e2e/helpers/badge.ts create mode 100644 e2e/helpers/notificationClick.ts create mode 100644 e2e/specs/notification_trigger/notification_badge.test.ts delete mode 100644 e2e/specs/notification_trigger/notification_badge_in_dock.test.ts delete mode 100644 e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index 9aa8e3ea9d0..6a72b936131 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': diff --git a/.github/workflows/e2e-functional.yml b/.github/workflows/e2e-functional.yml index 7ef15fc2fa9..4fb4916b113 100644 --- a/.github/workflows/e2e-functional.yml +++ b/.github/workflows/e2e-functional.yml @@ -359,7 +359,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 +367,7 @@ jobs: const description = formatStatusDescription({ passed: passCount, failed: failureCount, - skipped: skipCount, - total: totalCount, + collectionFailed, }); try { diff --git a/e2e/helpers/badge.ts b/e2e/helpers/badge.ts new file mode 100644 index 00000000000..cc0a5bf2810 --- /dev/null +++ b/e2e/helpers/badge.ts @@ -0,0 +1,140 @@ +// 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'; + +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 app.evaluate((showUnreadBadge) => { + const setUnread = (global as any).__testTriggerSetUnreadBadgeSetting; + if (typeof setUnread !== 'function') { + throw new Error('__testTriggerSetUnreadBadgeSetting is not registered'); + } + setUnread(showUnreadBadge); + }, enabled); +} + +export async function updateServerBadgeViaAppState( + app: ElectronApplication, + serverName: string, + mentions: number, + unreads: boolean, +): Promise { + await app.evaluate((_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 app.evaluate((_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 app.evaluate(() => { + 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); + } + const setUnread = (global as any).__testTriggerSetUnreadBadgeSetting; + if (typeof setUnread === 'function') { + setUnread(false); + } + }); +} + +export async function readOsBadge(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(() => { + const {app} = require('electron') as typeof import('electron'); + const refs = (global as any).__e2eTestRefs; + const mainWindow = refs?.MainWindow?.get?.(); + 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. + const count = app.isUnityRunning() ? + app.getBadgeCount() : + (testState ? testState.mentionCount + (testState.sessionExpired ? 1 : 0) : 0); + const symbol = testState?.resolvedType ?? (count > 0 ? 'mention' : 'none'); + return {count, symbol, hasOverlay: false}; + } + + const overlay = mainWindow?.getOverlayIcon?.()?.[0]; + const hasOverlay = Boolean(overlay && !overlay.isEmpty()); + const symbol = testState?.resolvedType ?? (hasOverlay ? 'mention' : 'none'); + return { + count: testState?.mentionCount ?? (hasOverlay ? 1 : 0), + symbol, + hasOverlay, + }; + }); +} + +export async function readBadgeCount(app: ElectronApplication): Promise { + const state = await readOsBadge(app); + return state.count; +} diff --git a/e2e/helpers/mattermostShell.ts b/e2e/helpers/mattermostShell.ts index 5a29557e7c8..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 = [ @@ -97,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)}), @@ -108,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}); } @@ -137,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(); @@ -155,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(); @@ -180,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} @@ -227,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/notificationClick.ts b/e2e/helpers/notificationClick.ts new file mode 100644 index 00000000000..49ae3bbd22b --- /dev/null +++ b/e2e/helpers/notificationClick.ts @@ -0,0 +1,93 @@ +// 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'; + +export type NotificationClickPayload = { + webContentsId: number; + channelId: string; + teamId: string; + url: string; +}; + +export type DisplayMentionPayload = NotificationClickPayload & { + title: string; + body: 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 app.evaluate((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); +} + +/** + * Display a mention via NotificationManager, poll until it is registered, then click it. + * Use when a test needs the full display→click path; OS show may still fail in headless CI. + */ +export async function displayMentionAndClick( + app: ElectronApplication, + payload: DisplayMentionPayload, +): Promise { + await app.evaluate(({webContents}, p) => { + const refs = (global as any).__e2eTestRefs; + const manager = refs?.NotificationManager; + if (!manager) { + throw new Error('__e2eTestRefs.NotificationManager not exposed'); + } + + const wc = webContents.fromId(p.webContentsId); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${p.webContentsId} is not available`); + } + + void manager.displayMention( + p.title, + p.body, + p.channelId, + p.teamId, + p.url, + true, + wc, + '', + ); + }, payload); + + await expect.poll(async () => { + try { + await app.evaluate((channelId) => { + const clickActive = (global as any).__e2eClickActiveMention as + | ((id: string) => void) + | undefined; + if (!clickActive) { + throw new Error('__e2eClickActiveMention not exposed (NODE_ENV must be test)'); + } + clickActive(channelId); + }, payload.channelId); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('No active mention for channel')) { + return false; + } + throw error; + } + }, { + timeout: 5_000, + message: 'Active mention must exist after displayMention', + }).toBe(true); +} diff --git a/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts index aae8ac395ac..2d59748fa59 100644 --- a/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts +++ b/e2e/specs/notification_trigger/desktop_notification_delivery.test.ts @@ -1,29 +1,14 @@ // 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 {readBadgeCount} from '../../helpers/badge'; import {demoMattermostConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; import {triggerTestNotification, verifyNotificationReceivedInDM} from './helpers'; -// Only called when the platform actually supports a badge count (darwin, or -// linux with Unity running — see the unityRunning check at the call site), so -// app.getBadgeCount() isn't expected to throw here; let a real failure surface -// instead of masking it as "0 badge". -async function readBadgeCount(electronApp: ElectronApplication): Promise { - return electronApp.evaluate(({app}) => { - if (process.platform === 'darwin') { - const badge = app.dock?.getBadge() ?? ''; - return badge === '' || Number.isNaN(Number(badge)) ? 0 : parseInt(badge, 10); - } - return app.getBadgeCount(); - }); -} - test.describe('notification_trigger/desktop_notification_delivery', () => { test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); 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 62afad331aa..00000000000 --- a/e2e/specs/notification_trigger/notification_badge_in_dock.test.ts +++ /dev/null @@ -1,61 +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.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 542ed21e016..00000000000 --- a/e2e/specs/notification_trigger/notification_badge_windows_linux.test.ts +++ /dev/null @@ -1,463 +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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - // 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - // 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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}) => { - 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 11306560cc5..2c0e7657489 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -5,6 +5,7 @@ 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 {hideMainWindow, isMainWindowVisible} from '../../helpers/tray'; @@ -39,22 +40,7 @@ test( {timeout: 5_000, message: 'Main window should be hidden before notification click'}, ).toBe(false); - await electronApp.evaluate((payload) => { - const displayAndClick = (global as any).__e2eDisplayAndClickMention as - | ((value: typeof payload) => Promise) - | undefined; - if (!displayAndClick) { - throw new Error('__e2eDisplayAndClickMention not exposed (NODE_ENV must be test)'); - } - return displayAndClick({ - webContentsId: payload.webContentsId, - title: 'E2E mention', - body: 'Notification click test', - channelId: payload.channelId, - teamId: payload.teamId, - url: payload.url, - }); - }, { + await simulateNotificationClick(electronApp, { webContentsId: serverEntry!.webContentsId, channelId: targetChannel.id, teamId: targetChannel.teamId, diff --git a/e2e/utils/github-actions.js b/e2e/utils/github-actions.js index f9dd44d27b2..a52f663c8dc 100644 --- a/e2e/utils/github-actions.js +++ b/e2e/utils/github-actions.js @@ -47,7 +47,7 @@ async function updateInitialStatus({github, context, platforms}) { * - any failure: "161 ran, 157 passed, 4 failed" */ function formatStatusDescription({passed, failed, collectionFailed}) { - if (collectionFailed || (passed === 0 && failed > 0 && passed + failed === failed)) { + if (collectionFailed) { return 'No tests ran (collection failed)'; } @@ -101,6 +101,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; @@ -116,10 +117,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({ diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index c97566913f8..c0cbb447582 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -41,6 +41,7 @@ import { SERVER_PRE_AUTH_SECRET_CHANGED, SERVER_URL_CHANGED, } from 'common/communication'; +import AppState from 'common/appState'; import Config from 'common/config'; import {MATTERMOST_PROTOCOL} from 'common/constants'; import {Logger} from 'common/log'; @@ -58,7 +59,7 @@ import Diagnostics from 'main/diagnostics'; import downloadsManager from 'main/downloadsManager'; import i18nManager from 'main/i18nManager'; import NonceManager from 'main/nonceManager'; -import {getDoNotDisturb} from 'main/notifications'; +import notificationManager, {getDoNotDisturb} from 'main/notifications'; import parseArgs from 'main/ParseArgs'; import PerformanceMonitor from 'main/performanceMonitor'; import secureStorage from 'main/secureStorage'; @@ -295,7 +296,9 @@ function initializeInterCommunicationEventListeners() { async function initializeAfterAppReady() { const e2eTestRefs = { + AppState, MainWindow, + NotificationManager: notificationManager, ServerManager, TabManager, ViewManager, diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index 161b6127c2b..c1949d453f6 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -93,16 +93,7 @@ class NotificationManager { log.debug('notification click', server.id, mention.uId); this.allActiveNotifications?.delete(mention.uId); - - // Show the window after navigation has finished to avoid the focus handler - // being called before the current channel has updated - const focus = () => { - MainWindow.show(); - TabManager.switchToTab(view.id); - ipcMain.off(BROWSER_HISTORY_PUSH, focus); - }; - ipcMain.on(BROWSER_HISTORY_PUSH, focus); - webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); + this.handleMentionClick(view, webcontents, channelId, teamId, url); }); mention.on('close', () => { @@ -244,11 +235,23 @@ class NotificationManager { } } - /** Test-only accessor: find the MOST-RECENTLY-INSERTED active Mention for a channel. - * Used by __e2eDisplayAndClickMention to target the notification the test just displayed - * (there can be older ones for the same channel that haven't dismissed yet, and - * Map preserves insertion order so scanning to the end always gives us the newest). */ - public findActiveMentionByChannelId(channelId: string): Mention | undefined { + private handleMentionClick( + view: {id: string}, + webcontents: 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); + webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); + } + + private findActiveMentionByChannelId(channelId: string): Mention | undefined { let latest: Mention | undefined; for (const notification of this.allActiveNotifications?.values() ?? []) { if (notification instanceof Mention && notification.channelId === channelId) { @@ -257,6 +260,35 @@ class NotificationManager { } return latest; } + + /** NODE_ENV=test only — invoked via __e2eSimulateNotificationClick. */ + simulateMentionClickForTest(payload: { + webContentsId: number; + channelId: string; + teamId: string; + url: string; + }) { + 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}`); + } + + this.handleMentionClick(view, wc, payload.channelId, payload.teamId, payload.url); + } + + /** NODE_ENV=test only — invoked via __e2eClickActiveMention after displayMention. */ + clickActiveMentionForTest(channelId: string) { + const mention = this.findActiveMentionByChannelId(channelId); + if (!mention) { + throw new Error(`No active mention for channel ${channelId}`); + } + mention.emit('click'); + } } export async function getDoNotDisturb() { @@ -293,50 +325,21 @@ function flashFrame(flash: boolean) { } } +const notificationManager = new NotificationManager(); + if (process.env.NODE_ENV === 'test') { setTestField('__e2eNotificationEffects', flashFrame); - setTestField('__e2eDisplayAndClickMention', async (payload: { + setTestField('__e2eSimulateNotificationClick', (payload: { webContentsId: number; - title: string; - body: string; channelId: string; teamId: string; url: string; }) => { - const wc = webContents.fromId(payload.webContentsId); - if (!wc || wc.isDestroyed()) { - throw new Error(`webContents ${payload.webContentsId} is not available`); - } - - const displayPromise = notificationManager.displayMention( - payload.title, - payload.body, - payload.channelId, - payload.teamId, - payload.url, - true, - wc, - '', - ); - - let mention: Mention | undefined; - const deadline = Date.now() + 5_000; - while (!mention && Date.now() < deadline) { - mention = notificationManager.findActiveMentionByChannelId(payload.channelId); - if (!mention) { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => setTimeout(resolve, 50)); - } - } - - if (!mention) { - throw new Error('Mention notification was not created'); - } - - mention.emit('click'); - await displayPromise.catch(() => {}); + notificationManager.simulateMentionClickForTest(payload); + }); + setTestField('__e2eClickActiveMention', (channelId: string) => { + notificationManager.clickActiveMentionForTest(channelId); }); } -const notificationManager = new NotificationManager(); export default notificationManager; From cff4fce7e5bb6daabdc878f231fd25c1e04f21ec Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 02:36:19 +0530 Subject: [PATCH 12/22] clean up --- e2e/helpers/badge.ts | 35 +++++---- e2e/helpers/errorView.ts | 13 ++-- e2e/helpers/menu.ts | 5 +- e2e/helpers/notificationClick.ts | 10 ++- e2e/helpers/serverView.ts | 77 +++++++++++-------- e2e/helpers/testRefs.ts | 62 ++++++++++++++- .../menu_bar/devtools_current_server.test.ts | 7 +- .../notification_click.test.ts | 3 +- src/app/system/badge.ts | 2 - src/main/app/initialize.ts | 2 +- 10 files changed, 146 insertions(+), 70 deletions(-) diff --git a/e2e/helpers/badge.ts b/e2e/helpers/badge.ts index cc0a5bf2810..f795474d14c 100644 --- a/e2e/helpers/badge.ts +++ b/e2e/helpers/badge.ts @@ -4,6 +4,8 @@ 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'; @@ -21,12 +23,12 @@ export async function waitForBadgeInfrastructure(app: ElectronApplication): Prom } export async function setUnreadBadgeSetting(app: ElectronApplication, enabled: boolean): Promise { - await app.evaluate((showUnreadBadge) => { - const setUnread = (global as any).__testTriggerSetUnreadBadgeSetting; - if (typeof setUnread !== 'function') { - throw new Error('__testTriggerSetUnreadBadgeSetting is not registered'); + await evaluateInMainProcessWithArg(app, (_electron, showUnreadBadge) => { + const Config = (global as any).__e2eTestRefs?.Config; + if (!Config) { + throw new Error('Config missing from __e2eTestRefs'); } - setUnread(showUnreadBadge); + Config.set('showUnreadBadge', showUnreadBadge); }, enabled); } @@ -36,7 +38,7 @@ export async function updateServerBadgeViaAppState( mentions: number, unreads: boolean, ): Promise { - await app.evaluate((_electron, {serverName: name, mentions: mentionCount, unreads: hasUnreads}) => { + await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, mentions: mentionCount, unreads: hasUnreads}) => { const refs = (global as any).__e2eTestRefs; const AppState = refs?.AppState; const ServerManager = refs?.ServerManager; @@ -56,7 +58,7 @@ export async function setServerExpiredViaAppState( serverName: string, expired: boolean, ): Promise { - await app.evaluate((_electron, {serverName: name, expired: isExpired}) => { + await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, expired: isExpired}) => { const refs = (global as any).__e2eTestRefs; const AppState = refs?.AppState; const ServerManager = refs?.ServerManager; @@ -83,16 +85,12 @@ export async function clearAllBadgesViaAppState(app: ElectronApplication): Promi AppState.updateUnreadsAndMentionsPerServer(server.id, 0, false); AppState.updateExpired(server.id, false); } - const setUnread = (global as any).__testTriggerSetUnreadBadgeSetting; - if (typeof setUnread === 'function') { - setUnread(false); - } + refs.Config?.set?.('showUnreadBadge', false); }); } export async function readOsBadge(electronApp: ElectronApplication): Promise { - return electronApp.evaluate(() => { - const {app} = require('electron') as typeof import('electron'); + return evaluateInMainProcess(electronApp, ({app}) => { const refs = (global as any).__e2eTestRefs; const mainWindow = refs?.MainWindow?.get?.(); const testState = (global as any).__testBadgeState; @@ -116,9 +114,14 @@ export async function readOsBadge(electronApp: ElectronApplication): Promise 0 ? 'mention' : 'none'); return {count, symbol, hasOverlay: false}; } diff --git a/e2e/helpers/errorView.ts b/e2e/helpers/errorView.ts index 052d44b20e4..52a5e1bcacf 100644 --- a/e2e/helpers/errorView.ts +++ b/e2e/helpers/errorView.ts @@ -5,7 +5,7 @@ import {expect} from '@playwright/test'; import type {ElectronApplication} from 'playwright'; import {clearCertificateErrorCallbacks} from './dialog'; -import {evaluateInMainProcessWithArg, isTransientEvaluateError} from './testRefs'; +import {evaluateInMainProcessWithArg, isTransientEvaluateError, resolveMainIndexWindow} from './testRefs'; type WaitForErrorViewOptions = { serverName?: string; @@ -80,8 +80,10 @@ export async function waitForRendererThenReload( app: ElectronApplication, serverName?: string, ): Promise { - const mainWindow = app.windows().find((window) => window.url().includes('index')); - if (!mainWindow) { + let mainWindow; + try { + mainWindow = await resolveMainIndexWindow(app); + } catch { return; } @@ -152,10 +154,7 @@ export async function waitForErrorView( 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'); - } + const mainWindow = await resolveMainIndexWindow(app); await waitForRendererThenReload(app, options.serverName); await mainWindow.waitForSelector('.ErrorView', { timeout: Math.min(10_000, deadline - Date.now()), 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/notificationClick.ts b/e2e/helpers/notificationClick.ts index 49ae3bbd22b..1f40708c011 100644 --- a/e2e/helpers/notificationClick.ts +++ b/e2e/helpers/notificationClick.ts @@ -4,6 +4,8 @@ import {expect} from '@playwright/test'; import type {ElectronApplication} from 'playwright'; +import {evaluateInMainProcessWithArg} from './testRefs'; + export type NotificationClickPayload = { webContentsId: number; channelId: string; @@ -24,7 +26,7 @@ export async function simulateNotificationClick( app: ElectronApplication, payload: NotificationClickPayload, ): Promise { - await app.evaluate((p) => { + await evaluateInMainProcessWithArg(app, (_electron, p) => { const simulate = (global as any).__e2eSimulateNotificationClick as | ((value: typeof p) => void) | undefined; @@ -43,7 +45,7 @@ export async function displayMentionAndClick( app: ElectronApplication, payload: DisplayMentionPayload, ): Promise { - await app.evaluate(({webContents}, p) => { + await evaluateInMainProcessWithArg(app, async ({webContents}, p) => { const refs = (global as any).__e2eTestRefs; const manager = refs?.NotificationManager; if (!manager) { @@ -55,7 +57,7 @@ export async function displayMentionAndClick( throw new Error(`webContents ${p.webContentsId} is not available`); } - void manager.displayMention( + await manager.displayMention( p.title, p.body, p.channelId, @@ -69,7 +71,7 @@ export async function displayMentionAndClick( await expect.poll(async () => { try { - await app.evaluate((channelId) => { + await evaluateInMainProcessWithArg(app, (_electron, channelId) => { const clickActive = (global as any).__e2eClickActiveMention as | ((id: string) => void) | undefined; 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/testRefs.ts b/e2e/helpers/testRefs.ts index 0e576e44d7f..57056d9d052 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,9 +15,13 @@ export function isTransientEvaluateError(error: unknown): boolean { return TRANSIENT_EVALUATE_ERRORS.some((part) => message.includes(part)); } +type MainProcessEvaluator = ( + _electron: typeof import('electron'), +) => T | Promise; + export async function evaluateInMainProcess( app: ElectronApplication, - pageFunction: () => T, + pageFunction: MainProcessEvaluator, options: {timeoutMs?: number; retryDelayMs?: number} = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 15_000; @@ -26,7 +30,7 @@ export async function evaluateInMainProcess( 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)) { throw error; @@ -71,6 +75,58 @@ export async function evaluateInMainProcessWithArg( throw new Error('Timed out waiting for electron main-process evaluate'); } +function findMainIndexWindow(app: ElectronApplication): Page | undefined { + return app.windows().find((window) => { + try { + return window.url().includes('index'); + } catch { + return false; + } + }); +} + +/** + * 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 — poll and show the window from main process before giving up. + */ +export async function resolveMainIndexWindow( + app: ElectronApplication, + timeout = 15_000, +): Promise { + let mainWindow: Page | undefined; + + await expect.poll(async () => { + await evaluateInMainProcess(app, () => { + const win = (global as any).__e2eTestRefs?.MainWindow?.get?.(); + if (win && !win.isDestroyed() && !win.isVisible()) { + win.show(); + } + }).catch(() => {}); + + mainWindow = findMainIndexWindow(app); + return mainWindow ?? null; + }, { + timeout, + message: 'Main index window should be available', + }).not.toBeNull(); + + if (mainWindow) { + return mainWindow; + } + + return app.waitForEvent('window', { + predicate: (window) => { + try { + return window.url().includes('index'); + } catch { + return false; + } + }, + timeout: Math.min(5_000, timeout), + }); +} + export async function getMainWindowId(app: ElectronApplication): Promise { let mainWindowId: number | null = null; await expect.poll(async () => { diff --git a/e2e/specs/menu_bar/devtools_current_server.test.ts b/e2e/specs/menu_bar/devtools_current_server.test.ts index 816b148ea1e..978f694749f 100644 --- a/e2e/specs/menu_bar/devtools_current_server.test.ts +++ b/e2e/specs/menu_bar/devtools_current_server.test.ts @@ -80,10 +80,9 @@ test.describe('menu_bar/devtools_current_server', () => { {timeout: 15_000, message: 'DevTools must close after toggle'}, ).toBe(true); - const serverStillFunctional = await firstServer!.evaluate(() => { - return document.querySelector('#post_textbox') !== null; - }); - expect(serverStillFunctional, 'Server view should still be functional after DevTools 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/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index 2c0e7657489..fff552bd741 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -7,6 +7,7 @@ import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; import {loginToMattermost} from '../../helpers/login'; import {simulateNotificationClick} from '../../helpers/notificationClick'; import {resolveChannelByName} from '../../helpers/server_api/channel'; +import {getActiveServerWebContentsId} from '../../helpers/testRefs'; import {hideMainWindow, isMainWindowVisible} from '../../helpers/tray'; test.use({appConfig: demoMattermostConfig}); @@ -41,7 +42,7 @@ test( ).toBe(false); await simulateNotificationClick(electronApp, { - webContentsId: serverEntry!.webContentsId, + webContentsId: await getActiveServerWebContentsId(electronApp), channelId: targetChannel.id, teamId: targetChannel.teamId, url: targetChannel.url, diff --git a/src/app/system/badge.ts b/src/app/system/badge.ts index 3f0c75c3ec5..bd9949e95b6 100644 --- a/src/app/system/badge.ts +++ b/src/app/system/badge.ts @@ -155,6 +155,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.ts b/src/main/app/initialize.ts index c0cbb447582..59c672cc466 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -19,6 +19,7 @@ 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 { QUIT, NOTIFY_MENTION, @@ -41,7 +42,6 @@ import { SERVER_PRE_AUTH_SECRET_CHANGED, SERVER_URL_CHANGED, } from 'common/communication'; -import AppState from 'common/appState'; import Config from 'common/config'; import {MATTERMOST_PROTOCOL} from 'common/constants'; import {Logger} from 'common/log'; From dd1e92249f504440f8687e59c7d4e22cdf3a7a1d Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 03:18:05 +0530 Subject: [PATCH 13/22] remove unwanted artifacts and fix failures --- .github/workflows/e2e-functional-template.yml | 20 ---- .github/workflows/e2e-functional.yml | 4 +- e2e/fixtures/index.ts | 1 + e2e/helpers/badge.ts | 21 ++-- e2e/helpers/errorView.ts | 5 +- e2e/helpers/prepareServerView.ts | 3 +- e2e/helpers/testRefs.ts | 95 +++++++++++++------ e2e/playwright.config.ts | 7 +- .../menu_bar/devtools_current_server.test.ts | 10 +- e2e/specs/notification_trigger/helpers.ts | 15 ++- src/main/app/initialize.ts | 3 +- 11 files changed, 114 insertions(+), 70 deletions(-) diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index 6a72b936131..25dc2583db6 100644 --- a/.github/workflows/e2e-functional-template.yml +++ b/.github/workflows/e2e-functional-template.yml @@ -430,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 4fb4916b113..cbb2bd96a1d 100644 --- a/.github/workflows/e2e-functional.yml +++ b/.github/workflows/e2e-functional.yml @@ -52,7 +52,9 @@ jobs: steps: - id: generate 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. + echo "platforms=$(echo '${{ inputs.instance_details }}' | jq -c 'map(if .runner == "macos-latest" then .runner = "macos-26" else . end)')" >> $GITHUB_OUTPUT update-initial-status: name: Update initial status 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 index f795474d14c..c042552bbfb 100644 --- a/e2e/helpers/badge.ts +++ b/e2e/helpers/badge.ts @@ -24,11 +24,12 @@ export async function waitForBadgeInfrastructure(app: ElectronApplication): Prom export async function setUnreadBadgeSetting(app: ElectronApplication, enabled: boolean): Promise { await evaluateInMainProcessWithArg(app, (_electron, showUnreadBadge) => { - const Config = (global as any).__e2eTestRefs?.Config; - if (!Config) { - throw new Error('Config missing from __e2eTestRefs'); + const refs = (global as any).__e2eTestRefs; + if (!refs?.setUnreadBadgeSetting) { + throw new Error('setUnreadBadgeSetting missing from __e2eTestRefs'); } - Config.set('showUnreadBadge', showUnreadBadge); + refs.Config?.set?.('showUnreadBadge', showUnreadBadge); + refs.setUnreadBadgeSetting(showUnreadBadge); }, enabled); } @@ -74,7 +75,7 @@ export async function setServerExpiredViaAppState( } export async function clearAllBadgesViaAppState(app: ElectronApplication): Promise { - await app.evaluate(() => { + await evaluateInMainProcess(app, () => { const refs = (global as any).__e2eTestRefs; const AppState = refs?.AppState; const ServerManager = refs?.ServerManager; @@ -86,6 +87,7 @@ export async function clearAllBadgesViaAppState(app: ElectronApplication): Promi AppState.updateExpired(server.id, false); } refs.Config?.set?.('showUnreadBadge', false); + refs.setUnreadBadgeSetting?.(false); }); } @@ -127,10 +129,13 @@ export async function readOsBadge(electronApp: ElectronApplication): 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/testRefs.ts b/e2e/helpers/testRefs.ts index 57056d9d052..059432d96ba 100644 --- a/e2e/helpers/testRefs.ts +++ b/e2e/helpers/testRefs.ts @@ -78,53 +78,94 @@ export async function evaluateInMainProcessWithArg( function findMainIndexWindow(app: ElectronApplication): Page | undefined { return app.windows().find((window) => { try { - return window.url().includes('index'); + const url = window.url(); + return url.includes('index') || url.includes('mattermost-desktop://renderer/'); } 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 — poll and show the window from main process before giving up. + * load fails fast — show/focus from main process and match by BrowserWindow id. */ export async function resolveMainIndexWindow( app: ElectronApplication, - timeout = 15_000, + timeout = 30_000, ): Promise { + const deadline = Date.now() + timeout; let mainWindow: Page | undefined; - await expect.poll(async () => { - await evaluateInMainProcess(app, () => { - const win = (global as any).__e2eTestRefs?.MainWindow?.get?.(); - if (win && !win.isDestroyed() && !win.isVisible()) { - win.show(); - } - }).catch(() => {}); + while (Date.now() < deadline) { + const mainWindowId = await ensureMainWindowVisible(app); mainWindow = findMainIndexWindow(app); - return mainWindow ?? null; - }, { - timeout, - message: 'Main index window should be available', - }).not.toBeNull(); + if (!mainWindow && mainWindowId != null) { + mainWindow = await findMainIndexWindowByBrowserId(app, mainWindowId); + } + + if (mainWindow) { + return mainWindow; + } - if (mainWindow) { - return mainWindow; + const remaining = deadline - Date.now(); + if (remaining <= 0) { + break; + } + + try { + return await app.waitForEvent('window', { + predicate: (window) => { + try { + const url = window.url(); + return url.includes('index') || url.includes('mattermost-desktop://renderer/'); + } catch { + return false; + } + }, + timeout: Math.min(2_000, remaining), + }); + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)); + } } - return app.waitForEvent('window', { - predicate: (window) => { - try { - return window.url().includes('index'); - } catch { - return false; - } - }, - timeout: Math.min(5_000, timeout), - }); + throw new Error('Main index window should be available'); } export async function getMainWindowId(app: ElectronApplication): Promise { diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index addfa1bf7f3..e9ddf6fb804 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -115,9 +115,12 @@ 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/menu_bar/devtools_current_server.test.ts b/e2e/specs/menu_bar/devtools_current_server.test.ts index 978f694749f..948ee5bc0a9 100644 --- a/e2e/specs/menu_bar/devtools_current_server.test.ts +++ b/e2e/specs/menu_bar/devtools_current_server.test.ts @@ -16,7 +16,7 @@ import {loginToMattermost} from '../../helpers/login'; import {clickApplicationMenuItem} from '../../helpers/menu'; import {closeOverlayWindowsIfOpen} from '../../helpers/overlayWindows'; import {prepareMattermostServerView} from '../../helpers/prepareServerView'; -import {getActiveServerWebContentsId} from '../../helpers/testRefs'; +import {evaluateInMainProcessWithArg, getActiveServerWebContentsId} from '../../helpers/testRefs'; test.describe('menu_bar/devtools_current_server', () => { test.use({appConfig: demoMattermostConfig}); @@ -40,7 +40,7 @@ test.describe('menu_bar/devtools_current_server', () => { const webContentsId = serverEntry!.webContentsId ?? await getActiveServerWebContentsId(electronApp); - const webContentsExists = await electronApp.evaluate(({webContents}, id) => { + const webContentsExists = await evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { const wc = webContents.fromId(id); return wc !== undefined && !wc.isDestroyed(); }, webContentsId); @@ -53,7 +53,7 @@ test.describe('menu_bar/devtools_current_server', () => { {webContentsId}, ); await expect.poll( - () => electronApp.evaluate(({webContents}, id) => { + () => evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { const wc = webContents.fromId(id); return Boolean(wc && !wc.isDestroyed() && wc.isDevToolsOpened()); }, webContentsId), @@ -62,7 +62,7 @@ test.describe('menu_bar/devtools_current_server', () => { // Toggle closed instead of closeDevTools() evaluate, which can race with // DevTools teardown and destabilize the app on Linux CI. - await electronApp.evaluate(({webContents}, id) => { + await evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { try { const wc = webContents.fromId(id); if (wc && !wc.isDestroyed() && wc.isDevToolsOpened()) { @@ -73,7 +73,7 @@ test.describe('menu_bar/devtools_current_server', () => { } }, webContentsId).catch(() => {}); await expect.poll( - () => electronApp.evaluate(({webContents}, id) => { + () => evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { const wc = webContents.fromId(id); return wc && !wc.isDestroyed() ? !wc.isDevToolsOpened() : true; }, webContentsId).catch(() => true), 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/src/main/app/initialize.ts b/src/main/app/initialize.ts index 59c672cc466..07c8da20274 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -14,7 +14,7 @@ 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 {setUnreadBadgeSetting, setupBadge} from 'app/system/badge'; import Tray from 'app/system/tray/tray'; import TabManager from 'app/tabs/tabManager'; import WebContentsManager from 'app/views/webContentsManager'; @@ -308,6 +308,7 @@ async function initializeAfterAppReady() { Diagnostics, PopoutManager, updateNotifier, + setUnreadBadgeSetting, }; setTestField('__e2eTestRefs', e2eTestRefs); From 487b5cfa1ae22454aaaa391bc95a0089be38fa80 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 03:53:48 +0530 Subject: [PATCH 14/22] fix badge tests --- e2e/helpers/badge.ts | 15 +- e2e/helpers/errorView.ts | 138 ++++++++---------- e2e/helpers/testRefs.ts | 12 +- e2e/playwright.config.ts | 1 + .../server_management/bad_servers.test.ts | 8 +- src/app/system/badge.ts | 4 +- 6 files changed, 80 insertions(+), 98 deletions(-) diff --git a/e2e/helpers/badge.ts b/e2e/helpers/badge.ts index c042552bbfb..167c33964d6 100644 --- a/e2e/helpers/badge.ts +++ b/e2e/helpers/badge.ts @@ -93,8 +93,6 @@ export async function clearAllBadgesViaAppState(app: ElectronApplication): Promi export async function readOsBadge(electronApp: ElectronApplication): Promise { return evaluateInMainProcess(electronApp, ({app}) => { - const refs = (global as any).__e2eTestRefs; - const mainWindow = refs?.MainWindow?.get?.(); const testState = (global as any).__testBadgeState; if (process.platform === 'darwin') { @@ -128,16 +126,13 @@ export async function readOsBadge(electronApp: ElectronApplication): Promise { - let mainWindow; - try { - mainWindow = await resolveMainIndexWindow(app); - } catch { - 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( @@ -126,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 = await resolveMainIndexWindow( - app, - Math.min(30_000, Math.max(deadline - Date.now(), 1_000)), - ); - 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/testRefs.ts b/e2e/helpers/testRefs.ts index 059432d96ba..589d6eb47f3 100644 --- a/e2e/helpers/testRefs.ts +++ b/e2e/helpers/testRefs.ts @@ -75,11 +75,14 @@ export async function evaluateInMainProcessWithArg( throw new Error('Timed out waiting for electron main-process evaluate'); } -function findMainIndexWindow(app: ElectronApplication): Page | undefined { +function isMainIndexUrl(url: string): boolean { + return url.includes('index'); +} + +export function findMainIndexWindow(app: ElectronApplication): Page | undefined { return app.windows().find((window) => { try { - const url = window.url(); - return url.includes('index') || url.includes('mattermost-desktop://renderer/'); + return isMainIndexUrl(window.url()); } catch { return false; } @@ -152,8 +155,7 @@ export async function resolveMainIndexWindow( return await app.waitForEvent('window', { predicate: (window) => { try { - const url = window.url(); - return url.includes('index') || url.includes('mattermost-desktop://renderer/'); + return isMainIndexUrl(window.url()); } catch { return false; } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index e9ddf6fb804..5e7b449a5ee 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -115,6 +115,7 @@ export default defineConfig({ reporter: reporters, use: { + // 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. diff --git a/e2e/specs/server_management/bad_servers.test.ts b/e2e/specs/server_management/bad_servers.test.ts index 36a1fc2a155..547a34c4990 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'); diff --git a/src/app/system/badge.ts b/src/app/system/badge.ts index bd9949e95b6..fd3684e17ff 100644 --- a/src/app/system/badge.ts +++ b/src/app/system/badge.ts @@ -144,7 +144,9 @@ function showBadge(sessionExpired: boolean, mentionCount: number, showUnreadBadg } else { resolvedType = 'none'; } - setTestField('__testBadgeState', {sessionExpired, mentionCount, showUnreadBadge, resolvedType}); + + const hasOverlay = process.platform === 'win32' && resolvedType !== 'none'; + setTestField('__testBadgeState', {sessionExpired, mentionCount, showUnreadBadge, resolvedType, hasOverlay}); } } From c3c1dc32b0d67e1ea982db291ebc4b0b3ec885a5 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 04:45:36 +0530 Subject: [PATCH 15/22] fix tests --- .../menu_bar/devtools_current_server.test.ts | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/e2e/specs/menu_bar/devtools_current_server.test.ts b/e2e/specs/menu_bar/devtools_current_server.test.ts index 948ee5bc0a9..fbbc219d931 100644 --- a/e2e/specs/menu_bar/devtools_current_server.test.ts +++ b/e2e/specs/menu_bar/devtools_current_server.test.ts @@ -60,25 +60,36 @@ test.describe('menu_bar/devtools_current_server', () => { {timeout: 15_000, message: 'DevTools must open for the current server webContents after menu click'}, ).toBe(true); - // Toggle closed instead of closeDevTools() evaluate, which can race with - // DevTools teardown and destabilize the app on Linux CI. - await evaluateInMainProcessWithArg(electronApp, ({webContents}, id) => { - try { + // 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.toggleDevTools(); + wc.closeDevTools(); } - } 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); + }, 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); From 411e3c5a7a4d70dfe37a313494e6c8145b3f4fc2 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 05:16:12 +0530 Subject: [PATCH 16/22] clean up hooks --- e2e/helpers/callsWidget.ts | 31 +++++++ e2e/helpers/downloadsDropdown.ts | 33 ++----- e2e/helpers/ipcChannels.ts | 7 ++ e2e/helpers/notificationClick.ts | 66 +------------- e2e/helpers/settingsWindow.ts | 2 +- e2e/helpers/testRefs.ts | 23 ++++- e2e/specs/calls/calls_functionality.test.ts | 85 ++++++++---------- e2e/specs/menu_bar/window_menu.test.ts | 65 +++++++------- src/main/app/initialize.ts | 78 ++++++++++------- src/main/e2e/hooks.ts | 39 +++++++++ src/main/notifications/index.ts | 96 +++++---------------- 11 files changed, 245 insertions(+), 280 deletions(-) create mode 100644 e2e/helpers/callsWidget.ts create mode 100644 e2e/helpers/ipcChannels.ts create mode 100644 src/main/e2e/hooks.ts diff --git a/e2e/helpers/callsWidget.ts b/e2e/helpers/callsWidget.ts new file mode 100644 index 00000000000..1d4826088e1 --- /dev/null +++ b/e2e/helpers/callsWidget.ts @@ -0,0 +1,31 @@ +// 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 deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const widget = findCallsWidgetWindow(electronApp); + if (widget) { + return widget; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return null; +} diff --git a/e2e/helpers/downloadsDropdown.ts b/e2e/helpers/downloadsDropdown.ts index 1695cdcf7a5..3a65bce9a8b 100644 --- a/e2e/helpers/downloadsDropdown.ts +++ b/e2e/helpers/downloadsDropdown.ts @@ -3,35 +3,20 @@ import type {ElectronApplication} from 'playwright'; -import {CLOSE_DOWNLOADS_DROPDOWN, CLOSE_DOWNLOADS_DROPDOWN_MENU} from '../../src/common/communication'; +import {CLOSE_DOWNLOADS_DROPDOWN, CLOSE_DOWNLOADS_DROPDOWN_MENU} from './ipcChannels'; -function isTransientNavigationError(message: string): boolean { - return message.includes('Execution context was destroyed') || - message.includes('Target closed') || - message.includes('Protocol error'); -} +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 { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - try { - await app.evaluate(({ipcMain}, channels) => { - ipcMain.emit(channels.menu); - ipcMain.emit(channels.dropdown); - }, {dropdown: CLOSE_DOWNLOADS_DROPDOWN, menu: CLOSE_DOWNLOADS_DROPDOWN_MENU}); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!isTransientNavigationError(message)) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - - throw new Error('Timed out closing downloads dropdown after navigation'); + 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/ipcChannels.ts b/e2e/helpers/ipcChannels.ts new file mode 100644 index 00000000000..4181b26fc4a --- /dev/null +++ b/e2e/helpers/ipcChannels.ts @@ -0,0 +1,7 @@ +// 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'; diff --git a/e2e/helpers/notificationClick.ts b/e2e/helpers/notificationClick.ts index 1f40708c011..f1bb0f5c646 100644 --- a/e2e/helpers/notificationClick.ts +++ b/e2e/helpers/notificationClick.ts @@ -1,7 +1,6 @@ // 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 {evaluateInMainProcessWithArg} from './testRefs'; @@ -13,11 +12,6 @@ export type NotificationClickPayload = { url: string; }; -export type DisplayMentionPayload = NotificationClickPayload & { - title: string; - body: 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. @@ -28,68 +22,10 @@ export async function simulateNotificationClick( ): Promise { await evaluateInMainProcessWithArg(app, (_electron, p) => { const simulate = (global as any).__e2eSimulateNotificationClick as - | ((value: typeof p) => void) - | undefined; + ((value: typeof p) => void) | undefined; if (!simulate) { throw new Error('__e2eSimulateNotificationClick not exposed (NODE_ENV must be test)'); } simulate(p); }, payload); } - -/** - * Display a mention via NotificationManager, poll until it is registered, then click it. - * Use when a test needs the full display→click path; OS show may still fail in headless CI. - */ -export async function displayMentionAndClick( - app: ElectronApplication, - payload: DisplayMentionPayload, -): Promise { - await evaluateInMainProcessWithArg(app, async ({webContents}, p) => { - const refs = (global as any).__e2eTestRefs; - const manager = refs?.NotificationManager; - if (!manager) { - throw new Error('__e2eTestRefs.NotificationManager not exposed'); - } - - const wc = webContents.fromId(p.webContentsId); - if (!wc || wc.isDestroyed()) { - throw new Error(`webContents ${p.webContentsId} is not available`); - } - - await manager.displayMention( - p.title, - p.body, - p.channelId, - p.teamId, - p.url, - true, - wc, - '', - ); - }, payload); - - await expect.poll(async () => { - try { - await evaluateInMainProcessWithArg(app, (_electron, channelId) => { - const clickActive = (global as any).__e2eClickActiveMention as - | ((id: string) => void) - | undefined; - if (!clickActive) { - throw new Error('__e2eClickActiveMention not exposed (NODE_ENV must be test)'); - } - clickActive(channelId); - }, payload.channelId); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('No active mention for channel')) { - return false; - } - throw error; - } - }, { - timeout: 5_000, - message: 'Active mention must exist after displayMention', - }).toBe(true); -} diff --git a/e2e/helpers/settingsWindow.ts b/e2e/helpers/settingsWindow.ts index 0895dea01bf..625e2e839ad 100644 --- a/e2e/helpers/settingsWindow.ts +++ b/e2e/helpers/settingsWindow.ts @@ -3,7 +3,7 @@ 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 { diff --git a/e2e/helpers/testRefs.ts b/e2e/helpers/testRefs.ts index 589d6eb47f3..3ad098883e3 100644 --- a/e2e/helpers/testRefs.ts +++ b/e2e/helpers/testRefs.ts @@ -15,6 +15,19 @@ 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; @@ -22,17 +35,18 @@ type MainProcessEvaluator = ( export async function evaluateInMainProcess( app: ElectronApplication, pageFunction: MainProcessEvaluator, - 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) { try { 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)); @@ -51,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) { @@ -65,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)); diff --git a/e2e/specs/calls/calls_functionality.test.ts b/e2e/specs/calls/calls_functionality.test.ts index 450e8aeddd7..bcfed423e9f 100644 --- a/e2e/specs/calls/calls_functionality.test.ts +++ b/e2e/specs/calls/calls_functionality.test.ts @@ -5,34 +5,46 @@ 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 {loginToMattermost} from '../../helpers/login'; import type {ServerView} from '../../helpers/serverView'; -async function findCallsWidgetWindow(electronApp: ElectronApplication): Promise { - return electronApp.windows().find((w) => { - try { - const url = w.url(); - return url.includes('/plugins/com.mattermost.calls/standalone/widget.html'); - } catch { - return false; - } - }) ?? null; -} +type CallStartOutcome = {kind: 'widget'} | {kind: 'post'}; -async function waitForCallsWidgetWindow( +async function pollCallStartOutcome( electronApp: ElectronApplication, - timeoutMs = 20_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const widget = await findCallsWidgetWindow(electronApp); - if (widget) { - return widget; + serverWin: ServerView, + postIdBefore: string | null, +): Promise { + let outcome: CallStartOutcome | null = null; + + await expect.poll(async (): Promise => { + if (findCallsWidgetWindow(electronApp)) { + outcome = {kind: 'widget'}; + return true; } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - return null; + + 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', () => { @@ -116,32 +128,9 @@ test.describe('calls/calls_functionality', () => { await serverWin.fill('#post_textbox', '/call start'); await serverWin.press('#post_textbox', 'Enter'); - let detectedKind: 'widget' | 'post' | null = null; + let outcome: CallStartOutcome; try { - await expect.poll(async (): Promise => { - if (await findCallsWidgetWindow(electronApp)) { - detectedKind = '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) { - detectedKind = 'post'; - return true; - } - return false; - }, { - timeout: 20_000, - message: '/call start produced neither a Calls widget window nor a new ephemeral response.', - }).toBe(true); + outcome = await pollCallStartOutcome(electronApp, serverWin, postIdBefore); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes('/call start produced neither')) { @@ -151,8 +140,8 @@ test.describe('calls/calls_functionality', () => { throw error; } - if (detectedKind === 'widget') { - const widgetWindow = await findCallsWidgetWindow(electronApp); + 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', diff --git a/e2e/specs/menu_bar/window_menu.test.ts b/e2e/specs/menu_bar/window_menu.test.ts index cecdee23c5f..a23e21a454d 100644 --- a/e2e/specs/menu_bar/window_menu.test.ts +++ b/e2e/specs/menu_bar/window_menu.test.ts @@ -57,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) { @@ -248,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) { @@ -391,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'); diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 07c8da20274..a255d27189e 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -5,7 +5,7 @@ import path from 'path'; import {pathToFileURL} from 'url'; import type {IpcMainInvokeEvent} from 'electron'; -import {app, BrowserWindow, ipcMain, nativeTheme, net, protocol, session} from 'electron'; +import {app, BrowserWindow, ipcMain, nativeTheme, net, protocol, session, webContents} from 'electron'; import installExtension, {REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS} from 'electron-devtools-installer'; import isDev from 'electron-is-dev'; import Joi from 'joi'; @@ -47,7 +47,6 @@ 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'; @@ -57,9 +56,10 @@ import CriticalErrorHandler from 'main/CriticalErrorHandler'; import DeveloperMode from 'main/developerMode'; import Diagnostics from 'main/diagnostics'; import downloadsManager from 'main/downloadsManager'; +import {registerE2eHooks} from 'main/e2e/hooks'; import i18nManager from 'main/i18nManager'; import NonceManager from 'main/nonceManager'; -import notificationManager, {getDoNotDisturb} from 'main/notifications'; +import notificationManager, {dispatchMentionClick, getDoNotDisturb, triggerNotificationFrameEffects} from 'main/notifications'; import parseArgs from 'main/ParseArgs'; import PerformanceMonitor from 'main/performanceMonitor'; import secureStorage from 'main/secureStorage'; @@ -311,37 +311,52 @@ async function initializeAfterAppReady() { setUnreadBadgeSetting, }; - setTestField('__e2eTestRefs', e2eTestRefs); + registerE2eHooks({ + e2eTestRefs, + openDeepLink, + clickTrayMenuItem: (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; + } - setTestField('__e2eOpenDeepLink', (url: string) => { - openDeepLink(url); - }); + if (!clickItem(createTrayMenu().items)) { + throw new Error(`Tray menu item not found: ${label}`); + } + }, + triggerNotificationFrameEffects, + simulateNotificationClick: (payload) => { + const wc = webContents.fromId(payload.webContentsId); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.webContentsId} is not available`); + } - 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; - } + const view = WebContentsManager.getViewByWebContentsId(wc.id); + if (!view) { + throw new Error(`No view for webContents ${payload.webContentsId}`); } - return false; - } - if (!clickItem(createTrayMenu().items)) { - throw new Error(`Tray menu item not found: ${label}`); - } + dispatchMentionClick(view, wc, payload.channelId, payload.teamId, payload.url); + }, + installMessageBoxStub, + restoreMessageBoxStub, + clearCertificateErrorCallbacks: () => certificateErrorCallbacks.clear(), }); // Block all NTLM/Negotiate requests by default @@ -387,9 +402,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}]); diff --git a/src/main/e2e/hooks.ts b/src/main/e2e/hooks.ts new file mode 100644 index 00000000000..3c9d2e51c5b --- /dev/null +++ b/src/main/e2e/hooks.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {setTestField} from 'common/utils/util'; + +type MessageBoxResponse = {response: number}; + +type SimulateNotificationClickPayload = { + webContentsId: number; + channelId: string; + teamId: string; + url: string; +}; + +type RegisterE2eHooksOptions = { + e2eTestRefs: Record; + 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/notifications/index.ts b/src/main/notifications/index.ts index c1949d453f6..d050ef29392 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {app, shell, Notification, ipcMain, webContents} from 'electron'; +import {app, shell, Notification, ipcMain} from 'electron'; import isDev from 'electron-is-dev'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; @@ -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'; @@ -93,7 +92,7 @@ class NotificationManager { log.debug('notification click', server.id, mention.uId); this.allActiveNotifications?.delete(mention.uId); - this.handleMentionClick(view, webcontents, channelId, teamId, url); + dispatchMentionClick(view, webcontents, channelId, teamId, url); }); mention.on('close', () => { @@ -128,7 +127,7 @@ class NotificationManager { if (notificationSound) { MainWindow.sendToRenderer(PLAY_SOUND, notificationSound); } - flashFrame(true); + triggerNotificationFrameEffects(true); clearTimeout(timeout); resolve({status: 'success'}); } @@ -169,7 +168,7 @@ class NotificationManager { this.allActiveNotifications?.set(download.uId, download); download.on('show', () => { - flashFrame(true); + triggerNotificationFrameEffects(true); }); download.on('click', () => { @@ -234,61 +233,22 @@ class NotificationManager { break; } } +} - private handleMentionClick( - view: {id: string}, - webcontents: 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); - webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); - } - - private findActiveMentionByChannelId(channelId: string): Mention | undefined { - let latest: Mention | undefined; - for (const notification of this.allActiveNotifications?.values() ?? []) { - if (notification instanceof Mention && notification.channelId === channelId) { - latest = notification; - } - } - return latest; - } - - /** NODE_ENV=test only — invoked via __e2eSimulateNotificationClick. */ - simulateMentionClickForTest(payload: { - webContentsId: number; - channelId: string; - teamId: string; - url: string; - }) { - 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}`); - } - - this.handleMentionClick(view, wc, payload.channelId, payload.teamId, payload.url); - } - - /** NODE_ENV=test only — invoked via __e2eClickActiveMention after displayMention. */ - clickActiveMentionForTest(channelId: string) { - const mention = this.findActiveMentionByChannelId(channelId); - if (!mention) { - throw new Error(`No active mention for channel ${channelId}`); - } - mention.emit('click'); - } +export function dispatchMentionClick( + view: {id: string}, + webcontents: 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); + webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); } export async function getDoNotDisturb() { @@ -314,7 +274,7 @@ export async function getDoNotDisturb() { return false; } -function flashFrame(flash: boolean) { +function triggerNotificationFrameEffects(flash: boolean) { if (process.platform === 'linux' || process.platform === 'win32') { if (Config.notifications.flashWindow) { MainWindow.get()?.flashFrame(flash); @@ -327,19 +287,5 @@ function flashFrame(flash: boolean) { const notificationManager = new NotificationManager(); -if (process.env.NODE_ENV === 'test') { - setTestField('__e2eNotificationEffects', flashFrame); - setTestField('__e2eSimulateNotificationClick', (payload: { - webContentsId: number; - channelId: string; - teamId: string; - url: string; - }) => { - notificationManager.simulateMentionClickForTest(payload); - }); - setTestField('__e2eClickActiveMention', (channelId: string) => { - notificationManager.clickActiveMentionForTest(channelId); - }); -} - +export {triggerNotificationFrameEffects}; export default notificationManager; From 17077d09bfe5fa578b0d8be277525a8e51a02eec Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 05:25:48 +0530 Subject: [PATCH 17/22] move hookes to e2e/hooks --- src/app/system/badge.ts | 26 +-------- src/main/app/initialize.ts | 95 ++----------------------------- src/main/app/intercom.ts | 36 +----------- src/main/e2e/appReady.ts | 40 +++++++++++++ src/main/e2e/badgeState.ts | 46 +++++++++++++++ src/main/e2e/hooks.ts | 9 +-- src/main/e2e/notificationClick.ts | 32 +++++++++++ src/main/e2e/register.ts | 71 +++++++++++++++++++++++ src/main/e2e/trayMenu.ts | 33 +++++++++++ 9 files changed, 233 insertions(+), 155 deletions(-) create mode 100644 src/main/e2e/appReady.ts create mode 100644 src/main/e2e/badgeState.ts create mode 100644 src/main/e2e/notificationClick.ts create mode 100644 src/main/e2e/register.ts create mode 100644 src/main/e2e/trayMenu.ts diff --git a/src/app/system/badge.ts b/src/app/system/badge.ts index fd3684e17ff..f32b81b8163 100644 --- a/src/app/system/badge.ts +++ b/src/app/system/badge.ts @@ -7,7 +7,7 @@ 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 {recordBadgeTestState} from 'main/e2e/badgeState'; import {localizeMessage} from 'main/i18nManager'; import MainWindow from '../mainWindow/mainWindow'; @@ -125,29 +125,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'; - } - - const hasOverlay = process.platform === 'win32' && resolvedType !== 'none'; - setTestField('__testBadgeState', {sessionExpired, mentionCount, showUnreadBadge, resolvedType, hasOverlay}); - } + recordBadgeTestState(sessionExpired, mentionCount, showUnreadBadge, showUnreadBadgeSetting); } export function setUnreadBadgeSetting(showUnreadBadge: boolean) { diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index a255d27189e..7ac09e3a0b8 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -5,21 +5,17 @@ import path from 'path'; import {pathToFileURL} from 'url'; import type {IpcMainInvokeEvent} from 'electron'; -import {app, BrowserWindow, ipcMain, nativeTheme, net, protocol, session, webContents} from 'electron'; +import {app, BrowserWindow, ipcMain, nativeTheme, net, protocol, session} from 'electron'; import installExtension, {REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS} from 'electron-devtools-installer'; import isDev from 'electron-is-dev'; 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 {setUnreadBadgeSetting, setupBadge} from 'app/system/badge'; +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 AppState from 'common/appState'; import { QUIT, NOTIFY_MENTION, @@ -35,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, @@ -48,18 +43,16 @@ import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import {parseURL} from 'common/utils/url'; 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 {registerE2eHooks} from 'main/e2e/hooks'; +import {maybeRegisterE2eHooks} from 'main/e2e/register'; import i18nManager from 'main/i18nManager'; import NonceManager from 'main/nonceManager'; -import notificationManager, {dispatchMentionClick, getDoNotDisturb, triggerNotificationFrameEffects} from 'main/notifications'; +import {getDoNotDisturb} from 'main/notifications'; import parseArgs from 'main/ParseArgs'; import PerformanceMonitor from 'main/performanceMonitor'; import secureStorage from 'main/secureStorage'; @@ -68,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'; @@ -80,7 +72,6 @@ import { handleAppWillFinishLaunching, handleAppWindowAllClosed, handleChildProcessGone, - certificateErrorCallbacks, } from './app'; import { handleConfigUpdate, @@ -99,12 +90,10 @@ import { handleQuit, handlePingDomain, handleToggleSecureInput, - handleShowSettingsModal, } from './intercom'; import { clearAppCache, getDeeplinkingURL, - openDeepLink, shouldShowTrayIcon, updateSpellCheckerLocales, wasUpdated, @@ -285,79 +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 = { - AppState, - MainWindow, - NotificationManager: notificationManager, - ServerManager, - TabManager, - ViewManager, - WebContentsManager, - Config, - TrayIcon: Tray, - Diagnostics, - PopoutManager, - updateNotifier, - setUnreadBadgeSetting, - }; - - registerE2eHooks({ - e2eTestRefs, - openDeepLink, - clickTrayMenuItem: (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}`); - } - }, - triggerNotificationFrameEffects, - simulateNotificationClick: (payload) => { - 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); - }, - installMessageBoxStub, - restoreMessageBoxStub, - clearCertificateErrorCallbacks: () => certificateErrorCallbacks.clear(), - }); + maybeRegisterE2eHooks(); // Block all NTLM/Negotiate requests by default session.defaultSession.allowNTLMCredentialsForDomains(''); @@ -402,14 +325,6 @@ async function initializeAfterAppReady() { ServerManager.on(SERVER_URL_CHANGED, updateServerInfo); ServerManager.on(SERVER_PRE_AUTH_SECRET_CHANGED, updateServerInfo); - 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 index 3c9d2e51c5b..15a36264aa4 100644 --- a/src/main/e2e/hooks.ts +++ b/src/main/e2e/hooks.ts @@ -3,14 +3,9 @@ import {setTestField} from 'common/utils/util'; -type MessageBoxResponse = {response: number}; +import type {SimulateNotificationClickPayload} from './notificationClick'; -type SimulateNotificationClickPayload = { - webContentsId: number; - channelId: string; - teamId: string; - url: string; -}; +type MessageBoxResponse = {response: number}; type RegisterE2eHooksOptions = { e2eTestRefs: Record; diff --git a/src/main/e2e/notificationClick.ts b/src/main/e2e/notificationClick.ts new file mode 100644 index 00000000000..23727f119ff --- /dev/null +++ b/src/main/e2e/notificationClick.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {webContents} from 'electron'; + +import WebContentsManager from 'app/views/webContentsManager'; +import type {dispatchMentionClick} from 'main/notifications'; + +export type SimulateNotificationClickPayload = { + webContentsId: number; + channelId: string; + teamId: string; + url: string; +}; + +export function createSimulateNotificationClick( + mentionClick: typeof dispatchMentionClick, +) { + return (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}`); + } + + mentionClick(view, wc, payload.channelId, payload.teamId, payload.url); + }; +} diff --git a/src/main/e2e/register.ts b/src/main/e2e/register.ts new file mode 100644 index 00000000000..87a3f1a3418 --- /dev/null +++ b/src/main/e2e/register.ts @@ -0,0 +1,71 @@ +// 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 {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, {dispatchMentionClick, triggerNotificationFrameEffects} from 'main/notifications'; +import {installMessageBoxStub, restoreMessageBoxStub} from 'main/testMessageBoxStub'; +import updateNotifier from 'main/updateNotifier'; + +import {registerE2eHooks} from './hooks'; +import {createSimulateNotificationClick} from './notificationClick'; +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; + } + + 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: createSimulateNotificationClick(dispatchMentionClick), + 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}`); + } + }; +} From ca1c50c927de53eb1b9291bd2d6d9ff8f909b79a Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 05:41:00 +0530 Subject: [PATCH 18/22] move hookes to e2e/hooks --- e2e/helpers/callsWidget.ts | 22 +++++++---- e2e/helpers/ipcChannels.ts | 3 ++ e2e/helpers/testRefs.ts | 2 +- e2e/helpers/tray.ts | 11 ++++++ e2e/specs/calls/calls_functionality.test.ts | 7 ++-- e2e/specs/focus.test.ts | 4 +- e2e/specs/linux_dark_mode.test.ts | 7 ++-- .../notification_click.test.ts | 10 +---- e2e/specs/permissions/permissions_ipc.test.ts | 2 +- .../server_management/bad_servers.test.ts | 14 +++---- e2e/specs/settings/keyboard_shortcuts.test.ts | 3 +- src/app/system/badge.ts | 11 +++++- src/main/app/initialize.test.js | 2 + src/main/e2e/hooks.ts | 23 +++++++++++- src/main/e2e/notificationClick.ts | 2 +- src/main/e2e/register.ts | 8 +++- .../notifications/dispatchMentionClick.ts | 30 +++++++++++++++ src/main/notifications/index.ts | 37 ++----------------- .../notifications/notificationFrameEffects.ts | 18 +++++++++ 19 files changed, 141 insertions(+), 75 deletions(-) create mode 100644 src/main/notifications/dispatchMentionClick.ts create mode 100644 src/main/notifications/notificationFrameEffects.ts diff --git a/e2e/helpers/callsWidget.ts b/e2e/helpers/callsWidget.ts index 1d4826088e1..d8d68e2ecd9 100644 --- a/e2e/helpers/callsWidget.ts +++ b/e2e/helpers/callsWidget.ts @@ -19,13 +19,19 @@ export async function waitForCallsWidgetWindow( electronApp: ElectronApplication, timeoutMs = 20_000, ): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const widget = findCallsWidgetWindow(electronApp); - if (widget) { - return widget; - } - await new Promise((resolve) => setTimeout(resolve, 500)); + const existing = findCallsWidgetWindow(electronApp); + if (existing) { + return existing; } - return null; + + 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/ipcChannels.ts b/e2e/helpers/ipcChannels.ts index 4181b26fc4a..bab9c9199e4 100644 --- a/e2e/helpers/ipcChannels.ts +++ b/e2e/helpers/ipcChannels.ts @@ -5,3 +5,6 @@ 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/testRefs.ts b/e2e/helpers/testRefs.ts index 3ad098883e3..1c1eb530ea0 100644 --- a/e2e/helpers/testRefs.ts +++ b/e2e/helpers/testRefs.ts @@ -91,7 +91,7 @@ export async function evaluateInMainProcessWithArg( } function isMainIndexUrl(url: string): boolean { - return url.includes('index'); + return url.includes('mattermost-desktop://renderer/index'); } export function findMainIndexWindow(app: ElectronApplication): Page | undefined { 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/specs/calls/calls_functionality.test.ts b/e2e/specs/calls/calls_functionality.test.ts index bcfed423e9f..22d96095dbf 100644 --- a/e2e/specs/calls/calls_functionality.test.ts +++ b/e2e/specs/calls/calls_functionality.test.ts @@ -7,6 +7,7 @@ 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'; @@ -204,9 +205,9 @@ async function closeCallsWidget( }); if (!leaveClicked) { - await electronApp.evaluate(({ipcMain}) => { - ipcMain.emit('calls-leave-call'); - }); + await electronApp.evaluate(({ipcMain}, channel) => { + ipcMain.emit(channel); + }, CALLS_LEAVE_CALL); } await expect.poll( diff --git a/e2e/specs/focus.test.ts b/e2e/specs/focus.test.ts index c42afe5c545..d45f3ea3c4d 100644 --- a/e2e/specs/focus.test.ts +++ b/e2e/specs/focus.test.ts @@ -9,12 +9,10 @@ import {test, expect} from '../fixtures/index'; import {waitForAppReady} from '../helpers/appReadiness'; import {electronBinaryPath, appDir, demoMattermostConfig, writeConfigFile} from '../helpers/config'; 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: [ 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/notification_trigger/notification_click.test.ts b/e2e/specs/notification_trigger/notification_click.test.ts index fff552bd741..1c723551c5c 100644 --- a/e2e/specs/notification_trigger/notification_click.test.ts +++ b/e2e/specs/notification_trigger/notification_click.test.ts @@ -8,7 +8,7 @@ import {loginToMattermost} from '../../helpers/login'; import {simulateNotificationClick} from '../../helpers/notificationClick'; import {resolveChannelByName} from '../../helpers/server_api/channel'; import {getActiveServerWebContentsId} from '../../helpers/testRefs'; -import {hideMainWindow, isMainWindowVisible} from '../../helpers/tray'; +import {hideMainWindow, isMainWindowVisible, showMainWindowIfHidden} from '../../helpers/tray'; test.use({appConfig: demoMattermostConfig}); test.setTimeout(120_000); @@ -58,13 +58,7 @@ test( {timeout: 10_000, message: 'Main window should be visible after notification click navigation'}, ).toBe(true); } finally { - await electronApp.evaluate(() => { - const refs = (global as any).__e2eTestRefs; - const mainWindow = refs?.MainWindow?.get?.(); - if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { - refs?.MainWindow?.show?.(); - } - }).catch(() => {}); + await showMainWindowIfHidden(electronApp); await releaseLock(); } }, diff --git a/e2e/specs/permissions/permissions_ipc.test.ts b/e2e/specs/permissions/permissions_ipc.test.ts index 4e790550098..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) { diff --git a/e2e/specs/server_management/bad_servers.test.ts b/e2e/specs/server_management/bad_servers.test.ts index 547a34c4990..c272c2017b8 100644 --- a/e2e/specs/server_management/bad_servers.test.ts +++ b/e2e/specs/server_management/bad_servers.test.ts @@ -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/src/app/system/badge.ts b/src/app/system/badge.ts index f32b81b8163..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 {recordBadgeTestState} from 'main/e2e/badgeState'; 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,7 +132,7 @@ function showBadge(sessionExpired: boolean, mentionCount: number, showUnreadBadg break; } - recordBadgeTestState(sessionExpired, mentionCount, showUnreadBadge, showUnreadBadgeSetting); + badgeTestRecorder?.(sessionExpired, mentionCount, showUnreadBadge, showUnreadBadgeSetting); } export function setUnreadBadgeSetting(showUnreadBadge: boolean) { 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/e2e/hooks.ts b/src/main/e2e/hooks.ts index 15a36264aa4..02de2e7d031 100644 --- a/src/main/e2e/hooks.ts +++ b/src/main/e2e/hooks.ts @@ -7,8 +7,29 @@ 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: Record; + e2eTestRefs: E2eGlobalRefs; openDeepLink: (url: string) => void; clickTrayMenuItem: (label: string) => void; triggerNotificationFrameEffects: (flash: boolean) => void; diff --git a/src/main/e2e/notificationClick.ts b/src/main/e2e/notificationClick.ts index 23727f119ff..fe94afad5b3 100644 --- a/src/main/e2e/notificationClick.ts +++ b/src/main/e2e/notificationClick.ts @@ -4,7 +4,7 @@ import {webContents} from 'electron'; import WebContentsManager from 'app/views/webContentsManager'; -import type {dispatchMentionClick} from 'main/notifications'; +import type {dispatchMentionClick} from 'main/notifications/dispatchMentionClick'; export type SimulateNotificationClickPayload = { webContentsId: number; diff --git a/src/main/e2e/register.ts b/src/main/e2e/register.ts index 87a3f1a3418..f4b4c6b1bf9 100644 --- a/src/main/e2e/register.ts +++ b/src/main/e2e/register.ts @@ -5,7 +5,7 @@ import {ipcMain} from 'electron'; import MainWindow from 'app/mainWindow/mainWindow'; import createTrayMenu from 'app/menus/tray'; -import {setUnreadBadgeSetting} from 'app/system/badge'; +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'; @@ -19,10 +19,13 @@ 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, {dispatchMentionClick, triggerNotificationFrameEffects} from 'main/notifications'; +import notificationManager from 'main/notifications'; +import {dispatchMentionClick} from 'main/notifications/dispatchMentionClick'; +import {triggerNotificationFrameEffects} from 'main/notifications/notificationFrameEffects'; import {installMessageBoxStub, restoreMessageBoxStub} from 'main/testMessageBoxStub'; import updateNotifier from 'main/updateNotifier'; +import {recordBadgeTestState} from './badgeState'; import {registerE2eHooks} from './hooks'; import {createSimulateNotificationClick} from './notificationClick'; import {createClickTrayMenuItem} from './trayMenu'; @@ -36,6 +39,7 @@ export function maybeRegisterE2eHooks(): void { return; } + setBadgeTestRecorder(recordBadgeTestState); ipcMain.on(SHOW_SETTINGS_WINDOW, handleShowSettingsModal); registerE2eHooks({ diff --git a/src/main/notifications/dispatchMentionClick.ts b/src/main/notifications/dispatchMentionClick.ts new file mode 100644 index 00000000000..2804bd34e11 --- /dev/null +++ b/src/main/notifications/dispatchMentionClick.ts @@ -0,0 +1,30 @@ +// 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 TabManager from 'app/tabs/tabManager'; +import {BROWSER_HISTORY_PUSH, NOTIFICATION_CLICKED} from 'common/communication'; + +/** + * Handle a mention notification click: notify the webapp and show/focus the + * window after navigation completes. + */ +export function dispatchMentionClick( + view: {id: string}, + webcontents: Electron.WebContents, + channelId: string, + teamId: string, + url: string, +) { + // Show the window after navigation has finished to avoid the focus handler + // being called before the current channel has updated + const focus = () => { + MainWindow.show(); + TabManager.switchToTab(view.id); + ipcMain.off(BROWSER_HISTORY_PUSH, focus); + }; + ipcMain.on(BROWSER_HISTORY_PUSH, focus); + webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); +} diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index d050ef29392..de1a7cab25d 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -1,25 +1,25 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {app, shell, Notification, ipcMain} from 'electron'; +import {shell, Notification, ipcMain} from 'electron'; import isDev from 'electron-is-dev'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; import MainWindow from 'app/mainWindow/mainWindow'; -import TabManager from 'app/tabs/tabManager'; import WebContentsManager from 'app/views/webContentsManager'; -import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication'; -import Config from 'common/config'; +import {PLAY_SOUND, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication'; import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import viewManager from 'common/views/viewManager'; import DeveloperMode from 'main/developerMode'; import PermissionsManager from 'main/security/permissionsManager'; +import {dispatchMentionClick} from './dispatchMentionClick'; import getLinuxDoNotDisturb from './dnd-linux'; import getWindowsDoNotDisturb from './dnd-windows'; import {DownloadNotification} from './Download'; import {Mention} from './Mention'; +import {triggerNotificationFrameEffects} from './notificationFrameEffects'; import {NewVersionNotification, UpgradeNotification} from './Upgrade'; const log = new Logger('Notifications'); @@ -235,22 +235,6 @@ class NotificationManager { } } -export function dispatchMentionClick( - view: {id: string}, - webcontents: 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); - webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); -} - export async function getDoNotDisturb() { if (process.platform === 'win32') { return getWindowsDoNotDisturb(); @@ -274,18 +258,5 @@ export async function getDoNotDisturb() { return false; } -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); - } -} - const notificationManager = new NotificationManager(); - -export {triggerNotificationFrameEffects}; export default notificationManager; diff --git a/src/main/notifications/notificationFrameEffects.ts b/src/main/notifications/notificationFrameEffects.ts new file mode 100644 index 00000000000..31e2560f144 --- /dev/null +++ b/src/main/notifications/notificationFrameEffects.ts @@ -0,0 +1,18 @@ +// 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'; + +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); + } +} From 8181131a71a5ebfb20bbc010c2f1f573cc5ea496 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 06:07:42 +0530 Subject: [PATCH 19/22] clean up label --- .github/workflows/e2e-functional.yml | 19 +++++++++++++------ e2e/utils/github-actions.js | 2 ++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-functional.yml b/.github/workflows/e2e-functional.yml index cbb2bd96a1d..df61d80f467 100644 --- a/.github/workflows/e2e-functional.yml +++ b/.github/workflows/e2e-functional.yml @@ -183,12 +183,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'); } diff --git a/e2e/utils/github-actions.js b/e2e/utils/github-actions.js index a52f663c8dc..e0ebe449b8b 100644 --- a/e2e/utils/github-actions.js +++ b/e2e/utils/github-actions.js @@ -141,6 +141,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({ @@ -150,6 +151,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}`); }), From e309707219e74db702c32161262c9b70083bbe91 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 06:17:18 +0530 Subject: [PATCH 20/22] revert production app changes --- e2e/helpers/notificationEffects.ts | 2 +- src/main/e2e/notificationClick.ts | 45 ++++++++++++------- .../notificationFrameEffects.ts | 1 + src/main/e2e/register.ts | 7 ++- .../notifications/dispatchMentionClick.ts | 30 ------------- src/main/notifications/index.ts | 34 +++++++++++--- 6 files changed, 62 insertions(+), 57 deletions(-) rename src/main/{notifications => e2e}/notificationFrameEffects.ts (91%) delete mode 100644 src/main/notifications/dispatchMentionClick.ts 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/src/main/e2e/notificationClick.ts b/src/main/e2e/notificationClick.ts index fe94afad5b3..2f92f8ccd51 100644 --- a/src/main/e2e/notificationClick.ts +++ b/src/main/e2e/notificationClick.ts @@ -1,10 +1,12 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {webContents} from 'electron'; +import {ipcMain, webContents} from 'electron'; +import MainWindow from 'app/mainWindow/mainWindow'; +import TabManager from 'app/tabs/tabManager'; import WebContentsManager from 'app/views/webContentsManager'; -import type {dispatchMentionClick} from 'main/notifications/dispatchMentionClick'; +import {BROWSER_HISTORY_PUSH, NOTIFICATION_CLICKED} from 'common/communication'; export type SimulateNotificationClickPayload = { webContentsId: number; @@ -13,20 +15,33 @@ export type SimulateNotificationClickPayload = { url: string; }; -export function createSimulateNotificationClick( - mentionClick: typeof dispatchMentionClick, +/** Mirrors notifications/index.ts mention click handler — E2E only. */ +function dispatchMentionClick( + view: {id: string}, + wc: Electron.WebContents, + channelId: string, + teamId: string, + url: string, ) { - return (payload: SimulateNotificationClickPayload) => { - const wc = webContents.fromId(payload.webContentsId); - if (!wc || wc.isDestroyed()) { - throw new Error(`webContents ${payload.webContentsId} is not available`); - } + 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); +} - const view = WebContentsManager.getViewByWebContentsId(wc.id); - if (!view) { - throw new Error(`No view for webContents ${payload.webContentsId}`); - } +export function simulateNotificationClick(payload: SimulateNotificationClickPayload) { + const wc = webContents.fromId(payload.webContentsId); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.webContentsId} is not available`); + } - mentionClick(view, wc, payload.channelId, payload.teamId, payload.url); - }; + 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/notifications/notificationFrameEffects.ts b/src/main/e2e/notificationFrameEffects.ts similarity index 91% rename from src/main/notifications/notificationFrameEffects.ts rename to src/main/e2e/notificationFrameEffects.ts index 31e2560f144..04980194b37 100644 --- a/src/main/notifications/notificationFrameEffects.ts +++ b/src/main/e2e/notificationFrameEffects.ts @@ -6,6 +6,7 @@ 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) { diff --git a/src/main/e2e/register.ts b/src/main/e2e/register.ts index f4b4c6b1bf9..641828bbbee 100644 --- a/src/main/e2e/register.ts +++ b/src/main/e2e/register.ts @@ -20,14 +20,13 @@ import {handleShowSettingsModal} from 'main/app/intercom'; import {openDeepLink} from 'main/app/utils'; import Diagnostics from 'main/diagnostics'; import notificationManager from 'main/notifications'; -import {dispatchMentionClick} from 'main/notifications/dispatchMentionClick'; -import {triggerNotificationFrameEffects} from 'main/notifications/notificationFrameEffects'; import {installMessageBoxStub, restoreMessageBoxStub} from 'main/testMessageBoxStub'; import updateNotifier from 'main/updateNotifier'; import {recordBadgeTestState} from './badgeState'; import {registerE2eHooks} from './hooks'; -import {createSimulateNotificationClick} from './notificationClick'; +import {simulateNotificationClick} from './notificationClick'; +import {triggerNotificationFrameEffects} from './notificationFrameEffects'; import {createClickTrayMenuItem} from './trayMenu'; /** @@ -61,7 +60,7 @@ export function maybeRegisterE2eHooks(): void { openDeepLink, clickTrayMenuItem: createClickTrayMenuItem(createTrayMenu), triggerNotificationFrameEffects, - simulateNotificationClick: createSimulateNotificationClick(dispatchMentionClick), + simulateNotificationClick, installMessageBoxStub, restoreMessageBoxStub, clearCertificateErrorCallbacks: () => certificateErrorCallbacks.clear(), diff --git a/src/main/notifications/dispatchMentionClick.ts b/src/main/notifications/dispatchMentionClick.ts deleted file mode 100644 index 2804bd34e11..00000000000 --- a/src/main/notifications/dispatchMentionClick.ts +++ /dev/null @@ -1,30 +0,0 @@ -// 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 TabManager from 'app/tabs/tabManager'; -import {BROWSER_HISTORY_PUSH, NOTIFICATION_CLICKED} from 'common/communication'; - -/** - * Handle a mention notification click: notify the webapp and show/focus the - * window after navigation completes. - */ -export function dispatchMentionClick( - view: {id: string}, - webcontents: Electron.WebContents, - channelId: string, - teamId: string, - url: string, -) { - // Show the window after navigation has finished to avoid the focus handler - // being called before the current channel has updated - const focus = () => { - MainWindow.show(); - TabManager.switchToTab(view.id); - ipcMain.off(BROWSER_HISTORY_PUSH, focus); - }; - ipcMain.on(BROWSER_HISTORY_PUSH, focus); - webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); -} diff --git a/src/main/notifications/index.ts b/src/main/notifications/index.ts index de1a7cab25d..c0fc46e5466 100644 --- a/src/main/notifications/index.ts +++ b/src/main/notifications/index.ts @@ -1,25 +1,25 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shell, Notification, ipcMain} from 'electron'; +import {app, shell, Notification, ipcMain} from 'electron'; import isDev from 'electron-is-dev'; import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state'; import MainWindow from 'app/mainWindow/mainWindow'; +import TabManager from 'app/tabs/tabManager'; import WebContentsManager from 'app/views/webContentsManager'; -import {PLAY_SOUND, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication'; +import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication'; +import Config from 'common/config'; import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import viewManager from 'common/views/viewManager'; import DeveloperMode from 'main/developerMode'; import PermissionsManager from 'main/security/permissionsManager'; -import {dispatchMentionClick} from './dispatchMentionClick'; import getLinuxDoNotDisturb from './dnd-linux'; import getWindowsDoNotDisturb from './dnd-windows'; import {DownloadNotification} from './Download'; import {Mention} from './Mention'; -import {triggerNotificationFrameEffects} from './notificationFrameEffects'; import {NewVersionNotification, UpgradeNotification} from './Upgrade'; const log = new Logger('Notifications'); @@ -92,7 +92,16 @@ class NotificationManager { log.debug('notification click', server.id, mention.uId); this.allActiveNotifications?.delete(mention.uId); - dispatchMentionClick(view, webcontents, channelId, teamId, url); + + // Show the window after navigation has finished to avoid the focus handler + // being called before the current channel has updated + const focus = () => { + MainWindow.show(); + TabManager.switchToTab(view.id); + ipcMain.off(BROWSER_HISTORY_PUSH, focus); + }; + ipcMain.on(BROWSER_HISTORY_PUSH, focus); + webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url); }); mention.on('close', () => { @@ -127,7 +136,7 @@ class NotificationManager { if (notificationSound) { MainWindow.sendToRenderer(PLAY_SOUND, notificationSound); } - triggerNotificationFrameEffects(true); + flashFrame(true); clearTimeout(timeout); resolve({status: 'success'}); } @@ -168,7 +177,7 @@ class NotificationManager { this.allActiveNotifications?.set(download.uId, download); download.on('show', () => { - triggerNotificationFrameEffects(true); + flashFrame(true); }); download.on('click', () => { @@ -258,5 +267,16 @@ export async function getDoNotDisturb() { return false; } +function flashFrame(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); + } +} + const notificationManager = new NotificationManager(); export default notificationManager; From 2d2bb519a3387e27e4270b88a4d61da150923878 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 06:32:20 +0530 Subject: [PATCH 21/22] clean up label --- e2e/utils/github-actions.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/e2e/utils/github-actions.js b/e2e/utils/github-actions.js index e0ebe449b8b..f399f5cd41b 100644 --- a/e2e/utils/github-actions.js +++ b/e2e/utils/github-actions.js @@ -62,6 +62,13 @@ function formatStatusDescription({passed, failed, collectionFailed}) { } 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, @@ -71,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; } /** From 1422f63ac7f921f3e9b502bc5d21b6d64dd34daf Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Fri, 3 Jul 2026 06:33:18 +0530 Subject: [PATCH 22/22] clean up label --- .github/workflows/e2e-functional.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-functional.yml b/.github/workflows/e2e-functional.yml index df61d80f467..88c6b6f7f6d 100644 --- a/.github/workflows/e2e-functional.yml +++ b/.github/workflows/e2e-functional.yml @@ -51,10 +51,13 @@ jobs: platforms: ${{ steps.generate.outputs.platforms }} steps: - id: generate + env: + INSTANCE_DETAILS: ${{ inputs.instance_details }} run: | # Matterwick still dispatches macos-latest; pin explicitly so the job # does not drift when GitHub retargets that label to macOS 26. - echo "platforms=$(echo '${{ inputs.instance_details }}' | jq -c 'map(if .runner == "macos-latest" then .runner = "macos-26" else . end)')" >> $GITHUB_OUTPUT + 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