From 3cd4202268b57d9477a9c913d54f2ed275bf919a Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Tue, 7 Jul 2026 14:39:27 +0530 Subject: [PATCH 01/15] e2e(ci): enable public links before Playwright and improve flaky-test reporting. Co-authored-by: Cursor --- .github/workflows/e2e-functional-template.yml | 4 + e2e/scripts/enable-public-links.mjs | 81 +++++++++++++++++++ e2e/utils/analyze-flaky-test.js | 3 + 3 files changed, 88 insertions(+) create mode 100644 e2e/scripts/enable-public-links.mjs diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index bd9a1909b3d..e9829f32393 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: node e2e/scripts/enable-public-links.mjs + - name: e2e/suppress-macos-dialogs if: runner.os == 'macOS' run: | diff --git a/e2e/scripts/enable-public-links.mjs b/e2e/scripts/enable-public-links.mjs new file mode 100644 index 00000000000..dd7aad41b37 --- /dev/null +++ b/e2e/scripts/enable-public-links.mjs @@ -0,0 +1,81 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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) { + console.error('MM_TEST_SERVER_URL, MM_TEST_USER_NAME, and MM_TEST_PASSWORD are required'); + process.exit(1); +} + +async function apiLogin() { + const response = await fetch(`${baseUrl}/api/v4/users/login`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({login_id: username, password}), + }); + if (!response.ok) { + throw new Error(`Login failed: ${response.status} ${await response.text()}`); + } + + const token = response.headers.get('Token') ?? response.headers.get('token'); + if (token) { + return token; + } + + const body = await response.json(); + if (body.token) { + return body.token; + } + + throw new Error('Login did not return a session token'); +} + +async function apiRequest(token, path, init = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...init.headers, + }, + }); + if (!response.ok) { + throw new Error(`${init.method ?? 'GET'} ${path} failed: ${response.status} ${await response.text()}`); + } + return response.json(); +} + +async function main() { + const token = await apiLogin(); + const config = await apiRequest(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; + await apiRequest(token, '/api/v4/config', { + method: 'PUT', + body: JSON.stringify({ + ...config, + ServiceSettings: {...config.ServiceSettings, SiteURL: siteURL}, + FileSettings: {...config.FileSettings, EnablePublicLink: true}, + }), + }); + + const updated = await apiRequest(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) => { + console.error(error); + process.exit(1); +}); 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'); From 57c3771d871992b41c568d9ab7e46cc97652f440 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Tue, 7 Jul 2026 15:17:50 +0530 Subject: [PATCH 02/15] fix(e2e): patch only public link settings instead of full config. Co-authored-by: Cursor --- e2e/scripts/enable-public-links.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/scripts/enable-public-links.mjs b/e2e/scripts/enable-public-links.mjs index dd7aad41b37..485abc949a4 100644 --- a/e2e/scripts/enable-public-links.mjs +++ b/e2e/scripts/enable-public-links.mjs @@ -58,13 +58,13 @@ async function main() { } const siteURL = config.ServiceSettings?.SiteURL ?? baseUrl; - await apiRequest(token, '/api/v4/config', { + const patch = { + FileSettings: {EnablePublicLink: true}, + ...(siteURL ? {ServiceSettings: {SiteURL: siteURL}} : {}), + }; + await apiRequest(token, '/api/v4/config/patch', { method: 'PUT', - body: JSON.stringify({ - ...config, - ServiceSettings: {...config.ServiceSettings, SiteURL: siteURL}, - FileSettings: {...config.FileSettings, EnablePublicLink: true}, - }), + body: JSON.stringify(patch), }); const updated = await apiRequest(token, '/api/v4/config'); From 7374cae878f217750d5e6da9a723f94a75fe7f57 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Tue, 7 Jul 2026 15:20:08 +0530 Subject: [PATCH 03/15] refactor(e2e): convert enable-public-links script to TypeScript. Co-authored-by: Cursor --- .github/workflows/e2e-functional-template.yml | 2 +- e2e/package-lock.json | 545 +++++++++++++++++- e2e/package.json | 4 +- e2e/scripts/enable-public-links.ts | 99 ++++ e2e/tsconfig.json | 2 +- 5 files changed, 648 insertions(+), 4 deletions(-) create mode 100644 e2e/scripts/enable-public-links.ts diff --git a/.github/workflows/e2e-functional-template.yml b/.github/workflows/e2e-functional-template.yml index e9829f32393..459afcbd798 100644 --- a/.github/workflows/e2e-functional-template.yml +++ b/.github/workflows/e2e-functional-template.yml @@ -289,7 +289,7 @@ jobs: - name: e2e/enable-public-links if: env.MM_TEST_SERVER_URL != '' - run: node e2e/scripts/enable-public-links.mjs + run: cd e2e && npm run enable-public-links - name: e2e/suppress-macos-dialogs if: runner.os == 'macOS' 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..0cd63725776 --- /dev/null +++ b/e2e/scripts/enable-public-links.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type ServerConfig = { + FileSettings?: { + EnablePublicLink?: boolean; + }; + ServiceSettings?: { + SiteURL?: string; + }; +}; + +type ConfigPatch = { + FileSettings: { + EnablePublicLink: boolean; + }; + ServiceSettings?: { + SiteURL: string; + }; +}; + +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) { + console.error('MM_TEST_SERVER_URL, MM_TEST_USER_NAME, and MM_TEST_PASSWORD are required'); + process.exit(1); +} + +async function apiLogin(): Promise { + const response = await fetch(`${baseUrl}/api/v4/users/login`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({login_id: username, password}), + }); + if (!response.ok) { + throw new Error(`Login failed: ${response.status} ${await response.text()}`); + } + + const token = response.headers.get('Token') ?? response.headers.get('token'); + if (token) { + return token; + } + + const body = await response.json() as {token?: string}; + if (body.token) { + return body.token; + } + + throw new Error('Login did not return a session token'); +} + +async function apiRequest(token: string, path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...init.headers, + }, + }); + if (!response.ok) { + throw new Error(`${init.method ?? 'GET'} ${path} failed: ${response.status} ${await response.text()}`); + } + return response.json() as Promise; +} + +async function main(): Promise { + const token = await apiLogin(); + const config = await apiRequest(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}, + ...(siteURL ? {ServiceSettings: {SiteURL: siteURL}} : {}), + }; + await apiRequest(token, '/api/v4/config/patch', { + method: 'PUT', + body: JSON.stringify(patch), + }); + + const updated = await apiRequest(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.exit(1); +}); 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"] } From d2cbfad1d54f78fe2665a4372a66ad8521fb1c02 Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Tue, 7 Jul 2026 15:21:32 +0530 Subject: [PATCH 04/15] chore(e2e): remove enable-public-links.mjs after TypeScript conversion. Co-authored-by: Cursor --- e2e/scripts/enable-public-links.mjs | 81 ----------------------------- 1 file changed, 81 deletions(-) delete mode 100644 e2e/scripts/enable-public-links.mjs diff --git a/e2e/scripts/enable-public-links.mjs b/e2e/scripts/enable-public-links.mjs deleted file mode 100644 index 485abc949a4..00000000000 --- a/e2e/scripts/enable-public-links.mjs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -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) { - console.error('MM_TEST_SERVER_URL, MM_TEST_USER_NAME, and MM_TEST_PASSWORD are required'); - process.exit(1); -} - -async function apiLogin() { - const response = await fetch(`${baseUrl}/api/v4/users/login`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({login_id: username, password}), - }); - if (!response.ok) { - throw new Error(`Login failed: ${response.status} ${await response.text()}`); - } - - const token = response.headers.get('Token') ?? response.headers.get('token'); - if (token) { - return token; - } - - const body = await response.json(); - if (body.token) { - return body.token; - } - - throw new Error('Login did not return a session token'); -} - -async function apiRequest(token, path, init = {}) { - const response = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - ...init.headers, - }, - }); - if (!response.ok) { - throw new Error(`${init.method ?? 'GET'} ${path} failed: ${response.status} ${await response.text()}`); - } - return response.json(); -} - -async function main() { - const token = await apiLogin(); - const config = await apiRequest(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 = { - FileSettings: {EnablePublicLink: true}, - ...(siteURL ? {ServiceSettings: {SiteURL: siteURL}} : {}), - }; - await apiRequest(token, '/api/v4/config/patch', { - method: 'PUT', - body: JSON.stringify(patch), - }); - - const updated = await apiRequest(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) => { - console.error(error); - process.exit(1); -}); From e5244b88ee2fc7e6ca651fa747957ff1df0b0efb Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Tue, 7 Jul 2026 15:26:30 +0530 Subject: [PATCH 05/15] fix(e2e): reuse shared API client and satisfy lint in enable-public-links. Co-authored-by: Cursor --- e2e/scripts/enable-public-links.ts | 68 +++++++----------------------- 1 file changed, 16 insertions(+), 52 deletions(-) diff --git a/e2e/scripts/enable-public-links.ts b/e2e/scripts/enable-public-links.ts index 0cd63725776..b2b5ca27af7 100644 --- a/e2e/scripts/enable-public-links.ts +++ b/e2e/scripts/enable-public-links.ts @@ -1,5 +1,8 @@ // 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?: { @@ -14,61 +17,22 @@ type ConfigPatch = { FileSettings: { EnablePublicLink: boolean; }; - ServiceSettings?: { + ServiceSettings: { SiteURL: string; }; }; -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) { - console.error('MM_TEST_SERVER_URL, MM_TEST_USER_NAME, and MM_TEST_PASSWORD are required'); - process.exit(1); -} - -async function apiLogin(): Promise { - const response = await fetch(`${baseUrl}/api/v4/users/login`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({login_id: username, password}), - }); - if (!response.ok) { - throw new Error(`Login failed: ${response.status} ${await response.text()}`); - } - - const token = response.headers.get('Token') ?? response.headers.get('token'); - if (token) { - return token; - } - - const body = await response.json() as {token?: string}; - if (body.token) { - return body.token; - } - - throw new Error('Login did not return a session token'); -} +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; -async function apiRequest(token: string, path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - ...init.headers, - }, - }); - if (!response.ok) { - throw new Error(`${init.method ?? 'GET'} ${path} failed: ${response.status} ${await response.text()}`); + if (!baseUrl || !username || !password) { + throw new Error('MM_TEST_SERVER_URL, MM_TEST_USER_NAME, and MM_TEST_PASSWORD are required'); } - return response.json() as Promise; -} -async function main(): Promise { - const token = await apiLogin(); - const config = await apiRequest(token, '/api/v4/config'); + 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'); @@ -78,14 +42,14 @@ async function main(): Promise { const siteURL = config.ServiceSettings?.SiteURL ?? baseUrl; const patch: ConfigPatch = { FileSettings: {EnablePublicLink: true}, - ...(siteURL ? {ServiceSettings: {SiteURL: siteURL}} : {}), + ServiceSettings: {SiteURL: siteURL}, }; - await apiRequest(token, '/api/v4/config/patch', { + await apiRequest(baseUrl, token, '/api/v4/config/patch', { method: 'PUT', body: JSON.stringify(patch), }); - const updated = await apiRequest(token, '/api/v4/config'); + 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'); } @@ -95,5 +59,5 @@ async function main(): Promise { main().catch((error: unknown) => { console.error(error); - process.exit(1); + process.exitCode = 1; }); From 73496d620e65685dd5a744abf6fd78ba66999646 Mon Sep 17 00:00:00 2001 From: yasser khan Date: Tue, 7 Jul 2026 16:05:14 +0530 Subject: [PATCH 06/15] e2e(hooks): expose tray menu actions for Playwright and fix history navigation. (#3886) --- src/app/menus/appMenu/history.test.js | 30 +++++++- src/app/menus/appMenu/history.ts | 9 ++- src/app/menus/appMenu/view.test.js | 18 +++++ src/app/menus/appMenu/view.ts | 4 +- src/main/e2e/trayMenu.ts | 107 +++++++++++++++++++++----- 5 files changed, 143 insertions(+), 25 deletions(-) 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..7aa016b8c12 100644 --- a/src/app/menus/appMenu/history.ts +++ b/src/app/menus/appMenu/history.ts @@ -1,9 +1,14 @@ // 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'; import {localizeMessage} from 'main/i18nManager'; +function getHistoryNavigationView() { + return WebContentsManager.getFocusedView() ?? TabManager.getCurrentActiveTabView(); +} + export default function createHistoryMenu() { return { id: 'history', @@ -12,13 +17,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); + getHistoryNavigationView()?.goToOffset(-1); }, }, { label: localizeMessage('main.menus.app.history.forward', 'Forward'), accelerator: process.platform === 'darwin' ? 'Cmd+]' : 'Alt+Right', click: () => { - WebContentsManager.getFocusedView()?.goToOffset(1); + getHistoryNavigationView()?.goToOffset(1); }, }], }; diff --git a/src/app/menus/appMenu/view.test.js b/src/app/menus/appMenu/view.test.js index 3bc1743849f..567ed500137 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,23 @@ 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 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..56bf73c7c27 100644 --- a/src/app/menus/appMenu/view.ts +++ b/src/app/menus/appMenu/view.ts @@ -111,7 +111,7 @@ export default function createViewMenu() { label: localizeMessage('main.menus.app.view.reload', 'Reload'), accelerator: 'CmdOrCtrl+R', click() { - const view = WebContentsManager.getFocusedView(); + const view = WebContentsManager.getFocusedView() ?? TabManager.getCurrentActiveTabView(); if (view) { view.reload(view.currentURL); } @@ -120,7 +120,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 = WebContentsManager.getFocusedView() ?? TabManager.getCurrentActiveTabView(); if (view) { WebContentsManager.clearCacheAndReloadView(view.id); } diff --git a/src/main/e2e/trayMenu.ts b/src/main/e2e/trayMenu.ts index 2b26b0c3ff5..07428a8dfb9 100644 --- a/src/main/e2e/trayMenu.ts +++ b/src/main/e2e/trayMenu.ts @@ -1,32 +1,101 @@ // 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 clickTrayMenuItemsByRole(items: MenuItem[], role: string): boolean { + for (const item of items) { + if (item.role === role && 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}`); } }; From edd38a1b7d741029ba435b8f82d55b2ebbb012fb Mon Sep 17 00:00:00 2001 From: yasserfaraazkhan Date: Tue, 7 Jul 2026 15:01:18 +0530 Subject: [PATCH 07/15] e2e(helpers): shared Playwright helpers for server views, menus, and shell. Co-authored-by: Cursor --- e2e/fixtures/index.ts | 41 +- e2e/helpers/appMetrics.ts | 71 ++++ e2e/helpers/appReadiness.ts | 60 ++- e2e/helpers/blockingOverlays.ts | 23 ++ e2e/helpers/channelMenu.ts | 65 ++- e2e/helpers/channelNavigation.ts | 138 +++++++ e2e/helpers/channelReadiness.ts | 228 +++++++++++ e2e/helpers/downloads.ts | 3 +- e2e/helpers/electronApp.ts | 6 +- e2e/helpers/helpMenuLinks.ts | 66 ++++ e2e/helpers/historyMenu.ts | 39 ++ e2e/helpers/login.ts | 5 +- e2e/helpers/loginSso.ts | 185 +++++++++ e2e/helpers/mainWindowFocus.ts | 60 +++ e2e/helpers/mattermostShell.ts | 251 +++++------- e2e/helpers/menu.ts | 38 ++ e2e/helpers/overlayWindows.ts | 27 ++ e2e/helpers/popoutWindow.ts | 252 ++++++++++++ e2e/helpers/prepareServerView.ts | 30 +- e2e/helpers/rendererUtils.ts | 161 ++++++++ e2e/helpers/serverContext.ts | 193 +++++++++ e2e/helpers/server_api/channel.ts | 4 + e2e/helpers/server_api/post.ts | 38 ++ e2e/helpers/server_api/publicLinks.ts | 51 +++ e2e/helpers/shell.ts | 33 ++ e2e/helpers/trayMenu.ts | 95 +++++ e2e/helpers/userAttributes.ts | 549 ++++++++++++++++++++++++++ e2e/helpers/webappMenu.ts | 119 ++++++ 28 files changed, 2609 insertions(+), 222 deletions(-) create mode 100644 e2e/helpers/appMetrics.ts create mode 100644 e2e/helpers/blockingOverlays.ts create mode 100644 e2e/helpers/channelNavigation.ts create mode 100644 e2e/helpers/channelReadiness.ts create mode 100644 e2e/helpers/helpMenuLinks.ts create mode 100644 e2e/helpers/historyMenu.ts create mode 100644 e2e/helpers/loginSso.ts create mode 100644 e2e/helpers/mainWindowFocus.ts create mode 100644 e2e/helpers/popoutWindow.ts create mode 100644 e2e/helpers/rendererUtils.ts create mode 100644 e2e/helpers/serverContext.ts create mode 100644 e2e/helpers/server_api/post.ts create mode 100644 e2e/helpers/server_api/publicLinks.ts create mode 100644 e2e/helpers/shell.ts create mode 100644 e2e/helpers/trayMenu.ts create mode 100644 e2e/helpers/userAttributes.ts create mode 100644 e2e/helpers/webappMenu.ts diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index 61519d536ab..9c936c01aa5 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -8,7 +8,7 @@ import {test as base, type Page} from '@playwright/test'; import type {ElectronApplication} from 'playwright'; import {_electron as electron} from 'playwright'; -import {waitForAppReady} from '../helpers/appReadiness'; +import {waitForAppReady, waitForMainWindow, waitForMainWindowChrome} from '../helpers/appReadiness'; import {electronBinaryPath, appDir, demoConfig, writeConfigFile, type AppConfig} from '../helpers/config'; import { closeElectronApp, @@ -39,9 +39,9 @@ type Fixtures = { electronApp: ElectronApplication; /** - * Side-effect fixture: waits until __e2eAppReady is true in the main process. - * Both serverMap and mainWindow depend on this. Playwright deduplicates it — - * waitForAppReady() runs exactly once even if both fixtures are requested. + * Side-effect fixture: waits until __e2eAppReady is true, then (when config + * lists servers) until the main-window server dropdown button is visible. + * Both serverMap and mainWindow depend on this. Playwright deduplicates it. */ appReady: void; @@ -139,13 +139,18 @@ export const test = base.extend({ await fs.rm(userDataDir, {recursive: true, force: true}).catch(() => {}); }, - appReady: async ({electronApp}, use) => { + appReady: async ({electronApp, appConfig}, use) => { await waitForAppReady(electronApp); // Setup path: the main process is freshly launched and responsive, so // the default 3s bound is ample for sub-100ms dropdown closes and still // fails fast if app.evaluate hangs. No larger setup timeout is needed. await closeOverlayWindowsIfOpen(electronApp); + + if (appConfig.servers.length > 0) { + await waitForMainWindowChrome(electronApp, {requireServerDropdown: true}); + } + await use(); }, @@ -157,31 +162,7 @@ export const test = base.extend({ // eslint-disable-next-line @typescript-eslint/no-unused-vars mainWindow: async ({electronApp, appReady: _appReady}, use) => { - let win: Page | undefined; - const timeoutAt = Date.now() + 30_000; - - while (Date.now() < timeoutAt) { - win = electronApp.windows().find((w) => { - try { - return w.url().includes('index'); - } catch { - return false; - } - }); - - if (win) { - break; - } - - await new Promise((resolve) => setTimeout(resolve, 200)); - } - - if (!win) { - throw new Error( - 'mainWindow fixture: no window with \'index\' in URL.\n' + - `Available: ${electronApp.windows().map((w) => w.url()).join(', ')}`, - ); - } + const win = await waitForMainWindow(electronApp); await use(win); }, }); diff --git a/e2e/helpers/appMetrics.ts b/e2e/helpers/appMetrics.ts new file mode 100644 index 00000000000..01823dbd6e9 --- /dev/null +++ b/e2e/helpers/appMetrics.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +/** Shape returned by Electron `app.getAppMetrics()` (subset used by tests). */ +export type AppProcessMetric = { + pid: number; + type: string; + name?: string; +}; + +export type AppProcessMetricsSummary = { + metrics: AppProcessMetric[]; + totalCount: number; + tabCount: number; + nonTabCount: number; + types: string[]; + pids: number[]; +}; + +/** + * Upper bounds for non-Tab processes under the sandboxed E2E launch flags in + * `e2e/fixtures/index.ts` (`--disable-gpu`, `--no-zygote`, etc.). These differ + * from a normal Task Manager baseline on an end-user install. + */ +const NON_TAB_PROCESS_MAX: Partial> = { + darwin: 10, + linux: 10, + win32: 12, +}; + +/** Tab/renderer processes for demoConfig (2 servers + main chrome). */ +const TAB_PROCESS_MAX = 30; + +export async function getAppProcessMetrics(app: ElectronApplication): Promise { + return app.evaluate(({app: electronApp}) => { + return electronApp.getAppMetrics().map((metric) => ({ + pid: metric.pid, + type: metric.type, + name: metric.name, + })); + }); +} + +export function summarizeAppProcessMetrics(metrics: AppProcessMetric[]): AppProcessMetricsSummary { + const tabMetrics = metrics.filter((metric) => metric.type === 'Tab'); + const nonTabMetrics = metrics.filter((metric) => metric.type !== 'Tab'); + + return { + metrics, + totalCount: metrics.length, + tabCount: tabMetrics.length, + nonTabCount: nonTabMetrics.length, + types: [...new Set(metrics.map((metric) => metric.type))].sort(), + pids: metrics.map((metric) => metric.pid), + }; +} + +export function getNonTabProcessMax(): number { + return NON_TAB_PROCESS_MAX[process.platform] ?? 12; +} + +export function getTabProcessMax(): number { + return TAB_PROCESS_MAX; +} + +export async function summarizeProcessMetrics(app: ElectronApplication): Promise { + const metrics = await getAppProcessMetrics(app); + return summarizeAppProcessMetrics(metrics); +} diff --git a/e2e/helpers/appReadiness.ts b/e2e/helpers/appReadiness.ts index 28cf23c0258..d28b8edd646 100644 --- a/e2e/helpers/appReadiness.ts +++ b/e2e/helpers/appReadiness.ts @@ -2,7 +2,65 @@ // See LICENSE.txt for license information. import {expect} from '@playwright/test'; -import type {ElectronApplication} from 'playwright'; +import type {ElectronApplication, Page} from 'playwright'; + +const MAIN_WINDOW_POLL_MS = 200; + +function findMainWindow(app: ElectronApplication): Page | undefined { + return app.windows().find((window) => { + try { + return window.url().includes('index'); + } catch { + return false; + } + }); +} + +/** Resolve the internal main window (index.html wrapper). */ +export async function waitForMainWindow( + app: ElectronApplication, + options?: {timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 30_000; + let mainWindow: Page | undefined; + + await expect.poll(async () => { + mainWindow = findMainWindow(app); + return mainWindow; + }, { + timeout, + intervals: [MAIN_WINDOW_POLL_MS, 500, 1000], + message: 'Main window (index.html) must appear', + }).not.toBeUndefined(); + + if (!mainWindow) { + throw new Error( + 'Main window was not available.\n' + + `Available: ${app.windows().map((window) => window.url()).join(', ')}`, + ); + } + + return mainWindow; +} + +/** + * Wait until main-window chrome needed for server management is rendered. + * Used when config already lists servers — catches broken wrapper UI that + * __e2eAppReady alone would miss. + */ +export async function waitForMainWindowChrome( + app: ElectronApplication, + options?: {requireServerDropdown?: boolean; timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 30_000; + const mainWindow = await waitForMainWindow(app, {timeout}); + + if (options?.requireServerDropdown) { + await mainWindow.waitForSelector('.ServerDropdownButton', {timeout}); + } + + return mainWindow; +} export async function waitForAppReady(app: ElectronApplication): Promise { const timeout = process.platform === 'linux' ? 30_000 : 60_000; diff --git a/e2e/helpers/blockingOverlays.ts b/e2e/helpers/blockingOverlays.ts new file mode 100644 index 00000000000..63e3d3061d4 --- /dev/null +++ b/e2e/helpers/blockingOverlays.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {activateServerView} from './serverContext'; +import {closeDownloadsDropdownIfOpen} from './downloadsDropdown'; +import {closeOverlayWindowsIfOpen} from './overlayWindows'; +import type {ServerView} from './serverView'; + +/** Close desktop overlays that steal focus from server views (dropdowns, modals). */ +export async function dismissBlockingOverlays(win: ServerView): Promise { + await closeDownloadsDropdownIfOpen(win.app); + await closeOverlayWindowsIfOpen(win.app); + await activateServerView(win.app, win.webContentsId); + const mainWindow = win.app.windows().find((window) => { + try { + return window.url().includes('index'); + } catch { + return false; + } + }); + await mainWindow?.keyboard.press('Escape').catch(() => undefined); + await win.keyboard.press('Escape').catch(() => undefined); +} diff --git a/e2e/helpers/channelMenu.ts b/e2e/helpers/channelMenu.ts index 4f89a06e2f9..df5e367464c 100644 --- a/e2e/helpers/channelMenu.ts +++ b/e2e/helpers/channelMenu.ts @@ -28,10 +28,12 @@ export const COPY_LINK_SELECTORS = [ * The webapp migrated from #channelHeaderDropdownButton to Menu.Button * with an aria-label like "off-topic channel menu". */ -export async function openChannelHeaderMenu(win: ServerView): Promise { - await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: 15_000}); +export async function openChannelHeaderMenu(win: ServerView, timeout = 20_000): Promise { + const menuTimeout = Math.min(Math.max(Math.floor(timeout * 0.25), 500), 5_000); + const triggerTimeout = Math.max(timeout - menuTimeout, 500); + await win.waitForSelector(CHANNEL_HEADER_MENU_TRIGGER, {state: 'visible', timeout: triggerTimeout}); await win.click(CHANNEL_HEADER_MENU_TRIGGER); - await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: 5_000}); + await win.waitForSelector('#channelHeaderDropdownMenu, .a11y__popup', {timeout: menuTimeout}); } const SIDEBAR_CHANNEL_MENU_BUTTON = (channelItemSelector: string) => [ @@ -117,7 +119,7 @@ export async function clickCopyLinkInMenu(win: ServerView): Promise { * helper only toggles the preference — callers wait for bookmark items later. */ export async function enableBookmarksBar(win: ServerView): Promise { - const alreadyVisible = await win.runInRenderer(` + const isBookmarksBarVisible = async (): Promise => win.runInRenderer(` const container = document.querySelector('[data-testid="channel-bookmarks-container"]'); if (!container) { return false; @@ -125,26 +127,51 @@ export async function enableBookmarksBar(win: ServerView): Promise { const rect = container.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; `); - if (alreadyVisible) { + + if (await isBookmarksBarVisible()) { return; } - await openChannelHeaderMenu(win); - const toggled = await win.runInRenderer(` - const items = Array.from(document.querySelectorAll( - '[role="menuitem"], .MenuItem, [id^="channel-menu-"]', - )); - const barItem = items.find((item) => /bookmarks bar/i.test((item.textContent || '').trim())); - if (!barItem) { - return false; + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const remaining = Math.max(deadline - Date.now(), 1_000); + await openChannelHeaderMenu(win, remaining); + + const toggleResult = await win.runInRenderer(` + const submenuTrigger = document.querySelector('[id^="channel-menu-"][id$="-bookmarks"]'); + if (submenuTrigger instanceof HTMLElement) { + submenuTrigger.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true})); + submenuTrigger.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); + } + + const items = Array.from(document.querySelectorAll( + '[role="menuitem"], .MenuItem, [id^="channel-menu-"]', + )); + const barItem = items.find((item) => { + const text = (item.textContent || '').trim(); + return /bookmarks bar/i.test(text) && !/add a link|add bookmark|attach file/i.test(text); + }); + if (!barItem) { + // Modern webapp: bookmarks live under the Bookmarks submenu and the bar + // autoshows once a bookmark exists — no separate Show/Hide toggle. + return submenuTrigger ? 'submenu-only' : 'missing'; + } + const label = (barItem.textContent || '').trim().toLowerCase(); + const checked = barItem.getAttribute('aria-checked'); + if (label.includes('hide') || checked === 'true') { + return 'enabled'; + } + barItem.click(); + return 'clicked'; + `, true); + await win.keyboard.press('Escape').catch(() => undefined); + if (toggleResult === 'enabled' || toggleResult === 'clicked' || toggleResult === 'submenu-only') { + return; } - barItem.click(); - return true; - `, true); - if (!toggled) { - throw new Error('Bookmarks Bar menu item not found in channel header menu'); + await new Promise((resolve) => setTimeout(resolve, 300)); } - await win.keyboard.press('Escape'); + + throw new Error('Bookmarks Bar menu item not found in channel header menu'); } export const TEAM_SIDEBAR_BUTTON = [ diff --git a/e2e/helpers/channelNavigation.ts b/e2e/helpers/channelNavigation.ts new file mode 100644 index 00000000000..05c0cac2db5 --- /dev/null +++ b/e2e/helpers/channelNavigation.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {popoutWindowCount} from './popoutWindow'; +import {resolvedChannelPath, resolveChannelByName} from './server_api/channel'; +import type {ServerView} from './serverView'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function channelPathname(win: ServerView): Promise { + return win.evaluate(() => window.location.pathname); +} + +export async function navigateViewToChannel(win: ServerView, channelName: string): Promise { + const channel = await resolveChannelByName(channelName); + const targetPath = resolvedChannelPath(channel); + const channelSlug = targetPath.split('/').pop() ?? channelName; + + await expect.poll(async () => { + return win.evaluate(({path, slug}) => { + if (window.location.pathname.includes(slug) && + !document.body.textContent?.includes('Team Not Found')) { + return true; + } + + if (document.body.textContent?.includes('Team Not Found')) { + window.location.assign(path); + return false; + } + + const link = Array.from(document.querySelectorAll('a[href*="/channels/"]')). + find((anchor) => (anchor.getAttribute('href') ?? '').includes(slug)); + if (link instanceof HTMLAnchorElement) { + link.click(); + } else { + window.location.assign(path); + } + + return window.location.pathname.includes(slug); + }, {path: targetPath, slug: channelSlug}); + }, {timeout: 30_000, message: `Server view must navigate to ${channelName}`}).toBe(true); +} + +export async function modifierClickSidebarChannel( + app: ElectronApplication, + win: ServerView, + channelSelector: string, +): Promise { + await win.waitForSelector(channelSelector, {timeout: 15_000}); + const baseline = popoutWindowCount(app); + const useMetaKey = process.platform === 'darwin'; + + await win.runInRenderer(` + const el = document.querySelector(${JSON.stringify(channelSelector)}); + if (!el) { + return false; + } + el.scrollIntoView({block: 'center', inline: 'center'}); + const target = el.closest('a') ?? el; + const eventInit = { + bubbles: true, + cancelable: true, + metaKey: ${useMetaKey}, + ctrlKey: ${!useMetaKey}, + button: 0, + }; + target.dispatchEvent(new MouseEvent('mousedown', eventInit)); + target.dispatchEvent(new MouseEvent('mouseup', eventInit)); + target.dispatchEvent(new MouseEvent('click', eventInit)); + return true; + `, true); + + await sleep(750); + if (popoutWindowCount(app) > baseline) { + return; + } + + const point = await win.runInRenderer<{x: number; y: number} | null>(` + const el = document.querySelector(${JSON.stringify(channelSelector)}); + if (!el) { + return null; + } + const rect = el.getBoundingClientRect(); + return { + x: Math.round(rect.left + (rect.width / 2)), + y: Math.round(rect.top + (rect.height / 2)), + }; + `, true); + expect(point, `Channel sidebar item must exist: ${channelSelector}`).toBeTruthy(); + + const modifier: 'meta' | 'control' = useMetaKey ? 'meta' : 'control'; + await app.evaluate(({webContents}, payload: {id: number; x: number; y: number; modifier: 'meta' | 'control'}) => { + const wc = webContents.fromId(payload.id); + if (!wc || wc.isDestroyed()) { + throw new Error(`webContents ${payload.id} is not available`); + } + wc.focus(); + wc.sendInputEvent({type: 'mouseMove', x: payload.x, y: payload.y}); + wc.sendInputEvent({ + type: 'mouseDown', + x: payload.x, + y: payload.y, + button: 'left', + clickCount: 1, + modifiers: [payload.modifier], + }); + wc.sendInputEvent({ + type: 'mouseUp', + x: payload.x, + y: payload.y, + button: 'left', + clickCount: 1, + modifiers: [payload.modifier], + }); + }, {id: win.webContentsId, ...point!, modifier}); + + await sleep(750); + if (popoutWindowCount(app) > baseline) { + return; + } + + const triggered = await win.runInRenderer(` + const el = document.querySelector(${JSON.stringify(channelSelector)}); + const href = (el?.closest('a') ?? el)?.getAttribute('href'); + const api = window.desktopAPI; + if (!href || !api?.openPopout) { + return false; + } + void api.openPopout(href, {}); + return true; + `, true); + expect(triggered, 'Modifier-click must open a popout via webapp or desktopAPI.openPopout').toBe(true); +} diff --git a/e2e/helpers/channelReadiness.ts b/e2e/helpers/channelReadiness.ts new file mode 100644 index 00000000000..6f9b0e7449f --- /dev/null +++ b/e2e/helpers/channelReadiness.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; +import type {ElectronApplication} from 'playwright'; + +import {dismissBlockingOverlays} from './blockingOverlays'; +import { + channelItemSelector, + HAS_CLIENT_JS_ERROR_JS, + IS_CHANNEL_POST_LIST_LOADED_JS, + IS_CHANNEL_VIEW_LOADED_JS, + IS_COMPOSER_INTERACTIVE_JS, +} from './rendererUtils'; +import {activateServerView, loadServerViewUrl} from './serverContext'; +import {resolveChannelByName} from './server_api/channel'; +import type {ServerEntry} from './serverMap'; +import type {ServerView} from './serverView'; + +export type PrepareInteractiveChannelOptions = { + channelName?: string; + channelItem?: string; + timeout?: number; + recover?: boolean; +}; + +function resolveChannelItem(options?: {channelName?: string; channelItem?: string}): string { + if (options?.channelItem) { + return options.channelItem; + } + if (options?.channelName) { + return channelItemSelector(options.channelName); + } + return channelItemSelector('town-square'); +} + +function resolveChannelName(channelItem: string, explicitName?: string): string { + if (explicitName) { + return explicitName; + } + const match = channelItem.match(/#sidebarItem_(.+)$/); + if (!match) { + throw new Error('channelName is required when channelItem is not a #sidebarItem_ selector'); + } + return match[1]; +} + +export async function isComposerInteractive(win: ServerView): Promise { + return win.runInRenderer(IS_COMPOSER_INTERACTIVE_JS).catch(() => false); +} + +export async function isChannelViewLoaded(win: ServerView): Promise { + return win.runInRenderer(IS_CHANNEL_VIEW_LOADED_JS).catch(() => false); +} + +/** @deprecated Use isChannelViewLoaded — kept for existing imports. */ +export async function isChannelPostListLoaded(win: ServerView): Promise { + return win.runInRenderer(IS_CHANNEL_POST_LIST_LOADED_JS).catch(() => false); +} + +export async function isOnChannelUrl(win: ServerView, channelName: string): Promise { + return win.runInRenderer(` + return window.location.pathname.includes('/channels/${channelName}'); + `).catch(() => false); +} + +/** Wait until the Mattermost sidebar exposes the target channel item. */ +export async function waitForMattermostShell( + win: ServerView, + options?: {channelItem?: string; channelName?: string; timeout?: number}, +): Promise { + const channelItem = resolveChannelItem(options); + const timeout = options?.timeout ?? 60_000; + + await expect.poll(async () => { + try { + await win.waitForSelector(channelItem, {timeout: 2_000}); + return true; + } catch { + return false; + } + }, {timeout, message: `Mattermost shell must expose ${channelItem}`}).toBe(true); +} + +async function loadChannelByName(win: ServerView, channelName: string): Promise { + const channel = await resolveChannelByName(channelName); + await loadServerViewUrl(win.app, win.webContentsId, channel.url); + await activateServerView(win.app, win.webContentsId); +} + +/** + * Recover a stuck server view: main-process navigation, sidebar click, then reload. + */ +export async function recoverInteractiveChannel( + win: ServerView, + options?: {channelItem?: string; channelName?: string; timeout?: number}, +): Promise { + if (await isChannelViewLoaded(win)) { + return; + } + + const channelItem = resolveChannelItem(options); + const channelName = resolveChannelName(channelItem, options?.channelName); + const shellTimeout = Math.max(Math.floor((options?.timeout ?? 60_000) / 2), 2_000); + + await loadChannelByName(win, channelName); + await waitForMattermostShell(win, {channelItem, channelName, timeout: shellTimeout}); + + if (await isChannelViewLoaded(win)) { + return; + } + + await win.click(channelItem).catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (await isChannelViewLoaded(win)) { + return; + } + + await loadChannelByName(win, channelName); + await waitForMattermostShell(win, {channelItem, channelName, timeout: shellTimeout}); +} + +/** Reload when the channel shell failed to mount (blank hex background). */ +export async function recoverServerViewIfNeeded( + win: ServerView, + options?: {channelItem?: string; channelName?: string}, +): Promise { + const channelItem = resolveChannelItem(options); + const healthy = await win.runInRenderer(` + return Boolean( + document.querySelector('#channelHeaderTitle') + && document.querySelector(${JSON.stringify(channelItem)}), + ); + `).catch(() => false); + + if (healthy) { + return; + } + + await win.runInRenderer('window.location.reload(); return true;', true); + await waitForMattermostShell(win, {channelItem}); +} + +/** @deprecated Use recoverServerViewIfNeeded or recoverInteractiveChannel. */ +export const recoverChannelViewIfNeeded = recoverServerViewIfNeeded; + +export async function waitForInteractiveChannel( + win: ServerView, + options?: PrepareInteractiveChannelOptions, +): Promise { + const timeout = options?.timeout ?? 30_000; + const recover = options?.recover ?? true; + const pollStart = Date.now(); + const recoveryAt = pollStart + Math.min(timeout / 2, 10_000); + let recovered = false; + + await expect.poll(async () => { + if (recover && !recovered && Date.now() >= recoveryAt) { + recovered = true; + const remaining = Math.max(timeout - (Date.now() - pollStart), 2_000); + await recoverInteractiveChannel(win, {...options, timeout: remaining}); + } + return isChannelViewLoaded(win); + }, {timeout, message: 'Channel must become interactive'}).toBe(true); +} + +/** + * Activate the server view, dismiss overlays, optionally navigate, and wait until + * the channel composer is interactive. + */ +export async function prepareInteractiveChannel( + app: ElectronApplication, + entry: Pick, + options?: PrepareInteractiveChannelOptions, +): Promise { + const channelItem = resolveChannelItem(options); + const channelName = resolveChannelName(channelItem, options?.channelName); + const recover = options?.recover ?? true; + const timeout = options?.timeout ?? 30_000; + + await activateServerView(app, entry.webContentsId); + await dismissBlockingOverlays(entry.win); + + const needsNavigation = options?.channelName && !(await isOnChannelUrl(entry.win, channelName)); + const hasJsError = await entry.win.runInRenderer(`return (${HAS_CLIENT_JS_ERROR_JS});`).catch(() => false); + + if (needsNavigation || (recover && hasJsError)) { + await loadChannelByName(entry.win, channelName); + await activateServerView(app, entry.webContentsId); + await dismissBlockingOverlays(entry.win); + } + + if (recover && !(await isChannelViewLoaded(entry.win))) { + await recoverInteractiveChannel(entry.win, {channelItem, channelName}); + } + + await waitForInteractiveChannel(entry.win, {channelItem, channelName, timeout, recover}); +} + +/** @deprecated Use prepareInteractiveChannel or waitForInteractiveChannel. */ +export async function ensureChannelReady( + win: ServerView, + options?: {channelItem?: string; channelName?: string}, +): Promise { + await prepareInteractiveChannel(win.app, {win, webContentsId: win.webContentsId}, options); +} + +/** Wait for sidebar shell, then recover with a reload if the channel content failed to render. */ +export async function waitForMattermostShellReady( + win: ServerView, + options?: {channelItem?: string; channelName?: string; timeout?: number}, +): Promise { + await waitForMattermostShell(win, options); + await recoverServerViewIfNeeded(win, options); +} + +/** Wait until the channel post list finishes its initial load. */ +export async function waitForChannelPostListLoaded( + win: ServerView, + options?: {timeout?: number}, +): Promise { + const timeout = options?.timeout ?? 15_000; + await expect.poll( + async () => isChannelPostListLoaded(win), + {timeout, message: 'Channel post list must finish loading'}, + ).toBe(true); +} diff --git a/e2e/helpers/downloads.ts b/e2e/helpers/downloads.ts index e6d4b5a6415..ea33d8e97ca 100644 --- a/e2e/helpers/downloads.ts +++ b/e2e/helpers/downloads.ts @@ -11,7 +11,7 @@ import type {ElectronApplication} from 'playwright'; import {waitForAppReady} from './appReadiness'; import {electronBinaryPath, appDir, emptyConfig} from './config'; -import {closeElectronAppFast} from './electronApp'; +import {closeElectronAppFast, registerElectronMainProcess} from './electronApp'; export type DownloadServer = { server: http.Server; @@ -138,6 +138,7 @@ export async function launchAppWithDownloadsDir(userDataDir: string, downloadLoc env: {...process.env, NODE_ENV: 'test'}, timeout: 60_000, }); + registerElectronMainProcess(app.process()?.pid); await waitForAppReady(app); return app; } diff --git a/e2e/helpers/electronApp.ts b/e2e/helpers/electronApp.ts index 3d9ac9f22b5..c3d7068fc70 100644 --- a/e2e/helpers/electronApp.ts +++ b/e2e/helpers/electronApp.ts @@ -169,7 +169,11 @@ export async function cleanupAllRegisteredElectronProcesses(): Promise { */ export function clearAllRegistryFiles(): void { for (const file of listRegistryFiles()) { - fs.rmSync(file, {force: true}); + try { + fs.rmSync(file, {force: true, recursive: true}); + } catch { + // best-effort stale shard cleanup + } } } diff --git a/e2e/helpers/helpMenuLinks.ts b/e2e/helpers/helpMenuLinks.ts new file mode 100644 index 00000000000..4c9b802700a --- /dev/null +++ b/e2e/helpers/helpMenuLinks.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +export type HelpMenuRemoteInfoPatch = { + helpLink?: string; + reportProblemLink?: string; + serverVersion?: string; +}; + +export async function patchHelpMenuRemoteInfo( + app: ElectronApplication, + patch: HelpMenuRemoteInfoPatch, +): Promise { + await app.evaluate(({ipcMain}, payload) => { + const refs = (global as any).__e2eTestRefs; + const serverId = refs?.ServerManager?.getCurrentServerId?.(); + if (!serverId) { + throw new Error('No current server is registered'); + } + + const existing = refs.ServerManager.getRemoteInfo(serverId) ?? {}; + const nextRemoteInfo = { + ...existing, + serverVersion: payload.serverVersion ?? existing.serverVersion ?? '10.0.0', + }; + if (payload.helpLink !== undefined) { + nextRemoteInfo.helpLink = payload.helpLink; + } + if (payload.reportProblemLink !== undefined) { + nextRemoteInfo.reportProblemLink = payload.reportProblemLink; + } + refs.ServerManager.updateRemoteInfo(serverId, nextRemoteInfo); + + const configData = refs.Config?.data ?? refs.Config?.combinedData; + if (!configData) { + throw new Error('Config.data is not available for menu refresh'); + } + + ipcMain.emit('emit-configuration', null, configData); + }, patch); +} + +export async function getHelpSubmenuLabels(app: ElectronApplication): Promise { + return app.evaluate(({app: electronApp}) => { + const helpMenu = electronApp.applicationMenu?.getMenuItemById('help'); + const labels: string[] = []; + const stack = [...(helpMenu?.submenu?.items ?? [])]; + + while (stack.length) { + const item = stack.shift(); + if (!item) { + continue; + } + if (typeof item.label === 'string') { + labels.push(item.label.trim()); + } + if (item.submenu?.items?.length) { + stack.push(...item.submenu.items); + } + } + + return labels; + }); +} diff --git a/e2e/helpers/historyMenu.ts b/e2e/helpers/historyMenu.ts new file mode 100644 index 00000000000..6f17cb71a76 --- /dev/null +++ b/e2e/helpers/historyMenu.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ElectronApplication} from 'playwright'; + +import {activateServerView} from './serverContext'; + +export async function clickHistoryMenuItem( + app: ElectronApplication, + label: 'Back' | 'Forward', + webContentsId: number, +): Promise { + await activateServerView(app, webContentsId); + + const clicked = await app.evaluate(({Menu}, itemLabel) => { + const root = Menu.getApplicationMenu(); + if (!root) { + return false; + } + const stack = [...root.items]; + while (stack.length) { + const item = stack.shift()!; + if (item.label === '&History' || item.label === 'History') { + const target = item.submenu?.items?.find((sub) => sub.label === itemLabel); + if (target) { + target.click(); + return true; + } + } + if (item.submenu) { + stack.push(...item.submenu.items); + } + } + return false; + }, label); + if (!clicked) { + throw new Error(`History menu item not found: ${label}`); + } +} diff --git a/e2e/helpers/login.ts b/e2e/helpers/login.ts index 2ad13f32441..5ce050e66c7 100644 --- a/e2e/helpers/login.ts +++ b/e2e/helpers/login.ts @@ -4,6 +4,7 @@ import {expect} from '@playwright/test'; import {isTransientEvaluateError} from './testRefs'; +import {CHANNEL_HEADER_SELECTORS, POST_TEXTBOX_SELECTOR} from './rendererUtils'; import type {ServerView} from './serverView'; async function isMattermostServerUrl(win: ServerView): Promise { @@ -29,8 +30,8 @@ async function hasAppShell(win: ServerView): Promise { return runRendererProbe(win, ` return Boolean( - document.querySelector('#post_textbox') - || document.querySelector('#channelHeaderTitle') + document.querySelector(${JSON.stringify(POST_TEXTBOX_SELECTOR)}) + || document.querySelector(${JSON.stringify(CHANNEL_HEADER_SELECTORS)}) || document.querySelector('input.search-bar.form-control'), ); `); diff --git a/e2e/helpers/loginSso.ts b/e2e/helpers/loginSso.ts new file mode 100644 index 00000000000..68091e7634c --- /dev/null +++ b/e2e/helpers/loginSso.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect} from '@playwright/test'; + +import type {ServerView} from './serverView'; + +const MOCK_IDP_TITLE_SELECTOR = '#mock-idp-title'; + +export async function waitForLoginForm(serverWin: ServerView): Promise { + await serverWin.waitForSelector('#input_loginId', {timeout: 60_000}); +} + +/** + * Patch client config fetch so the login page renders a real OpenID external-login + * button with the desktop handleExternalAuth onClick handler (no injected DOM). + */ +export async function enableOpenIdOnLoginPage(serverWin: ServerView): Promise { + const installFetchPatch = async () => { + await serverWin.evaluate(() => { + const originalFetch = window.fetch.bind(window); + (window as any).__e2eOriginalFetch = originalFetch; + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await originalFetch(input, init); + let url: string; + if (typeof input === 'string') { + url = input; + } else if (input instanceof URL) { + url = input.href; + } else { + url = input.url; + } + if (!url.includes('/api/v4/config/client')) { + return response; + } + + const json = await response.clone().json(); + return new Response(JSON.stringify({ + ...json, + EnableSignUpWithOpenId: 'true', + OpenIdButtonText: 'Open ID', + }), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + }; + }); + }; + + await installFetchPatch(); + await serverWin.evaluate(() => { + window.location.reload(); + }); + await installFetchPatch(); + await waitForLoginForm(serverWin); + + try { + await expect.poll( + () => serverWin.locator('#openid.external-login-button, #openid').count(), + {timeout: 30_000}, + ).toBeGreaterThan(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ((/timeout/i).test(message)) { + return false; + } + throw error; + } + + return true; +} + +export async function restoreLoginPageFetch(serverWin: ServerView): Promise { + await serverWin.evaluate(() => { + const original = (window as any).__e2eOriginalFetch; + if (original) { + window.fetch = original; + } + delete (window as any).__e2eOriginalFetch; + }); +} + +export async function waitForOpenIdLoginButton(serverWin: ServerView): Promise { + await serverWin.waitForSelector('#openid.external-login-button', {timeout: 30_000}); +} + +/** Desktop login header back control shown during /login/desktop (HeaderFooterRoute BackButton). */ +export async function clickLoginHeaderBack(serverWin: ServerView): Promise { + const backButton = serverWin.locator('[data-testid="back_button"]'); + await expect.poll( + () => backButton.isVisible(), + {timeout: 10_000, message: 'Login header Back must be visible during desktop SSO'}, + ).toBe(true); + await backButton.click(); +} + +/** + * Desktop global-header history control (visible only when logged in). + * MM-T2633 login SSO uses clickLoginHeaderBack instead; this is used when the + * webapp shell is still mounted after in-window navigation. + */ +export async function clickWebappHistoryBackIfVisible(serverWin: ServerView): Promise { + const back = serverWin.locator('[aria-label="Back"]'); + if ((await back.count()) === 0) { + return false; + } + await back.nth(0).click(); + return true; +} + +export type WindowOpenStubMode = 'noop' | 'mock-idp'; + +export async function installWindowOpenStub(serverWin: ServerView, mode: WindowOpenStubMode): Promise { + await serverWin.evaluate((stubMode) => { + (window as any).__e2eOriginalWindowOpen = window.open.bind(window); + window.open = () => { + if (stubMode === 'noop') { + return null; + } + + const mockHtml = 'Mock SSO' + + '

Mock SSO Provider

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