diff --git a/.eslintrc.json b/.eslintrc.json index 4df01d85145..48f9c7d299e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -94,7 +94,8 @@ { "files": [ "e2e/**/*", - "src/**/*.test.js" + "src/**/*.test.js", + "src/**/*.test.ts" ], "env": { "jest": true diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index bd9a1909b3d..459afcbd798 100644 --- a/.github/workflows/e2e-functional-template.yml +++ b/.github/workflows/e2e-functional-template.yml @@ -287,6 +287,10 @@ jobs: npm ci cd e2e && npm ci + - name: e2e/enable-public-links + if: env.MM_TEST_SERVER_URL != '' + run: cd e2e && npm run enable-public-links + - name: e2e/suppress-macos-dialogs if: runner.os == 'macOS' run: | diff --git a/e2e/helpers/badge.ts b/e2e/helpers/badge.ts index 167c33964d6..27530716e51 100644 --- a/e2e/helpers/badge.ts +++ b/e2e/helpers/badge.ts @@ -4,6 +4,9 @@ import {expect} from '@playwright/test'; import type {ElectronApplication} from 'playwright'; +import type {BadgeTestState} from 'src/main/e2e/badgeState'; +import type {E2eGlobalRefs} from 'src/main/e2e/hooks'; + import {evaluateInMainProcess, evaluateInMainProcessWithArg} from './testRefs'; export type OsBadgeState = { @@ -15,7 +18,8 @@ export type OsBadgeState = { export async function waitForBadgeInfrastructure(app: ElectronApplication): Promise { await expect.poll( async () => app.evaluate(() => { - const refs = (global as any).__e2eTestRefs; + const e2eTestRefsKey = '__e2eTestRefs'; + const refs = (global as Record)[e2eTestRefsKey] as E2eGlobalRefs | undefined; return Boolean(refs?.AppState && refs?.ServerManager); }), {timeout: 30_000, message: 'AppState and ServerManager must be exposed on __e2eTestRefs'}, @@ -24,7 +28,8 @@ export async function waitForBadgeInfrastructure(app: ElectronApplication): Prom export async function setUnreadBadgeSetting(app: ElectronApplication, enabled: boolean): Promise { await evaluateInMainProcessWithArg(app, (_electron, showUnreadBadge) => { - const refs = (global as any).__e2eTestRefs; + const e2eTestRefsKey = '__e2eTestRefs'; + const refs = (global as Record)[e2eTestRefsKey] as E2eGlobalRefs | undefined; if (!refs?.setUnreadBadgeSetting) { throw new Error('setUnreadBadgeSetting missing from __e2eTestRefs'); } @@ -40,7 +45,8 @@ export async function updateServerBadgeViaAppState( unreads: boolean, ): Promise { await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, mentions: mentionCount, unreads: hasUnreads}) => { - const refs = (global as any).__e2eTestRefs; + const e2eTestRefsKey = '__e2eTestRefs'; + const refs = (global as Record)[e2eTestRefsKey] as E2eGlobalRefs | undefined; const AppState = refs?.AppState; const ServerManager = refs?.ServerManager; if (!AppState || !ServerManager) { @@ -60,7 +66,8 @@ export async function setServerExpiredViaAppState( expired: boolean, ): Promise { await evaluateInMainProcessWithArg(app, (_electron, {serverName: name, expired: isExpired}) => { - const refs = (global as any).__e2eTestRefs; + const e2eTestRefsKey = '__e2eTestRefs'; + const refs = (global as Record)[e2eTestRefsKey] as E2eGlobalRefs | undefined; const AppState = refs?.AppState; const ServerManager = refs?.ServerManager; if (!AppState || !ServerManager) { @@ -76,7 +83,8 @@ export async function setServerExpiredViaAppState( export async function clearAllBadgesViaAppState(app: ElectronApplication): Promise { await evaluateInMainProcess(app, () => { - const refs = (global as any).__e2eTestRefs; + const e2eTestRefsKey = '__e2eTestRefs'; + const refs = (global as Record)[e2eTestRefsKey] as E2eGlobalRefs | undefined; const AppState = refs?.AppState; const ServerManager = refs?.ServerManager; if (!AppState || !ServerManager) { @@ -91,9 +99,54 @@ export async function clearAllBadgesViaAppState(app: ElectronApplication): Promi }); } +/** + * Clear all servers and set one server's badge state in a single main-process + * turn so external AppState updates cannot land between separate clear/set IPC calls. + */ +export async function prepareServerBadgeViaAppState( + app: ElectronApplication, + serverName: string, + mentions: number, + unreads: boolean, + options: {enableUnreadBadge?: boolean} = {}, +): Promise { + const {enableUnreadBadge = false} = options; + await evaluateInMainProcessWithArg(app, (_, payload) => { + const e2eTestRefsKey = '__e2eTestRefs'; + const refs = (global as Record)[e2eTestRefsKey] as E2eGlobalRefs | undefined; + const AppState = refs?.AppState; + const ServerManager = refs?.ServerManager; + if (!AppState || !ServerManager) { + throw new Error('AppState or ServerManager missing from __e2eTestRefs'); + } + + for (const server of ServerManager.getAllServers()) { + AppState.updateUnreadsAndMentionsPerServer(server.id, 0, false); + AppState.updateExpired(server.id, false); + } + refs.Config?.set?.('showUnreadBadge', payload.enableUnreadBadge); + refs.setUnreadBadgeSetting?.(payload.enableUnreadBadge); + + const server = ServerManager.getAllServers().find((s: {name: string}) => s.name === payload.serverName); + if (!server) { + throw new Error(`Server not found: ${payload.serverName}`); + } + AppState.updateUnreadsAndMentionsPerServer(server.id, payload.mentions, payload.unreads); + }, {serverName, mentions, unreads, enableUnreadBadge}); +} + +export async function refreshBadgeStateForTest(app: ElectronApplication): Promise { + await evaluateInMainProcess(app, () => { + const e2eTestRefsKey = '__e2eTestRefs'; + const refs = (global as Record)[e2eTestRefsKey] as E2eGlobalRefs | undefined; + refs?.AppState?.emitStatus(); + }); +} + export async function readOsBadge(electronApp: ElectronApplication): Promise { return evaluateInMainProcess(electronApp, ({app}) => { - const testState = (global as any).__testBadgeState; + const testBadgeStateKey = '__testBadgeState'; + const testState = (global as Record)[testBadgeStateKey] as BadgeTestState | undefined; if (process.platform === 'darwin') { const badge = app.dock?.getBadge() ?? ''; diff --git a/e2e/package-lock.json b/e2e/package-lock.json index d08fb62a609..02046476f34 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -16,7 +16,8 @@ "devDependencies": { "@playwright/test": "1.61.0", "cross-env": "^10.1.0", - "playwright": "1.61.0" + "playwright": "1.61.0", + "tsx": "4.19.4" } }, "node_modules/@epic-web/invariant": { @@ -26,6 +27,448 @@ "dev": true, "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@playwright/test": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", @@ -89,6 +532,48 @@ "node": ">= 8" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/fast-xml-parser": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz", @@ -122,6 +607,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -179,6 +677,16 @@ "table-parser": "^0.1.3" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -221,6 +729,41 @@ "connected-domain": "^1.0.0" } }, + "node_modules/tsx": { + "version": "4.19.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", + "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/e2e/package.json b/e2e/package.json index e66a29f0da1..af1295dd104 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,6 +5,7 @@ "scripts": { "clean": "rm -rf node_modules/ testUserData/ playwright-report/ playwright-report-merged/ blob-report/ test-results/ mochawesome-report/", "test": "playwright test", + "enable-public-links": "tsx scripts/enable-public-links.ts", "run:policy": "cross-env RUN_POLICY_E2E=true playwright test specs/policy/policy.test.ts", "send-report": "playwright merge-reports --reporter=html blob-report" }, @@ -26,6 +27,7 @@ "devDependencies": { "@playwright/test": "1.61.0", "cross-env": "^10.1.0", - "playwright": "1.61.0" + "playwright": "1.61.0", + "tsx": "4.19.4" } } diff --git a/e2e/scripts/enable-public-links.ts b/e2e/scripts/enable-public-links.ts new file mode 100644 index 00000000000..b2b5ca27af7 --- /dev/null +++ b/e2e/scripts/enable-public-links.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +/* eslint-disable no-console -- CI bootstrap script logs status to stdout/stderr */ + +import {apiLogin, apiRequest} from '../helpers/server_api/client'; + +type ServerConfig = { + FileSettings?: { + EnablePublicLink?: boolean; + }; + ServiceSettings?: { + SiteURL?: string; + }; +}; + +type ConfigPatch = { + FileSettings: { + EnablePublicLink: boolean; + }; + ServiceSettings: { + SiteURL: string; + }; +}; + +async function main(): Promise { + const baseUrl = (process.env.MM_TEST_SERVER_URL ?? '').replace(/\/$/, ''); + const username = process.env.MM_TEST_USER_NAME; + const password = process.env.MM_TEST_PASSWORD; + + if (!baseUrl || !username || !password) { + throw new Error('MM_TEST_SERVER_URL, MM_TEST_USER_NAME, and MM_TEST_PASSWORD are required'); + } + + const token = await apiLogin(baseUrl, username, password); + const config = await apiRequest(baseUrl, token, '/api/v4/config'); + + if (config.FileSettings?.EnablePublicLink === true) { + console.log('Public links already enabled on the E2E server'); + return; + } + + const siteURL = config.ServiceSettings?.SiteURL ?? baseUrl; + const patch: ConfigPatch = { + FileSettings: {EnablePublicLink: true}, + ServiceSettings: {SiteURL: siteURL}, + }; + await apiRequest(baseUrl, token, '/api/v4/config/patch', { + method: 'PUT', + body: JSON.stringify(patch), + }); + + const updated = await apiRequest(baseUrl, token, '/api/v4/config'); + if (updated.FileSettings?.EnablePublicLink !== true) { + throw new Error('Failed to enable public links on the E2E server'); + } + + console.log('Enabled public links on the E2E server'); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/e2e/specs/notification_trigger/notification_badge.test.ts b/e2e/specs/notification_trigger/notification_badge.test.ts index 46c15805839..c139fc445a4 100644 --- a/e2e/specs/notification_trigger/notification_badge.test.ts +++ b/e2e/specs/notification_trigger/notification_badge.test.ts @@ -1,20 +1,37 @@ // 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 { clearAllBadgesViaAppState, + prepareServerBadgeViaAppState, readOsBadge, + refreshBadgeStateForTest, setServerExpiredViaAppState, setUnreadBadgeSetting, updateServerBadgeViaAppState, waitForBadgeInfrastructure, + type OsBadgeState, } from '../../helpers/badge'; import {demoConfig} from '../../helpers/config'; import {acquireExclusiveLock} from '../../helpers/exclusiveLock'; const FIRST_SERVER = demoConfig.servers[0].name; +const WIN_BADGE_POLL_TIMEOUT = 30_000; + +async function expectWindowsBadge( + electronApp: ElectronApplication, + expected: Partial, + message: string, +) { + await expect.poll(async () => { + await refreshBadgeStateForTest(electronApp); + return readOsBadge(electronApp); + }, {timeout: WIN_BADGE_POLL_TIMEOUT, message}).toMatchObject(expected); +} test.describe('notification_trigger/notification_badge', () => { test.use({appConfig: demoConfig}); @@ -151,13 +168,13 @@ test.describe('notification_trigger/notification_badge', () => { async ({electronApp}) => { const releaseLock = await acquireExclusiveLock('notification-badge-state'); try { - await clearAllBadgesViaAppState(electronApp); - await updateServerBadgeViaAppState(electronApp, FIRST_SERVER, 5, false); + await prepareServerBadgeViaAppState(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}); + await expectWindowsBadge( + electronApp, + {hasOverlay: true, symbol: 'mention', count: 5}, + 'Windows taskbar overlay must appear for mentions', + ); } finally { await releaseLock(); } @@ -169,14 +186,13 @@ test.describe('notification_trigger/notification_badge', () => { 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 prepareServerBadgeViaAppState(electronApp, FIRST_SERVER, 0, true, {enableUnreadBadge: true}); - await expect.poll( - () => readOsBadge(electronApp), - {timeout: 10_000, message: 'Windows taskbar overlay must appear for unreads when enabled'}, - ).toMatchObject({hasOverlay: true, symbol: 'unread'}); + await expectWindowsBadge( + electronApp, + {hasOverlay: true, symbol: 'unread'}, + 'Windows taskbar overlay must appear for unreads when enabled', + ); } finally { await releaseLock(); } @@ -188,13 +204,14 @@ test.describe('notification_trigger/notification_badge', () => { 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}); + await prepareServerBadgeViaAppState(electronApp, FIRST_SERVER, 2, false); + await prepareServerBadgeViaAppState(electronApp, FIRST_SERVER, 0, false); + + await expectWindowsBadge( + electronApp, + {hasOverlay: false, symbol: 'none', count: 0}, + 'Windows taskbar overlay must clear when AppState totals reset', + ); } finally { await releaseLock(); } diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index f3b314ece18..05b578e7c61 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -13,6 +13,6 @@ "src/*": ["../src/*"] } }, - "include": ["fixtures/**/*", "helpers/**/*", "specs/**/*.ts"], + "include": ["fixtures/**/*", "helpers/**/*", "scripts/**/*", "specs/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/e2e/utils/analyze-flaky-test.js b/e2e/utils/analyze-flaky-test.js index 095cf1900fb..683aec879f7 100644 --- a/e2e/utils/analyze-flaky-test.js +++ b/e2e/utils/analyze-flaky-test.js @@ -1,6 +1,9 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +// CommonJS is required here: GitHub Actions workflows load this file via +// require() in actions/github-script, which does not support ES modules. + const fs = require('fs'); const path = require('path'); const {createRequire} = require('module'); diff --git a/src/app/menus/appMenu/history.test.js b/src/app/menus/appMenu/history.test.js index 4d3d73a573f..86b5b47b9a5 100644 --- a/src/app/menus/appMenu/history.test.js +++ b/src/app/menus/appMenu/history.test.js @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import WebContentsManager from 'app/views/webContentsManager'; +import TabManager from 'app/tabs/tabManager'; import {localizeMessage} from 'main/i18nManager'; import createHistoryMenu from './history'; @@ -14,6 +15,10 @@ jest.mock('app/views/webContentsManager', () => ({ getFocusedView: jest.fn(), })); +jest.mock('app/tabs/tabManager', () => ({ + getCurrentActiveTabView: jest.fn(), +})); + describe('app/menus/appMenu/history', () => { const mockView = { goToOffset: jest.fn(), @@ -21,6 +26,7 @@ describe('app/menus/appMenu/history', () => { beforeEach(() => { WebContentsManager.getFocusedView.mockReturnValue(mockView); + TabManager.getCurrentActiveTabView.mockReturnValue(undefined); localizeMessage.mockImplementation((id) => { const translations = { 'main.menus.app.history': '&History', @@ -119,19 +125,39 @@ describe('app/menus/appMenu/history', () => { it('should handle back click when no focused view is available', () => { WebContentsManager.getFocusedView.mockReturnValue(null); + TabManager.getCurrentActiveTabView.mockReturnValue(mockView); const menu = createHistoryMenu(); const backItem = menu.submenu.find((item) => item.label === 'Back'); - // Should not throw an error when no view is available expect(() => backItem.click()).not.toThrow(); + expect(mockView.goToOffset).toHaveBeenCalledWith(-1); }); it('should handle forward click when no focused view is available', () => { WebContentsManager.getFocusedView.mockReturnValue(null); + TabManager.getCurrentActiveTabView.mockReturnValue(mockView); + const menu = createHistoryMenu(); + const forwardItem = menu.submenu.find((item) => item.label === 'Forward'); + + expect(() => forwardItem.click()).not.toThrow(); + expect(mockView.goToOffset).toHaveBeenCalledWith(1); + }); + + it('should handle back click when no view is available', () => { + WebContentsManager.getFocusedView.mockReturnValue(null); + TabManager.getCurrentActiveTabView.mockReturnValue(null); + const menu = createHistoryMenu(); + const backItem = menu.submenu.find((item) => item.label === 'Back'); + + expect(() => backItem.click()).not.toThrow(); + }); + + it('should handle forward click when no view is available', () => { + WebContentsManager.getFocusedView.mockReturnValue(null); + TabManager.getCurrentActiveTabView.mockReturnValue(null); const menu = createHistoryMenu(); const forwardItem = menu.submenu.find((item) => item.label === 'Forward'); - // Should not throw an error when no view is available expect(() => forwardItem.click()).not.toThrow(); }); diff --git a/src/app/menus/appMenu/history.ts b/src/app/menus/appMenu/history.ts index c35426b5f8b..73aef1475fe 100644 --- a/src/app/menus/appMenu/history.ts +++ b/src/app/menus/appMenu/history.ts @@ -1,9 +1,10 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import WebContentsManager from 'app/views/webContentsManager'; import {localizeMessage} from 'main/i18nManager'; +import {getFocusedOrActiveTabView} from './menuTargetView'; + export default function createHistoryMenu() { return { id: 'history', @@ -12,13 +13,13 @@ export default function createHistoryMenu() { label: localizeMessage('main.menus.app.history.back', 'Back'), accelerator: process.platform === 'darwin' ? 'Cmd+[' : 'Alt+Left', click: () => { - WebContentsManager.getFocusedView()?.goToOffset(-1); + getFocusedOrActiveTabView()?.goToOffset(-1); }, }, { label: localizeMessage('main.menus.app.history.forward', 'Forward'), accelerator: process.platform === 'darwin' ? 'Cmd+]' : 'Alt+Right', click: () => { - WebContentsManager.getFocusedView()?.goToOffset(1); + getFocusedOrActiveTabView()?.goToOffset(1); }, }], }; diff --git a/src/app/menus/appMenu/menuTargetView.ts b/src/app/menus/appMenu/menuTargetView.ts new file mode 100644 index 00000000000..e8e7e16bb0e --- /dev/null +++ b/src/app/menus/appMenu/menuTargetView.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import TabManager from 'app/tabs/tabManager'; +import WebContentsManager from 'app/views/webContentsManager'; + +export function getFocusedOrActiveTabView() { + return WebContentsManager.getFocusedView() ?? TabManager.getCurrentActiveTabView(); +} diff --git a/src/app/menus/appMenu/view.test.js b/src/app/menus/appMenu/view.test.js index 3bc1743849f..2e9e8d728fd 100644 --- a/src/app/menus/appMenu/view.test.js +++ b/src/app/menus/appMenu/view.test.js @@ -280,6 +280,7 @@ describe('app/menus/appMenu/view', () => { it('should handle reload when no focused view is available', () => { WebContentsManager.getFocusedView.mockReturnValue(null); + TabManager.getCurrentActiveTabView.mockReturnValue(null); localizeMessage.mockImplementation((id) => { if (id === 'main.menus.app.view.reload') { @@ -296,6 +297,40 @@ describe('app/menus/appMenu/view', () => { expect(() => reloadMenuItem.click()).not.toThrow(); }); + it('should reload active tab when menu open clears focused view', () => { + WebContentsManager.getFocusedView.mockReturnValue(null); + TabManager.getCurrentActiveTabView.mockReturnValue(mockView); + + localizeMessage.mockImplementation((id) => { + if (id === 'main.menus.app.view.reload') { + return 'Reload'; + } + return id; + }); + + const menu = createViewMenu(); + const reloadMenuItem = menu.submenu.find((item) => item.label === 'Reload'); + reloadMenuItem.click(); + expect(mockView.reload).toHaveBeenCalledWith('https://example.com/current-page'); + }); + + it('should clear cache and reload active tab when menu open clears focused view', () => { + WebContentsManager.getFocusedView.mockReturnValue(null); + TabManager.getCurrentActiveTabView.mockReturnValue(mockView); + + localizeMessage.mockImplementation((id) => { + if (id === 'main.menus.app.view.clearCacheAndReload') { + return 'Clear Cache and Reload'; + } + return id; + }); + + const menu = createViewMenu(); + const clearCacheMenuItem = menu.submenu.find((item) => item.label === 'Clear Cache and Reload'); + clearCacheMenuItem.click(); + expect(WebContentsManager.clearCacheAndReloadView).toHaveBeenCalledWith(mockView.id); + }); + it('should show developer mode options when developer mode is enabled', () => { DeveloperMode.enabled.mockReturnValue(true); DeveloperMode.get.mockImplementation((key) => { diff --git a/src/app/menus/appMenu/view.ts b/src/app/menus/appMenu/view.ts index 50aa030c24c..93a99e2358d 100644 --- a/src/app/menus/appMenu/view.ts +++ b/src/app/menus/appMenu/view.ts @@ -14,6 +14,8 @@ import DeveloperMode from 'main/developerMode'; import downloadsManager from 'main/downloadsManager'; import {localizeMessage} from 'main/i18nManager'; +import {getFocusedOrActiveTabView} from './menuTargetView'; + export default function createViewMenu() { const devToolsSubMenu: MenuItemConstructorOptions[] = [ { @@ -111,7 +113,7 @@ export default function createViewMenu() { label: localizeMessage('main.menus.app.view.reload', 'Reload'), accelerator: 'CmdOrCtrl+R', click() { - const view = WebContentsManager.getFocusedView(); + const view = getFocusedOrActiveTabView(); if (view) { view.reload(view.currentURL); } @@ -120,7 +122,7 @@ export default function createViewMenu() { label: localizeMessage('main.menus.app.view.clearCacheAndReload', 'Clear Cache and Reload'), accelerator: 'Shift+CmdOrCtrl+R', click() { - const view = WebContentsManager.getFocusedView(); + const view = getFocusedOrActiveTabView(); if (view) { WebContentsManager.clearCacheAndReloadView(view.id); } diff --git a/src/main/e2e/trayMenu.ts b/src/main/e2e/trayMenu.ts index 2b26b0c3ff5..9050e87e415 100644 --- a/src/main/e2e/trayMenu.ts +++ b/src/main/e2e/trayMenu.ts @@ -1,32 +1,112 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {Menu} from 'electron'; +import type {Menu, MenuItem} from 'electron'; + +function normalizeTrayMenuLabel(value: string): string { + return value.toLowerCase().replace(/&/g, '').replace(/[.\u2026…]+$/u, '').trim(); +} + +function trayMenuLabelMatches(itemLabel: string, label: string, truncated: string): boolean { + const normalizedItem = normalizeTrayMenuLabel(itemLabel); + const normalizedTarget = normalizeTrayMenuLabel(label); + return itemLabel === label || + itemLabel === truncated || + normalizedItem === normalizedTarget; +} + +function normalizeTrayMenuRole(role: string): string { + return role.toLowerCase(); +} + +function clickTrayMenuItemsByRole(items: MenuItem[], role: string): boolean { + const normalizedRole = normalizeTrayMenuRole(role); + for (const item of items) { + if ( + item.role && + normalizeTrayMenuRole(item.role) === normalizedRole && + item.enabled !== false && + item.visible !== false && + typeof item.click === 'function' + ) { + item.click(); + return true; + } + if (item.submenu?.items && clickTrayMenuItemsByRole(item.submenu.items, role)) { + return true; + } + } + return false; +} + +function findTrayMenuItemByLabelPredicate( + items: MenuItem[], + predicate: (normalizedLabel: string, itemLabel: string) => boolean, +): MenuItem | null { + for (const item of items) { + const itemLabel = typeof item.label === 'string' ? item.label : ''; + if ( + predicate(normalizeTrayMenuLabel(itemLabel), itemLabel) && + item.enabled !== false && + item.visible !== false && + typeof item.click === 'function' + ) { + return item; + } + if (item.submenu?.items) { + const nested = findTrayMenuItemByLabelPredicate(item.submenu.items, predicate); + if (nested) { + return nested; + } + } + } + return null; +} + +function clickTrayMenuItems(items: MenuItem[], label: string, truncated: string): boolean { + const item = findTrayMenuItemByLabelPredicate(items, (normalized, raw) => + trayMenuLabelMatches(raw, label, truncated) || normalized === normalizeTrayMenuLabel(label), + ); + if (!item) { + return false; + } + item.click(); + return true; +} + +function clickTraySettingsMenuItem(items: MenuItem[]): boolean { + const item = findTrayMenuItemByLabelPredicate(items, (normalized) => + normalized === 'settings' || + normalized === 'preferences' || + normalized.startsWith('settings') || + normalized.startsWith('preferences'), + ); + if (!item) { + return false; + } + item.click(); + return true; +} export function createClickTrayMenuItem(getTrayMenu: () => Menu) { return (label: string) => { - const truncated = label.length > 50 ? `${label.slice(0, 50)}...` : label; + if (label.startsWith('role:')) { + const role = label.slice('role:'.length); + if (!clickTrayMenuItemsByRole(getTrayMenu().items, role)) { + throw new Error(`Tray menu item with role not found: ${role}`); + } + return; + } - 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; - } + if (label === 'tray:settings') { + if (!clickTraySettingsMenuItem(getTrayMenu().items)) { + throw new Error('Tray settings menu item not found'); } - return false; + return; } - if (!clickItem(getTrayMenu().items)) { + const truncated = label.length > 50 ? `${label.slice(0, 50)}...` : label; + if (!clickTrayMenuItems(getTrayMenu().items, label, truncated)) { throw new Error(`Tray menu item not found: ${label}`); } }; diff --git a/src/main/trayMenu.test.ts b/src/main/trayMenu.test.ts new file mode 100644 index 00000000000..f8ccf6594cd --- /dev/null +++ b/src/main/trayMenu.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Menu, MenuItem} from 'electron'; + +import {createClickTrayMenuItem} from 'main/e2e/trayMenu'; + +function createMenuItem(partial: Partial & {label?: string; click?: jest.Mock}): MenuItem { + return { + enabled: true, + visible: true, + click: jest.fn(), + ...partial, + } as MenuItem; +} + +function createTrayMenu(items: MenuItem[]): Menu { + return {items} as Menu; +} + +describe('main/e2e/trayMenu', () => { + describe('createClickTrayMenuItem', () => { + it('should click a tray menu item by role', () => { + const quitClick = jest.fn(); + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([ + createMenuItem({role: 'quit', click: quitClick}), + ])); + + clickTrayMenuItem('role:quit'); + + expect(quitClick).toHaveBeenCalled(); + }); + + it('should match tray menu roles case-insensitively', () => { + const quitClick = jest.fn(); + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([ + createMenuItem({role: 'quit', click: quitClick}), + ])); + + clickTrayMenuItem('role:Quit'); + + expect(quitClick).toHaveBeenCalled(); + }); + + it('should ignore disabled or hidden role items', () => { + const disabledQuit = jest.fn(); + const enabledQuit = jest.fn(); + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([ + createMenuItem({role: 'quit', enabled: false, click: disabledQuit}), + createMenuItem({role: 'quit', visible: false, click: jest.fn()}), + createMenuItem({role: 'quit', click: enabledQuit}), + ])); + + clickTrayMenuItem('role:quit'); + + expect(disabledQuit).not.toHaveBeenCalled(); + expect(enabledQuit).toHaveBeenCalled(); + }); + + it('should throw when a tray menu role item is not found', () => { + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([])); + + expect(() => clickTrayMenuItem('role:quit')).toThrow('Tray menu item with role not found: quit'); + }); + + it('should click the tray settings menu item', () => { + const settingsClick = jest.fn(); + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([ + createMenuItem({label: 'Settings', click: settingsClick}), + ])); + + clickTrayMenuItem('tray:settings'); + + expect(settingsClick).toHaveBeenCalled(); + }); + + it('should throw when the tray settings menu item is not found', () => { + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([])); + + expect(() => clickTrayMenuItem('tray:settings')).toThrow('Tray settings menu item not found'); + }); + + it('should click a tray menu item by exact label', () => { + const showClick = jest.fn(); + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([ + createMenuItem({label: 'Show Mattermost', click: showClick}), + ])); + + clickTrayMenuItem('Show Mattermost'); + + expect(showClick).toHaveBeenCalled(); + }); + + it('should click a tray menu item by truncated label', () => { + const longLabel = 'A'.repeat(60); + const truncated = `${'A'.repeat(50)}...`; + const longLabelClick = jest.fn(); + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([ + createMenuItem({label: truncated, click: longLabelClick}), + ])); + + clickTrayMenuItem(longLabel); + + expect(longLabelClick).toHaveBeenCalled(); + }); + + it('should throw when a tray menu label item is not found', () => { + const clickTrayMenuItem = createClickTrayMenuItem(() => createTrayMenu([])); + + expect(() => clickTrayMenuItem('Missing Item')).toThrow('Tray menu item not found: Missing Item'); + }); + }); +});