first commit bois
Some checks failed
release / begin-notification (push) Has been cancelled
release / build-linux (push) Has been cancelled
release / build-msi-installer (push) Has been cancelled
release / build-mac-installer (push) Has been cancelled
release / upload-to-s3 (push) Has been cancelled
release / github-release (push) Has been cancelled
release / end-notification (push) Has been cancelled

This commit is contained in:
aaron 2024-10-03 06:35:14 -07:00
commit 057377207a
741 changed files with 433214 additions and 0 deletions

View file

@ -0,0 +1,34 @@
# Notice.txt File Configuration
We are automatically generating Notice.txt by using first-level dependencies of the project. The related pipeline uses `config.yaml` stored in this folder.
## Configuration
Sample:
```
title: "Mattermost Desktop"
copyright: "© 2016-2017 Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information."
description: "This document includes a list of open source components used in Mattermost Desktop, including those that have been modified."
reviewers:
- mattermost/release-managers
- mattermost/team-desktop
search:
- "package.json"
dependencies:
- "wix"
devDependencies:
- "webpack"
```
| Field | Type | Purpose |
| :-- | :-- | :-- |
| title | string | Field content will be used as a title of the application. See first line of `NOTICE.txt` file. |
| copyright | string | Field content will be used as a copyright message. See second line of `NOTICE.txt` file. |
| description | string | Field content will be used as notice file description. See third line of `NOTICE.txt` file. |
| reviewers | array of GitHub user/teams | Those will be automatically assigned to the PRs as reviewers. |
| dependencies | array | If any dependency name mentioned, it will be automatically added even if it is not a first-level dependency. |
| devDependencies | array | If any dependency name mentioned, it will be added when it is referenced in devDependency section. |
| search | array | Pipeline will search for package.json files mentioned here. Globstar format is supported ie. `packages/**/package.json`. |

View file

@ -0,0 +1,14 @@
---
title: "Mattermost Desktop"
copyright: "© 2016-2017 Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information."
description: "This document includes a list of open source components used in Mattermost Desktop, including those that have been modified."
reviewers:
- mattermost/release-managers
- mattermost/team-desktop
search:
- "package.json"
additionalDependencies:
- wix
includeDevDependencies: true
...

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*.{js|ts}]
end_of_line = lf
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true

5
.eslintignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
api-types/lib
e2e/mochawesome-report
e2e/dist

174
.eslintrc.json Normal file
View file

@ -0,0 +1,174 @@
{
"root": true,
"extends": [
"plugin:@mattermost/react"
],
"plugins": [
"formatjs",
"no-only-tests"
],
"settings": {
"import/resolver": {
"webpack": {
"config": "webpack.config.base.js"
}
}
},
"rules": {
"header/header": [
2,
"line",
[
" Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.",
" See LICENSE.txt for license information."
]
],
"import/no-commonjs": 2,
"no-process-env": 0,
"no-var": 2,
"react/no-find-dom-node": 2,
"no-underscore-dangle": ["warn"],
"@mattermost/use-external-link": 0,
"linebreak-style": 0,
"import/order": [
2,
{
"newlines-between": "always",
"groups": [
"builtin",
"external",
"internal",
"sibling",
"parent",
"index"
],
"pathGroups": [
{
"pattern": "@mattermost/**",
"group": "external",
"position": "after"
},
{
"pattern": "@(app|common|main|renderer){,/**}",
"group": "internal",
"position": "before"
},
{
"pattern": "types{,/**}",
"group": "internal",
"position": "after"
}
],
"alphabetize": {
"order": "asc",
"caseInsensitive": true
},
"distinctGroup": true,
"pathGroupsExcludedImportTypes": ["builtin"]
}
]
},
"overrides": [
{
"files": [
"scripts/**/*",
"src/main/preload/**/*",
"src/renderer/**/*"
],
"rules": {
"no-console": 0
}
},
{
"files": [
"scripts/**/*",
"babel.config.js",
"webpack.config.*.js"
],
"rules": {
"import/no-commonjs": 0
}
},
{
"files": [
"e2e/**/*",
"src/**/*.test.js"
],
"env": {
"jest": true
},
"rules": {
"no-unused-expressions": 0, //TODO: rework tests to use correct notation
"func-names": 0,
"global-require": 0,
"new-cap": 0,
"prefer-arrow-callback": 0,
"no-import-assign": 0,
"no-only-tests/no-only-tests": 1,
"import/no-commonjs": 0
}
},
{
"files": ["e2e/**/*"],
"settings": {
"import/resolver": {
"webpack": {
"config": "webpack.config.js"
}
}
}
},
{
"files": [
"webpack.config.renderer.js",
"e2e/specs/startup/app.test.js",
"e2e/specs/settings.test.js",
"e2e/modules/utils.js",
"e2e/modules/environment.js",
"CHANGELOG.md",
"webpack.config.base.js",
"./babel.config.js",
"README.md",
"scripts/check_build_config.js",
"LICENSE.txt",
"src/main/contextMenu.ts",
"src/main/badge.ts",
"src/common/config/index.ts",
"src/common/config/buildConfig.ts",
"src/common/config/pastDefaultPreferences.ts",
"src/common/config/upgradePreferences.ts",
"src/common/config/RegistryConfig.ts",
"src/common/config/defaultPreferences.ts",
"src/common/JsonFileManager.ts",
"src/main/certificateStore.ts",
"src/main/allowProtocolDialog.ts",
"src/main/AutoLauncher.ts",
"src/main/menus/tray.ts",
"src/main/CriticalErrorHandler.ts",
"src/main/utils.ts",
"src/main/menus/app.ts",
"src/renderer/components/RemoveServerModal.tsx",
"src/renderer/components/MainPage.tsx",
"src/renderer/components/AutoSaveIndicator.tsx",
"src/renderer/components/TabBar.tsx",
"src/renderer/components/DestructiveConfirmModal.tsx",
"src/renderer/components/ErrorView.tsx",
"src/renderer/components/SettingsPage.tsx",
"src/renderer/components/NewServerModal.tsx",
"src/renderer/settings.tsx",
"src/renderer/index.tsx"
],
"rules": {
"header/header": [
2,
"line",
[
" Copyright (c) 2015-2016 Yuya Ochiai",
" Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.",
" See LICENSE.txt for license information."
]
]
}
}
]
}

85
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,85 @@
name: Bug report
description: Create a report about an issue you found in the Mattermost Desktop App
title: "[Bug]: "
labels: "Type/Bug"
body:
- type: markdown
attributes:
value: |
## STOP! Before you file a bug report
Check that the issue is truly a Desktop App issue and not an issue with our Web App. The Desktop App makes use of the Mattermost Web App to render Mattermost. This repository is for issues with the Desktop App wrapper.
You can do this by going to your web browser (preferably Chrome) and attempting to reproduce the issue there.
If it does, the issue should be reported to the [main Mattermost repository](https://github.com/mattermost/mattermost/issues).
- type: checkboxes
attributes:
label: Checks before filing an issue
description: Please ensure you can confirm the following
options:
- label: This issue doesn't reproduce on web browsers (such as in Chrome). If it does, [issue reports go to the Mattermost Server repository](https://github.com/mattermost/mattermost-server/issues).
required: true
- label: I have checked the [issue tracker](https://github.com/mattermost/desktop/issues) and have not found an issue that matches the one I'm filing.
required: true
- label: "This issue is not a troubleshooting question. Troubleshooting questions go here: https://forum.mattermost.com/c/trouble-shoot/16."
required: true
- label: "This issue is not a feature request. You can request features and make product suggestions here: https://mattermost.com/suggestions/."
required: true
- label: This issue reproduces on the most recent [stable version](https://github.com/mattermost/desktop/releases/latest), or the most recent [prerelease version](https://github.com/mattermost/desktop/releases) of the Mattermost Desktop App.
required: true
- label: I have read the [contribution guidelines](https://github.com/mattermost/desktop/blob/master/CONTRIBUTING.md).
required: true
- type: input
attributes:
label: Mattermost Desktop Version
description: |
What version of the Desktop App are you using? You can find it by going to [Help] > [Version Number].
validations:
required: true
- type: input
attributes:
label: Operating System
description: |
What operating system does this issue occur on? Please include the distribution name (if necessary) and architecture.
Examples:
- Windows 10 x64
- macOS Ventura 13.2 Apple Silicon
- Ubuntu Linux 22.04 LTS x64
validations:
required: true
- type: input
attributes:
label: Mattermost Server Version
description: |
Which version of the Mattermost Server did this occur on?
You can find your Mattermost Server version by [Mattermost Menu] > [About Mattermost], where [Mattermost Menu] can be accessed by clicking on the grid in the top-left corner.
- type: textarea
attributes:
label: Steps to reproduce
description: |
Include a clear description of the steps taken to reproduce this issue.
It is also helpful to attach a screenshot or video if applicable.
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: Include a clear description of what you expect to happen.
validations:
required: true
- type: textarea
attributes:
label: Observed behavior
description: Include a clear description of what actually happens.
validations:
required: true
- type: textarea
attributes:
label: Log Output
description: Please include output from the log files. You can find the location of the log files by going to [Help] > [Show logs].
render: shell
validations:
required: true
- type: textarea
attributes:
label: Additional Information
description: If you have anything else to add to the ticket, you may do so here.

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1 @@
blank_issues_enabled: false

72
.github/ISSUE_TEMPLATE/crash_report.yml vendored Normal file
View file

@ -0,0 +1,72 @@
name: Crash report
description: Create a report about a crash you experienced while using the Mattermost Desktop App
title: "[Crash]: "
labels: "Type/Crash Report"
body:
- type: checkboxes
attributes:
label: Before you file a crash report
description: Please ensure you can confirm the following
options:
- label: I have checked the [issue tracker](https://github.com/mattermost/desktop/issues) and have not found an issue that matches the one I'm filing.
required: true
- label: This issue doesn't reproduce on web browsers (such as in Chrome). If it does, [issue reports go to the Mattermost Server repository](https://github.com/mattermost/mattermost-server/issues).
required: true
- label: I have read the [contribution guidelines](https://github.com/mattermost/desktop/blob/master/CONTRIBUTING.md).
required: true
- type: input
attributes:
label: Mattermost Desktop Version
description: |
What version of the Desktop App are you using? You can find it by going to [Help] > [Version Number]. If you cannot access that, please check which version you downloaded, or ask your system administrator.
validations:
required: true
- type: input
attributes:
label: Operating System
description: |
What operating system does this issue occur on? Please include the distribution name (if necessary) and architecture.
Examples:
- Windows 10 x64
- macOS Ventura 13.2 Apple Silicon
- Ubuntu Linux 22.04 LTS x64
validations:
required: true
- type: input
attributes:
label: Mattermost Server Version
description: |
Which version of the Mattermost Server did this occur on?
You can find your Mattermost Server version by [Mattermost Menu] > [About Mattermost], where [Mattermost Menu] can be accessed by clicking on the grid in the top-left corner.
If you cannot access this, ask your system administrator.
- type: dropdown
attributes:
label: What type of crash did you experience?
options:
- White screen (I can see the top bar, but I cannot see my Mattermost server)
- Uncaught exception (I saw a dialog pop up that said "The Mattermost app quit unexpectedly")
- System crash (The application quit unexpectedly with no warning, or the operating system reported a crash)
validations:
required: true
- type: textarea
attributes:
label: Crash report details
description: |
Please provide any information you can about the crash you experienced.
- White screen: If you experience a white screen, please first verify that the same behaviour doesn't reproduce on the browser and if it doesn't, you can go to [View] > [Developer Tools for Current Server], clicking on the Console tab, then right-clicking on the logs area and clicking [Save as]. Then you can copy and paste the contents of that file here.
- Uncaught exception: If you receive a dialog that says "The Mattermost app quit unexpectedly", click on the Show Details button. Copy the text that is shown and paste it there.
- System crash: For any other crashes, please provide the trace log or Event Viewer output or anything else that might be relevant here.
render: shell
validations:
required: true
- type: textarea
attributes:
label: Log Output
description: Please include output from the log files if relevant. You can find the location of the log files by going to [Help] > [Show logs]. If you cannot access that, you can find them [here](https://docs.mattermost.com/install/troubleshooting.html#mattermost-desktop-app-logs).
render: shell
- type: textarea
attributes:
label: Additional Information
description: If you have anything else to add to the ticket, you may do so here.

64
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,64 @@
<!-- Thank you for contributing a pull request! Here are a few tips to help you:
1. If this is your first contribution, make sure you've read the Contribution Checklist https://developers.mattermost.com/contribute/getting-started/contribution-checklist/
2. Read our blog post about "Submitting Great PRs" https://developers.mattermost.com/blog/2019-01-24-submitting-great-prs
3. Take a look at other repository specific documentation at https://developers.mattermost.com/contribute
-->
#### Summary
<!--
A brief description of what this pull request does.
-->
#### Ticket Link
<!--
If this pull request addresses a Help Wanted ticket, please link the relevant GitHub issue, e.g.
Fixes https://github.com/mattermost/desktop/issues/XXXXX
Otherwise, link the JIRA ticket.
-->
#### Checklist
<!--
Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fields.
-->
- [ ] Added or updated unit tests (required for all new features)
- [ ] Has UI changes
- [ ] read and understood our [Contributing Guidelines](https://github.com/mattermost/desktop/blob/master/CONTRIBUTING.md)
- [ ] completed [Mattermost Contributor Agreement](https://mattermost.com/contribute/)
- [ ] executed `npm run lint:js` for proper code formatting
- [ ] Run E2E tests by adding label `Run Desktop E2E Tests`
#### Device Information
This PR was tested on: <!-- Device name(s), OS version(s) -->
#### Screenshots
<!--
If the PR includes UI changes, include screenshots/GIFs.
-->
#### Release Note
<!--
Add a release note for each of the following conditions:
* New features and improvements, including behavioural changes, UI changes
* Bug fixes and fixes of previous known issues
* Deprecation warnings, breaking changes, or compatibility notes
If no release notes are required write NONE. Use past-tense. Newlines are stripped.
Examples:
```release-note
Added a new config setting ServiceSettings.FooBar. Added a new column Foo to the Users table.
```
```release-note
NONE
```
-->
```release-note
```

View file

@ -0,0 +1,11 @@
# Copyright 2022 Mattermost, Inc.
name: "patch-version"
description: This action is used to patch package.json version with the nightly build
runs:
using: "composite"
steps:
- name: ci/generate-version
id: generate-version
shell: bash
run: go run ${GITHUB_ACTION_PATH}/patch-version.go . # https://github.com/orgs/community/discussions/25910

View file

@ -0,0 +1,42 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"strings"
"time"
)
func main() {
args := os.Args[1:]
packageFileName := fmt.Sprintf("%s/package.json", args[0])
packageJson, err := os.Open(packageFileName)
if err != nil {
log.Fatal(err)
}
packageBytes, err := ioutil.ReadAll(packageJson)
if err != nil {
log.Fatal(err)
}
var packageInfo map[string]interface{}
json.Unmarshal(packageBytes, &packageInfo)
originalVersion := fmt.Sprintf("%s", packageInfo["version"])
nightlyVersion := fmt.Sprintf("%s-nightly.%s", strings.Split(originalVersion, "-")[0], time.Now().Format("20060102"))
packageInfo["version"] = nightlyVersion
newPackageJson := strings.Replace(string(packageBytes), originalVersion, nightlyVersion, 1)
err = ioutil.WriteFile(packageFileName, []byte(newPackageJson), 0644)
if err != nil {
log.Fatal(err)
}
packageJson.Close()
fmt.Println("Update package.json with version:", nightlyVersion)
}

30
.github/actions/test/action.yaml vendored Normal file
View file

@ -0,0 +1,30 @@
# Copyright 2022 Mattermost, Inc.
name: "test-ci"
description: This action used to run universal tests for all OS
inputs:
shell:
description: The shell to run the test
required: true
default: bash
runs:
using: "composite"
steps:
- name: ci/run-eslint
run: npm run lint:js-quiet
shell: ${{ inputs.shell }}
- name: ci/run-check-types
run: npm run check-types
shell: ${{ inputs.shell }}
- name: ci/run-i18n-check
shell: ${{ inputs.shell }}
run: |
npm run i18n-extract -- --desktop-dir .
git --no-pager diff --exit-code i18n/en.json
- name: ci/run-unit-ci
shell: ${{ inputs.shell }}
env:
ELECTRON_DISABLE_SANDBOX: "1"
run: |
npm run test:unit

11
.github/codeql/codeql-config.yml vendored Normal file
View file

@ -0,0 +1,11 @@
name: "CodeQL config"
query-filters:
- exclude:
problem.severity:
- warning
- recommendation
paths-ignore:
- e2e
- '**/*.test.*'

181
.github/workflows/build-for-pr.yml vendored Normal file
View file

@ -0,0 +1,181 @@
name: build-for-pr
on:
pull_request:
types:
- labeled
defaults:
run:
shell: bash
env:
TERM: xterm
jobs:
build-linux-for-pr:
runs-on: ubuntu-22.04
if: ${{ github.event.label.name == 'Build Apps for PR' }}
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/install-dependencies
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
wget -qO - https://download.opensuse.org/repositories/Emulators:/Wine:/Debian/xUbuntu_22.04/Release.key | sudo apt-key add -
wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.20.1/yq_linux_amd64 && chmod a+x /usr/local/bin/yq
sudo apt-get update || true && sudo apt-get install -y ca-certificates libxtst-dev libpng++-dev gcc-aarch64-linux-gnu g++-aarch64-linux-gnu jq icnsutils graphicsmagick tzdata
npm ci
- name: ci/build
run: |
mkdir -p ./build/linux
npm run package:linux-tar
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/linux
- name: ci/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-linux
path: ./build/linux
retention-days: 10 ## No need to keep CI builds more than 10 days
windows-install-deps:
runs-on: windows-2022
if: ${{ github.event.label.name == 'Build Apps for PR' }}
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/cache-node-modules
id: cache-node-modules
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: node_modules
key: ${{ runner.os }}-build-node-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-node-modules
${{ runner.os }}-build-
${{ runner.os }}-
- name: ci/install-dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: npm ci --openssl_fips=''
build-win-for-pr:
runs-on: windows-2022
if: ${{ github.event.label.name == 'Build Apps for PR' }}
needs:
- windows-install-deps
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/cache-node-modules
id: cache-node-modules
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: node_modules
key: ${{ runner.os }}-build-node-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-node-modules
${{ runner.os }}-build-
${{ runner.os }}-
- name: ci/install-node-gyp
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
choco install yq --version 4.15.1 -y
npm i -g node-gyp
node-gyp install
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers"
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers" --arch arm64
- name: ci/install-dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
npm ci --openssl_fips=''
- name: ci/build
env:
MM_WIN_INSTALLERS: 1
PFX_KEY: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX_KEY }}
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_KEY_PASSWORD }}
PFX: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_LINK }}
run: |
mkdir -p ./build/win
npm run package:windows
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/win
- name: ci/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-windows
path: ./build/win
retention-days: 10 ## No need to keep CI builds more than 10 days
build-mac-for-pr:
runs-on: macos-12
if: ${{ github.event.label.name == 'Build Apps for PR' }}
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/install-dependencies
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
brew install yq
jq '.mac.target=["zip"]' electron-builder.json | jq '.mac.gatekeeperAssess=false' > /tmp/electron-builder.json && cp /tmp/electron-builder.json .
npm ci
- name: ci/build
env:
APPLE_ID: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID_PASS }}
CSC_FOR_PULL_REQUEST: true
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_LINK }}
MAC_PROFILE: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_DMG_PROFILE }}
run: |
echo $MAC_PROFILE | base64 -D > ./mac.provisionprofile
mkdir -p ./build/macos
npm run package:mac
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/macos/
- name: ci/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-macos
path: ./build/macos/
retention-days: 10 ## No need to keep CI builds more than 10 days

214
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,214 @@
name: ci
on:
pull_request:
defaults:
run:
shell: bash
env:
TERM: xterm
jobs:
build-linux:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/install-dependencies
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
wget -qO - https://download.opensuse.org/repositories/Emulators:/Wine:/Debian/xUbuntu_22.04/Release.key | sudo apt-key add -
wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.20.1/yq_linux_amd64 && chmod a+x /usr/local/bin/yq
sudo apt-get update || true && sudo apt-get install -y ca-certificates libxtst-dev libpng++-dev gcc-aarch64-linux-gnu g++-aarch64-linux-gnu jq icnsutils graphicsmagick tzdata
npm ci
- name: ci/test
uses: ./.github/actions/test
- name: ci/build
run: |
mkdir -p ./build/linux
npm run package:linux-tar
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/linux
- name: ci/upload-test-results
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: linux-test-results
path: test-results.xml
retention-days: 5
- name: ci/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-linux
path: ./build/linux
retention-days: 10 ## No need to keep CI builds more than 10 days
windows-install-deps:
runs-on: windows-2022
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/cache-node-modules
id: cache-node-modules
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: node_modules
key: ${{ runner.os }}-build-node-modules-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-node-modules
${{ runner.os }}-build-
${{ runner.os }}-
- name: ci/install-dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
choco install yq --version 4.15.1 -y
npm i -g node-gyp
node-gyp install
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers"
npm ci --openssl_fips=''
build-win-no-installer:
runs-on: windows-2022
needs:
- windows-install-deps
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/cache-node-modules
id: cache-node-modules
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: node_modules
key: ${{ runner.os }}-build-node-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-node-modules
${{ runner.os }}-build-
${{ runner.os }}-
- name: ci/install-node-gyp
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
choco install yq --version 4.15.1 -y
npm i -g node-gyp
node-gyp install
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers"
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers" --arch arm64
- name: ci/install-dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
npm ci --openssl_fips=''
- name: ci/test
uses: ./.github/actions/test
- name: ci/build
run: |
mkdir -p ./build/win
npm run package:windows
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/win
- name: ci/upload-test-results
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: windows-test-results
path: test-results.xml
retention-days: 5
- name: ci/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-windows
path: ./build/win
retention-days: 10 ## No need to keep CI builds more than 10 days
build-mac-no-dmg:
runs-on: macos-12
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/install-dependencies
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
brew install yq
jq '.mac.target=["zip"]' electron-builder.json | jq '.mac.gatekeeperAssess=false' > /tmp/electron-builder.json && cp /tmp/electron-builder.json .
npm ci
- name: ci/test
uses: ./.github/actions/test
- name: ci/build
run: |
mkdir -p ./build/macos
npm run package:mac
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/macos/
- name: ci/upload-test-results
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: macos-test-results
path: test-results.xml
retention-days: 5
- name: ci/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-macos
path: ./build/macos/
retention-days: 10 ## No need to keep CI builds more than 10 days
report-test-results:
if: always()
needs:
- build-mac-no-dmg
- build-win-no-installer
- build-linux
runs-on: ubuntu-22.04
permissions:
checks: write
pull-requests: write
steps:
- name: ci/download-macos-test-results
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: macos-test-results
path: macos-test-results
- name: ci/download-windows-test-results
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: windows-test-results
path: windows-test-results
- name: ci/download-linux-test-results
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
name: linux-test-results
path: linux-test-results
- name: ci/publish-results
uses: EnricoMi/publish-unit-test-result-action@a3caf02865c0604ad3dc1ecfcc5cdec9c41b7936 # v2.3.0
with:
comment_mode: failures
compare_to_earlier_commit: false
junit_files: "**/*.xml"

40
.github/workflows/codeql-analysis.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: "0 0 * * 0"
permissions:
contents: read
jobs:
analyze:
permissions:
security-events: write
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ["javascript"]
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Initialize CodeQL
uses: github/codeql-action/init@423a04bb2cb7cd2643007122588f1387778f14d0 # v2.16.5
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
# Autobuild attempts to build any compiled languages
- name: Autobuild
uses: github/codeql-action/autobuild@423a04bb2cb7cd2643007122588f1387778f14d0 # v2.16.5
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@423a04bb2cb7cd2643007122588f1387778f14d0 # v2.16.5

View file

@ -0,0 +1,136 @@
name: Compatibility Matrix Testing
on:
workflow_dispatch:
inputs:
CMT_MATRIX:
description: "A JSON object representing the testing matrix"
required: true
type: string
DESKTOP_VERSION:
description: "The desktop version to test"
required: true
jobs:
## This is picked up after the finish for cleanup
upload-cmt-server-detals:
runs-on: ubuntu-22.04
steps:
- name: cmt/generate-instance-details-file
run: echo '${{ inputs.CMT_MATRIX }}' > instance-details.json
- name: cmt/upload-instance-details
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: instance-details.json
path: instance-details.json
retention-days: 1
calculate-commit-hash:
runs-on: ubuntu-22.04
outputs:
DESKTOP_SHA: ${{ steps.repo.outputs.DESKTOP_SHA }}
steps:
- name: cmt/checkout-desktop
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ inputs.DESKTOP_VERSION }}
- name: cmt/calculate-mattermost-sha
id: repo
run: echo "DESKTOP_SHA=$(git rev-parse HEAD)" >> ${GITHUB_OUTPUT}
update-initial-status:
runs-on: ubuntu-22.04
needs:
- calculate-commit-hash
steps:
- uses: mattermost/actions/delivery/update-commit-status@746563b58e737a17a8ceb00b84a813b9e6e1b236
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
repository_full_name: mattermost/desktop
commit_sha: ${{ needs.calculate-commit-hash.outputs.DESKTOP_SHA }}
context: e2e/compatibility-matrix-testing
description: "Compatibility Matrix Testing for ${{ inputs.DESKTOP_VERSION }} version"
status: pending
# Input follows the below schema
# {
# "environment": [
# {
# "os": "linux",
# "runner": "ubuntu-22.04"
# },
# {
# "os": "macos",
# "runner": "macos-13"
# },
# {
# "os": "windows",
# "runner": "windows-2022"
# }
# ],
# "server": [
# {
# "version": "9.6.1",
# "url": "https://delivery-cmt-8467830017-9-6-1.test.mattermost.cloud/"
# },
# {
# "version": "9.5.2",
# "url": "https://delivery-cmt-8467830017-9-5-2.test.mattermost.cloud/"
# }
# ]
# }
e2e:
name: ${{ matrix.environment.os }}-${{ matrix.server.version }}
uses: ./.github/workflows/e2e-functional-template.yml
needs:
- update-initial-status
strategy:
fail-fast: false
matrix: ${{ fromJson(inputs.CMT_MATRIX) }}
secrets: inherit
with:
runs-on: ${{ matrix.environment.runner }}
cmt: true
MM_TEST_SERVER_URL: ${{ matrix.server.url }}
DESKTOP_VERSION: ${{ inputs.DESKTOP_VERSION }}
MM_SERVER_VERSION: ${{ matrix.server.version }}
# We need to duplicate here in order to set the proper commit status
# https://mattermost.atlassian.net/browse/CLD-5815
update-failure-final-status:
runs-on: ubuntu-22.04
if: failure() || cancelled()
needs:
- calculate-commit-hash
- e2e
steps:
- uses: mattermost/actions/delivery/update-commit-status@746563b58e737a17a8ceb00b84a813b9e6e1b236
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
repository_full_name: mattermost/desktop
commit_sha: ${{ needs.calculate-commit-hash.outputs.DESKTOP_SHA }}
context: e2e/compatibility-matrix-testing
description: "Compatibility Matrix Testing for ${{ inputs.DESKTOP_VERSION }} version"
status: failure
# https://mattermost.atlassian.net/browse/CLD-5815
update-success-final-status:
runs-on: ubuntu-22.04
if: success()
needs:
- calculate-commit-hash
- e2e
steps:
- uses: mattermost/actions/delivery/update-commit-status@746563b58e737a17a8ceb00b84a813b9e6e1b236
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
repository_full_name: mattermost/desktop
commit_sha: ${{ needs.calculate-commit-hash.outputs.DESKTOP_SHA }}
context: e2e/compatibility-matrix-testing
description: "Compatibility Matrix Testing for ${{ inputs.DESKTOP_VERSION }} version"
status: success

View file

@ -0,0 +1,209 @@
name: E2E Functional Tests Template
on:
workflow_call:
inputs:
MM_TEST_SERVER_URL:
description: "The test server URL"
required: false
type: string
MM_TEST_USER_NAME:
description: "The admin username of the test instance"
required: false
type: string
MM_TEST_PASSWORD:
description: "The admin password of the test instance"
required: false
type: string
DESKTOP_VERSION:
description: "The desktop version to test"
required: false
default: ${{ github.ref }}
type: string
runs-on:
type: string
description: "The E2E tests underlying OS"
required: true
default: "ubuntu-22.04"
nightly:
type: boolean
description: "True if this is nigtly build"
required: false
default: false
cmt:
type: boolean
description: "True if this is Compatibility Matrix Testing"
required: false
default: false
MM_SERVER_VERSION:
type: string
required: false
default: "9.9.1"
outputs:
COMMENT_BODY:
description: "The output to comment"
value: ${{ jobs.e2e.outputs.COMMENT_BODY }}
env:
AWS_S3_BUCKET: "mattermost-cypress-report"
BRANCH: ${{ github.head_ref || github.ref_name }}
BUILD_TAG: ${{ github.event.pull_request.head.sha || github.sha }}
JIRA_PROJECT_KEY: "MM"
MM_TEST_SERVER_URL: ${{ inputs.MM_TEST_SERVER_URL || secrets.MM_DESKTOP_E2E_SERVER_URL }}
MM_TEST_USER_NAME: ${{ inputs.MM_TEST_USER_NAME || secrets.MM_DESKTOP_E2E_USER_NAME }}
MM_TEST_PASSWORD: ${{ inputs.MM_TEST_PASSWORD || secrets.MM_DESKTOP_E2E_USER_CREDENTIALS }}
PULL_REQUEST: "https://github.com/mattermost/desktop/pull/${{ github.event.number }}"
ZEPHYR_ENVIRONMENT_NAME: "Desktop app"
ZEPHYR_FOLDER_ID: "12413253"
TEST_CYCLE_LINK_PREFIX: ${{ secrets.MM_DESKTOP_E2E_TEST_CYCLE_LINK_PREFIX }}
AWS_ACCESS_KEY_ID: ${{ secrets.MM_DESKTOP_E2E_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.MM_DESKTOP_E2E_AWS_SECRET_ACCESS_KEY }}
AWS_REGION: "us-east-1"
WEBHOOK_URL: ${{ secrets.MM_DESKTOP_E2E_WEBHOOK_URL }}
ZEPHYR_API_KEY: ${{ secrets.MM_DESKTOP_E2E_ZEPHYR_API_KEY }}
REPORT_LINK: "none"
jobs:
e2e:
runs-on: ${{ inputs.runs-on }}
defaults:
run:
shell: bash
outputs:
COMMENT_BODY: ${{ steps.analyze-flaky-tests.outputs.COMMENT_BODY }}
steps:
- name: e2e/set-required-variables
id: variables
run: |
RUNNER_OS=$(echo "${{ runner.os }}" | tr '[:upper:]' '[:lower:]')
if [ "${{ github.event_name }}" == "pull_request" ]; then
echo "BUILD_SUFFIX=desktop-pr-${RUNNER_OS}" >> $GITHUB_OUTPUT
echo "TYPE=PR" >> $GITHUB_ENV
elif [ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.cmt }}" ]; then
echo "BUILD_SUFFIX=desktop-release-${RUNNER_OS}" >> $GITHUB_OUTPUT
echo "TYPE=CMT" >> $GITHUB_ENV
echo "ZEPHYR_ENABLE=true" >> $GITHUB_ENV
echo "ZEPHYR_FOLDER_LINUX_REPORT=12358649" >> $GITHUB_ENV
echo "ZEPHYR_FOLDER_MACOS_REPORT=12358650" >> $GITHUB_ENV
echo "ZEPHYR_FOLDER_WIN_REPORT=12358651" >> $GITHUB_ENV
elif [ "${{ github.event_name }}" == "workflow_dispatch" ] && ! ${{ inputs.nightly }}; then
echo "BUILD_SUFFIX=desktop-manual-trigger-${RUNNER_OS}" >> $GITHUB_OUTPUT
echo "TYPE=MANUAL" >> $GITHUB_ENV
elif [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/master" ]; then
echo "BUILD_SUFFIX=desktop-master-push-${RUNNER_OS}" >> $GITHUB_OUTPUT
echo "TYPE=MASTER" >> $GITHUB_ENV
echo "ZEPHYR_ENABLE=true" >> $GITHUB_ENV
elif ${{ inputs.nightly }}; then
echo "BUILD_SUFFIX=desktop-nightly-${RUNNER_OS}" >> $GITHUB_OUTPUT
echo "TYPE=NIGHTLY" >> $GITHUB_ENV
echo "ZEPHYR_ENABLE=true" >> $GITHUB_ENV
echo "ZEPHYR_FOLDER_LINUX_REPORT=12363689" >> $GITHUB_ENV
echo "ZEPHYR_FOLDER_MACOS_REPORT=12363687" >> $GITHUB_ENV
echo "ZEPHYR_FOLDER_WIN_REPORT=12363690" >> $GITHUB_ENV
fi
- name: e2e/set-build-id
run: echo "BUILD_ID=${{ github.run_id }}-${{ steps.variables.outputs.BUILD_SUFFIX }}" >> $GITHUB_ENV
- name: e2e/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ inputs.DESKTOP_VERSION }}
- name: e2e/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: e2e/cache-node-modules
id: cache-node-modules
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
with:
path: |
node_modules
C:\Users\runneradmin\.electron-gyp
key: ${{ runner.os }}-build-node-modules-${{ hashFiles('./package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-node-modules
${{ runner.os }}-build-
${{ runner.os }}-
- name: e2e/setup-python
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
with:
python-version: "3.10"
## Linux Depdendencies
- name: e2e/install-dependencies-linux
if: runner.os == 'Linux'
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 0
run: |
wget -qO - https://download.opensuse.org/repositories/Emulators:/Wine:/Debian/xUbuntu_22.04/Release.key | sudo apt-key add -
wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.20.1/yq_linux_amd64 && chmod a+x /usr/local/bin/yq
sudo apt-get update || true && sudo apt-get install -y ca-certificates libxtst-dev libpng++-dev gcc-aarch64-linux-gnu g++-aarch64-linux-gnu jq icnsutils graphicsmagick tzdata xsel
npm ci
cd e2e
npm ci
npx electron-rebuild --platform=linux -f -t prod,optional,dev -w robotjs --module-dir ../
- name: e2e/install-dependencies-macos
if: runner.os == 'macOS'
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
jq '.mac.target=["zip"]' electron-builder.json | jq '.mac.gatekeeperAssess=false' > /tmp/electron-builder.json && cp /tmp/electron-builder.json .
npm ci
cd e2e
npm ci
npx electron-rebuild --platform=darwin -f -t prod,optional,dev -w robotjs --module-dir ../
## Windows Dependencies
- name: e2e/install-dependencies-windows
if: steps.cache-node-modules.outputs.cache-hit != 'true' && runner.os == 'Windows'
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
choco install yq --version 4.15.1 -y
npm i -g node-gyp
node-gyp install
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers"
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers" --arch arm64
npm ci --openssl_fips=''
cd e2e
npm ci
npx electron-rebuild --platform=win32 -f -t prod,optional,dev -w robotjs --module-dir ../
- name: e2e/run-playright-tests-${{ runner.os }}
run: |
if [ ${{ runner.os }} == 'Linux' ]; then
export DISPLAY=:99
Xvfb $DISPLAY -screen 0 1280x960x24 > /dev/null 2>&1 &
fi
npm run build-test
cd e2e
npm run run:e2e || true # making job pass even if the tests fail due to flakyness
npm run send-report
env:
SERVER_VERSION: ${{ inputs.MM_SERVER_VERSION}}
DESKTOP_VERSION: ${{ inputs.DESKTOP_VERSION }}
- name: e2e/analyze-flaky-tests
id: analyze-flaky-tests
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
process.chdir('./e2e');
const { analyzeFlakyTests } = require('./utils/analyze-flaky-test.js');
const { commentBody, newFailedTests } = analyzeFlakyTests();
core.setOutput('COMMENT_BODY', commentBody);
if (newFailedTests.length > 0) {
core.setFailed('E2E tests failed.');
}

129
.github/workflows/e2e-functional.yml vendored Normal file
View file

@ -0,0 +1,129 @@
name: Electron Playwright Tests
on:
push:
branches:
- master
pull_request:
types:
- labeled
workflow_dispatch:
inputs:
version_name:
type: string
description: "Desktop Version name eg: 5.6"
required: true
job_name:
type: choice
description: "Job name"
required: true
default: "All"
options:
- "e2e-linux"
- "e2e-macos"
- "e2e-windows"
- "All"
jobs:
e2e-linux:
if: ${{
(
(inputs.job_name == 'e2e-linux' || inputs.job_name == 'All')
&&
github.event_name == 'workflow_dispatch'
) ||
(
github.event_name == 'push'
) ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.labels &&
contains(github.event.pull_request.labels.*.name, 'Run Desktop E2E Tests')
)
}}
uses: ./.github/workflows/e2e-functional-template.yml
secrets: inherit
with:
runs-on: ubuntu-22.04
DESKTOP_VERSION: ${{ inputs.version_name || github.head_ref || github.ref }}
e2e-macos:
if: ${{
(
(inputs.job_name == 'e2e-macos' || inputs.job_name == 'All')
&&
github.event_name == 'workflow_dispatch'
) ||
(
github.event_name == 'push'
) ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.labels &&
contains(github.event.pull_request.labels.*.name, 'Run Desktop E2E Tests')
)
}}
uses: ./.github/workflows/e2e-functional-template.yml
secrets: inherit
with:
runs-on: macos-13
DESKTOP_VERSION: ${{ inputs.version_name || github.head_ref || github.ref }}
e2e-windows:
if: ${{
(
(inputs.job_name == 'e2e-windows' || inputs.job_name == 'All')
&&
github.event_name == 'workflow_dispatch'
) ||
(
github.event_name == 'push'
) ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.labels &&
contains(github.event.pull_request.labels.*.name, 'Run Desktop E2E Tests')
)
}}
uses: ./.github/workflows/e2e-functional-template.yml
secrets: inherit
with:
runs-on: windows-2022
DESKTOP_VERSION: ${{ inputs.version_name || github.head_ref || github.ref }}
e2e-remove-label:
if: ${{ always() && contains(github.event.pull_request.labels.*.name, 'Run Desktop E2E Tests') }}
needs: [e2e-linux, e2e-macos, e2e-windows]
runs-on: ubuntu-22.04
steps:
- name: e2e/unify-comments-in-single-comment
run: |
echo "PR_COMMENT<<EOF" >> "${GITHUB_ENV}"
echo "Here are the test results below:" >> "${GITHUB_ENV}"
echo "${{ needs.e2e-linux.outputs.COMMENT_BODY }}" >> "${GITHUB_ENV}"
echo "${{ needs.e2e-macos.outputs.COMMENT_BODY }}" >> "${GITHUB_ENV}"
echo "${{ needs.e2e-windows.outputs.COMMENT_BODY }}" >> "${GITHUB_ENV}"
echo "EOF" >> "${GITHUB_ENV}"
- name: e2e/send-comment-results-in-pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: process.env.PR_COMMENT,
});
- name: e2e/remove-label-from-pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
continue-on-error: true # Label might have been removed manually
with:
script: |
github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: 'Run Desktop E2E Tests',
});

96
.github/workflows/e2e-performance.yml vendored Normal file
View file

@ -0,0 +1,96 @@
name: E2E Performance Tests (Desktop)
on:
pull_request:
branches: [master]
types:
- labeled
env:
RESULTS_PATH: e2e/performance/perf-test-report.json
jobs:
build:
if: ${{ github.event.label.name == 'Run E2E Performance Tests' }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node-version: [16]
steps:
- name: Add start comment
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `E2E Performance Tests started 🏎️`,
});
- name: Set env variable for timestamp
run: echo "NOW=$(date +'%Y-%m-%dT%H:%M:%S')" >> $GITHUB_ENV
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Install packages
run: sudo apt-get install libxtst-dev libpng++-dev
- name: Install dependencies 👨🏻‍💻
run: npm ci
- name: E2E Performance Tests for Electron 🧪
run: ELECTRON_DISABLE_SANDBOX=1 xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- cd e2e && npm run test:performance
- name: Upload artifact to Github
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: perf-test-report.json
path: ${{ env.RESULTS_PATH }}
if-no-files-found: error
retention-days: 14
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_PERFORMANCE_TESTS_PUT_BUCKET }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY_PERFORMANCE_TESTS_PUT_BUCKET }}
aws-region: ${{ secrets.AWS_REGION_PERFORMANCE_TESTS_PUT_BUCKET }}
mask-aws-account-id: true
- name: Upload report to S3
run: aws s3 cp ${{ env.RESULTS_PATH }} s3://${{ secrets.AWS_BUCKET_PERFORMANCE_TESTS }}/${{ github.head_ref }}-${{ github.sha }}-${{ env.NOW }}.json
- name: Add results in PR comment
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const fs = require('fs');
const {generateCommentBodyPerformanceTest} = require('./e2e/utils/pr-e2e-durations-report.js');
const fileContents = fs.readFileSync('${{ env.RESULTS_PATH }}');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: generateCommentBodyPerformanceTest(fileContents),
});
- name: Remove "Run E2E Performance Tests" label
if: always()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
continue-on-error: true # Label might have been removed manually
with:
script: |
github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: 'Run E2E Performance Tests',
});

60
.github/workflows/nightly-builds.yaml vendored Normal file
View file

@ -0,0 +1,60 @@
name: nightly-builds
on:
workflow_dispatch:
schedule:
- cron: 0 4 * * 0-5
jobs:
tag-nightly-build:
runs-on: ubuntu-22.04
outputs:
tag: ${{ steps.tag-creation.outputs.tag }}
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: nightly/patch-version
uses: ./.github/actions/patch-nightly-version
- name: nightly/create-nightly-build-tag
id: tag-creation
run: |
git config --global user.email "nightly-build@mattermost.com"
git config --global user.name "Nightly Build"
git checkout -b "$(jq -r .version package.json)"
git add package.json
git commit -m "Nightly build $(jq -r .version package.json)"
git tag "$(jq -r .version package.json)" -m "Nightly build $(jq -r .version package.json)"
git push --tags --force
echo "tag=$(jq -r .version package.json)" >> $GITHUB_OUTPUT
nightly-main:
needs:
- tag-nightly-build
uses: ./.github/workflows/nightly-main.yml
secrets: inherit
with:
tag: ${{ needs.tag-nightly-build.outputs.tag }}
nightly-rainforest:
needs:
- tag-nightly-build
uses: ./.github/workflows/nightly-rainforest.yml
secrets: inherit
with:
tag: ${{ needs.tag-nightly-build.outputs.tag }}
nightly-e2e:
needs:
- tag-nightly-build
strategy:
matrix:
runs-on:
- macos-13
- ubuntu-22.04
- windows-2022
uses: ./.github/workflows/e2e-functional-template.yml
secrets: inherit
with:
runs-on: ${{ matrix.runs-on }}
nightly: true
DESKTOP_VERSION: ${{ needs.tag-nightly-build.outputs.tag }}

251
.github/workflows/nightly-main.yml vendored Normal file
View file

@ -0,0 +1,251 @@
name: nightly-main
on:
workflow_call:
inputs:
tag:
description: "Reference tag of the nightly build"
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: "Reference tag of the nightly build"
required: true
type: string
defaults:
run:
shell: bash
env:
TERM: xterm
MM_WIN_INSTALLERS: 1
REFERENCE: ${{ inputs.tag }}
jobs:
build-linux:
runs-on: ubuntu-22.04
steps:
- name: ci/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: ci/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: ci/install-dependencies
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
wget -qO - https://download.opensuse.org/repositories/Emulators:/Wine:/Debian/xUbuntu_22.04/Release.key | sudo apt-key add -
wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.20.1/yq_linux_amd64 && chmod a+x /usr/local/bin/yq
sudo apt-get update || true && sudo apt-get install -y ca-certificates libxtst-dev libpng++-dev gcc-aarch64-linux-gnu g++-aarch64-linux-gnu jq icnsutils graphicsmagick tzdata
npm ci
- name: ci/build
run: |
mkdir -p ./build/linux
npm run package:linux
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/linux
- name: ci/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-nightly-main-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 5 ## No need to keep them since they are uploaded on S3
build-msi-installer:
runs-on: windows-2022
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: nightly/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: nightly/install-deps
shell: powershell
run: |
choco install yq --version 4.15.1 -y
npm i -g node-gyp
node-gyp install
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers"
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers" --arch arm64
npm ci --openssl_fips=''
- name: nightly/test
uses: ./.github/actions/test
- name: nightly/build
shell: powershell
env:
MM_WIN_INSTALLERS: 1
PFX_KEY: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX_KEY }}
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_KEY_PASSWORD }}
PFX: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_LINK }}
run: npm run package:windows-installers
- name: nightly/package
run: |
mkdir -p ./build/win-release
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/win-release
- name: nightly/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-nightly-main-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 5 ## No need to keep them since they are uploaded on S3
mac-app-store-preflight:
runs-on: macos-12
env:
MAS_PROFILE: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MAS_PROFILE }}
MACOS_API_KEY_ID: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MACOS_API_KEY_ID }}
MACOS_API_KEY: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MACOS_API_KEY }}
MACOS_API_ISSUER_ID: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MACOS_API_ISSUER_ID }}
CSC_FOR_PULL_REQUEST: true
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_CSC_KEY_PASSWORD}}
CSC_LINK: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_CSC_LINK }}
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: nightly/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: nightly/install-dependencies
run: |
brew install yq
npm ci
- name: nightly/copy-provisioning-profile
run: echo $MAS_PROFILE | base64 -D > ./mas.provisionprofile
- name: nightly/patch-version-number-for-MAS
run: ./scripts/patch_mas_version.sh
- name: nightly/test
uses: ./.github/actions/test
- name: nightly/package
run: npm run package:mas
- name: nightly/publish
run: fastlane publish_test path:"$(find . -name \*.pkg -print -quit)"
build-mac-installer:
runs-on: macos-12
needs:
- mac-app-store-preflight
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: nightly/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: nightly/install-dependencies
run: |
brew install yq rename
npm ci
- name: nightly/test
uses: ./.github/actions/test
- name: nightly/build
env:
APPLE_ID: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID_PASS }}
CSC_FOR_PULL_REQUEST: true
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_LINK }}
MAC_PROFILE: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_DMG_PROFILE }}
run: |
echo $MAC_PROFILE | base64 -D > ./mac.provisionprofile
mkdir -p ./build/macos-release
npm run package:mac-with-universal
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/macos-release
- name: nightly/rename-arm64-to-m1
run: rename 's/arm64/m1/' ./build/macos-release/$(jq -r .version package.json)/*
- name: nightly/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-nightly-main-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 5 ## No need to keep them since they are uploaded on S3
upload-to-s3:
runs-on: ubuntu-22.04
outputs:
links: ${{ steps.generate-linklist.outputs.linklist }}
needs:
- build-mac-installer
- build-msi-installer
- build-linux
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: nightly/setup-aws-credentials
uses: aws-actions/configure-aws-credentials@67fbcbb121271f7775d2e7715933280b06314838 # v1.7.0
with:
aws-region: us-east-1
aws-access-key-id: ${{ secrets.MM_DESKTOP_RELEASE_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.MM_DESKTOP_RELEASE_AWS_SECRET_ACCESS_KEY }}
- name: nightly/download-builds
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
path: build
pattern: build-nightly-main-*
merge-multiple: true
- name: nightly/setup-files-for-aws
run: |
mkdir -p ./aws-s3-dist
cp -r --backup=numbered ./build/{macos-release,win-release,linux}/* ./aws-s3-dist
- name: nightly/upload-to-s3
run: aws s3 cp ./aws-s3-dist/ s3://releases.mattermost.com/desktop/ --acl public-read --cache-control "no-cache" --recursive
- name: nightly/generate-linklist
id: generate-linklist
run: |
mkdir -p ./links
echo "### Nightly builds:" > ./links/linklist.txt
echo "Links for $(date +"%b-%d-%Y")" >> ./links/linklist.txt
echo "##### :tux: Linux" >> ./links/linklist.txt
for i in `ls ./build/linux/$(jq -r .version package.json)/` ; do echo "- [$i](https://s3.amazonaws.com/releases.mattermost.com/desktop/$(jq -r .version package.json)/$i)" ; done >> ./links/linklist.txt
echo "##### :apple_logo: macOS" >> ./links/linklist.txt
for i in `ls ./build/macos-release/$(jq -r .version package.json)/` ; do echo "- [$i](https://s3.amazonaws.com/releases.mattermost.com/desktop/$(jq -r .version package.json)/$i)" ; done >> ./links/linklist.txt
echo "##### :windows: Windows" >> ./links/linklist.txt
for i in `ls ./build/win-release/$(jq -r .version package.json)/` ; do echo "- [$i](https://s3.amazonaws.com/releases.mattermost.com/desktop/$(jq -r .version package.json)/$i)" ; done >> ./links/linklist.txt
cat ./links/linklist.txt
LINKLIST=$(<./links/linklist.txt)
## https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
echo 'linklist<<EOF' >> $GITHUB_OUTPUT
echo "$LINKLIST" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
share-links-to-channel:
runs-on: ubuntu-22.04
needs:
- upload-to-s3
steps:
- name: nightly/share-links-to-channel
run: |
jq --null-input \
--arg icon_url "https://upload.wikimedia.org/wikipedia/commons/1/17/Luna_symbol.png" \
--arg username "NightBuilder" \
--arg text "${{ needs.upload-to-s3.outputs.links }}" \
'{"username":$username,"icon_url": $icon_url, "text": $text }' > /tmp/webhook-data.json
curl -i -X POST -H "Content-Type: application/json" -d @/tmp/webhook-data.json ${{ secrets.MM_DESKTOP_NIGHTLY_WEBHOOK_URL }}

149
.github/workflows/nightly-rainforest.yml vendored Normal file
View file

@ -0,0 +1,149 @@
name: nightly-rainforest
on:
workflow_call:
inputs:
tag:
description: "Reference tag of the nightly build"
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: "Reference tag of the nightly build"
required: true
type: string
defaults:
run:
shell: bash
env:
TERM: xterm
MM_DESKTOP_BUILD_DISABLEGPU: true
MM_DESKTOP_BUILD_SKIPONBOARDINGSCREENS: true
MM_WIN_INSTALLERS: 1
REFERENCE: ${{ inputs.tag }}
jobs:
build-msi-installer:
runs-on: windows-2022
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: nightly/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: nightly/install-deps
shell: powershell
run: |
choco install yq --version 4.15.1 -y
npm i -g node-gyp
node-gyp install
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers"
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers" --arch arm64
npm ci --openssl_fips=''
- name: nightly/test
uses: ./.github/actions/test
- name: nightly/build
shell: powershell
env:
MM_WIN_INSTALLERS: 1
PFX_KEY: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX_KEY }}
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_KEY_PASSWORD }}
PFX: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_LINK }}
run: npm run package:windows-installers
- name: nightly/package
run: |
mkdir -p ./build/win
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/win
- name: nightly/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-rainforest-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 5 ## No need to keep them since they are uploaded on S3
build-mac-installer:
runs-on: macos-12
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: nightly/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: nightly/install-dependencies
run: |
brew install yq rename
npm ci
- name: nightly/test
uses: ./.github/actions/test
- name: nightly/build
env:
APPLE_ID: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID_PASS }}
CSC_FOR_PULL_REQUEST: true
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_LINK }}
MAC_PROFILE: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_DMG_PROFILE }}
run: |
echo $MAC_PROFILE | base64 -D > ./mac.provisionprofile
mkdir -p ./build/macos
npm run package:mac-with-universal
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/macos
- name: nightly/rename-arm64-to-m1
run: rename 's/arm64/m1/' ./build/macos/$(jq -r .version package.json)/*
- name: nightly/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-rainforest-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 5 ## No need to keep them since they are uploaded on S3
upload-to-s3-daily:
runs-on: ubuntu-22.04
needs:
- build-mac-installer
- build-msi-installer
steps:
- name: nightly/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: ${{ env.REFERENCE }}
- name: nightly/setup-aws-credentials
uses: aws-actions/configure-aws-credentials@67fbcbb121271f7775d2e7715933280b06314838 # v1.7.0
with:
aws-region: us-east-1
aws-access-key-id: ${{ secrets.MM_DESKTOP_DAILY_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.MM_DESKTOP_DAILY_AWS_SECRET_ACCESS_KEY }}
- name: nightly/download-builds
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
path: build
pattern: build-rainforest-*
merge-multiple: true
- name: nightly/install-missing-deps
run: |
sudo apt-get update
sudo apt-get install rename jq -y
- name: nightly/setup-files-for-aws
run: |
rename 's/\d+\.\d+\.\d+\-nightly\.\d+\/mattermost(.+)\d+\.\d+\.\d+\-nightly\.\d+/mattermost$1daily-develop/' ./build/macos/$(jq -r .version package.json)/*
rename 's/\d+\.\d+\.\d+\-nightly\.\d+\/mattermost(.+)\d+\.\d+\.\d+\-nightly\.\d+/mattermost$1daily-develop/' ./build/win/$(jq -r .version package.json)/*
- name: nightly/upload-to-s3
run: aws s3 cp ./build/ s3://mattermost-desktop-daily-builds/ --acl public-read --cache-control "no-cache" --recursive

49
.github/workflows/release-mas.yaml vendored Normal file
View file

@ -0,0 +1,49 @@
name: release-mas
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-mas.[0-9]+"
defaults:
run:
shell: bash
env:
TERM: xterm
jobs:
mac-app-store-preflight:
runs-on: macos-12
env:
MAS_PROFILE: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MAS_PROFILE }}
MACOS_API_KEY_ID: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MACOS_API_KEY_ID }}
MACOS_API_KEY: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MACOS_API_KEY }}
MACOS_API_ISSUER_ID: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_MACOS_API_ISSUER_ID }}
CSC_FOR_PULL_REQUEST: true
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_CSC_KEY_PASSWORD}}
CSC_LINK: ${{ secrets.MM_DESKTOP_MAC_APP_STORE_CSC_LINK }}
steps:
- name: release/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: release/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: release/install-dependencies
run: |
brew install yq
npm ci
- name: release/copy-provisioning-profile
run: echo $MAS_PROFILE | base64 -D > ./mas.provisionprofile
- name: release/patch-version-number-for-MAS
run: ./scripts/patch_mas_version.sh
- name: release/test
uses: ./.github/actions/test
- name: release/package
run: npm run package:mas
- name: release/publish
run: fastlane publish_test path:"$(find . -name \*.pkg -print -quit)"

248
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,248 @@
name: release
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
defaults:
run:
shell: bash
env:
TERM: xterm
MM_WIN_INSTALLERS: 1
jobs:
begin-notification:
runs-on: ubuntu-22.04
steps:
- name: release/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: release/fetch-version
id: calc
run: echo "VERSION=$(jq -r .version package.json)" >> ${GITHUB_OUTPUT}
- name: release/notify-channel
uses: mattermost/action-mattermost-notify@60f5da6e1796b033cf5a038b57031fa011962e27
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MM_DESKTOP_RELEASE_WEBHOOK_URL }}
MATTERMOST_USERNAME: MattermostRelease
MATTERMOST_ICON_URL: https://mattermost.com/wp-content/uploads/2022/02/icon.png
TEXT: |
[${{ steps.calc.outputs.VERSION }}] Release process for the desktop app has started, it should take about 30 minutes to complete.
build-linux:
runs-on: ubuntu-22.04
needs:
- begin-notification
steps:
- name: release/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: release/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: release/install-dependencies
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: |
wget -qO - https://download.opensuse.org/repositories/Emulators:/Wine:/Debian/xUbuntu_22.04/Release.key | sudo apt-key add -
wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.20.1/yq_linux_amd64 && chmod a+x /usr/local/bin/yq
sudo apt-get update || true && sudo apt-get install -y ca-certificates libxtst-dev libpng++-dev gcc-aarch64-linux-gnu g++-aarch64-linux-gnu jq icnsutils graphicsmagick tzdata
npm ci
- name: release/test
uses: ./.github/actions/test
- name: release/build
run: |
mkdir -p ./build/linux
npm run package:linux
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/linux
- name: release/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 14 ## No need to keep CI builds more than 14 days
build-msi-installer:
runs-on: windows-2022
needs:
- begin-notification
steps:
- name: release/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: release/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: release/install-deps
shell: powershell
run: |
choco install yq --version 4.15.1 -y
npm i -g node-gyp
node-gyp install
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers"
node-gyp install --devdir="C:\Users\runneradmin\.electron-gyp" --target=$(jq -r .devDependencies.electron package.json) --dist-url="https://electronjs.org/headers" --arch arm64
npm ci --openssl_fips=''
- name: release/test
uses: ./.github/actions/test
- name: release/build
shell: powershell
env:
MM_WIN_INSTALLERS: 1
PFX_KEY: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX_KEY }}
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_KEY_PASSWORD }}
PFX: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_PFX }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MSI_INSTALLER_CSC_LINK }}
run: |
npm run package:windows
- name: release/package
run: |
mkdir -p ./build/win-release
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/win-release
- name: release/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 14
build-mac-installer:
runs-on: macos-12
needs:
- begin-notification
steps:
- name: release/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: release/setup-node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version-file: "package.json"
cache: "npm"
cache-dependency-path: package-lock.json
- name: release/create-build-folder
run: mkdir -p ./build
- name: release/install-dependencies
run: |
brew install yq rename
npm ci
- name: release/test
uses: ./.github/actions/test
- name: release/build
env:
APPLE_ID: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_APPLE_ID_PASS }}
CSC_FOR_PULL_REQUEST: true
CSC_KEY_PASSWORD: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_CSC_LINK }}
MAC_PROFILE: ${{ secrets.MM_DESKTOP_MAC_INSTALLER_DMG_PROFILE }}
run: |
echo $MAC_PROFILE | base64 -D > ./mac.provisionprofile
mkdir -p ./build/macos-release
npm run package:mac-with-universal
bash -x ./scripts/patch_updater_yml.sh
bash -x ./scripts/cp_artifacts.sh release ./build/macos-release
- name: release/rename-arm64-to-m1
run: rename 's/arm64/m1/' ./build/macos-release/$(jq -r .version package.json)/*
- name: release/upload-build
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: build-${{ runner.os }}
path: ./build
compression-level: 0
retention-days: 14
upload-to-s3:
runs-on: ubuntu-22.04
needs:
- build-mac-installer
- build-msi-installer
- build-linux
steps:
- name: release/setup-aws-credentials
uses: aws-actions/configure-aws-credentials@67fbcbb121271f7775d2e7715933280b06314838 # v1.7.0
with:
aws-region: us-east-1
aws-access-key-id: ${{ secrets.MM_DESKTOP_RELEASE_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.MM_DESKTOP_RELEASE_AWS_SECRET_ACCESS_KEY }}
- name: release/download-builds
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
path: build
pattern: build-*
merge-multiple: true
- name: release/setup-files-for-aws
run: |
mkdir -p ./aws-s3-dist
cp -r --backup=numbered ./build/{macos-release,win-release,linux}/* ./aws-s3-dist
- name: release/upload-to-s3
run: aws s3 cp ./aws-s3-dist/ s3://releases.mattermost.com/desktop/ --acl public-read --cache-control "no-cache" --recursive
github-release:
runs-on: ubuntu-22.04
needs:
- upload-to-s3
steps:
- name: release/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: release/download-builds
uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
with:
path: build
pattern: build-*
merge-multiple: true
- name: release/setup-files-for-github-release
run: |
mkdir -p ./ghr-dist
find ./build/{macos-release,win-release,linux} -type f -exec cp --backup=numbered -t ./ghr-dist {} +
- name: release/publish-release
env:
GITHUB_TOKEN: ${{ secrets.MATTERMOST_BUILD_GH_TOKEN }}
run: |
VERSION=$(jq -r .version package.json)
./scripts/generate_release_markdown.sh ${VERSION} > release-notes.md
RELEASE_TITLE="v${VERSION} ($(date -u "+%Y-%m-%d"))"
[[ $VERSION =~ "-rc" ]] && PRERELEASE="--prerelease"
gh release create --draft ${PRERELEASE} --verify-tag -F release-notes.md --target "${GITHUB_SHA}" --title "${RELEASE_TITLE}" "${GITHUB_REF_NAME}" ./ghr-dist/*
end-notification:
runs-on: ubuntu-22.04
needs:
- github-release
steps:
- name: release/checkout-repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
- name: release/fetch-version
id: calc
run: |
echo "BODY<<EOF" >> "${GITHUB_OUTPUT}"
bash -x scripts/generate_release_post.sh $(jq -r .version package.json) >> "${GITHUB_OUTPUT}"
echo "EOF" >> "${GITHUB_OUTPUT}"
- name: release/notify-channel
uses: mattermost/action-mattermost-notify@60f5da6e1796b033cf5a038b57031fa011962e27
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MM_DESKTOP_RELEASE_WEBHOOK_URL }}
MATTERMOST_USERNAME: MattermostRelease
MATTERMOST_ICON_URL: https://mattermost.com/wp-content/uploads/2022/02/icon.png
TEXT: |
${{ steps.calc.outputs.BODY }}

View file

@ -0,0 +1,47 @@
name: Scorecards supply-chain security
on:
# Only the default branch is supported.
branch_protection_rule:
schedule:
- cron: "44 7 * * 5"
push:
branches: [master]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecards analysis
runs-on: ubuntu-latest
permissions:
# Needed if using Code scanning alerts
security-events: write
# Needed for GitHub OIDC token if publish_results is true
id-token: write
steps:
- name: "Checkout code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
publish_results: true
# Upload the results as artifacts (optional).
- name: "Upload artifact"
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@423a04bb2cb7cd2643007122588f1387778f14d0 # v2.16.5
with:
sarif_file: results.sarif

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
Thumbs.db
.DS_Store
*.log
node_modules/
*_bundle.js
#release*/
npm-debug.log*
build/
coverage/
dist/
mochawesome-report/
test-results.xml
/e2e/performance/perf-test-report.json
test_config.json
.idea
testUserData
.gitattributes
fastlane/README.md
fastlane/report.xml
*.provisionprofile
*.tsbuildinfo
.eslintcache

8
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,8 @@
---
include:
- project: mattermost/ci/desktop
ref: main
file: private.yml

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v20.15.0

11
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"dbaeumer.vscode-eslint",
"msjsdiag.debugger-for-chrome",
"streetsidesoftware.code-spell-checker",
"lokalise.i18n-ally",
"EditorConfig.EditorConfig"
]
}

74
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,74 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Main Process",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
},
"program": "${workspaceRoot}/dist/index.js",
"preLaunchTask": "Build sources"
},
/*
{
"name": "Debug Renderer Process",
"type": "chrome",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
},
"runtimeArgs": [
"${workspaceRoot}/src",
"--disable-dev-mode",
"--remote-debugging-port=9222"
],
"webRoot": "${workspaceRoot}/src/browser",
"sourceMaps": true,
"preLaunchTask": "Build sources"
},
*/
{
"type": "node",
"request": "launch",
"name": "E2E Tests",
"program": "${workspaceRoot}/node_modules/electron-mocha/bin/electron-mocha",
"args": [
"--recursive",
"--timeout",
"999999",
"--colors",
"${workspaceRoot}/dist/tests/e2e_bundle.js"
],
"internalConsoleOptions": "openOnSessionStart",
"preLaunchTask": "prepare-e2e"
},
{
"type": "node",
"request": "launch",
"name": "E2E Performance Tests",
"program": "${workspaceRoot}/node_modules/electron-mocha/bin/electron-mocha",
"args": [
"--recursive",
"--timeout",
"999999",
"--colors",
"${workspaceRoot}/dist/tests/e2e_bundle.js"
],
"internalConsoleOptions": "openOnSessionStart",
"preLaunchTask": "prepare-e2e-performance",
},
{
"type": "node",
"request": "launch",
"name": "Unit Tests",
"program": "${workspaceRoot}/node_modules/jest/bin/jest",
"args": [],
"internalConsoleOptions": "openOnSessionStart",
}
]
}

55
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,55 @@
{
"i18n-ally.localesPaths": [
"i18n"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always",
},
"cSpell.words": [
"appimage",
"automations",
"Autoupgrade",
"browserview",
"btns",
"chromedriver",
"chromedriverlog",
"deauthorize",
"deeplink",
"deeplinking",
"diskimage",
"dont",
"favicons",
"filechooser",
"flac",
"FOCALBOARD",
"gsettings",
"ICONNAME",
"inputflash",
"libxtst",
"loadscreen",
"mailhost",
"mailserver",
"MMAUTHTOKEN",
"MMCSRF",
"mmjstool",
"MMUSERID",
"mochawesome",
"NOSERVERS",
"Ochiai",
"officedocument",
"openxmlformats",
"presentationml",
"servernum",
"showunreadbadge",
"spreadsheetml",
"textbox",
"UNCLOSEABLE",
"Unmaximize",
"Unreads",
"webcontents",
"wordprocessingml",
"xvfb",
"Yuya"
],
"i18n-ally.keystyle": "nested"
}

25
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,25 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Build sources",
"type": "npm",
"script": "build",
"problemMatcher": []
},
{
"label": "prepare-e2e",
"type": "shell",
"command": "npm run build; npm run build-robotjs; npm run test:e2e:build",
"problemMatcher": []
},
{
"label": "prepare-e2e-performance",
"type": "shell",
"command": "cross-env NODE_ENV=test PERFORMANCE=true npm run build; npm run build-robotjs; npm run test:e2e:build-performance",
"problemMatcher": []
}
]
}

3
CHANGELOG.md Normal file
View file

@ -0,0 +1,3 @@
# Mattermost Desktop Application Changelog
The Desktop App changelog has moved to the [Admin Documentation](https://docs.mattermost.com/help/apps/desktop-changelog.html).

46
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,46 @@
# Contributing Guidelines
Thank you for your interest in contributing! Please see the guidelines below before contributing and [join our "Developers: Desktop App" community channel](https://community.mattermost.com/core/channels/desktop-app) to ask questions from community members and the Mattermost Desktop team.
You can also visit our [developer guide](https://developers.mattermost.com/contribute/desktop/) to learn more information about how to set up your environment, as well as develop and test changes to the Desktop App.
## Issue
We really appreciate your feedback on the Desktop App. We'd ask that before you file an issue that you go through a few steps beforehand.
### Does it reproduce in a web browser?
Mattermost Desktop is based on Electron, which integrates the Chrome engine within a standalone application.
If the problem you encounter can be reproduced on web browsers, it may be an issue with Mattermost server (or Chrome).
If this is the case, please create an issue in the [mattermost-server](https://github.com/mattermost/mattermost-server) or [mattermost-webapp](https://github.com/mattermost/mattermost-webapp) repositories.
### Try "Clear Cache and Reload"
Sometimes issues can be resolved simply by refreshing your Mattermost server within the app.
You can do this by pressing `CMD/CTRL+SHIFT+R` in the Mattermost Desktop App, or you can go to the menu and select **View > Clear Cache and Reload**.
### Write detailed information
If the issue still persists, please provide detailed information to help us to understand the problem. Include information such as:
* How to reproduce, step-by-step
* Expected behavior (or what is wrong)
* Screenshots (for GUI issues)
* Desktop App version (can be viewed by going to 3-dot menu > Help, or **Menu > Mattermost > About Mattermost** on macOS).
* Operating System
* Mattermost Server version
## Feature idea
If you have an idea for a new feature, we'd love to hear about it!
Please let us know in the Mattermost Community server by making a post in the [Feature Proposals](https://community-daily.mattermost.com/core/channels/feature-ideas) channel.
## Pull request
If you are interested on working on an issue, we would very much appreciate your help!
We have a list of issues marked as [Help Wanted](https://mattermost.com/pl/help-wanted-desktop) that are available to be worked on.
If you'd like to take on an issue, simply comment on the issue and one of the Core Contributors will assign it to you.
Once your change is ready, please make sure you perform the following tasks before submitting a pull request:
1. Make sure that the PR passes all automated checks. You can do this by running the following commands:
```
npm run lint:js
npm run check-types
npm run test
```
2. If you are fixing a bug, consider writing a unit test for the change so that the issue does not resurface. If you are adding a new feature, consider additionally writing end-to-end (E2E) tests to thoroughly test the changes.
3. Please complete the [Mattermost CLA](https://mattermost.com/contribute/) prior to submitting a PR.

204
LICENSE.txt Normal file
View file

@ -0,0 +1,204 @@
Copyright (c) 2015-2016 Yuya Ochiai
Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

55
Makefile Normal file
View file

@ -0,0 +1,55 @@
SIGNER?="origin"
GPG=$(shell command which gpg || echo "N/A")
DPKG_SIG=$(shell command which dpkg-sig || echo "N/A")
#define sign_debian_package
# dpkg-sig -k ${GPG_KEY_ID} --sign ${SIGNER} $1
# dpkg-sig --verify $1
#endef
.PHONY: check-sign-linux-deb
check-sign-linux-deb: ##Check running environment to sign debian packages
ifeq ("$(GPG)","N/A")
@echo "Path does not contain gpg executable. Consider install!"
@exit 128
else
@echo "gpg Found in path!"
endif
ifeq ("$(DPKG_SIG)","N/A")
@echo "Path does not contain dpkg_sig executable. Consider install!"
@exit 128
else
@echo "dpkg_sig Found in path!"
endif
ifndef GPG_KEY_ID
@echo "Please define GPG_KEY_ID environment variable!"
@exit 128
else
@echo "GPG_KEY_ID is defined"
endif
.PHONY: npm-ci
npm-ci: ## Install all npm dependencies
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci
.PHONY: package
package: package-linux-deb ## Generates packages for all environments
.PHONY: package-linux-deb
package-linux-deb: npm-ci ## Generates linux packages under build/linux folder
npm run package:linux-deb
mkdir -p artifacts
find ./release -name '*.deb' -exec cp "{}" artifacts/ \;
.PHONY: sign
sign: sign-linux-deb ## Sign packages in artifacts directory
.PHONY: sign-linux-deb
sign-linux-deb: check-sign-linux-deb ## Sign debian packages
$(foreach file, $(wildcard artifacts/*.deb), $(call sign_debian_package,${file});)
## Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
help:
@grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' ./Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

2647
NOTICE.txt Normal file

File diff suppressed because it is too large Load diff

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# Mattermost Desktop
[Mattermost](https://mattermost.com) is an open source platform for secure collaboration across the entire software development lifecycle. This repo is for the native desktop application that's built on [Electron](http://electron.atom.io/); it runs on Windows, Mac, and Linux.
Originally created as "electron-mattermost" by Yuya Ochiai.
![mm-desktop-screenshot](https://user-images.githubusercontent.com/52460000/146078917-e1ba8c1f-24e5-4613-8b4b-f3507422f4f2.png)
[![nightly-builds](https://github.com/mattermost/desktop/actions/workflows/nightly-builds.yaml/badge.svg)](https://github.com/mattermost/desktop/actions/workflows/nightly-builds.yaml)
## Features
### Desktop integration
* Server dropdown for access to multiple servers
* Dedicated tabs for Channels, Boards and Playbooks
* Desktop Notifications
* Badges for unread channels and mentions
* Deep Linking to open Mattermost links directly in the app
* Runs in background to reduce number of open windows
## Usage
### Installation
Detailed guides are available at [docs.mattermost.com](https://docs.mattermost.com/install/desktop-app-install.html).
1. Download a file from the [downloads page](https://mattermost.com/download/#mattermostApps) or from the [releases page](https://github.com/mattermost/desktop/releases).
2. Run the installer or unzip the archive.
3. Launch Mattermost from your Applications folder, menu, or the unarchived folder.
3. On the first launch, please enter a name and URL for your Mattermost server. For example, `https://mattermost.example.com`.
### Configuration
You can show the dialog from menu bar.
Configuration will be saved into Electron's userData directory:
* `%APPDATA%\Mattermost` on Windows
* `~/Library/Application Support/Mattermost` on OS X
* `~/.config/Mattermost` on Linux
A custom data directory location can be specified with:
* `Mattermost.exe --args --data-dir C:\my-mattermost-data` on Windows
* `open /Applications/Mattermost.app/ --args --data-dir ~/my-mattermost-data/` on macOS
* `./mattermost-desktop --args --data-dir ~/my-mattermost-data/` on Linux
## Custom App Deployments
Our [docs provide a guide](https://docs.mattermost.com/deployment/desktop-app-deployment.html) on how to customize and distribute your own Mattermost Desktop App, including how to distribute the official Windows Desktop App silently to end users, pre-configured with the server URL and other app settings.
## Development and Making Contributions
Our [developer guide](https://developers.mattermost.com/contribute/desktop/) has detailed information on how to set up your development environment, develop, and test changes to the Desktop App.

25
SECURITY.md Normal file
View file

@ -0,0 +1,25 @@
Security
========
Safety and data security is of the utmost priority for the Mattermost community. If you are a security researcher and have discovered a security vulnerability in our codebase, we would appreciate your help in disclosing it to us in a responsible manner.
Reporting security issues
-------------------------
**Please do not use GitHub issues for security-sensitive communication.**
Security issues in the community test server, any of the open source codebases maintained by Mattermost, or any of our commercial offerings should be reported via email to [responsibledisclosure@mattermost.com](mailto:responsibledisclosure@mattermost.com). Mattermost is committed to working together with researchers and keeping them updated throughout the patching process. Researchers who responsibly report valid security issues will be publicly credited for their efforts (if they so choose).
For a more detailed description of the disclosure process and a list of researchers who have previously contributed to the disclosure program, see [Report a Security Vulnerability](https://mattermost.com/security-vulnerability-report/) on the Mattermost website.
Security updates
----------------
Mattermost has a mandatory upgrade policy, and updates are only provided for the latest release. Critical updates are delivered as dot releases. Details on security updates are announced 30 days after the availability of the update.
For more details about the security content of past releases, see the [Security Updates](https://mattermost.com/security-updates/) page on the Mattermost website. For timely notifications about new security updates, subscribe to the [Security Bulletins Mailing List](https://mattermost.com/security-updates/#sign-up).
Contributing to this policy
---------------------------
If you have feedback or suggestions on improving this policy document, please [create an issue](https://github.com/mattermost/desktop/issues/new).

40
TESTING.md Normal file
View file

@ -0,0 +1,40 @@
# Mattermost Desktop App Testing
## Automated Testing Guide
You can find information about our automated tests in the [developer guide](https://developers.mattermost.com/contribute/desktop/testing/)
If you are interested in contributing to our automated test library, please read our [contributing guidelines](https://github.com/mattermost/desktop/blob/master/CONTRIBUTING.md).
## Release Testing Guide
Thank you for your interest in improving Mattermost software prior to its next release. Your bug reports increase the quality of the Mattermost experience for thousands of people around the world using Mattermost.
New bug reports benefiting the next release will be documented in the release notes to recognize your unique contribution in the history of the Mattermost open source project.
To contribute to the process of testing the Mattermost Desktop App:
1. If you haven't already, create an account on our [Community Server](https://community.mattermost.com/)
- Set your username to be the same as your GitHub username
2. Install the latest Mattermost Desktop App
- Download the latest pre-release Mattermost Desktop App from the [GitHub Releases page](https://github.com/mattermost/desktop/releases).
- Follow the [Desktop Application Install Guides](https://docs.mattermost.com/install/desktop-app-install.html) to install the app for your platform.
- Use the [Managing Servers Guide](https://docs.mattermost.com/messaging/managing-desktop-app-servers.html) to add https://community.mattermost.com/core as a new server.
- Select "Save" and log in to Mattermost.
3. Go to the [Public Test Channel](https://community.mattermost.com/core/channels/public-test-channel) and try the following:
- Post a message with information on what you're testing, for example: `Testing Mattermost Desktop App 5.0.2 on Windows 10 64-bit`.
- Reply to the post by clicking on "..." then "Reply" with This is a comment including files and upload five (5) files including at least one image, one sound file and one video clip from your Desktop App.
- Search for the word "Desktop" and click "Jump" on the search result of your own post in Step 3.1. Click into the preview of the files you uploaded and try to download each one.
- Verify [Server Management works as documented](https://docs.mattermost.com/messaging/managing-desktop-app-servers.html).
- Verify [App Options work as documented](https://docs.mattermost.com/messaging/managing-desktop-app-options.html).
- Verify Menu Bar options work as documented.
- Use the desktop app for another 15 minutes, trying different features and functionality on the user interface.
4. For any bugs found, please [file a new issue report for each](https://github.com/mattermost/desktop/issues/new).
- Please include:
- Operating System
- Mattermost Desktop App version (See File Menu > Help > Version Number)
- Mattermost Server version (See Mattermost Menu > About Mattermost, where Mattermost Menu can be accessed by clicking on three dots next to your profile name)
- Clear steps to reproduce the issue
- [See example of Desktop App issue](https://github.com/mattermost/desktop/issues/355)

84
api-types/index.ts Normal file
View file

@ -0,0 +1,84 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type DesktopSourcesOptions = {
types: Array<'screen' | 'window'>;
thumbnailSize?: {height: number; width: number};
fetchWindowIcons?: boolean;
};
export type DesktopCaptureSource = {
id: string;
name: string;
thumbnailURL: string;
};
export type DesktopAPI = {
// Initialization
isDev: () => Promise<boolean>;
getAppInfo: () => Promise<{name: string; version: string}>;
reactAppInitialized: () => void;
// Session
setSessionExpired: (isExpired: boolean) => void;
onUserActivityUpdate: (listener: (
userIsActive: boolean,
idleTime: number,
isSystemEvent: boolean,
) => void) => () => void;
onLogin: () => void;
onLogout: () => void;
// Unreads/mentions/notifications
sendNotification: (title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, soundName: string) => Promise<{status: string; reason?: string; data?: string}>;
onNotificationClicked: (listener: (channelId: string, teamId: string, url: string) => void) => () => void;
setUnreadsAndMentions: (isUnread: boolean, mentionCount: number) => void;
// Navigation
requestBrowserHistoryStatus: () => Promise<{canGoBack: boolean; canGoForward: boolean}>;
onBrowserHistoryStatusUpdated: (listener: (canGoBack: boolean, canGoForward: boolean) => void) => () => void;
onBrowserHistoryPush: (listener: (pathName: string) => void) => () => void;
sendBrowserHistoryPush: (path: string) => void;
// Calls
joinCall: (opts: {
callID: string;
title: string;
rootID: string;
channelURL: string;
}) => Promise<{callID: string; sessionID: string}>;
leaveCall: () => void;
callsWidgetConnected: (callID: string, sessionID: string) => void;
resizeCallsWidget: (width: number, height: number) => void;
sendCallsError: (err: string, callID?: string, errMsg?: string) => void;
onCallsError: (listener: (err: string, callID?: string, errMsg?: string) => void) => () => void;
getDesktopSources: (opts: DesktopSourcesOptions) => Promise<DesktopCaptureSource[]>;
openScreenShareModal: () => void;
onOpenScreenShareModal: (listener: () => void) => () => void;
shareScreen: (sourceID: string, withAudio: boolean) => void;
onScreenShared: (listener: (sourceID: string, withAudio: boolean) => void) => () => void;
sendJoinCallRequest: (callId: string) => void;
onJoinCallRequest: (listener: (callID: string) => void) => () => void;
openLinkFromCalls: (url: string) => void;
focusPopout: () => void;
openThreadForCalls: (threadID: string) => void;
onOpenThreadForCalls: (listener: (threadID: string) => void) => () => void;
openStopRecordingModal: (channelID: string) => void;
onOpenStopRecordingModal: (listener: (channelID: string) => void) => () => void;
openCallsUserSettings: () => void;
onOpenCallsUserSettings: (listener: () => void) => () => void;
// Utility
unregister: (channel: string) => void;
}

69
api-types/lib/index.d.ts vendored Normal file
View file

@ -0,0 +1,69 @@
export type DesktopSourcesOptions = {
types: Array<'screen' | 'window'>;
thumbnailSize?: {
height: number;
width: number;
};
fetchWindowIcons?: boolean;
};
export type DesktopCaptureSource = {
id: string;
name: string;
thumbnailURL: string;
};
export type DesktopAPI = {
isDev: () => Promise<boolean>;
getAppInfo: () => Promise<{
name: string;
version: string;
}>;
reactAppInitialized: () => void;
setSessionExpired: (isExpired: boolean) => void;
onUserActivityUpdate: (listener: (userIsActive: boolean, idleTime: number, isSystemEvent: boolean) => void) => () => void;
onLogin: () => void;
onLogout: () => void;
sendNotification: (title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, soundName: string) => Promise<{
status: string;
reason?: string;
data?: string;
}>;
onNotificationClicked: (listener: (channelId: string, teamId: string, url: string) => void) => () => void;
setUnreadsAndMentions: (isUnread: boolean, mentionCount: number) => void;
requestBrowserHistoryStatus: () => Promise<{
canGoBack: boolean;
canGoForward: boolean;
}>;
onBrowserHistoryStatusUpdated: (listener: (canGoBack: boolean, canGoForward: boolean) => void) => () => void;
onBrowserHistoryPush: (listener: (pathName: string) => void) => () => void;
sendBrowserHistoryPush: (path: string) => void;
joinCall: (opts: {
callID: string;
title: string;
rootID: string;
channelURL: string;
}) => Promise<{
callID: string;
sessionID: string;
}>;
leaveCall: () => void;
callsWidgetConnected: (callID: string, sessionID: string) => void;
resizeCallsWidget: (width: number, height: number) => void;
sendCallsError: (err: string, callID?: string, errMsg?: string) => void;
onCallsError: (listener: (err: string, callID?: string, errMsg?: string) => void) => () => void;
getDesktopSources: (opts: DesktopSourcesOptions) => Promise<DesktopCaptureSource[]>;
openScreenShareModal: () => void;
onOpenScreenShareModal: (listener: () => void) => () => void;
shareScreen: (sourceID: string, withAudio: boolean) => void;
onScreenShared: (listener: (sourceID: string, withAudio: boolean) => void) => () => void;
sendJoinCallRequest: (callId: string) => void;
onJoinCallRequest: (listener: (callID: string) => void) => () => void;
openLinkFromCalls: (url: string) => void;
focusPopout: () => void;
openThreadForCalls: (threadID: string) => void;
onOpenThreadForCalls: (listener: (threadID: string) => void) => () => void;
openStopRecordingModal: (channelID: string) => void;
onOpenStopRecordingModal: (listener: (channelID: string) => void) => () => void;
openCallsUserSettings: () => void;
onOpenCallsUserSettings: (listener: () => void) => () => void;
unregister: (channel: string) => void;
};

4
api-types/lib/index.js Normal file
View file

@ -0,0 +1,4 @@
"use strict";
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
Object.defineProperty(exports, "__esModule", { value: true });

21
api-types/package-lock.json generated Normal file
View file

@ -0,0 +1,21 @@
{
"name": "@mattermost/desktop-api",
"version": "5.10.0-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mattermost/desktop-api",
"version": "5.10.0-1",
"license": "MIT",
"peerDependencies": {
"typescript": "^4.3.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
}
}

34
api-types/package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "@mattermost/desktop-api",
"version": "5.10.0-1",
"description": "Shared types for the Desktop App API provided to the Web App",
"keywords": [
"mattermost"
],
"homepage": "https://github.com/mattermost/desktop",
"license": "MIT",
"files": [
"lib"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"repository": {
"type": "git",
"url": "github:mattermost/desktop",
"directory": "api-types"
},
"peerDependencies": {
"typescript": "^4.3.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"scripts": {
"build": "tsc --build --verbose",
"run": "tsc --watch --preserveWatchOutput",
"clean": "rm -rf tsconfig.tsbuildinfo ./lib",
"prepublishOnly": "npm run build && rm -rf ./lib/tsconfig.tsbuildinfo"
}
}

21
api-types/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es6",
"declaration": true,
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"outDir": "./lib",
"rootDir": ".",
"composite": true,
"types": []
},
"include": [
"./index.ts"
]
}

27
babel.config.js Normal file
View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
module.exports = (api) => {
api.cache.forever();
return {
presets: [
['@babel/preset-env', {
targets: {
browsers: ['Electron >= 29.0'],
node: '20.9',
},
}],
'@babel/preset-react',
['@babel/typescript', {
allExtensions: true,
isTSX: true,
}],
],
plugins: [
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-transform-class-properties',
'@babel/plugin-transform-private-methods',
],
};
};

11
e2e/babel.config.js Normal file
View file

@ -0,0 +1,11 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
module.exports = (api) => {
api.cache.forever();
return {
presets: [
'@babel/typescript',
],
};
};

330
e2e/modules/environment.js Normal file
View file

@ -0,0 +1,330 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const {execSync} = require('child_process');
const fs = require('fs');
const path = require('path');
const chai = require('chai');
const {ipcRenderer} = require('electron');
const {_electron: electron} = require('playwright');
const ps = require('ps-node');
const {SHOW_SETTINGS_WINDOW} = require('src/common/communication');
const {asyncSleep, mkDirAsync, rmDirAsync, unlinkAsync} = require('./utils');
chai.should();
const sourceRootDir = path.join(__dirname, '../..');
const electronBinaryPath = (() => {
if (process.platform === 'darwin') {
return path.join(sourceRootDir, 'node_modules/electron/dist/Electron.app/Contents/MacOS/Electron');
}
const exeExtension = (process.platform === 'win32') ? '.exe' : '';
return path.join(sourceRootDir, 'node_modules/electron/dist/electron' + exeExtension);
})();
const userDataDir = path.join(sourceRootDir, 'e2e/testUserData/');
const configFilePath = path.join(userDataDir, 'config.json');
const downloadsFilePath = path.join(userDataDir, 'downloads.json');
const downloadsLocation = path.join(userDataDir, 'Downloads');
const boundsInfoPath = path.join(userDataDir, 'bounds-info.json');
const appUpdatePath = path.join(userDataDir, 'app-update.yml');
const exampleURL = 'http://example.com/';
const mattermostURL = process.env.MM_TEST_SERVER_URL || 'http://localhost:8065/';
if (process.platform === 'win32') {
const robot = require('robotjs');
robot.mouseClick();
}
const exampleServer = {
name: 'example',
url: exampleURL,
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
};
const githubServer = {
name: 'github',
url: 'https://github.com/',
order: 1,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
isOpen: true,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
isOpen: true,
},
],
lastActiveTab: 0,
};
const demoConfig = {
version: 3,
teams: [exampleServer, githubServer],
showTrayIcon: false,
trayIconTheme: 'light',
minimizeToTray: false,
notifications: {
flashWindow: 0,
bounceIcon: false,
bounceIconType: 'informational',
},
showUnreadBadge: true,
useSpellChecker: true,
enableHardwareAcceleration: true,
autostart: true,
hideOnStart: false,
spellCheckerLocales: [],
darkMode: false,
lastActiveTeam: 0,
startInFullscreen: false,
autoCheckForUpdates: true,
appLanguage: 'en',
logLevel: 'silly',
};
const demoMattermostConfig = {
...demoConfig,
teams: [{
...exampleServer,
url: mattermostURL,
}, githubServer],
};
const cmdOrCtrl = process.platform === 'darwin' ? 'command' : 'control';
module.exports = {
sourceRootDir,
configFilePath,
downloadsFilePath,
downloadsLocation,
userDataDir,
boundsInfoPath,
appUpdatePath,
exampleURL,
mattermostURL,
demoConfig,
demoMattermostConfig,
cmdOrCtrl,
async clearElectronInstances() {
return new Promise((resolve, reject) => {
ps.lookup({
command: process.platform === 'darwin' ? 'Electron' : 'electron',
}, (err, resultList) => {
if (err) {
reject(err);
}
resultList.forEach((process) => {
if (process && process.command === electronBinaryPath && !process.arguments.some((arg) => arg.includes('electron-mocha'))) {
ps.kill(process.pid);
}
});
resolve();
});
});
},
cleanTestConfig() {
[configFilePath, downloadsFilePath, boundsInfoPath].forEach((file) => {
try {
fs.unlinkSync(file);
} catch (err) {
if (err.code !== 'ENOENT') {
// eslint-disable-next-line no-console
console.error(err);
}
}
});
},
async cleanTestConfigAsync() {
await Promise.all(
[configFilePath, downloadsFilePath, boundsInfoPath].map((file) => {
return unlinkAsync(file);
}),
);
},
cleanDataDir() {
try {
fs.rmdirSync(userDataDir, {recursive: true});
} catch (err) {
if (err.code !== 'ENOENT') {
// eslint-disable-next-line no-console
console.error(err);
}
}
},
cleanDataDirAsync() {
return rmDirAsync(userDataDir);
},
createTestUserDataDir() {
if (!fs.existsSync(userDataDir)) {
fs.mkdirSync(userDataDir);
}
},
clipboard(textToCopy) {
switch (process.platform) {
case 'linux':
execSync(`echo "${textToCopy}" | xsel --clipboard`);
break;
case 'win32':
execSync(`echo ${textToCopy} | clip`);
break;
case 'darwin':
execSync(`pbcopy <<< ${textToCopy}`);
break;
}
},
async createTestUserDataDirAsync() {
await mkDirAsync(userDataDir);
},
async getApp(args = []) {
const options = {
downloadsPath: downloadsLocation,
env: {
...process.env,
RESOURCES_PATH: userDataDir,
},
executablePath: electronBinaryPath,
args: [`${path.join(sourceRootDir, 'e2e/dist')}`, `--user-data-dir=${userDataDir}`, '--disable-dev-mode', '--no-sandbox', ...args],
};
// if (process.env.MM_DEBUG_SETTINGS) {
// options.chromeDriverLogPath = './chromedriverlog.txt';
// }
// if (process.platform === 'darwin' || process.platform === 'linux') {
// // on a mac, debugging port might conflict with other apps
// // this changes the default debugging port so chromedriver can run without issues.
// options.chromeDriverArgs.push('remote-debugging-port=9222');
//}
return electron.launch(options).then(async (eapp) => {
await eapp.evaluate(async ({app}) => {
const promise = new Promise((resolve) => {
app.on('e2e-app-loaded', () => {
resolve();
});
});
return promise;
});
return eapp;
});
},
async getServerMap(app) {
const map = {};
await Promise.all(app.windows().map(async (win) => {
return win.evaluate(async () => {
if (!window.testHelper) {
return null;
}
const info = await window.testHelper.getViewInfoForTest();
return {viewName: `${info.serverName}___${info.viewType}`, webContentsId: info.webContentsId};
}).then((result) => {
if (result) {
map[result.viewName] = {win, webContentsId: result.webContentsId};
}
});
}));
return map;
},
async loginToMattermost(window) {
// Do this twice because sometimes the app likes to load the login screen, then go to Loading... again
await asyncSleep(1000);
await window.waitForSelector('#input_loginId');
await window.waitForSelector('#input_password-input');
await window.waitForSelector('#saveSetting');
await window.type('#input_loginId', process.env.MM_TEST_USER_NAME);
await window.type('#input_password-input', process.env.MM_TEST_PASSWORD);
await window.click('#saveSetting');
},
async openDownloadsDropdown(app) {
const mainWindow = app.windows().find((window) => window.url().includes('index'));
await mainWindow.waitForLoadState();
await mainWindow.bringToFront();
const dlButtonLocator = await mainWindow.waitForSelector('.DownloadsDropdownButton');
await dlButtonLocator.click();
await asyncSleep(500);
const downloadsWindow = app.windows().find((window) => window.url().includes('downloadsDropdown.html'));
await downloadsWindow.waitForLoadState();
await downloadsWindow.bringToFront();
await downloadsWindow.isVisible('.DownloadsDropdown');
return downloadsWindow;
},
async downloadsDropdownIsOpen(app) {
const downloadsWindow = app.windows().find((window) => window.url().includes('downloadsDropdown.html'));
const result = await downloadsWindow.isVisible('.DownloadsDropdown');
return result;
},
addClientCommands(client) {
client.addCommand('loadSettingsPage', function async() {
ipcRenderer.send(SHOW_SETTINGS_WINDOW);
});
client.addCommand('isNodeEnabled', function async() {
return this.execute(() => {
try {
if (require('child_process')) {
return true;
}
return false;
} catch (e) {
return false;
}
}).then((requireResult) => {
return requireResult.value;
});
});
client.addCommand('waitForAppOptionsAutoSaved', function async() {
const ID_APP_OPTIONS_SAVE_INDICATOR = '#appOptionsSaveIndicator';
const TIMEOUT = 5000;
return this.
waitForVisible(ID_APP_OPTIONS_SAVE_INDICATOR, TIMEOUT).
waitForVisible(ID_APP_OPTIONS_SAVE_INDICATOR, TIMEOUT, true);
});
},
// execute the test only when `condition` is true
shouldTest(it, condition) {
return condition ? it : it.skip;
},
isOneOf(platforms) {
return (platforms.indexOf(process.platform) !== -1);
},
};

20
e2e/modules/test.html Normal file
View file

@ -0,0 +1,20 @@
<html>
<head>
<title>Mattermost Desktop testing html</title>
</head>
<body>
<h1>window.open() test</h1>
<p>
<input type="button" value="window.open()" onclick="open_window()">
</p>
<script>
function open_window() {
window.open('./test.html');
}
</script>
</body>
</html>

104
e2e/modules/utils.js Normal file
View file

@ -0,0 +1,104 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const fs = require('fs');
function asyncSleep(timeout) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, timeout);
});
}
function dirExistsAsync(path) {
return new Promise((resolve, reject) => {
fs.stat(path, (error, stats) => {
if (error) {
if (error.code === 'ENOENT') {
resolve(false);
} else {
reject(error);
}
return;
}
resolve(stats.isDirectory());
});
});
}
function mkDirAsync(path) {
return new Promise((resolve, reject) => {
dirExistsAsync(path).then((exists) => {
if (exists) {
resolve();
return;
}
fs.mkdir(path, {recursive: true}, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}).catch((err) => {
reject(err);
});
});
}
function rmDirAsync(path) {
return new Promise((resolve, reject) => {
dirExistsAsync(path).then((exists) => {
if (exists) {
fs.rm(path, {recursive: true, force: true}, (error) => {
if (error) {
if (error.code === 'ENOENT') {
resolve();
}
reject(error);
}
resolve();
});
}
resolve();
}).catch((err) => {
reject(err);
});
});
}
function unlinkAsync(path) {
return new Promise((resolve, reject) => {
fs.unlink(path, (error) => {
if (error) {
if (error.code === 'ENOENT') {
resolve();
}
reject(error);
}
resolve();
});
});
}
function writeFileAsync(path, data) {
return new Promise((resolve, reject) => {
fs.writeFile(path, data, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
module.exports = {
asyncSleep,
dirExistsAsync,
mkDirAsync,
rmDirAsync,
unlinkAsync,
writeFileAsync,
};

4847
e2e/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
e2e/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "desktop-e2e",
"version": "1.0.0",
"description": "E2E tests for the Desktop App",
"main": "dist/e2e_bundle.js",
"scripts": {
"clean": "rm -rf dist/ mochawesome-report/ node_modules/ testUserData/",
"run:e2e": "npm run build && npm run test",
"build": "webpack-cli --config webpack.config.js",
"build:performance": "webpack-cli --config webpack.config.performance.js",
"test": "electron-mocha --reporter mochawesome dist/e2e_bundle.js",
"test:performance": "electron-mocha --reporter json --reporter-option output=./performance/perf-test-report.json dist/e2e_bundle.js",
"send-report": "node ./save_report.js",
"postinstall": "cross-env CL='/std:c++17' electron-rebuild -m ./node_modules/robotjs"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/mattermost/desktop.git"
},
"author": "Mattermost, Inc. <feedback@mattermost.com>",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/mattermost/desktop/issues"
},
"homepage": "https://github.com/mattermost/desktop#readme",
"dependencies": {
"@aws-sdk/client-s3": "3.529.0",
"@aws-sdk/lib-storage": "3.445.0",
"@electron/rebuild": "3.6.0",
"axios": "1.7.4",
"chai": "4.3.6",
"electron-mocha": "12.2.0",
"fast-xml-parser": "^4.4.1",
"mochawesome": "7.1.3",
"playwright": "1.42.0",
"ps-node": "0.1.6",
"recursive-readdir": "2.2.3",
"robotjs": "0.6.0"
},
"devDependencies": {
"mochawesome-report-generator": "^6.2.0"
}
}

View file

@ -0,0 +1,29 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const env = require('../modules/environment');
describe('startup/app', function desc() {
this.timeout(30000);
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
this.app = await env.getApp();
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('should show the welcome screen modal when no servers exist', async () => {
const welcomeScreenModal = this.app.windows().find((window) => window.url().includes('welcomeScreen'));
const modalButton = await welcomeScreenModal.innerText('.WelcomeScreen .WelcomeScreen__button');
modalButton.should.equal('Get Started');
});
});

105
e2e/save_report.js Normal file
View file

@ -0,0 +1,105 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
/*
* This is used for saving artifacts to AWS S3, sending data to automation dashboard and
* publishing quick summary to community channels.
*
* Usage: [ENV] node save_report.js
*
* Environment variables:
* BRANCH=[branch] : Branch identifier from CI
* BUILD_ID=[build_id] : Build identifier from CI
* BUILD_TAG=[build_tag] : Docker image used to run the test
*
* For saving artifacts to AWS S3
* - AWS_S3_BUCKET, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
* For saving test cases to Test Management
* - ZEPHYR_ENABLE=true|false
* - ZEPHYR_API_KEY=[api_key]
* - JIRA_PROJECT_KEY=[project_key], e.g. "MM",
* - ZEPHYR_FOLDER_ID=[folder_id], e.g. 847997
* For sending hooks to Mattermost channels
* - FULL_REPORT, WEBHOOK_URL and DIAGNOSTIC_WEBHOOK_URL
* Test type
* - TYPE=[type], e.g. "MASTER", "PR", "RELEASE", "CLOUD"
*/
const path = require('path');
const generator = require('mochawesome-report-generator');
const {saveArtifacts} = require('./utils/artifacts');
const {MOCHAWESOME_REPORT_DIR} = require('./utils/constants');
const {
generateShortSummary,
generateTestReport,
removeOldGeneratedReports,
sendReport,
readJsonFromFile,
writeJsonToFile,
} = require('./utils/report');
const {createTestCycle, createTestExecutions} = require('./utils/test_cases');
require('dotenv').config();
const saveReport = async () => {
const {
BRANCH,
BUILD_ID,
BUILD_TAG,
ZEPHYR_ENABLE,
ZEPHYR_CYCLE_KEY,
TYPE,
WEBHOOK_URL,
} = process.env;
removeOldGeneratedReports();
// Import
const jsonReport = readJsonFromFile(path.join(MOCHAWESOME_REPORT_DIR, 'mochawesome.json'));
// Generate the html report file
await generator.create(
jsonReport,
{
reportDir: MOCHAWESOME_REPORT_DIR,
reportTitle: `Desktop E2E - Build: ${BUILD_ID} Branch: ${BRANCH} Tag: ${BUILD_TAG}`,
},
);
// Generate short summary, write to file and then send report via webhook
const {stats, statsFieldValue} = generateShortSummary(jsonReport);
const summary = {stats, statsFieldValue};
console.log(summary);
writeJsonToFile(summary, 'summary.json', MOCHAWESOME_REPORT_DIR);
const result = await saveArtifacts();
if (result && result.success) {
console.log('Successfully uploaded artifacts to S3:', result.reportLink);
}
// Create or use an existing test cycle
let testCycle = {};
if (ZEPHYR_ENABLE === 'true') {
const {start, end} = jsonReport.stats;
testCycle = ZEPHYR_CYCLE_KEY ? {key: ZEPHYR_CYCLE_KEY} : await createTestCycle(start, end);
}
// Send test report to "QA: UI Test Automation" channel via webhook
if (TYPE && TYPE !== 'NONE' && WEBHOOK_URL) {
const data = generateTestReport(summary, result && result.success, result && result.reportLink, testCycle.key);
await sendReport('summary report to Community channel', WEBHOOK_URL, data);
}
// Save test cases to Test Management
if (ZEPHYR_ENABLE === 'true') {
await createTestExecutions(jsonReport, testCycle);
}
// chai.expect(Boolean(jsonReport.stats.failures), FAILURE_MESSAGE).to.be.false;
};
saveReport();

View file

@ -0,0 +1,54 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('application', function desc() {
this.timeout(30000);
const config = env.demoConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
});
afterEach(async () => {
if (this.app) {
try {
await this.app.close();
// eslint-disable-next-line no-empty
} catch (err) {}
}
await env.clearElectronInstances();
});
if (process.platform === 'win32') {
it('MM-T1304/MM-T1306 should open the app on the requested deep link', async () => {
this.app = await env.getApp(['mattermost://github.com/test/url']);
this.serverMap = await env.getServerMap(this.app);
if (!this.app.windows().some((window) => window.url().includes('github.com'))) {
await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('github.com'),
});
}
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const webContentsId = this.serverMap[`${config.teams[1].name}___TAB_MESSAGING`].webContentsId;
const isActive = await browserWindow.evaluate((window, id) => {
return window.getBrowserViews().find((view) => view.webContents.id === id).webContents.getURL();
}, webContentsId);
isActive.should.equal('https://github.com/test/url/');
const dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton');
dropdownButtonText.should.equal('github');
await this.app.close();
});
}
});

View file

@ -0,0 +1,193 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const path = require('path');
const env = require('../../modules/environment');
const {asyncSleep, mkDirAsync, rmDirAsync, writeFileAsync} = require('../../modules/utils');
const config = env.demoConfig;
const file1 = {
addedAt: Date.UTC(2022, 8, 8, 10), // Aug 08, 2022 10:00AM UTC
filename: 'file1.txt',
mimeType: 'plain/text',
location: path.join(env.downloadsLocation, 'file1.txt'),
progress: 100,
receivedBytes: 3917388,
state: 'completed',
totalBytes: 3917388,
type: 'file',
};
const file2 = {
addedAt: Date.UTC(2022, 8, 8, 11), // Aug 08, 2022 11:00AM UTC
filename: 'file2.txt',
mimeType: 'plain/text',
location: path.join(env.downloadsLocation, 'file2.txt'),
progress: 100,
receivedBytes: 7917388,
state: 'completed',
totalBytes: 7917388,
type: 'file',
};
describe('downloads/downloads_dropdown_items', function desc() {
this.timeout(30000);
describe('The list has one downloaded file', () => {
const downloads = {
[file1.filename]: file1,
};
beforeEach(async () => {
await env.createTestUserDataDirAsync();
await env.cleanTestConfigAsync();
await writeFileAsync(env.configFilePath, JSON.stringify(config));
await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads));
await mkDirAsync(env.downloadsLocation);
await writeFileAsync(path.join(env.downloadsLocation, 'file1.txt'), 'file1 content');
await asyncSleep(1000);
this.app = await env.getApp();
this.downloadsWindow = await env.openDownloadsDropdown(this.app);
});
afterEach(async () => {
await rmDirAsync(env.downloadsLocation);
await this.app?.close?.();
await env.clearElectronInstances();
});
it('MM-22239 should display the file correctly (downloaded)', async () => {
const filenameTextLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__Filename');
const filenameInnerText = await filenameTextLocator.innerText();
filenameInnerText.should.equal(downloads['file1.txt'].filename);
const fileStateLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__FileSizeAndStatus');
const fileStateInnerText = await fileStateLocator.innerText();
fileStateInnerText.should.equal('3.92 MB • Downloaded');
const fileThumbnailLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__Thumbnail');
const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image'));
thumbnailBackgroundImage.should.include('text..svg');
});
});
describe('The list has one downloaded file but it is deleted from the folder', () => {
const downloads = {
[file1.filename]: file1,
};
beforeEach(async () => {
await env.createTestUserDataDirAsync();
await env.cleanTestConfigAsync();
await writeFileAsync(env.configFilePath, JSON.stringify(config));
await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads));
await mkDirAsync(env.downloadsLocation);
await asyncSleep(1000);
this.app = await env.getApp();
this.downloadsWindow = await env.openDownloadsDropdown(this.app);
});
afterEach(async () => {
await rmDirAsync(env.downloadsLocation);
await this.app?.close?.();
await env.clearElectronInstances();
});
it('MM-22239 should display the file correctly (deleted)', async () => {
const filenameTextLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__Filename');
const filenameInnerText = await filenameTextLocator.innerText();
filenameInnerText.should.equal(downloads['file1.txt'].filename);
const fileStateLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__FileSizeAndStatus');
const fileStateInnerText = await fileStateLocator.innerText();
fileStateInnerText.should.equal('3.92 MB • Deleted');
const fileThumbnailLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__Thumbnail');
const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image'));
thumbnailBackgroundImage.should.include('text..svg');
});
});
describe('The list has one cancelled file', () => {
const downloads = {
[file1.filename]: {
...file1,
state: 'progressing',
progress: 50,
receivedBytes: 1958694,
totalBytes: 3917388,
},
};
beforeEach(async () => {
await env.createTestUserDataDirAsync();
await env.cleanTestConfigAsync();
await writeFileAsync(env.configFilePath, JSON.stringify(config));
await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads));
await mkDirAsync(env.downloadsLocation);
await writeFileAsync(path.join(env.downloadsLocation, 'file1.txt'), 'file1 content');
await asyncSleep(1000);
this.app = await env.getApp();
this.downloadsWindow = await env.openDownloadsDropdown(this.app);
});
afterEach(async () => {
await rmDirAsync(env.downloadsLocation);
await this.app?.close?.();
await env.clearElectronInstances();
});
it('MM-22239 should display the file correctly (cancelled)', async () => {
const filenameTextLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__Filename');
const filenameInnerText = await filenameTextLocator.innerText();
filenameInnerText.should.equal(downloads['file1.txt'].filename);
const fileStateLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__File__Body__Details__FileSizeAndStatus');
const fileStateInnerText = await fileStateLocator.innerText();
fileStateInnerText.should.equal('3.92 MB • Cancelled');
const fileThumbnailLocator = await this.downloadsWindow.waitForSelector('.DownloadsDropdown__Thumbnail');
const thumbnailBackgroundImage = await fileThumbnailLocator.evaluate((node) => window.getComputedStyle(node).getPropertyValue('background-image'));
thumbnailBackgroundImage.should.include('text..svg');
});
});
describe('The list has two downloaded files', () => {
const downloads = {
'file1.txt': file1,
'file2.txt': file2,
};
beforeEach(async () => {
await env.createTestUserDataDirAsync();
await env.cleanTestConfigAsync();
await writeFileAsync(env.configFilePath, JSON.stringify(config));
await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads));
await mkDirAsync(env.downloadsLocation);
await writeFileAsync(path.join(env.downloadsLocation, 'file1.txt'), 'file1 content');
await writeFileAsync(path.join(env.downloadsLocation, 'file2.txt'), 'file2 content');
await asyncSleep(1000);
this.app = await env.getApp();
this.downloadsWindow = await env.openDownloadsDropdown(this.app);
});
afterEach(async () => {
await rmDirAsync(env.downloadsLocation);
await this.app?.close?.();
await env.clearElectronInstances();
});
it('MM-22239 should display the files in correct order', async () => {
const filenameTextLocators = this.downloadsWindow.locator('.DownloadsDropdown__File__Body__Details__Filename');
(await filenameTextLocators.count()).should.equal(2);
const firstItemLocator = filenameTextLocators.first();
const file1InnerText = await firstItemLocator.innerText();
file1InnerText.should.equal(downloads['file2.txt'].filename); // newest first
const secondItemLocator = filenameTextLocators.nth(1);
const file2InnerText = await secondItemLocator.innerText();
file2InnerText.should.equal(downloads['file1.txt'].filename);
});
});
});

View file

@ -0,0 +1,81 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep, rmDirAsync} = require('../../modules/utils');
const config = {
...env.demoMattermostConfig,
teams: [
...env.demoMattermostConfig.teams,
{
url: 'https://community.mattermost.com',
name: 'community',
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
},
],
};
describe('downloads/downloads_manager', function desc() {
this.timeout(30000);
let firstServer;
const filename = `${Date.now().toString()}.txt`;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
const textbox = await firstServer.waitForSelector('#post_textbox');
const fileInput = await firstServer.waitForSelector('input[type="file"]');
await fileInput.setInputFiles({
name: filename,
mimeType: 'text/plain',
buffer: Buffer.from('this is test file'),
});
await asyncSleep(1000);
await textbox.focus();
robot.keyTap('enter');
});
afterEach(async () => {
await rmDirAsync(env.downloadsLocation);
await this.app?.close?.();
await env.clearElectronInstances();
});
it('MM-22239 should open downloads dropdown when a download starts', async () => {
await firstServer.locator(`a[download="${filename}"]`).click();
await asyncSleep(1000);
(await env.downloadsDropdownIsOpen(this.app)).should.equal(true);
});
});

View file

@ -0,0 +1,126 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const path = require('path');
const env = require('../../modules/environment');
const {asyncSleep, writeFileAsync} = require('../../modules/utils');
const config = env.demoConfig;
const downloads = {
'file1.txt': {
addedAt: Date.UTC(2022, 8, 8, 10), // Aug 08, 2022 10:00AM UTC
filename: 'file1.txt',
mimeType: 'plain/text',
location: path.join(env.downloadsLocation, 'file1.txt'),
progress: 100,
receivedBytes: 3917388,
state: 'completed',
totalBytes: 3917388,
type: 'file',
},
};
describe('downloads/downloads_menubar', function desc() {
this.timeout(30000);
describe('The download list is empty', () => {
beforeEach(async () => {
await env.createTestUserDataDirAsync();
await env.cleanTestConfigAsync();
await writeFileAsync(env.configFilePath, JSON.stringify(config));
await writeFileAsync(env.downloadsFilePath, JSON.stringify({}));
await asyncSleep(1000);
this.app = await env.getApp();
});
afterEach(async () => {
await this.app?.close?.();
await env.clearElectronInstances();
});
it('MM-22239 should not show the downloads dropdown and the menu item should be disabled', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
await mainWindow.waitForLoadState();
await mainWindow.bringToFront();
const dlButton = mainWindow.locator('.DownloadsDropdownButton');
(await dlButton.isVisible()).should.equal(false);
const saveMenuItem = await this.app.evaluate(async ({app}) => {
const viewMenu = app.applicationMenu.getMenuItemById('view');
const saveItem = viewMenu.submenu.getMenuItemById('app-menu-downloads');
return saveItem;
});
saveMenuItem.should.haveOwnProperty('enabled', false);
});
});
describe('The download list has one file', () => {
beforeEach(async () => {
await env.createTestUserDataDirAsync();
await env.cleanTestConfigAsync();
await writeFileAsync(env.configFilePath, JSON.stringify(config));
await writeFileAsync(env.downloadsFilePath, JSON.stringify(downloads));
await asyncSleep(1000);
this.app = await env.getApp();
});
afterEach(async () => {
await this.app?.close?.();
await env.clearElectronInstances();
});
it('MM-22239 should show the downloads dropdown button and the menu item should be enabled', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
await mainWindow.waitForLoadState();
await mainWindow.bringToFront();
const dlButton = await mainWindow.waitForSelector('.DownloadsDropdownButton', {state: 'attached'});
(await dlButton.isVisible()).should.equal(true);
const saveMenuItem = await this.app.evaluate(async ({app}) => {
const viewMenu = app.applicationMenu.getMenuItemById('view');
const saveItem = viewMenu.submenu.getMenuItemById('app-menu-downloads');
return saveItem;
});
saveMenuItem.should.haveOwnProperty('enabled', true);
});
it('MM-22239 should open the downloads dropdown when clicking the download button in the menubar', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
await mainWindow.waitForLoadState();
await mainWindow.bringToFront();
const dlButton = await mainWindow.waitForSelector('.DownloadsDropdownButton', {state: 'attached'});
(await dlButton.isVisible()).should.equal(true);
await dlButton.click();
await asyncSleep(500);
(await env.downloadsDropdownIsOpen(this.app)).should.equal(true);
});
it('MM-22239 should open the downloads dropdown from the app menu', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
await mainWindow.waitForLoadState();
await mainWindow.bringToFront();
await this.app.evaluate(async ({app}) => {
const viewMenu = app.applicationMenu.getMenuItemById('view');
const downloadsItem = viewMenu.submenu.getMenuItemById('app-menu-downloads');
downloadsItem.click();
});
await asyncSleep(500);
(await env.downloadsDropdownIsOpen(this.app)).should.equal(true);
});
});
});

162
e2e/specs/focus.test.js Normal file
View file

@ -0,0 +1,162 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const {SHOW_SETTINGS_WINDOW} = require('src/common/communication');
const env = require('../modules/environment');
const {asyncSleep} = require('../modules/utils');
describe('focus', function desc() {
this.timeout(40000);
const config = {
...env.demoMattermostConfig,
teams: [
...env.demoMattermostConfig.teams,
{
name: 'community',
url: 'https://community.mattermost.com',
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
isOpen: true,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
isOpen: true,
},
],
lastActiveTab: 0,
},
],
};
let firstServer;
let loadingScreen;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
const textbox = await firstServer.waitForSelector('#post_textbox');
textbox.focus();
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
describe('Focus textbox tests', () => {
it('MM-T1315 should return focus to the message box when closing the settings window', async () => {
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
await settingsWindow.close();
const isTextboxFocused = await firstServer.$eval('#post_textbox', (el) => el === document.activeElement);
isTextboxFocused.should.be.true;
await firstServer.fill('#post_textbox', '');
// Make sure you can just start typing and it'll go in the post textbox
await asyncSleep(500);
await firstServer.fill('#post_textbox', 'Mattermost');
await asyncSleep(500);
const textboxString = await firstServer.inputValue('#post_textbox');
textboxString.should.equal('Mattermost');
});
it('MM-T1316 should return focus to the message box when closing the settings window', async () => {
const mainView = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainView.click('.ServerDropdownButton');
await dropdownView.click('.ServerDropdown .ServerDropdown__button.addServer');
const newServerView = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('newServer'),
});
await newServerView.waitForSelector('#cancelNewServerModal');
await newServerView.click('#cancelNewServerModal');
const isTextboxFocused = await firstServer.$eval('#post_textbox', (el) => el === document.activeElement);
isTextboxFocused.should.be.true;
await firstServer.fill('#post_textbox', '');
// Make sure you can just start typing and it'll go in the post textbox
await asyncSleep(500);
await firstServer.fill('#post_textbox', 'Mattermost');
await asyncSleep(500);
const textboxString = await firstServer.inputValue('#post_textbox');
textboxString.should.equal('Mattermost');
});
it('MM-T1317 should return focus to the focused box when switching servers', async () => {
const mainView = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainView.click('.ServerDropdownButton');
await dropdownView.click('.ServerDropdown .ServerDropdown__button:has-text("community")');
// eslint-disable-next-line dot-notation
const secondServer = this.serverMap['community___TAB_MESSAGING'].win;
await secondServer.waitForSelector('#input_loginId');
await secondServer.focus('#input_loginId');
await mainView.click('.ServerDropdownButton');
await dropdownView.click(`.ServerDropdown .ServerDropdown__button:has-text("${config.teams[0].name}")`);
const isTextboxFocused = await firstServer.$eval('#post_textbox', (el) => el === document.activeElement);
isTextboxFocused.should.be.true;
await firstServer.fill('#post_textbox', '');
// Make sure you can just start typing and it'll go in the post textbox
await asyncSleep(500);
await firstServer.fill('#post_textbox', 'Mattermost');
await asyncSleep(500);
const textboxString = await firstServer.inputValue('#post_textbox');
textboxString.should.equal('Mattermost');
await mainView.click('.ServerDropdownButton');
await dropdownView.click('.ServerDropdown .ServerDropdown__button:has-text("community")');
const isLoginFocused = await secondServer.$eval('#input_loginId', (el) => el === document.activeElement);
isLoginFocused.should.be.true;
// Make sure you can just start typing and it'll go in the post textbox
await asyncSleep(500);
robot.typeString('username');
await asyncSleep(500);
const loginString = await secondServer.inputValue('#input_loginId');
loginString.should.equal('username');
});
});
});

View file

@ -0,0 +1,66 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../modules/environment');
const {asyncSleep} = require('../modules/utils');
describe('dark_mode', function desc() {
this.timeout(30000);
const config = env.demoConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
if (process.platform === 'linux') {
it('MM-T2465 Linux Dark Mode Toggle', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
mainWindow.should.not.be.null;
// Toggle Dark Mode
await toggleDarkMode();
const topBarElementWithDarkMode = await mainWindow.waitForSelector('.topBar');
const topBarElementClassWithDarkMode = await topBarElementWithDarkMode.getAttribute('class');
topBarElementClassWithDarkMode.should.contain('topBar darkMode row');
// Toggle Light Mode
await toggleDarkMode();
const topBarElementWithLightMode = await mainWindow.waitForSelector('.topBar');
const topBarElementClassWithLightMode = await topBarElementWithLightMode.getAttribute('class');
topBarElementClassWithLightMode.should.contain('topBar row');
});
}
});
async function toggleDarkMode() {
robot.keyTap('alt');
robot.keyTap('enter');
robot.keyTap('v');
robot.keyTap('t');
await asyncSleep(500); // Add a sleep because sometimes the second 't' doesn't fire
robot.keyTap('t'); // Click on "Toggle Dark Mode" menu item
robot.keyTap('enter');
}

View file

@ -0,0 +1,60 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const {clipboard} = require('electron');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('copylink', function desc() {
this.timeout(40000);
const config = env.demoMattermostConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('MM-T125 Copy Link can be used from channel LHS', async () => {
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#sidebarItem_town-square');
await firstServer.click('#sidebarItem_town-square', {button: 'right'});
switch (process.platform) {
case 'linux':
case 'win32':
robot.keyTap('down');
robot.keyTap('down');
break;
case 'darwin':
robot.keyTap('c');
break;
}
robot.keyTap('enter');
await firstServer.click('#sidebarItem_town-square');
await firstServer.click('#post_textbox');
const clipboardText = clipboard.readText();
await firstServer.fill('#post_textbox', clipboardText);
const content = await firstServer.locator('#post_textbox').textContent();
content.should.contain('/ad-1/channels/town-square');
});
});

View file

@ -0,0 +1,119 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
const config = env.demoConfig;
describe('menu_bar/dropdown', function desc() {
const beforeFunc = async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
};
const afterFunc = async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
};
this.timeout(30000);
it('MM-T4405 should set name of menu item from config file', async () => {
await beforeFunc();
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainWindow.click('.ServerDropdownButton');
const firstMenuItem = await dropdownView.innerText('.ServerDropdown button.ServerDropdown__button:nth-child(1) span');
const secondMenuItem = await dropdownView.innerText('.ServerDropdown button.ServerDropdown__button:nth-child(2) span');
firstMenuItem.should.equal(config.teams[0].name);
secondMenuItem.should.equal(config.teams[1].name);
await afterFunc();
});
describe('MM-T4406 should only show dropdown when button is clicked', async () => {
let mainWindow;
let browserWindow;
before(async () => {
await beforeFunc();
mainWindow = this.app.windows().find((window) => window.url().includes('index'));
browserWindow = await this.app.browserWindow(mainWindow);
});
after(afterFunc);
it('MM-T4406_1 should show the dropdown', async () => {
let dropdownHeight = await browserWindow.evaluate((window) => window.getBrowserViews().find((view) => view.webContents.getURL().includes('dropdown')).getBounds().height);
dropdownHeight.should.equal(0);
await mainWindow.click('.ServerDropdownButton');
dropdownHeight = await browserWindow.evaluate((window) => window.getBrowserViews().find((view) => view.webContents.getURL().includes('dropdown')).getBounds().height);
dropdownHeight.should.be.greaterThan(0);
});
it('MM-T4406_2 should hide the dropdown', async () => {
await mainWindow.click('.TabBar');
const dropdownHeight = await browserWindow.evaluate((window) => window.getBrowserViews().find((view) => view.webContents.getURL().includes('dropdown')).getBounds().height);
dropdownHeight.should.equal(0);
});
});
it('MM-T4407 should open the new server prompt after clicking the add button', async () => {
await beforeFunc();
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainWindow.click('.ServerDropdownButton');
await dropdownView.click('.ServerDropdown__button.addServer');
const newServerModal = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('newServer'),
});
const modalTitle = await newServerModal.innerText('#newServerModal .modal-title');
modalTitle.should.equal('Add Server');
await afterFunc();
});
describe('MM-T4408 Switch Servers', async () => {
let mainWindow;
let browserWindow;
let dropdownView;
before(async () => {
await beforeFunc();
mainWindow = this.app.windows().find((window) => window.url().includes('index'));
browserWindow = await this.app.browserWindow(mainWindow);
dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
});
after(afterFunc);
it('MM-T4408_1 should show the first view', async () => {
const firstViewIsAttached = await browserWindow.evaluate((window, url) => Boolean(window.getBrowserViews().find((view) => view.webContents.getURL() === url)), env.exampleURL);
firstViewIsAttached.should.be.true;
const secondViewIsAttached = await browserWindow.evaluate((window) => Boolean(window.getBrowserViews().find((view) => view.webContents.getURL() === 'https://github.com/')));
secondViewIsAttached.should.be.false;
});
it('MM-T4408_2 should show the second view after clicking the menu item', async () => {
await mainWindow.click('.ServerDropdownButton');
await dropdownView.click('.ServerDropdown button.ServerDropdown__button:nth-child(2)');
const firstViewIsAttached = await browserWindow.evaluate((window, url) => Boolean(window.getBrowserViews().find((view) => view.webContents.getURL() === url)), env.exampleURL);
firstViewIsAttached.should.be.false;
const secondViewIsAttached = await browserWindow.evaluate((window) => Boolean(window.getBrowserViews().find((view) => view.webContents.getURL() === 'https://github.com/')));
secondViewIsAttached.should.be.true;
});
});
});

View file

@ -0,0 +1,125 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('edit_menu', function desc() {
this.timeout(40000);
const config = env.demoMattermostConfig;
let firstServer;
before(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
});
after(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('MM-T807 Undo in the Menu Bar', async () => {
// click on sint channel
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', '');
await firstServer.type('#post_textbox', 'Mattermost');
await firstServer.click('#post_textbox');
robot.keyTap('z', [env.cmdOrCtrl]);
await asyncSleep(500);
const content = await firstServer.inputValue('#post_textbox');
content.should.be.equal('Mattermos');
});
it('MM-T808 Redo in the Menu Bar', async () => {
// click on sint channel
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', '');
await firstServer.type('#post_textbox', 'Mattermost');
await firstServer.click('#post_textbox');
robot.keyTap('z', [env.cmdOrCtrl]);
await asyncSleep(500);
const textAfterUndo = await firstServer.inputValue('#post_textbox');
textAfterUndo.should.be.equal('Mattermos');
await firstServer.click('#post_textbox');
robot.keyTap('z', ['shift', env.cmdOrCtrl]);
await asyncSleep(500);
const content = await firstServer.inputValue('#post_textbox');
content.should.be.equal('Mattermost');
});
it('MM-T809 Cut in the Menu Bar', async () => {
// click on sint channel
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', '');
await firstServer.type('#post_textbox', 'Mattermost');
robot.keyTap('a', [env.cmdOrCtrl]);
await asyncSleep(500);
robot.keyTap('x', [env.cmdOrCtrl]);
await asyncSleep(500);
const content = await firstServer.inputValue('#post_textbox');
content.should.be.equal('');
});
it('MM-T810 Copy in the Menu Bar', async () => {
// click on sint channel
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', '');
await firstServer.type('#post_textbox', 'Mattermost');
robot.keyTap('a', [env.cmdOrCtrl]);
await asyncSleep(500);
robot.keyTap('c', [env.cmdOrCtrl]);
await asyncSleep(500);
await firstServer.click('#post_textbox');
robot.keyTap('v', [env.cmdOrCtrl]);
await asyncSleep(500);
const content = await firstServer.inputValue('#post_textbox');
content.should.be.equal('MattermostMattermost');
});
it('MM-T811 Paste in the Menu Bar', async () => {
// click on sint channel
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', '');
await firstServer.type('#post_textbox', 'Mattermost');
robot.keyTap('a', [env.cmdOrCtrl]);
await asyncSleep(500);
robot.keyTap('c', [env.cmdOrCtrl]);
await asyncSleep(500);
robot.keyTap('a', [env.cmdOrCtrl]);
await asyncSleep(500);
robot.keyTap('v', [env.cmdOrCtrl]);
await asyncSleep(500);
const content = await firstServer.inputValue('#post_textbox');
content.should.be.equal('Mattermost');
});
it('MM-T812 Select All in the Menu Bar', async () => {
// click on sint channel
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', '');
await firstServer.fill('#post_textbox', 'Mattermost');
robot.keyTap('a', [env.cmdOrCtrl]);
await asyncSleep(500);
const channelHeaderText = await firstServer.evaluate('window.getSelection().toString()');
channelHeaderText.should.equal('Mattermost');
});
});

View file

@ -0,0 +1,99 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('file_menu/dropdown', function desc() {
this.timeout(30000);
const config = env.demoConfig;
let skipAfterEach = false;
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
skipAfterEach = false;
});
afterEach(async () => {
if (this.app && skipAfterEach === false) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('MM-T1313 Open Settings modal using keyboard shortcuts', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
mainWindow.should.not.be.null;
robot.keyTap(',', [env.cmdOrCtrl]);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
settingsWindow.should.not.be.null;
});
// TODO: No keyboard shortcut for macOS
if (process.platform !== 'darwin') {
it('MM-T805 Sign in to Another Server Window opens using menu item', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
mainWindow.should.not.be.null;
await mainWindow.click('button.three-dot-menu');
robot.keyTap('f');
robot.keyTap('s');
robot.keyTap('s');
robot.keyTap('enter');
const signInToAnotherServerWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('newServer'),
});
signInToAnotherServerWindow.should.not.be.null;
});
}
if (process.platform !== 'darwin') {
it('MM-T804 Preferences in Menu Bar open the Settings page', async () => {
//Opening the menu bar
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
mainWindow.should.not.be.null;
await mainWindow.click('button.three-dot-menu');
robot.keyTap('f');
robot.keyTap('s');
robot.keyTap('enter');
const settingsWindowFromMenu = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
settingsWindowFromMenu.should.not.be.null;
});
}
// TODO: Causes issues on Windows so skipping for Windows
if (process.platform !== 'win32') {
it('MM-T806 Exit in the Menu Bar', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
mainWindow.should.not.be.null;
if (process.platform === 'darwin') {
robot.keyTap('q', ['command']);
}
if (process.platform === 'linux' || process.platform === 'win32') {
robot.keyTap('q', ['control']);
}
await asyncSleep(500);
this.app.windows().find((window) => window.url().should.not.include('index'));
skipAfterEach = true; // Need to skip closing in aftereach as apps execution context is destroyed above
});
}
});

View file

@ -0,0 +1,66 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('menu/view', function desc() {
this.timeout(30000);
const config = env.demoMattermostConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
fs.writeFileSync(env.boundsInfoPath, JSON.stringify({x: 0, y: 0, width: 600, height: 240, maximized: false, fullscreen: false}));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
// TODO: No keyboard shortcut for macOS
if (process.platform !== 'darwin') {
it('MM-T816 Toggle Full Screen in the Menu Bar', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#post_textbox');
let currentWidth = await firstServer.evaluate('window.outerWidth');
let currentHeight = await firstServer.evaluate('window.outerHeight');
await mainWindow.click('button.three-dot-menu');
robot.keyTap('v');
robot.keyTap('t');
robot.keyTap('enter');
await asyncSleep(1000);
const fullScreenWidth = await firstServer.evaluate('window.outerWidth');
const fullScreenHeight = await firstServer.evaluate('window.outerHeight');
fullScreenWidth.should.be.greaterThan(currentWidth);
fullScreenHeight.should.be.greaterThan(currentHeight);
await mainWindow.click('button.three-dot-menu');
robot.keyTap('v');
robot.keyTap('t');
robot.keyTap('enter');
await asyncSleep(1000);
currentWidth = await firstServer.evaluate('window.outerWidth');
currentHeight = await firstServer.evaluate('window.outerHeight');
currentWidth.should.be.lessThan(fullScreenWidth);
currentHeight.should.be.lessThan(fullScreenHeight);
});
}
});

View file

@ -0,0 +1,51 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('history_menu', function desc() {
this.timeout(30000);
const config = env.demoMattermostConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('Click back and forward from history', async () => {
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#sidebarItem_off-topic');
// click on Off topic channel
await firstServer.click('#sidebarItem_off-topic');
// click on town square channel
await firstServer.click('#sidebarItem_town-square');
await firstServer.locator('[aria-label="Back"]').click();
let channelHeaderText = await firstServer.$eval('#channelHeaderTitle', (el) => el.firstChild.innerHTML);
channelHeaderText.should.equal('Off-Topic');
await firstServer.locator('[aria-label="Forward"]').click();
channelHeaderText = await firstServer.$eval('#channelHeaderTitle', (el) => el.firstChild.innerHTML);
channelHeaderText.should.equal('Town Square');
});
});

View file

@ -0,0 +1,53 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('menu/menu', function desc() {
this.timeout(30000);
const config = env.demoConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
if (process.platform !== 'darwin') {
it('MM-T4404 should open the 3 dot menu with Alt', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
mainWindow.should.not.be.null;
await mainWindow.bringToFront();
await mainWindow.click('#app');
// Settings window should open if Alt works
robot.keyTap('alt');
robot.keyTap('enter');
robot.keyTap('f');
robot.keyTap('s');
robot.keyTap('enter');
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
settingsWindow.should.not.be.null;
});
}
});

View file

@ -0,0 +1,233 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
async function setupPromise(window, id) {
const promise = new Promise((resolve) => {
const browserView = window.getBrowserViews().find((view) => view.webContents.id === id);
browserView.webContents.on('did-finish-load', () => {
resolve();
});
});
await promise;
return true;
}
function getZoomFactorOfServer(browserWindow, serverId) {
return browserWindow.evaluate(
(window, id) => window.getBrowserViews().find((view) => view.webContents.id === id).webContents.getZoomFactor(),
serverId,
);
}
function setZoomFactorOfServer(browserWindow, serverId, zoomFactor) {
return browserWindow.evaluate(
(window, {id, zoom}) => window.getBrowserViews().find((view) => view.webContents.id === id).webContents.setZoomFactor(zoom),
{id: serverId, zoom: zoomFactor},
);
}
describe('menu/view', function desc() {
this.timeout(30000);
const config = env.demoMattermostConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('MM-T813 Control+F should focus the search bar in Mattermost', async () => {
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#searchBox');
await asyncSleep(1000);
robot.keyTap('f', [process.platform === 'darwin' ? 'command' : 'control']);
await asyncSleep(500);
const isFocused = await firstServer.$eval('#searchBox', (el) => el === document.activeElement);
isFocused.should.be.true;
const text = await firstServer.inputValue('#searchBox');
text.should.include('in:');
});
it('MM-T817 Actual Size Zoom in the menu bar', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
const firstServerId = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].webContentsId;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#searchBox');
robot.keyTap('=', [env.cmdOrCtrl]);
await asyncSleep(1000);
let zoomLevel = await browserWindow.evaluate((window, id) => window.getBrowserViews().find((view) => view.webContents.id === id).webContents.getZoomFactor(), firstServerId);
zoomLevel.should.be.greaterThan(1);
robot.keyTap('0', [env.cmdOrCtrl]);
await asyncSleep(1000);
zoomLevel = await browserWindow.evaluate((window, id) => window.getBrowserViews().find((view) => view.webContents.id === id).webContents.getZoomFactor(), firstServerId);
zoomLevel.should.be.equal(1);
});
describe('MM-T818 Zoom in from the menu bar', () => {
it('MM-T818_1 Zoom in when CmdOrCtrl+Plus is pressed', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
const firstServerId = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].webContentsId;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#searchBox');
robot.keyTap('=', [env.cmdOrCtrl]);
await asyncSleep(1000);
const zoomLevel = await browserWindow.evaluate((window, id) => window.getBrowserViews().find((view) => view.webContents.id === id).webContents.getZoomFactor(), firstServerId);
zoomLevel.should.be.greaterThan(1);
});
it('MM-T818_2 Zoom in when CmdOrCtrl+Shift+Plus is pressed', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
const firstServerId = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].webContentsId;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#searchBox');
// reset zoom
await setZoomFactorOfServer(browserWindow, firstServerId, 1);
await asyncSleep(1000);
const initialZoom = await getZoomFactorOfServer(browserWindow, firstServerId);
initialZoom.should.be.equal(1);
robot.keyTap('=', [env.cmdOrCtrl, 'shift']);
await asyncSleep(1000);
const zoomLevel = await getZoomFactorOfServer(browserWindow, firstServerId);
zoomLevel.should.be.greaterThan(1);
});
});
describe('MM-T819 Zoom out from the menu bar', () => {
it('MM-T819_1 Zoom out when CmdOrCtrl+Minus is pressed', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
const firstServerId = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].webContentsId;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#searchBox');
robot.keyTap('-', [env.cmdOrCtrl]);
await asyncSleep(1000);
const zoomLevel = await browserWindow.evaluate((window, id) => window.getBrowserViews().find((view) => view.webContents.id === id).webContents.getZoomFactor(), firstServerId);
zoomLevel.should.be.lessThan(1);
});
it('MM-T819_2 Zoom out when CmdOrCtrl+Shift+Minus is pressed', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
const firstServerId = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].webContentsId;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#searchBox');
// reset zoom
await setZoomFactorOfServer(browserWindow, firstServerId, 1.0);
await asyncSleep(1000);
const initialZoom = await getZoomFactorOfServer(browserWindow, firstServerId);
initialZoom.should.be.equal(1);
robot.keyTap('-', [env.cmdOrCtrl, 'shift']);
await asyncSleep(1000);
const zoomLevel = await getZoomFactorOfServer(browserWindow, firstServerId);
zoomLevel.should.be.lessThan(1);
});
});
describe('Reload', () => {
let browserWindow;
let webContentsId;
beforeEach(async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
browserWindow = await this.app.browserWindow(mainWindow);
webContentsId = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].webContentsId;
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
});
it('MM-T814 should reload page when pressing Ctrl+R', async () => {
const check = browserWindow.evaluate(setupPromise, webContentsId);
await asyncSleep(500);
robot.keyTap('r', [env.cmdOrCtrl]);
const result = await check;
result.should.be.true;
});
it('MM-T815 should reload page when pressing Ctrl+Shift+R', async () => {
const check = browserWindow.evaluate(setupPromise, webContentsId);
await asyncSleep(500);
robot.keyTap('r', [env.cmdOrCtrl, 'shift']);
const result = await check;
result.should.be.true;
});
});
it('MM-T820 should open Developer Tools For Application Wrapper for main window', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index.html'));
const browserWindow = await this.app.browserWindow(mainWindow);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
let isDevToolsOpen = await browserWindow.evaluate((window) => {
return window.webContents.isDevToolsOpened();
});
isDevToolsOpen.should.be.false;
if (process.platform === 'darwin') {
// Press Command + Option + I
robot.keyTap('i', ['command', 'alt']);
await asyncSleep(3000);
}
if (process.platform === 'win32') {
robot.keyToggle('shift', 'down');
robot.keyToggle('control', 'down');
robot.keyTap('i');
}
await asyncSleep(1000);
isDevToolsOpen = await browserWindow.evaluate((window) => {
return window.webContents.isDevToolsOpened();
});
isDevToolsOpen.should.be.true;
});
});

View file

@ -0,0 +1,185 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('Menu/window_menu', function desc() {
const config = {
...env.demoConfig,
teams: [
...env.demoConfig.teams,
{
name: 'google',
url: 'https://google.com/',
order: 2,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
isOpen: true,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
isOpen: true,
},
],
lastActiveTab: 0,
},
],
lastActiveTeam: 2,
minimizeToTray: true,
alwaysMinimize: true,
};
const beforeFunc = async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
};
const afterFunc = async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
};
this.timeout(30000);
describe('MM-T826 should switch to servers when keyboard shortcuts are pressed', async () => {
let mainWindow;
before(async () => {
await beforeFunc();
await env.getServerMap(this.app);
mainWindow = this.app.windows().find((window) => window.url().includes('index'));
});
after(afterFunc);
it('MM-T826_1 should show the second server', async () => {
let dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton');
dropdownButtonText.should.equal('google');
robot.keyTap('2', ['control', process.platform === 'darwin' ? 'command' : 'shift']);
dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton:has-text("github")');
dropdownButtonText.should.equal('github');
});
it('MM-T826_2 should show the third server', async () => {
robot.keyTap('3', ['control', process.platform === 'darwin' ? 'command' : 'shift']);
const dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton:has-text("google")');
dropdownButtonText.should.equal('google');
});
it('MM-T826_3 should show the first server', async () => {
robot.keyTap('1', ['control', process.platform === 'darwin' ? 'command' : 'shift']);
const dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton:has-text("example")');
dropdownButtonText.should.equal('example');
});
});
describe('MM-T4385 select tab from menu', async () => {
let mainView;
before(async () => {
await beforeFunc();
mainView = this.app.windows().find((window) => window.url().includes('index'));
});
after(afterFunc);
it('MM-T4385_1 should show the second tab', async () => {
let tabViewButton = await mainView.innerText('.active');
tabViewButton.should.equal('Channels');
robot.keyTap('2', [env.cmdOrCtrl]);
await asyncSleep(500);
tabViewButton = await mainView.innerText('.active');
tabViewButton.should.equal('Boards');
});
it('MM-T4385_2 should show the third tab', async () => {
robot.keyTap('3', [env.cmdOrCtrl]);
await asyncSleep(500);
const tabViewButton = await mainView.innerText('.active');
tabViewButton.should.equal('Playbooks');
});
it('MM-T4385_3 should show the first tab', async () => {
robot.keyTap('1', [env.cmdOrCtrl]);
await asyncSleep(500);
const tabViewButton = await mainView.innerText('.active');
tabViewButton.should.equal('Channels');
});
});
it('MM-T827 select next/previous tab', async () => {
await beforeFunc();
const mainView = this.app.windows().find((window) => window.url().includes('index'));
let tabViewButton = await mainView.innerText('.active');
tabViewButton.should.equal('Channels');
robot.keyTap('tab', ['control']);
await asyncSleep(500);
tabViewButton = await mainView.innerText('.active');
tabViewButton.should.equal('Boards');
robot.keyTap('tab', ['shift', 'control']);
await asyncSleep(500);
tabViewButton = await mainView.innerText('.active');
tabViewButton.should.equal('Channels');
await afterFunc();
});
it('MM-T824 should be minimized when keyboard shortcuts are pressed', async () => {
await beforeFunc();
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
if (process.platform === 'darwin') {
robot.keyTap('m', [env.cmdOrCtrl]);
} else {
await mainWindow.click('button.three-dot-menu');
robot.keyTap('w');
robot.keyTap('m');
robot.keyTap('enter');
}
await asyncSleep(2000);
const isMinimized = await browserWindow.evaluate((window) => window.isMinimized());
isMinimized.should.be.true;
await afterFunc();
});
it('MM-T825 should be hidden when keyboard shortcuts are pressed', async () => {
await beforeFunc();
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
robot.keyTap('w', [env.cmdOrCtrl]);
await asyncSleep(2000);
const isVisible = await browserWindow.evaluate((window) => window.isVisible());
isVisible.should.be.false;
const isDestroyed = await browserWindow.evaluate((window) => window.isDestroyed());
isDestroyed.should.be.false;
await afterFunc();
});
});

116
e2e/specs/popup.test.js Normal file
View file

@ -0,0 +1,116 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const env = require('../modules/environment');
const {asyncSleep} = require('../modules/utils');
describe('popup', function desc() {
this.timeout(40000);
const config = env.demoMattermostConfig;
let popupWindow;
let firstServer;
before(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', '');
await firstServer.type('#post_textbox', '/github connect ');
await firstServer.click('button[data-testid="SendMessageButton"]');
const githubLink = await firstServer.waitForSelector('a.theme.markdown__link:has-text("GitHub account")');
githubLink.click();
popupWindow = await this.app.waitForEvent('window');
const loginField = await popupWindow.waitForSelector('#login_field');
await loginField.focus();
robot.typeString('Mattermost');
await asyncSleep(3000);
});
after(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
// NOTE: These tests requires that the test server have the GitHub plugin configured
it('MM-T2827_1 should be able to select all in popup windows', async () => {
robot.keyTap('a', env.cmdOrCtrl);
await asyncSleep(1000);
const selectedText = await popupWindow.evaluate(() => {
const box = document.querySelectorAll('#login_field')[0];
return box.value.substring(box.selectionStart,
box.selectionEnd);
});
await asyncSleep(3000);
selectedText.should.equal('Mattermost');
});
it('MM-T2827_2 should be able to cut and paste in popup windows', async () => {
await asyncSleep(1000);
const textbox = await popupWindow.waitForSelector('#login_field');
await textbox.selectText({force: true});
robot.keyTap('x', env.cmdOrCtrl);
let textValue = await textbox.inputValue();
textValue.should.equal('');
await textbox.focus();
robot.keyTap('v', env.cmdOrCtrl);
textValue = await textbox.inputValue();
textValue.should.equal('Mattermost');
});
it('MM-T2827_3 should be able to copy and paste in popup windows', async () => {
await asyncSleep(1000);
const textbox = await popupWindow.waitForSelector('#login_field');
await textbox.selectText({force: true});
robot.keyTap('c', env.cmdOrCtrl);
await textbox.focus();
await textbox.type('other-text');
robot.keyTap('v', env.cmdOrCtrl);
const textValue = await textbox.inputValue();
textValue.should.equal('other-textMattermost');
});
it('MM-T1659 should not be able to go Back or Forward in the popup window', async () => {
const currentURL = popupWindow.url();
// Try and go back
if (process.platform === 'darwin') {
robot.keyTap('[', ['command']);
} else {
robot.keyTap('left', ['alt']);
}
popupWindow.url().should.equal(currentURL);
// Try and go forward
if (process.platform === 'darwin') {
robot.keyTap(']', ['command']);
} else {
robot.keyTap('right', ['alt']);
}
popupWindow.url().should.equal(currentURL);
});
});

View file

@ -0,0 +1,46 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const {expect} = require('chai');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('copylink', function desc() {
this.timeout(30000);
const config = env.demoMattermostConfig;
beforeEach(async () => {
env.cleanDataDir();
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.serverMap = await env.getServerMap(this.app);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('MM-T1308 Check that external links dont open in the app', async () => {
const loadingScreen = this.app.windows().find((window) => window.url().includes('loadingScreen'));
await loadingScreen.waitForSelector('.LoadingScreen', {state: 'hidden'});
const firstServer = this.serverMap[`${config.teams[0].name}___TAB_MESSAGING`].win;
await env.loginToMattermost(firstServer);
await firstServer.waitForSelector('#post_textbox');
await firstServer.click('#post_textbox');
await firstServer.fill('#post_textbox', 'https://electronjs.org/apps/mattermost');
await firstServer.press('#post_textbox', 'Enter');
const newPageWindow = this.app.windows().find((window) => window.url().includes('apps/mattermost'));
expect(newPageWindow === undefined);
});
});

View file

@ -0,0 +1,150 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('Add Server Modal', function desc() {
this.timeout(30000);
const config = env.demoConfig;
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
const mainView = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainView.click('.ServerDropdownButton');
await dropdownView.click('.ServerDropdown .ServerDropdown__button.addServer');
newServerView = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('newServer'),
});
// wait for autofocus to finish
await asyncSleep(500);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
let newServerView;
it('MM-T1312 should focus the first text input', async () => {
const isFocused = await newServerView.$eval('#serverUrlInput', (el) => el.isSameNode(document.activeElement));
isFocused.should.be.true;
});
it('MM-T4388 should close the window after clicking cancel', async () => {
await newServerView.click('#cancelNewServerModal');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('newServer')));
existing.should.be.false;
});
describe('MM-T4389 Invalid messages', () => {
it('MM-T4389_1 should not be valid and save should be disabled if no server name or URL has been set', async () => {
const existing = await newServerView.isVisible('#nameValidation.error');
existing.should.be.true;
const disabled = await newServerView.getAttribute('#saveNewServerModal', 'disabled');
(disabled === '').should.be.true;
});
it('should warn the user if a server with the same URL exists, but still allow them to save', async () => {
await newServerView.type('#serverNameInput', 'some-new-server');
await newServerView.type('#serverUrlInput', config.teams[0].url);
await newServerView.waitForSelector('#urlValidation.warning');
const existing = await newServerView.isVisible('#urlValidation.warning');
existing.should.be.true;
const disabled = await newServerView.getAttribute('#saveNewServerModal', 'disabled');
(disabled === '').should.be.false;
});
describe('Valid server name', async () => {
beforeEach(async () => {
await newServerView.type('#serverNameInput', 'TestServer');
});
it('MM-T4389_2 Name should not be marked invalid, but should not be able to save', async () => {
await newServerView.waitForSelector('#nameValidation.error', {state: 'detached'});
const disabled = await newServerView.getAttribute('#saveNewServerModal', 'disabled');
(disabled === '').should.be.true;
});
});
describe('Valid server url', () => {
beforeEach(async () => {
await newServerView.type('#serverUrlInput', 'http://example.org');
});
it('MM-T4389_3 URL should not be marked invalid, name should be marked invalid', async () => {
const existingUrl = await newServerView.isVisible('#urlValidation.error');
const existingName = await newServerView.isVisible('#nameValidation.error');
const disabled = await newServerView.getAttribute('#saveNewServerModal', 'disabled');
existingName.should.be.true;
existingUrl.should.be.false;
(disabled === '').should.be.true;
});
});
});
it('MM-T2826_1 should not be valid if an invalid server address has been set', async () => {
await newServerView.type('#serverUrlInput', 'superInvalid url');
await newServerView.waitForSelector('#urlValidation.error');
const existing = await newServerView.isVisible('#urlValidation.error');
existing.should.be.true;
});
describe('Valid Team Settings', () => {
beforeEach(async () => {
await newServerView.type('#serverUrlInput', 'http://example.org');
await newServerView.type('#serverNameInput', 'TestServer');
await newServerView.waitForSelector('#urlValidation.warning');
});
it('should be possible to click add', async () => {
const disabled = await newServerView.getAttribute('#saveNewServerModal', 'disabled');
(disabled === null).should.be.true;
});
it('MM-T2826_2 should add the server to the config file', async () => {
await newServerView.click('#saveNewServerModal');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('newServer')));
existing.should.be.false;
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.deep.contain({
name: 'TestServer',
url: 'http://example.org/',
order: 2,
lastActiveTab: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
});
});
});
});

View file

@ -0,0 +1,105 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('Configure Server Modal', function desc() {
this.timeout(30000);
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
await asyncSleep(1000);
this.app = await env.getApp();
configureServerModal = this.app.windows().find((window) => window.url().includes('welcomeScreen'));
await configureServerModal.click('#getStartedWelcomeScreen');
await asyncSleep(1000);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
let configureServerModal;
it('MM-T5115 should not be valid if no display name has been set', async () => {
await configureServerModal.type('#input_name', '');
const connectButtonDisabled = await configureServerModal.getAttribute('#connectConfigureServer', 'disabled');
(connectButtonDisabled === '').should.be.true;
});
it('MM-T5116 should not be valid if no URL has been set', async () => {
await configureServerModal.type('#input_url', '');
const connectButtonDisabled = await configureServerModal.getAttribute('#connectConfigureServer', 'disabled');
(connectButtonDisabled === '').should.be.true;
});
it('MM-T5117 should be valid if display name and URL are set', async () => {
await configureServerModal.type('#input_name', 'TestServer');
await configureServerModal.type('#input_url', 'https://community.mattermost.com');
await configureServerModal.waitForSelector('#customMessage_url.Input___success');
const connectButtonDisabled = await configureServerModal.getAttribute('#connectConfigureServer', 'disabled');
(connectButtonDisabled === '').should.be.false;
});
it('MM-T5118 should not be valid if an invalid URL has been set', async () => {
await configureServerModal.type('#input_name', 'TestServer');
await configureServerModal.type('#input_url', '!@#$%^&*()');
await configureServerModal.waitForSelector('#customMessage_url.Input___error');
const errorClass = await configureServerModal.getAttribute('#customMessage_url', 'class');
errorClass.should.contain('Input___error');
const connectButtonDisabled = await configureServerModal.getAttribute('#connectConfigureServer', 'disabled');
(connectButtonDisabled === '').should.be.true;
});
it('MM-T5119 should add the server to the config file', async () => {
await configureServerModal.type('#input_name', 'TestServer');
await configureServerModal.type('#input_url', 'http://example.org');
await configureServerModal.click('#connectConfigureServer');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('welcomeScreen')));
existing.should.be.false;
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.deep.contain({
url: 'http://example.org/',
name: 'TestServer',
order: 0,
lastActiveTab: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
});
});
});

View file

@ -0,0 +1,172 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('server_management/drag_and_drop', function desc() {
const config = {
...env.demoConfig,
teams: [
...env.demoConfig.teams,
{
name: 'google',
url: 'https://google.com/',
order: 2,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
isOpen: true,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
isOpen: true,
},
],
lastActiveTab: 0,
},
],
lastActiveTeam: 2,
};
const beforeFunc = async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
};
const afterFunc = async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
};
this.timeout(30000);
describe('MM-T2634 should be able to drag and drop servers in the dropdown menu', async () => {
let mainWindow;
let dropdownView;
before(async () => {
await beforeFunc();
mainWindow = this.app.windows().find((window) => window.url().includes('index'));
dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainWindow.click('.ServerDropdownButton');
});
after(afterFunc);
it('MM-T2634_1 should appear the original order', async () => {
const firstMenuItem = await dropdownView.waitForSelector('.ServerDropdown button.ServerDropdown__button:nth-child(1) .ServerDropdown__draggable-handle');
const firstMenuItemText = await firstMenuItem.innerText();
firstMenuItemText.should.equal('example');
const secondMenuItem = await dropdownView.waitForSelector('.ServerDropdown button.ServerDropdown__button:nth-child(2) .ServerDropdown__draggable-handle');
const secondMenuItemText = await secondMenuItem.innerText();
secondMenuItemText.should.equal('github');
const thirdMenuItem = await dropdownView.waitForSelector('.ServerDropdown button.ServerDropdown__button:nth-child(3) .ServerDropdown__draggable-handle');
const thirdMenuItemText = await thirdMenuItem.innerText();
thirdMenuItemText.should.equal('google');
});
it('MM-T2634_2 after dragging the server down, should appear in the new order', async () => {
// Move the first server down, then re-open the dropdown
const initialMenuItem = await dropdownView.waitForSelector('.ServerDropdown button.ServerDropdown__button:nth-child(1) .ServerDropdown__draggable-handle');
await initialMenuItem.focus();
await dropdownView.keyboard.down(' ');
await dropdownView.keyboard.down('ArrowDown');
await dropdownView.keyboard.down(' ');
await asyncSleep(1000);
await mainWindow.keyboard.press('Escape');
await mainWindow.click('.ServerDropdownButton');
// Verify that the new order persists
const firstMenuItem = await dropdownView.waitForSelector('.ServerDropdown button.ServerDropdown__button:nth-child(1) .ServerDropdown__draggable-handle');
const firstMenuItemText = await firstMenuItem.innerText();
firstMenuItemText.should.equal('github');
const secondMenuItem = await dropdownView.waitForSelector('.ServerDropdown button.ServerDropdown__button:nth-child(2) .ServerDropdown__draggable-handle');
const secondMenuItemText = await secondMenuItem.innerText();
secondMenuItemText.should.equal('example');
const thirdMenuItem = await dropdownView.waitForSelector('.ServerDropdown button.ServerDropdown__button:nth-child(3) .ServerDropdown__draggable-handle');
const thirdMenuItemText = await thirdMenuItem.innerText();
thirdMenuItemText.should.equal('google');
});
it('MM-T2634_3 should update the config file', () => {
// Verify config is updated
const newConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
const order0 = newConfig.teams.find((team) => team.name === 'github');
order0.order.should.equal(0);
const order1 = newConfig.teams.find((team) => team.name === 'example');
order1.order.should.equal(1);
const order2 = newConfig.teams.find((team) => team.name === 'google');
order2.order.should.equal(2);
});
});
describe('MM-T2635 should be able to drag and drop tabs', async () => {
let mainWindow;
before(async () => {
await beforeFunc();
mainWindow = this.app.windows().find((window) => window.url().includes('index'));
});
after(afterFunc);
it('MM-T2635_1 should be in the original order', async () => {
// Verify the original order
const firstTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(1)');
const firstTabText = await firstTab.innerText();
firstTabText.should.equal('Channels');
const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)');
const secondTabText = await secondTab.innerText();
secondTabText.should.equal('Boards');
const thirdTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(3)');
const thirdTabText = await thirdTab.innerText();
thirdTabText.should.equal('Playbooks');
});
it('MM-T2635_2 after moving the tab to the right, the tab should be in the new order', async () => {
// Move the first tab to the right
let firstTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(1)');
await firstTab.focus();
await mainWindow.keyboard.down(' ');
await mainWindow.keyboard.down('ArrowRight');
await mainWindow.keyboard.down(' ');
await asyncSleep(1000);
// Verify that the new order is visible
firstTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(1)');
const firstTabText = await firstTab.innerText();
firstTabText.should.equal('Boards');
const secondTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(2)');
const secondTabText = await secondTab.innerText();
secondTabText.should.equal('Channels');
const thirdTab = await mainWindow.waitForSelector('.TabBar li.serverTabItem:nth-child(3)');
const thirdTabText = await thirdTab.innerText();
thirdTabText.should.equal('Playbooks');
});
it('MM-T2635_3 should update the config file', () => {
// Verify config is updated
const newConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
const firstTeam = newConfig.teams.find((team) => team.name === 'google');
const order0 = firstTeam.tabs.find((tab) => tab.name === 'TAB_FOCALBOARD');
order0.order.should.equal(0);
const order1 = firstTeam.tabs.find((tab) => tab.name === 'TAB_MESSAGING');
order1.order.should.equal(1);
const order2 = firstTeam.tabs.find((tab) => tab.name === 'TAB_PLAYBOOKS');
order2.order.should.equal(2);
});
});
});

View file

@ -0,0 +1,265 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('EditServerModal', function desc() {
this.timeout(30000);
const config = env.demoConfig;
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
const mainView = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainView.click('.ServerDropdownButton');
await dropdownView.hover('.ServerDropdown .ServerDropdown__button:nth-child(1)');
await dropdownView.click('.ServerDropdown .ServerDropdown__button:nth-child(1) button.ServerDropdown__button-edit');
editServerView = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('editServer'),
});
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
let editServerView;
it('should not edit server when Cancel is pressed', async () => {
await editServerView.click('#cancelNewServerModal');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('editServer')));
existing.should.be.false;
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.deep.contain({
name: 'example',
url: env.exampleURL,
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
});
it('MM-T4391_1 should not edit server when Save is pressed but nothing edited', async () => {
await editServerView.click('#saveNewServerModal');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('editServer')));
existing.should.be.false;
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.deep.contain({
name: 'example',
url: env.exampleURL,
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
});
it('MM-T2826_3 should not edit server if an invalid server address has been set', async () => {
await editServerView.type('#serverUrlInput', 'superInvalid url');
await editServerView.waitForSelector('#urlValidation.error');
const existing = await editServerView.isVisible('#urlValidation.error');
existing.should.be.true;
});
it('MM-T4391_2 should edit server when Save is pressed and name edited', async () => {
await editServerView.fill('#serverNameInput', 'NewTestServer');
await editServerView.click('#saveNewServerModal');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('editServer')));
existing.should.be.false;
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.not.deep.contain({
name: 'example',
url: env.exampleURL,
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
savedConfig.teams.should.deep.contain({
name: 'NewTestServer',
url: env.exampleURL,
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
});
it('MM-T4391_3 should edit server when Save is pressed and URL edited', async () => {
await editServerView.fill('#serverUrlInput', 'http://google.com');
await editServerView.click('#saveNewServerModal');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('editServer')));
existing.should.be.false;
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.not.deep.contain({
name: 'example',
url: env.exampleURL,
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
savedConfig.teams.should.deep.contain({
name: 'example',
url: 'http://google.com/',
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
});
it('MM-T4391_4 should edit server when Save is pressed and both edited', async () => {
await editServerView.fill('#serverNameInput', 'NewTestServer');
await editServerView.fill('#serverUrlInput', 'http://google.com');
await editServerView.click('#saveNewServerModal');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('editServer')));
existing.should.be.false;
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.not.deep.contain({
name: 'example',
url: env.exampleURL,
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
savedConfig.teams.should.deep.contain({
name: 'NewTestServer',
url: 'http://google.com/',
order: 0,
tabs: [
{
name: 'TAB_MESSAGING',
order: 0,
isOpen: true,
},
{
name: 'TAB_FOCALBOARD',
order: 1,
},
{
name: 'TAB_PLAYBOOKS',
order: 2,
},
],
lastActiveTab: 0,
});
});
});

View file

@ -0,0 +1,57 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('header', function desc() {
this.timeout(30000);
describe('MM-T2637 Double-Clicking on the header should minimize/maximize the app', async () => {
let header;
let browserWindow;
let initialBounds;
before(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
initialBounds = {x: 0, y: 0, width: 800, height: 400, maximized: false};
fs.writeFileSync(env.boundsInfoPath, JSON.stringify(initialBounds));
this.app = await env.getApp();
const mainWindow = await this.app.windows().find((window) => window.url().includes('index'));
browserWindow = await this.app.browserWindow(mainWindow);
header = await mainWindow.locator('div.topBar');
});
after(async () => {
if (this.app) {
try {
await this.app.close();
// eslint-disable-next-line no-empty
} catch (err) {}
}
await env.clearElectronInstances();
});
it('MM-T2637_1 should maximize on double-clicking the header', async () => {
const headerBounds = await header.boundingBox();
await header.dblclick({position: {x: headerBounds.width / 2, y: headerBounds.y / 2}});
await asyncSleep(1000);
const isMaximized = await browserWindow.evaluate((window) => window.isMaximized());
isMaximized.should.be.equal(true);
});
it('MM-T2637_2 should restore on double-clicking the header when maximized', async () => {
const maximizedHeaderBounds = await header.boundingBox();
await header.dblclick({position: {x: maximizedHeaderBounds.width / 2, y: maximizedHeaderBounds.y / 2}});
await asyncSleep(1000);
const revertedBounds = await browserWindow.evaluate((window) => window.getContentBounds());
revertedBounds.height.should.be.equal(initialBounds.height);
revertedBounds.width.should.be.equal(initialBounds.width);
});
});
});

View file

@ -0,0 +1,76 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('LongServerName', function desc() {
this.timeout(30000);
const config = env.demoConfig;
const longServerName = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus quis malesuada dolor, vel scelerisque sem';
const longServerUrl = 'https://example.org';
let newServerView;
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
const mainView = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainView.click('.ServerDropdownButton');
await dropdownView.click('.ServerDropdown .ServerDropdown__button.addServer');
newServerView = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('newServer'),
});
// wait for autofocus to finish
await asyncSleep(1000);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('MM-T4050 Long server name', async () => {
await newServerView.type('#serverNameInput', longServerName);
await newServerView.type('#serverUrlInput', longServerUrl);
await newServerView.click('#saveNewServerModal');
await asyncSleep(1000);
const existing = Boolean(this.app.windows().find((window) => window.url().includes('newServer')));
existing.should.be.false;
const mainView = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
const isServerTabExists = Boolean(mainView.locator(`text=${longServerName}`));
const isServerAddedDropdown = Boolean(dropdownView.locator(`text=${longServerName}`));
isServerTabExists.should.be.true;
isServerAddedDropdown.should.be.true;
const serverNameLocator = mainView.locator(`text=${longServerName}`);
const isTruncated = await serverNameLocator.evaluate((element) => {
return element.offsetWidth < element.scrollWidth;
});
isTruncated.should.be.true;
const isWithinMaxWidth = await serverNameLocator.evaluate((element) => {
const width = parseFloat(window.getComputedStyle(element).getPropertyValue('width'));
return width <= 400;
});
isWithinMaxWidth.should.be.true;
});
});

View file

@ -0,0 +1,82 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('RemoveServerModal', function desc() {
this.timeout(30000);
const config = env.demoConfig;
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
const mainView = this.app.windows().find((window) => window.url().includes('index'));
const dropdownView = this.app.windows().find((window) => window.url().includes('dropdown'));
await mainView.click('.ServerDropdownButton');
await dropdownView.hover('.ServerDropdown .ServerDropdown__button:nth-child(1)');
await dropdownView.click('.ServerDropdown .ServerDropdown__button:nth-child(1) button.ServerDropdown__button-remove');
removeServerView = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('removeServer'),
});
// wait for autofocus to finish
await asyncSleep(500);
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
let removeServerView;
it('MM-T4390_1 should remove existing server on click Remove', async () => {
await removeServerView.click('button:has-text("Remove")');
await asyncSleep(1000);
const expectedConfig = JSON.parse(JSON.stringify(config.teams.slice(1)));
expectedConfig.forEach((value) => {
value.order--;
});
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.deep.equal(expectedConfig);
});
it('MM-T4390_2 should NOT remove existing server on click Cancel', async () => {
await removeServerView.click('button:has-text("Cancel")');
await asyncSleep(1000);
const savedConfig = JSON.parse(fs.readFileSync(env.configFilePath, 'utf8'));
savedConfig.teams.should.deep.equal(config.teams);
});
it('MM-T4390_3 should disappear on click Close', async () => {
await removeServerView.click('button.close');
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('removeServer')));
existing.should.be.false;
});
it('MM-T4390_4 should disappear on click background', async () => {
// ignore any target closed error
try {
await removeServerView.click('.modal', {position: {x: 20, y: 20}});
} catch {} // eslint-disable-line no-empty
await asyncSleep(1000);
const existing = Boolean(await this.app.windows().find((window) => window.url().includes('removeServer')));
existing.should.be.false;
});
});

244
e2e/specs/settings.test.js Normal file
View file

@ -0,0 +1,244 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const {SHOW_SETTINGS_WINDOW} = require('src/common/communication');
const env = require('../modules/environment');
const {asyncSleep} = require('../modules/utils');
describe('Settings', function desc() {
this.timeout(30000);
const config = env.demoConfig;
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
fs.writeFileSync(env.appUpdatePath, '');
await asyncSleep(1000);
this.app = await env.getApp();
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
describe('Options', () => {
describe('Start app on login', () => {
it('MM-T4392 should appear on win32 or linux', async () => {
const expected = (process.platform === 'win32' || process.platform === 'linux');
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
await settingsWindow.waitForSelector('#inputAutoStart', {state: expected ? 'attached' : 'detached'});
const existing = await settingsWindow.isVisible('#inputAutoStart');
existing.should.equal(expected);
});
});
describe('Show icon in menu bar / notification area', () => {
it('MM-T4393_1 should appear on darwin or linux', async () => {
const expected = (process.platform === 'darwin' || process.platform === 'linux');
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
await settingsWindow.waitForSelector('#inputShowTrayIcon', {state: expected ? 'attached' : 'detached'});
const existing = await settingsWindow.isVisible('#inputShowTrayIcon');
existing.should.equal(expected);
});
describe('Save tray icon setting on mac', () => {
env.shouldTest(it, env.isOneOf(['darwin', 'linux']))('MM-T4393_2 should be saved when it\'s selected', async () => {
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
await settingsWindow.click('#inputShowTrayIcon');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saved")');
let config0 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config0.showTrayIcon.should.true;
await settingsWindow.click('#inputShowTrayIcon');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saved")');
config0 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config0.showTrayIcon.should.false;
});
});
describe('Save tray icon theme on linux', () => {
env.shouldTest(it, process.platform === 'linux')('MM-T4393_3 should be saved when it\'s selected', async () => {
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
await settingsWindow.click('#inputShowTrayIcon');
await settingsWindow.click('input[value="dark"]');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saved")');
const config0 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config0.trayIconTheme.should.equal('dark');
await settingsWindow.click('input[value="light"]');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saved")');
const config1 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config1.trayIconTheme.should.equal('light');
});
});
});
describe('Leave app running in notification area when application window is closed', () => {
it('MM-T4394 should appear on linux and win32', async () => {
const expected = (process.platform === 'linux' || process.platform === 'win32');
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
const existing = await settingsWindow.isVisible('#inputMinimizeToTray');
existing.should.equal(expected);
});
});
describe('Flash app window and taskbar icon when a new message is received', () => {
it('MM-T4395 should appear on win32 and linux', async () => {
const expected = (process.platform === 'win32' || process.platform === 'linux');
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
const existing = await settingsWindow.isVisible('#inputflashWindow');
existing.should.equal(expected);
});
});
describe('Show red badge on taskbar icon to indicate unread messages', () => {
it('MM-T4396 should appear on darwin or win32', async () => {
const expected = (process.platform === 'darwin' || process.platform === 'win32');
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
const existing = await settingsWindow.isVisible('#inputShowUnreadBadge');
existing.should.equal(expected);
});
});
describe('Check spelling', () => {
it('MM-T4397 should appear and be selectable', async () => {
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
const existing = await settingsWindow.isVisible('#inputSpellChecker');
existing.should.equal(true);
const selected = await settingsWindow.isChecked('#inputSpellChecker');
selected.should.equal(true);
await settingsWindow.click('#inputSpellChecker');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saved")');
const config1 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config1.useSpellChecker.should.equal(false);
});
});
describe('Enable GPU hardware acceleration', () => {
it('MM-T4398 should save selected option', async () => {
const ID_INPUT_ENABLE_HARDWARE_ACCELERATION = '#inputEnableHardwareAcceleration';
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
const selected = await settingsWindow.isChecked(ID_INPUT_ENABLE_HARDWARE_ACCELERATION);
selected.should.equal(true); // default is true
await settingsWindow.click(ID_INPUT_ENABLE_HARDWARE_ACCELERATION);
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saved")');
const config0 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config0.enableHardwareAcceleration.should.equal(false);
await settingsWindow.click(ID_INPUT_ENABLE_HARDWARE_ACCELERATION);
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.appOptionsSaveIndicator :text("Saved")');
const config1 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config1.enableHardwareAcceleration.should.equal(true);
});
});
if (process.platform !== 'darwin') {
describe('Enable automatic check for updates', () => {
it('MM-T4549 should save selected option', async () => {
const ID_INPUT_ENABLE_AUTO_UPDATES = '#inputAutoCheckForUpdates';
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
const settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
const selected = await settingsWindow.isChecked(ID_INPUT_ENABLE_AUTO_UPDATES);
selected.should.equal(true); // default is true
await settingsWindow.click(ID_INPUT_ENABLE_AUTO_UPDATES);
await settingsWindow.waitForSelector('.updatesSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.updatesSaveIndicator :text("Saved")');
const config0 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config0.autoCheckForUpdates.should.equal(false);
await settingsWindow.click(ID_INPUT_ENABLE_AUTO_UPDATES);
await settingsWindow.waitForSelector('.updatesSaveIndicator :text("Saving...")');
await settingsWindow.waitForSelector('.updatesSaveIndicator :text("Saved")');
const config1 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8'));
config1.autoCheckForUpdates.should.equal(true);
});
});
}
});
});

View file

@ -0,0 +1,99 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const robot = require('robotjs');
const {SHOW_SETTINGS_WINDOW} = require('src/common/communication');
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('settings/keyboard_shortcuts', function desc() {
this.timeout(30000);
const config = env.demoConfig;
let settingsWindow;
before(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
await asyncSleep(1000);
this.app = await env.getApp();
this.app.evaluate(({ipcMain}, showWindow) => {
ipcMain.emit(showWindow);
}, SHOW_SETTINGS_WINDOW);
settingsWindow = await this.app.waitForEvent('window', {
predicate: (window) => window.url().includes('settings'),
});
await settingsWindow.waitForSelector('.settingsPage.container');
const textbox = await settingsWindow.waitForSelector('#inputSpellCheckerLocalesDropdown');
await textbox.scrollIntoViewIfNeeded();
});
after(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
describe('MM-T1288 Manipulating Text', () => {
it('MM-T1288_1 should be able to select and deselect language in the settings window', async () => {
let textboxString;
await settingsWindow.click('#inputSpellCheckerLocalesDropdown');
await settingsWindow.type('#inputSpellCheckerLocalesDropdown', 'Afrikaans');
robot.keyTap('tab');
await settingsWindow.isVisible('#appOptionsSaveIndicator');
textboxString = await settingsWindow.innerText('div.SettingsPage__spellCheckerLocalesDropdown__multi-value__label');
textboxString.should.equal('Afrikaans');
await settingsWindow.isVisible('#appOptionsSaveIndicator');
await settingsWindow.click('[aria-label="Remove Afrikaans"]');
await settingsWindow.isVisible('#appOptionsSaveIndicator');
textboxString = await settingsWindow.inputValue('#inputSpellCheckerLocalesDropdown');
textboxString.should.equal('');
});
it('MM-T1288_2 should be able to cut and paste in the settings window', async () => {
const textToCopy = 'Afrikaans';
env.clipboard(textToCopy);
const textbox = await settingsWindow.waitForSelector('#inputSpellCheckerLocalesDropdown');
await textbox.selectText({force: true});
robot.keyTap('x', [env.cmdOrCtrl]);
let textValue = await textbox.getAttribute('value');
textValue.should.equal('');
await textbox.focus();
robot.keyTap('v', [env.cmdOrCtrl]);
textValue = await textbox.getAttribute('value');
textValue.trim().should.equal('Afrikaans');
});
it('MM-T1288_3 should be able to copy and paste in the settings window', async () => {
const textToCopy = 'Afrikaans';
env.clipboard(textToCopy);
const textbox = await settingsWindow.waitForSelector('#inputSpellCheckerLocalesDropdown');
await textbox.selectText({force: true});
robot.keyTap('c', [env.cmdOrCtrl]);
await textbox.focus();
await textbox.type('other-text');
robot.keyTap('v', [env.cmdOrCtrl]);
const textValue = await textbox.getAttribute('value');
textValue.trim().should.equal('other-textAfrikaans');
});
});
});

View file

@ -0,0 +1,65 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('startup/app', function desc() {
this.timeout(30000);
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
this.app = await env.getApp();
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
it('MM-T4400 should be stopped when the app instance already exists', (done) => {
const secondApp = env.getApp();
// In the correct case, 'start().then' is not called.
// So need to use setTimeout in order to finish this test.
const timer = setTimeout(() => {
done();
}, 3000);
secondApp.then(() => {
clearTimeout(timer);
return secondApp.close();
}).then(() => {
done(new Error('Second app instance exists'));
});
});
it('MM-T4975 should show the welcome screen modal when no servers exist', async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
env.createTestUserDataDir();
env.cleanTestConfig();
this.app = await env.getApp();
await asyncSleep(500);
const welcomeScreenModal = this.app.windows().find((window) => window.url().includes('welcomeScreen'));
const modalButton = await welcomeScreenModal.innerText('.WelcomeScreen .WelcomeScreen__button');
modalButton.should.equal('Get Started');
});
if (process.platform !== 'linux') {
it('MM-T4985 should show app name in title bar when no servers exist', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const titleBarText = await mainWindow.innerText('.app-title');
titleBarText.should.equal('Electron');
});
}
});

View file

@ -0,0 +1,77 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
describe('config', function desc() {
this.timeout(30000);
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
});
afterEach(async () => {
if (this.app) {
try {
await this.app.close();
// eslint-disable-next-line no-empty
} catch (err) {}
}
await env.clearElectronInstances();
});
describe('MM-T4401 should show servers in dropdown when there is config file', async () => {
const config = env.demoConfig;
beforeEach(async () => {
fs.writeFileSync(env.configFilePath, JSON.stringify(config));
this.app = await env.getApp();
});
afterEach(async () => {
if (this.app) {
try {
await this.app.close();
// eslint-disable-next-line no-empty
} catch (err) {}
}
});
it('MM-T4401_1 should show correct server in the dropdown button', async () => {
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton');
dropdownButtonText.should.equal('example');
});
it('MM-T4401_2 should set src of browser view from config file', async () => {
const firstServer = this.app.windows().find((window) => window.url() === config.teams[0].url);
const secondServer = this.app.windows().find((window) => window.url() === config.teams[1].url);
firstServer.should.not.be.null;
secondServer.should.not.be.null;
});
});
it('MM-T4402 should upgrade v0 config file', async () => {
const Config = require('src/common/config').Config;
const newConfig = new Config(env.configFilePath);
const oldConfig = {
url: env.exampleURL,
};
fs.writeFileSync(env.configFilePath, JSON.stringify(oldConfig));
this.app = await env.getApp();
const mainWindow = this.app.windows().find((window) => window.url().includes('index'));
const dropdownButtonText = await mainWindow.innerText('.ServerDropdownButton:has-text("Primary server")');
dropdownButtonText.should.equal('Primary server');
const str = fs.readFileSync(env.configFilePath, 'utf8');
const upgradedConfig = JSON.parse(str);
upgradedConfig.version.should.equal(newConfig.defaultData.version);
await this.app.close();
});
});

View file

@ -0,0 +1,154 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const env = require('../../modules/environment');
const {asyncSleep} = require('../../modules/utils');
describe('Welcome Screen Modal', function desc() {
this.timeout(30000);
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
await asyncSleep(1000);
this.app = await env.getApp();
welcomeScreenModal = this.app.windows().find((window) => window.url().includes('welcomeScreen'));
});
afterEach(async () => {
if (this.app) {
await this.app.close();
}
await env.clearElectronInstances();
});
let welcomeScreenModal;
it('MM-T4976 should show the slides in the expected order', async () => {
const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
const welcomeSlideTitle = await welcomeScreenModal.innerText('#welcome .WelcomeScreenSlide__title');
welcomeSlideTitle.should.equal('Welcome');
await welcomeScreenModal.click('#nextCarouselButton');
const channelSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
channelSlideClass.should.contain('Carousel__slide-current');
const channelSlideTitle = await welcomeScreenModal.innerText('div.Carousel__slide.inFromRight .WelcomeScreenSlide__title');
channelSlideTitle.should.equal('Collaborate in real time');
await welcomeScreenModal.click('#nextCarouselButton');
const callsSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
callsSlideClass.should.contain('Carousel__slide-current');
const callsSlideTitle = await welcomeScreenModal.innerText('div.Carousel__slide.inFromRight .WelcomeScreenSlide__title');
callsSlideTitle.should.equal('Start secure calls instantly');
await welcomeScreenModal.click('#nextCarouselButton');
const integrationSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
integrationSlideClass.should.contain('Carousel__slide-current');
const integrationSlideTitle = await welcomeScreenModal.innerText('div.Carousel__slide.inFromRight .WelcomeScreenSlide__title');
integrationSlideTitle.should.equal('Integrate with tools you love');
await welcomeScreenModal.click('#nextCarouselButton');
});
it('MM-T4977 should be able to move through slides clicking the navigation buttons', async () => {
let welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#nextCarouselButton');
const channelSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
channelSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#prevCarouselButton');
welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
});
it('MM-T4978 should be able to move through slides clicking the pagination indicator', async () => {
const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#PaginationIndicator3');
const integrationSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
integrationSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#PaginationIndicator2');
const callsSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromLeft', 'class');
callsSlideClass.should.contain('Carousel__slide-current');
});
it('MM-T4979 should be able to move forward through slides automatically every 5 seconds', async () => {
const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
await asyncSleep(5500);
const channelSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
channelSlideClass.should.contain('Carousel__slide-current');
const channelSlideTitle = await welcomeScreenModal.innerText('div.Carousel__slide.inFromRight .WelcomeScreenSlide__title');
channelSlideTitle.should.equal('Collaborate in real time');
await welcomeScreenModal.click('#nextCarouselButton');
});
it('MM-T4980 should show the slides in the expected order', async () => {
const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#nextCarouselButton');
const channelSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
channelSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#nextCarouselButton');
const callsSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
callsSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#nextCarouselButton');
const integrationSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
integrationSlideClass.should.contain('Carousel__slide-current');
});
it('MM-T4981 should be able to move from last to first slide', async () => {
await welcomeScreenModal.click('#PaginationIndicator3');
const integrationSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromRight', 'class');
integrationSlideClass.should.contain('Carousel__slide-current');
const integrationSlideTitle = await welcomeScreenModal.innerText('div.Carousel__slide.inFromRight .WelcomeScreenSlide__title');
integrationSlideTitle.should.equal('Integrate with tools you love');
await welcomeScreenModal.click('#nextCarouselButton');
const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
});
it('MM-T4982 should be able to move from first to last slide', async () => {
const welcomeSlideClass = await welcomeScreenModal.getAttribute('#welcome', 'class');
welcomeSlideClass.should.contain('Carousel__slide-current');
await welcomeScreenModal.click('#prevCarouselButton');
const integrationSlideClass = await welcomeScreenModal.getAttribute('div.Carousel__slide.inFromLeft', 'class');
integrationSlideClass.should.contain('Carousel__slide-current');
const integrationSlideTitle = await welcomeScreenModal.innerText('div.Carousel__slide.inFromLeft .WelcomeScreenSlide__title');
integrationSlideTitle.should.equal('Integrate with tools you love');
});
it('MM-T4983 should be able to click the get started button and be redirected to new server modal', async () => {
await welcomeScreenModal.click('#getStartedWelcomeScreen');
await asyncSleep(1000);
const modalCardTitle = await welcomeScreenModal.innerText('.ConfigureServer .ConfigureServer__card-title');
modalCardTitle.should.equal('Enter your server details');
});
});

View file

@ -0,0 +1,61 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
const fs = require('fs');
const env = require('../../modules/environment');
describe('window', function desc() {
this.timeout(30000);
beforeEach(async () => {
env.createTestUserDataDir();
env.cleanTestConfig();
});
afterEach(async () => {
if (this.app) {
try {
await this.app.close();
// eslint-disable-next-line no-empty
} catch (err) {}
}
await env.clearElectronInstances();
});
// TODO: this fails on Linux right now due to the window frame for some reason
if (process.platform !== 'linux') {
it('MM-T4403_1 should restore window bounds', async () => {
const expectedBounds = {x: 100, y: 200, width: 800, height: 400};
fs.writeFileSync(env.boundsInfoPath, JSON.stringify(expectedBounds));
this.app = await env.getApp();
const mainWindow = await this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const bounds = await browserWindow.evaluate((window) => window.getContentBounds());
bounds.should.deep.equal(expectedBounds);
await this.app.close();
});
}
it('MM-T4403_2 should NOT restore window bounds if x is located on outside of viewarea', async () => {
fs.writeFileSync(env.boundsInfoPath, JSON.stringify({x: -100000, y: 200, width: 800, height: 400}));
this.app = await env.getApp();
const mainWindow = await this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const bounds = await browserWindow.evaluate((window) => window.getContentBounds());
bounds.x.should.satisfy((x) => (x > -100000));
await this.app.close();
});
it('MM-T4403_3 should NOT restore window bounds if y is located on outside of viewarea', async () => {
fs.writeFileSync(env.boundsInfoPath, JSON.stringify({x: 100, y: 200000, width: 800, height: 400}));
this.app = await env.getApp();
const mainWindow = await this.app.windows().find((window) => window.url().includes('index'));
const browserWindow = await this.app.browserWindow(mainWindow);
const bounds = await browserWindow.evaluate((window) => window.getContentBounds());
bounds.y.should.satisfy((y) => (y < 200000));
await this.app.close();
});
});

View file

@ -0,0 +1,76 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
const path = require('path');
const {MOCHAWESOME_REPORT_DIR} = require('./constants');
const knownFlakyTests = require('./known_flaky_tests.json');
const {
generateShortSummary,
readJsonFromFile,
} = require('./report');
function analyzeFlakyTests() {
const os = process.platform;
try {
// Import
const jsonReport = readJsonFromFile(path.join(MOCHAWESOME_REPORT_DIR, 'mochawesome.json'));
const {failedFullTitles} = generateShortSummary(jsonReport);
// Get the list of known flaky tests for the provided operating system
const knownFlakyTestsForOS = new Set(knownFlakyTests[os] || []);
// Filter out the known flaky tests from the failed test titles
const newFailedTests = failedFullTitles.filter((test) => !knownFlakyTestsForOS.has(test));
// Check if any known failed tests are fixed
const fixedTests = [...knownFlakyTestsForOS].filter((test) => !failedFullTitles.includes(test));
const commentBody = generateCommentBodyFunctionalTest(newFailedTests, fixedTests);
// Print on CI
console.log(commentBody);
return {commentBody, newFailedTests};
} catch (error) {
console.error('Error analyzing failures:', error);
return {};
}
}
function generateCommentBodyFunctionalTest(newFailedTests, fixedTests) {
const osName = process.env.RUNNER_OS;
const build = process.env.BUILD_TAG;
let commentBody = `
## Test Summary for ${osName} on commit ${build}
`;
if (newFailedTests.length === 0 && fixedTests.length === 0) {
commentBody += `
All stable tests passed on ${osName}.
`;
return commentBody;
}
if (newFailedTests.length > 0) {
const newTestFailure = `New failed tests found on ${osName}:\n${newFailedTests.map((test) => `- ${test}`).join('\n')}`;
commentBody += `
${newTestFailure}
`;
}
if (fixedTests.length > 0) {
const fixedTestMessage = `The following known failed tests have been fixed on ${osName}:\n\t${fixedTests.map((test) => `- ${test}`).join('\n\t')}`;
commentBody += `
${fixedTestMessage}
`;
}
return commentBody;
}
module.exports = {
analyzeFlakyTests,
};

88
e2e/utils/artifacts.js Normal file
View file

@ -0,0 +1,88 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console,consistent-return */
const fs = require('fs');
const path = require('path');
const {S3} = require('@aws-sdk/client-s3');
const {Upload} = require('@aws-sdk/lib-storage');
const async = require('async');
const mime = require('mime-types');
const readdir = require('recursive-readdir');
const {MOCHAWESOME_REPORT_DIR} = require('./constants');
require('dotenv').config();
const {
AWS_S3_BUCKET,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
BUILD_ID,
BRANCH,
BUILD_TAG,
} = process.env;
const s3 = new S3({
credentials: {
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
},
});
function getFiles(dirPath) {
return fs.existsSync(dirPath) ? readdir(dirPath) : [];
}
async function saveArtifacts() {
if (!AWS_S3_BUCKET || !AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
console.log('No AWS credentials found. Test artifacts not uploaded to S3.');
return;
}
const s3Folder = `${BUILD_ID}-${BRANCH}-${BUILD_TAG}`.replace(/\./g, '-');
const uploadPath = path.resolve(__dirname, `../../${MOCHAWESOME_REPORT_DIR}`);
const filesToUpload = await getFiles(uploadPath);
return new Promise((resolve, reject) => {
async.eachOfLimit(
filesToUpload,
10,
async.asyncify(async (file) => {
const Key = file.replace(uploadPath, s3Folder).replaceAll('\\', '/');
const contentType = mime.lookup(file);
const charset = mime.charset(contentType);
try {
await new Upload({
client: s3,
params: {
Key,
Bucket: AWS_S3_BUCKET,
Body: fs.readFileSync(file),
ContentType: `${contentType}${charset ? '; charset=' + charset : ''}`,
},
}).done();
return {success: true};
} catch (e) {
console.log('Failed to upload artifact:', file);
throw new Error(e);
}
}),
(err) => {
if (err) {
console.log('Failed to upload artifacts');
return reject(new Error(err));
}
const reportLink = `https://${AWS_S3_BUCKET}.s3.amazonaws.com/${s3Folder}/mochawesome.html`;
resolve({success: true, reportLink});
},
);
});
}
module.exports = {saveArtifacts};

10
e2e/utils/constants.js Normal file
View file

@ -0,0 +1,10 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const MOCHAWESOME_REPORT_DIR = './mochawesome-report';
const PERFORMANCE_REPORT_DIR = './performance';
module.exports = {
MOCHAWESOME_REPORT_DIR,
PERFORMANCE_REPORT_DIR,
};

View file

@ -0,0 +1,14 @@
{
"darwin": [
"popup MM-T2827_1 should be able to select all in popup windows"
],
"linux": [
"menu/view MM-T820 should open Developer Tools For Application Wrapper for main window",
"Menu/window_menu MM-T824 should be minimized when keyboard shortcuts are pressed",
"Menu/window_menu MM-T825 should be hidden when keyboard shortcuts are pressed",
"header MM-T2637 Double-Clicking on the header should minimize/maximize the app MM-T2637_1 should maximize on double-clicking the header"
],
"win32": [
"application MM-T1304/MM-T1306 should open the app on the requested deep link"
]
}

View file

@ -0,0 +1,36 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
function generateCommentBodyPerformanceTest(fileContents) {
const data = JSON.parse(fileContents);
return `
E2E Performance Test results:
| Test | Duration |
| --- | --- |
${data?.passes?.reduce((acc, pass) => {
return `${acc}| ${pass.fullTitle || 'title'} | ${pass.duration}ms |\n`;
}, '')
}
${data?.failures?.length > 0 ? `
Some tests failed:
| Test | Duration |
| --- | --- |
${data.failures.forEach((failure) => `| ${failure.fullTitle} | ${failure.duration}`)}
` : ''}
<details>
<summary>Raw results</summary>
\`\`\`js
${fileContents}
\`\`\`
</details>
`;
}
module.exports = {
generateCommentBodyPerformanceTest,
};

298
e2e/utils/report.js Normal file
View file

@ -0,0 +1,298 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console, camelcase */
const os = require('os');
const axios = require('axios');
const fse = require('fs-extra');
const {MOCHAWESOME_REPORT_DIR} = require('./constants');
const package = require('../../package.json');
const e2ePackage = require('../package.json');
const MAX_FAILED_TITLES = 5;
let incrementalDuration = 0;
function getAllTests(results) {
const tests = [];
results.forEach((result) => {
result.tests.forEach((test) => {
incrementalDuration += test.duration;
tests.push({...test, incrementalDuration});
});
if (result.suites.length > 0) {
getAllTests(result.suites).forEach((test) => tests.push(test));
}
});
return tests;
}
function generateStatsFieldValue(stats, failedFullTitles) {
let statsFieldValue = `
| Key | Value |
|:---|:---|
| Passing Rate | ${stats.passPercent.toFixed(2)}% |
| Duration | ${(stats.duration / (60 * 1000)).toFixed(2)} mins |
| Suites | ${stats.suites} |
| Tests | ${stats.tests} |
| :white_check_mark: Passed | ${stats.passes} |
| :x: Failed | ${stats.failures} |
| :fast_forward: Skipped | ${stats.skipped} |
`;
// If present, add full title of failing tests.
// Only show per maximum number of failed titles with the last item as "more..." if failing tests are more than that.
let failedTests;
if (failedFullTitles && failedFullTitles.length > 0) {
const re = /[:'"\\]/gi;
const failed = failedFullTitles;
if (failed.length > MAX_FAILED_TITLES) {
failedTests = failed.slice(0, MAX_FAILED_TITLES - 1).map((f) => `- ${f.replace(re, '')}`).join('\n');
failedTests += '\n- more...';
} else {
failedTests = failed.map((f) => `- ${f.replace(re, '')}`).join('\n');
}
}
if (failedTests) {
statsFieldValue += '###### Failed Tests:\n' + failedTests;
}
return statsFieldValue;
}
function generateShortSummary(report) {
const {results, stats} = report;
const tests = getAllTests(results);
const failedFullTitles = tests.filter((t) => t.fail).map((t) => t.fullTitle);
const statsFieldValue = generateStatsFieldValue(stats, failedFullTitles);
return {
stats,
statsFieldValue,
failedFullTitles,
};
}
function removeOldGeneratedReports() {
[
'all.json',
'summary.json',
'mochawesome.html',
].forEach((file) => fse.removeSync(`${MOCHAWESOME_REPORT_DIR}/${file}`));
}
function writeJsonToFile(jsonObject, filename, dir) {
fse.writeJson(`${dir}/${filename}`, jsonObject).
then(() => console.log('Successfully written:', filename)).
catch((err) => console.error(err));
}
function readJsonFromFile(file) {
try {
return fse.readJsonSync(file);
} catch (err) {
return {err};
}
}
function getOS() {
switch (process.platform) {
case 'darwin':
return 'macOS';
case 'win32':
return 'Windows';
case 'linux':
return 'Linux';
default:
return 'Unknown';
}
}
function getEnvironmentValues() {
return {
playwrightVersion: e2ePackage.dependencies.playwright,
electronVersion: package.devDependencies.electron,
osName: getOS(),
osVersion: os.release(),
nodeVersion: process.version,
};
}
const result = [
{status: 'Passed', priority: 'none', cutOff: 100, color: '#43A047'},
{status: 'Failed', priority: 'low', cutOff: 98, color: '#FFEB3B'},
{status: 'Failed', priority: 'medium', cutOff: 95, color: '#FF9800'},
{status: 'Failed', priority: 'high', cutOff: 0, color: '#F44336'},
];
function generateTestReport(summary, isUploadedToS3, reportLink, testCycleKey) {
const {
FULL_REPORT,
TEST_CYCLE_LINK_PREFIX,
} = process.env;
const {statsFieldValue, stats} = summary;
const {
playwrightVersion,
electronVersion,
osName,
osVersion,
nodeVersion,
} = getEnvironmentValues();
let testResult;
for (let i = 0; i < result.length; i++) {
if (stats.passPercent >= result[i].cutOff) {
testResult = result[i];
break;
}
}
const title = generateTitle();
const envValue = `playwright@${playwrightVersion} | node@${nodeVersion} | electron@${electronVersion} | ${osName}@${osVersion}`;
if (FULL_REPORT === 'true') {
let reportField;
if (isUploadedToS3) {
reportField = {
short: false,
title: 'Test Report',
value: `[Link to the report](${reportLink})`,
};
}
let testCycleField;
if (testCycleKey) {
testCycleField = {
short: false,
title: 'Test Execution',
value: `[Recorded test executions](${TEST_CYCLE_LINK_PREFIX}${testCycleKey})`,
};
}
return {
username: 'Playwright UI Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
attachments: [{
color: testResult.color,
author_name: 'Desktop End-to-end Testing',
author_icon: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
author_link: 'https://www.mattermost.com',
title,
fields: [
{
short: false,
title: 'Environment',
value: envValue,
},
reportField,
testCycleField,
{
short: false,
title: `Key metrics (required support: ${testResult.priority})`,
value: statsFieldValue,
},
],
}],
};
}
let quickSummary = `${stats.passPercent.toFixed(2)}% (${stats.passes}/${stats.tests}) in ${stats.suites} suites`;
if (isUploadedToS3) {
quickSummary = `[${quickSummary}](${reportLink})`;
}
let testCycleLink = '';
if (testCycleKey) {
testCycleLink = testCycleKey ? `| [Recorded test executions](${TEST_CYCLE_LINK_PREFIX}${testCycleKey})` : '';
}
return {
username: 'Playwright UI Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
attachments: [{
color: testResult.color,
author_name: 'Desktop End-to-end Testing',
author_icon: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
author_link: 'https://www.mattermost.com/',
title,
text: `${quickSummary} | ${(stats.duration / (60 * 1000)).toFixed(2)} mins ${testCycleLink}\n${envValue}`,
}],
};
}
function generateTitle() {
const {
BRANCH,
DESKTOP_VERSION,
PULL_REQUEST,
RELEASE_VERSION,
SERVER_VERSION,
TYPE,
} = process.env;
let releaseVersion = '';
if (RELEASE_VERSION) {
releaseVersion = ` for ${RELEASE_VERSION}`;
}
let title;
switch (TYPE) {
case 'PR':
title = `E2E for Pull Request Build: [${BRANCH}](${PULL_REQUEST})`;
break;
case 'RELEASE':
title = `E2E for Release Build${releaseVersion}`;
break;
case 'NIGHTLY':
title = 'E2E for Master Nightly Build';
break;
case 'MASTER':
title = 'E2E for Post Merge to Master';
break;
case 'MANUAL':
title = `E2E for Manually triggered for ${BRANCH}`;
break;
case 'CMT':
title = `Compatibility Matrix Testing Report for Server v${SERVER_VERSION} and Desktop version v${DESKTOP_VERSION}`;
break;
default:
title = 'E2E for Build$';
}
return title;
}
async function sendReport(name, url, data) {
const requestOptions = {method: 'POST', url, data};
try {
const response = await axios(requestOptions);
if (response.data) {
console.log(`Successfully sent ${name}.`);
}
return response;
} catch (er) {
console.log(`Something went wrong while sending ${name}.`, er);
return false;
}
}
module.exports = {
generateShortSummary,
generateTestReport,
getAllTests,
removeOldGeneratedReports,
sendReport,
readJsonFromFile,
writeJsonToFile,
};

240
e2e/utils/test_cases.js Normal file
View file

@ -0,0 +1,240 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
// See reference: https://support.smartbear.com/tm4j-cloud/api-docs/
const os = require('os');
const axios = require('axios');
const chalk = require('chalk');
const {getAllTests} = require('./report');
const status = {
passed: 'Pass',
failed: 'Fail',
pending: 'Pending',
skipped: 'Skip',
};
const environment = {
chrome: 'Chrome',
firefox: 'Firefox',
};
function getStepStateResult(steps = []) {
return steps.reduce((acc, item) => {
if (acc[item.state]) {
acc[item.state] += 1;
} else {
acc[item.state] = 1;
}
return acc;
}, {});
}
function getStepStateSummary(steps = []) {
const result = getStepStateResult(steps);
return Object.entries(result).map(([key, value]) => `${value} ${key}`).join(',');
}
function getZEPHYRTestCases(report) {
return getAllTests(report.results).
filter((item) => /^(MM-T)\w+/g.test(item.title)). // eslint-disable-line wrap-regex
map((item) => {
return {
title: item.title,
duration: item.duration,
incrementalDuration: item.incrementalDuration,
state: item.state,
pass: item.pass,
fail: item.fail,
pending: item.pending,
};
}).
reduce((acc, item) => {
// Extract the key to exactly match with "MM-T[0-9]+"
const key = item.title.match(/(MM-T\d+)/)[0];
if (acc[key]) {
acc[key].push(item);
} else {
acc[key] = [item];
}
return acc;
}, {});
}
function saveToEndpoint(url, data) {
return axios({
method: 'POST',
url,
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: process.env.ZEPHYR_API_KEY,
},
data,
}).catch((error) => {
console.log('Something went wrong:', error.response.data);
return error.response.data;
});
}
async function getZEPHYRFolderID() {
const {
TYPE,
ZEPHYR_FOLDER_ID,
ZEPHYR_FOLDER_LINUX_REPORT,
ZEPHYR_FOLDER_MACOS_REPORT,
ZEPHYR_FOLDER_WIN_REPORT,
} = process.env;
if (TYPE === 'MASTER') {
return ZEPHYR_FOLDER_ID;
}
const platform = os.platform();
// Define Zephyr folder IDs for different run types and platforms.
// For PR we dont generate reports.
// Post Merge to master branch, default folderID will be used.
const folderIDs = {
RELEASE: {
darwin: ZEPHYR_FOLDER_MACOS_REPORT,
win32: ZEPHYR_FOLDER_WIN_REPORT,
linux: ZEPHYR_FOLDER_LINUX_REPORT,
default: ZEPHYR_FOLDER_ID,
},
NIGHTLY: {
darwin: ZEPHYR_FOLDER_MACOS_REPORT,
win32: ZEPHYR_FOLDER_WIN_REPORT,
linux: ZEPHYR_FOLDER_LINUX_REPORT,
default: ZEPHYR_FOLDER_ID,
},
};
// Get the folder ID based on the type and platform
const typeFolderIDs = folderIDs[TYPE];
const folderID = typeFolderIDs?.[platform] ?? typeFolderIDs?.default ?? ZEPHYR_FOLDER_ID;
return folderID;
}
async function createTestCycle(startDate, endDate) {
const {
BRANCH,
BUILD_ID,
JIRA_PROJECT_KEY,
ZEPHYR_CYCLE_NAME,
} = process.env;
const testCycle = {
projectKey: JIRA_PROJECT_KEY,
name: ZEPHYR_CYCLE_NAME ? `${ZEPHYR_CYCLE_NAME} (${BUILD_ID}-${BRANCH})` : `${BUILD_ID}-${BRANCH}`,
description: `Playwright automated test with ${BRANCH}`,
plannedStartDate: startDate,
plannedEndDate: endDate,
statusName: 'Done',
folderId: await getZEPHYRFolderID(),
};
const response = await saveToEndpoint('https://api.zephyrscale.smartbear.com/v2/testcycles', testCycle);
return response.data;
}
async function createTestExecutions(report, testCycle) {
const {
BROWSER,
JIRA_PROJECT_KEY,
ZEPHYR_ENVIRONMENT_NAME,
} = process.env;
const testCases = getZEPHYRTestCases(report);
const startDate = new Date(report.stats.start);
const startTime = startDate.getTime();
const promises = [];
Object.entries(testCases).forEach(([key, steps], index) => {
const testScriptResults = steps.
sort((a, b) => a.title.localeCompare(b.title)).
map((item) => {
return {
statusName: status[item.state],
actualEndDate: new Date(startTime + item.incrementalDuration).toISOString(),
actualResult: 'Playwright automated test completed',
};
});
const stateResult = getStepStateResult(steps);
const testExecution = {
projectKey: JIRA_PROJECT_KEY,
testCaseKey: key,
testCycleKey: testCycle.key,
statusName: stateResult.passed && stateResult.passed === steps.length ? 'Pass' : 'Fail',
testScriptResults,
environmentName: ZEPHYR_ENVIRONMENT_NAME || environment[BROWSER] || 'Chrome',
actualEndDate: testScriptResults[testScriptResults.length - 1].actualEndDate,
executionTime: steps.reduce((acc, prev) => {
acc += prev.duration; // eslint-disable-line no-param-reassign
return acc;
}, 0),
comment: `Playwright automated test - ${getStepStateSummary(steps)}`,
};
// Temporarily log to verify cases that were being saved.
console.log(index, key); // eslint-disable-line no-console
promises.push(saveTestExecution(testExecution, index));
});
await Promise.all(promises);
console.log('Successfully saved test cases into the Test Management System');
}
const saveTestCases = async (allReport) => {
const {start, end} = allReport.stats;
const testCycle = await createTestCycle(start, end);
await createTestExecutions(allReport, testCycle);
};
const RETRY = [];
async function saveTestExecution(testExecution, index) {
await axios({
method: 'POST',
url: 'https://api.zephyrscale.smartbear.com/v2/testexecutions',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: process.env.ZEPHYR_API_KEY,
},
data: testExecution,
}).then(() => {
console.log(chalk.green('Success:', index, testExecution.testCaseKey));
}).catch((error) => {
// Retry on 500 error code / internal server error
if (!error.response || error.response.data.errorCode === 500) {
if (RETRY[testExecution.testCaseKey]) {
RETRY[testExecution.testCaseKey] += 1;
} else {
RETRY[testExecution.testCaseKey] = 1;
}
saveTestExecution(testExecution, index);
console.log(chalk.magenta('Retry:', index, testExecution.testCaseKey, `(${RETRY[testExecution.testCaseKey]}x)`));
} else {
console.log(chalk.red('Error:', index, testExecution.testCaseKey, error.response.data.message));
}
});
}
module.exports = {
createTestCycle,
saveTestCases,
createTestExecutions,
};

56
e2e/webpack.config.js Normal file
View file

@ -0,0 +1,56 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const path = require('path');
const glob = require('glob');
const webpack = require('webpack');
module.exports = {
mode: 'development',
entry: {
e2e: glob.sync('./specs/**/*.js'),
},
output: {
path: path.resolve(__dirname, 'dist/'),
filename: '[name]_bundle.js',
},
plugins: [
new webpack.DefinePlugin({__IS_MAC_APP_STORE__: false}),
],
externals: {
electron: 'require("electron")',
fs: 'require("fs")',
ws: 'require("ws")',
child_process: 'require("child_process")',
dns: 'require("dns")',
http2: 'require("http2")',
net: 'require("net")',
repl: 'require("repl")',
tls: 'require("tls")',
playwright: 'require("playwright")',
robotjs: 'require("robotjs")',
},
module: {
rules: [{
test: /\.(js|ts)?$/,
exclude: /node_modules/,
loader: 'babel-loader',
}],
},
node: {
__filename: false,
__dirname: false,
},
target: 'node',
resolve: {
modules: [
'node_modules',
'../src',
],
alias: {
src: path.resolve(__dirname, '../src'),
},
extensions: ['.ts', '.js'],
},
};

View file

@ -0,0 +1,13 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const glob = require('glob');
const {merge} = require('webpack-merge');
const test = require('./webpack.config.test');
module.exports = merge(test, {
entry: {
e2e: glob.sync('./e2e/performance/**/*.test.js'),
},
});

190
electron-builder.json Normal file
View file

@ -0,0 +1,190 @@
{
"publish": [
{
"provider": "generic",
"url": "https://gitlab.peanutsmediaserver.com/aaron/mattermost-desktop"
}
],
"appId": "Mattermost.Desktop",
"artifactName": "${version}/${name}-${version}-${os}-${arch}.${ext}",
"directories": {
"buildResources": "src/assets",
"output": "release"
},
"extraMetadata": {
"main": "index.js"
},
"files": [
"!node_modules/**/*",
"node_modules/bindings/**/*",
"node_modules/file-uri-to-path/**/*",
"node_modules/macos-notification-state/**/*",
"node_modules/windows-focus-assist/**/*",
"!**/node_modules/macos-notification-state/bin/**/*",
"!**/node_modules/macos-notification-state/build/**/*",
"!**/node_modules/windows-focus-assist/bin/**/*",
"!**/node_modules/windows-focus-assist/build/**/*",
"node_modules/macos-notification-state/build/**/*.node",
"node_modules/windows-focus-assist/build/Release/**/*.node",
{
"from": "dist",
"to": ".",
"filter": "**/*"
}
],
"protocols": [
{
"name": "Mattermost",
"schemes": [
"mattermost"
]
}
],
"beforePack": "scripts/beforepack.js",
"afterPack": "scripts/afterpack.js",
"afterAllArtifactBuild": "scripts/afterbuild.js",
"deb": {
"artifactName": "${version}/${name}_${version}-1_${arch}.${ext}",
"synopsis": "Mattermost Desktop App",
"depends": [
"libnotify4",
"libxtst6",
"libnss3"
],
"priority": "optional"
},
"asarUnpack": [
"./node_modules/macos-notification-state/build/Release/**/*.node",
"./node_modules/windows-focus-assist/build/Release/**/*.node"
],
"linux": {
"category": "Network;InstantMessaging",
"target": [
"deb",
"tar.gz",
"appimage",
"rpm"
],
"extraFiles": [
{
"filter": [
"LICENSE.txt",
"NOTICE.txt"
]
},
{
"from": "src/assets/linux",
"filter": [
"create_desktop_file.sh",
"app_icon.png",
"README.md"
]
}
]
},
"appImage": {
"executableArgs": [" "]
},
"mac": {
"category": "public.app-category.productivity",
"target": [
"zip",
"dmg"
],
"darkModeSupport": true,
"extraResources": [
{
"filter": [
"LICENSE.txt",
"NOTICE.txt"
]
}
],
"hardenedRuntime": true,
"gatekeeperAssess": true,
"entitlements": "./resources/mac/entitlements.mac.plist",
"entitlementsInherit": "./resources/mac/entitlements.mac.inherit.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "Microphone access may be used by Mattermost plugins, such as Jitsi video conferencing.",
"NSCameraUsageDescription": "Camera access may be used by Mattermost plugins, such as Jitsi video conferencing.",
"NSFocusStatusUsageDescription": "Focus status is used by Mattermost to determine whether to send notifications or not.",
"LSFileQuarantineEnabled": true
},
"notarize": {
"teamId": "UQ8HT4Q2XM"
}
},
"mas": {
"hardenedRuntime": false,
"entitlements": "./resources/mac/entitlements.mas.plist",
"entitlementsInherit": "./resources/mac/entitlements.mas.inherit.plist",
"entitlementsLoginHelper": "./resources/mac/entitlements.mas.inherit.plist",
"provisioningProfile": "./mas.provisionprofile",
"extendInfo": {
"ITSAppUsesNonExemptEncryption": false,
"NSUserActivityTypes": ["INSendMessageIntent"]
},
"singleArchFiles": "*"
},
"masDev": {
"provisioningProfile": "./dev.provisionprofile"
},
"dmg": {
"background": "src/assets/osx/DMG_BG.png",
"contents": [
{
"x": 135,
"y": 165
},
{
"x": 407,
"y": 165,
"type": "link",
"path": "/Applications"
}
],
"iconSize": 120,
"iconTextSize": 14,
"window": {
"height": 380
}
},
"squirrelWindows": {
"iconUrl": "file://src/assets/icon.ico",
"artifactName": "${version}/${name}-setup-${version}-${arch}.${ext}"
},
"win": {
"target": [
"nsis",
"zip",
"msi"
],
"extraFiles": [
{
"filter": [
"LICENSE.txt",
"NOTICE.txt"
]
},
{
"from": "resources/windows/gpo",
"to": "gpo"
}
],
"signDlls": true,
"publisherName": "CN=\"aaron\", O=\"buds inc.\", L=turner somewhere, S=oregon, C=US"
},
"nsis": {
"artifactName": "${version}/${name}-setup-${version}-win.${ext}",
"packElevateHelper": false,
"uninstallDisplayName": "${productName}",
"include": "scripts/installer.nsh",
"warningsAsErrors": false
},
"msi": {
"additionalWixArgs": ["-ext", "WixUtilExtension"]
},
"rpm": {
"fpm": ["--rpm-rpmbuild-define", "_build_id_links none"]
}
}

36
fastlane/Fastfile Normal file
View file

@ -0,0 +1,36 @@
fastlane_version '2.71.0'
fastlane_require 'aws-sdk-s3'
fastlane_require 'erb'
fastlane_require 'json'
fastlane_require 'pathname'
lane :publish_test do |options|
api_key = ''
unless ENV['MACOS_API_KEY_ID'].nil? || ENV['MACOS_API_KEY_ID'].empty? ||
ENV['MACOS_API_ISSUER_ID'].nil? || ENV['MACOS_API_ISSUER_ID'].empty? ||
ENV['MACOS_API_KEY'].nil? || ENV['MACOS_API_KEY'].empty?
api_key_path = "#{ENV['MACOS_API_KEY_ID']}.p8"
File.open("../#{api_key_path}", 'w') do |f|
key_string = ENV['MACOS_API_KEY']
p8_array = key_string.split('\n')
p8_array.each_with_index do |value, index|
f.write(value)
f.write("\n") unless index == p8_array.length - 1
end
end
api_key = app_store_connect_api_key(
key_id: ENV['MACOS_API_KEY_ID'],
issuer_id: ENV['MACOS_API_ISSUER_ID'],
key_filepath: "./#{api_key_path}",
in_house: ENV['MACOS_IN_HOUSE'] == 'true', # optional but may be required if using match/sigh
)
File.delete("../#{api_key_path}")
end
pilot(
pkg: options[:path],
skip_waiting_for_build_processing: ENV['CI'] === 'true',
api_key: api_key
)
end

1
i18n/am.json Normal file
View file

@ -0,0 +1 @@
{}

304
i18n/ar.json Normal file
View file

@ -0,0 +1,304 @@
{
"common.permissions.canBasicAuth": "مصادقه الويب",
"common.tabs.TAB_FOCALBOARD": "اللوحات",
"common.tabs.TAB_MESSAGING": "القنوات",
"common.tabs.TAB_PLAYBOOKS": "خطط العمل",
"label.accept": "قبول",
"label.add": "أضف",
"label.allow": "سماح",
"label.cancel": "إلغاء",
"label.change": "تغيير",
"label.close": "اغلاق",
"label.deny": "رفض",
"label.denyPermanently": "رفض بشكل دائم",
"label.login": "تسجيل الدخول",
"label.no": "لا",
"label.ok": "نعم",
"label.remove": "إزالة",
"label.save": "حفظ",
"label.yes": "موافق",
"main.CriticalErrorHandler.uncaughtException.button.reopen": "اعاده فتح",
"main.CriticalErrorHandler.uncaughtException.button.showDetails": "اظهار التفاصيل",
"main.CriticalErrorHandler.uncaughtException.dialog.message": "تم إنهاء التطبيق {appName} بشكل غير متوقع. انقر على \"{showDetails}\" لمعرفة المزيد أو \"{reopen}\" لفتح التطبيق مرة أخرى.\n\nخطأ داخلي: {err}",
"main.CriticalErrorHandler.unresponsive.dialog.message": "النافذة لم تعد تستجيب.\nهل تريد الانتظار حتى تستجيب النافذة مرة أخرى؟",
"main.allowProtocolDialog.button.saveProtocolAsAllowed": "نعم (احفظ {protocol} على النحو المسموح به)",
"main.allowProtocolDialog.detail": "الرابط المطلوب هو {URL}. هل تريد الاستمرار ؟",
"main.allowProtocolDialog.message": "{protocol} الرابط يحتاج تطبيق خارجى.",
"main.allowProtocolDialog.title": "ليس بروتوكول http(s)",
"main.app.app.handleAppCertificateError.certError.button.cancelConnection": "ألغاء الاتصال",
"main.app.app.handleAppCertificateError.certError.button.moreDetails": "تفاصيل اكثر",
"main.app.app.handleAppCertificateError.certError.dialog.detail": "{extraDetail} الاصل: {origin}\nخطأ: {error}",
"main.app.app.handleAppCertificateError.certError.dialog.message": "هناك مشكلة في الاعدادات مع خادم Mattermost هذا ، أو أن شخصًا ما يحاول اعتراض اتصالك. قد تحتاج أيضًا إلى تسجيل الدخول إلى شبكة Wi-Fi التي تتصل بها باستخدام متصفح الويب الخاص بك.",
"main.app.app.handleAppCertificateError.certError.dialog.title": "خطأ فى شهادة التصديق",
"main.app.app.handleAppCertificateError.certNotTrusted.button.cancelConnection": "إلغاء الاتصال",
"main.app.app.handleAppCertificateError.certNotTrusted.button.trustInsecureCertificate": "مصادقة الشهادة غير الآمنة",
"main.app.app.handleAppCertificateError.certNotTrusted.dialog.message": "الشهادة من \"{issuerName}\" غير موثقة.",
"main.app.app.handleAppCertificateError.certNotTrusted.dialog.title": "الشهادة غير موثقة",
"main.app.app.handleAppCertificateError.dialog.extraDetail": "الشهادة مختلفة عن السابقة.\n\n",
"main.app.initialize.downloadBox.allFiles": "كل الملفات",
"main.app.utils.migrateMacAppStore.button.dontImport": "لا تستورد",
"main.app.utils.migrateMacAppStore.button.selectAndImport": "حدد مسار ثم استورد",
"main.app.utils.migrateMacAppStore.dialog.detail": "يبدو أن تهيئة {appName} موجودة ، هل ترغب في استيرادها؟ سيُطلب منك اختيار دليل التكوين الصحيح.",
"main.app.utils.migrateMacAppStore.dialog.message": "استيراد الاعدادات الحالية",
"main.autoUpdater.noUpdate.detail": "أنت تستخدم أحدث إصدار من {appName} نسخة سطح المكتب (الإصدار {version}). سيتم إشعارك عند توفر إصدار جديد للتثبيت.",
"main.autoUpdater.noUpdate.message": "لديك اخر اصدار",
"main.badge.noUnreads": "ليس لديك رسائل غير مقروءة",
"main.badge.sessionExpired": "انتهت الجلسة: الرجاء تسجيل الدخول لمتابعة تلقي الإخطارات.",
"main.badge.unreadChannels": "لديك قنوات غير مقروءة",
"main.badge.unreadMentions": "لديك ({mentionCount}) إشعار(ات) غير مقروءة",
"main.downloadsManager.resetDownloadsFolder": "يرجى إعادة تعيين المجلد الذي سيحتوي الملفات المنزلة",
"main.downloadsManager.specifyDownloadsFolder": "المجلد الذي سوف يحتوي الملفات المنزلة",
"main.menus.app.edit": "&تحرير",
"main.menus.app.edit.copy": "نسخ",
"main.menus.app.edit.cut": "قطع",
"main.menus.app.edit.paste": "لصق",
"main.menus.app.edit.pasteAndMatchStyle": "لصق ومطابقة النمط",
"main.menus.app.edit.redo": "إعادة",
"main.menus.app.edit.selectAll": "تحديد الكل",
"main.menus.app.edit.undo": "تراجع",
"main.menus.app.file": "&ملف",
"main.menus.app.file.about": "عن {appName}",
"main.menus.app.file.exit": "انهاء",
"main.menus.app.file.hide": "اخفاء {appName}",
"main.menus.app.file.hideOthers": "اخفاء الاخرين",
"main.menus.app.file.preferences": "التفضيلات..",
"main.menus.app.file.quit": "انهاء {appName}",
"main.menus.app.file.settings": "الاعدادات...",
"main.menus.app.file.signInToAnotherServer": "تسجيل الدخول لخادم اخر",
"main.menus.app.file.unhide": "اظهار الكل",
"main.menus.app.help": "المساعدة",
"main.menus.app.help.RunDiagnostics": "تشغيل التشخيصات",
"main.menus.app.help.ShowLogs": "اظهار السجلات",
"main.menus.app.help.checkForUpdates": "تحقق من وجود تحديثات",
"main.menus.app.help.commitString": " التعديل: {hashVersion}",
"main.menus.app.help.downloadUpdate": "تنزل التحديثات",
"main.menus.app.help.learnMore": "اعرف اكثر...",
"main.menus.app.help.restartAndUpdate": "اعد التشغيل والتحديث",
"main.menus.app.help.versionString": "اصدار{version}{commit}",
"main.menus.app.history": "&التاريخ",
"main.menus.app.history.back": "الرجوع",
"main.menus.app.history.forward": "الامام",
"main.menus.app.view": "&عرض",
"main.menus.app.view.actualSize": "المقاس الحقيقى",
"main.menus.app.view.clearCacheAndReload": "امسح ذاكرة التخزين المؤقت وإعادة التحميل",
"main.menus.app.view.devToolsAppWrapper": "أدوات المطور لغلاف التطبيق",
"main.menus.app.view.devToolsCurrentCallWidget": "أدوات المطور لعنصر (أداة) الاتصال الاضافي(ة)",
"main.menus.app.view.devToolsCurrentServer": "أدوات المطور للخادم الحالي",
"main.menus.app.view.devToolsSubMenu": "أدوات المطور",
"main.menus.app.view.downloads": "التنزيلات",
"main.menus.app.view.find": "بحث..",
"main.menus.app.view.fullscreen": "تبديل ملىء الشاشة",
"main.menus.app.view.reload": "اعاده تحميل",
"main.menus.app.view.toggleDarkMode": "تبديل الوضع المظلم",
"main.menus.app.view.zoomIn": "تكبير",
"main.menus.app.view.zoomOut": "تصغير",
"main.menus.app.window": "&النافذة",
"main.menus.app.window.bringAllToFront": "إحضار الكل إلى المقدمة",
"main.menus.app.window.close": "اغلاق",
"main.menus.app.window.closeWindow": "اغلاق النافذة",
"main.menus.app.window.minimize": "تصغير",
"main.menus.app.window.selectNextTab": "حدد التبويبه التاليه",
"main.menus.app.window.selectPreviousTab": "حدد التبويبة السابقة",
"main.menus.app.window.showServers": "اظهار الخوادم",
"main.menus.app.window.zoom": "تقريب",
"main.menus.tray.preferences": "التفضيلات ...",
"main.menus.tray.settings": "الاعدادات...",
"main.notifications.download.complete.body": "تم التحميل\n{fileName}",
"main.notifications.download.complete.title": "تم التنزيل",
"main.notifications.mention.title": "شخص م اشار اليك",
"main.notifications.upgrade.newVersion.body": "يتوفر إصدار جديد للتنزيل الآن.",
"main.notifications.upgrade.newVersion.title": "إصدار سطح المكتب الجديد متاح",
"main.notifications.upgrade.readyToInstall.body": "إصدار سطح المكتب الجديد جاهز للتثبيت الآن.",
"main.notifications.upgrade.readyToInstall.title": "انقر لإعادة التشغيل وتثبيت التحديث",
"main.permissionsManager.checkPermission.dialog.detail.geolocation": "سيستخدم {appName} الموقع لإعداد منطقتك الزمنية. ويمكنك دائمًا تغيير ذلك لاحقًا في إعدادات جهاز الكمبيوتر الخاص بك.",
"main.permissionsManager.checkPermission.dialog.detail.media": "{appName} سوف يستخدم المايكروفون و الكاميرا من أجل الاتصالات و الملاحظات الصوتية، يمكنك تغيير هذا لاحقاً من خلال الإعدادات.",
"main.permissionsManager.checkPermission.dialog.detail.notifications": "{appName} سوف يرسل اشعارات للرسائل والاتصالات. يمكنك ضبط تفضيلات الاشعارات في الإعدادات.",
"main.permissionsManager.checkPermission.dialog.detail.openExternal": "سيفتح {appName} الرابط المطلوب في تطبيق خارجي. إذا كنت لا تثق بهذا الرابط أو لا تتعرف عليه، فانقر فوق \"رفض\". يمكنك دائمًا تغيير هذا لاحقًا في إعدادات الكمبيوتر.",
"main.permissionsManager.checkPermission.dialog.detail.screenShare": "سيستخدم {appName} هذا الإذن لمشاركة شاشتك لإجراء المكالمات. يمكنك دائمًا تغيير هذا لاحقًا في إعدادات الكمبيوتر.",
"main.permissionsManager.checkPermission.dialog.message.geolocation": "{appName} ({url}) يود الوصول الى موقعك.",
"main.permissionsManager.checkPermission.dialog.message.media": "{appName} ({url}) يود الوصول الى الكاميرا والمايكروفون.",
"main.permissionsManager.checkPermission.dialog.message.notifications": "{appName} ({url}) يود إرسال الإشعارات.",
"main.permissionsManager.checkPermission.dialog.message.openExternal": "يريد {appName} ({url}) الحصول على إذن لفتح عنوان URL التالي: {externalURL}",
"main.permissionsManager.checkPermission.dialog.message.screenShare": "يرغب {appName} ({url}) في أن يتمكن من عرض شاشتك.",
"main.permissionsManager.checkPermission.dialog.title": "تم طلب الإذن",
"main.tray.tray.expired": "انتهت الجلسة: الرجاء تسجيل الدخول لمتابعة تلقي الإخطارات.",
"main.tray.tray.mention": "تم ذِكرك",
"main.tray.tray.unread": "لديك قنوات غير مقروءة",
"main.views.viewManager.handleDeepLink.error.body": "لا يوجد خادم مهيأ في التطبيق يطابق عنوان url المطلوب: {url}",
"main.views.viewManager.handleDeepLink.error.title": "لا يوجد خادم مطابق",
"main.windows.mainWindow.closeApp.dialog.checkboxLabel": "لا تسأل مرة أخرى",
"main.windows.mainWindow.closeApp.dialog.detail": "لن تتلقى بعد الآن إشعارات بالرسائل. إذا كنت تريد ترك {appName} قيد التشغيل في مصفوفة النظام ، فيمكنك تمكين هذا في الإعدادات.",
"main.windows.mainWindow.closeApp.dialog.message": "هل أنت متأكد من أنك تريد الخروج؟",
"main.windows.mainWindow.closeApp.dialog.title": "اغلاق التطبيق",
"main.windows.mainWindow.minimizeToTray.dialog.checkboxLabel": "لا تظهر مرة أخرى",
"main.windows.mainWindow.minimizeToTray.dialog.message": "سيستمر تشغيل {appName} في علبة النظام. يمكن تعطيل هذا في الإعدادات.",
"main.windows.mainWindow.minimizeToTray.dialog.title": "تصغير إلى الصينية",
"renderer.components.autoSaveIndicator.saved": "تم الحفظ",
"renderer.components.autoSaveIndicator.saving": "جاري الحفظ...",
"renderer.components.configureServer.cardtitle": "أدخل تفاصيل الخادم الخاص بك",
"renderer.components.configureServer.connect.default": "يتصل",
"renderer.components.configureServer.connect.override": "الإتصال على أي حال",
"renderer.components.configureServer.connect.saving": "جاري الإتصال…",
"renderer.components.configureServer.name.info": "الاسم الذي سيظهر في قائمة الخوادم",
"renderer.components.configureServer.name.placeholder": "اسم الخادم",
"renderer.components.configureServer.subtitle": "بدء اعداد اول خادم للاتصال بـ <br></br> على منصة التواصل مع الفريق",
"renderer.components.configureServer.title": "لنتصل بالخادم",
"renderer.components.configureServer.url.info": "رابط الخادم الذي يخص Mattermost",
"renderer.components.configureServer.url.insecure": "الخادم URL غير امن. لإتصال امن, نأمل استخدام URL مع HTTPS protocol.",
"renderer.components.configureServer.url.notMattermost": "لا يبدو أن عنوان URL للخادم المقدم يشير إلى خادم Mattermost صالح. يرجى التحقق من عنوان URL والتحقق من اتصالك.",
"renderer.components.configureServer.url.ok": "عنوان URL للخادم صالح. إصدار الخادم: {serverVersion}",
"renderer.components.configureServer.url.placeholder": "URL الخادم",
"renderer.components.configureServer.url.urlNotMatched": "لا يتطابق عنوان URL للخادم المقدم مع عنوان URL للموقع الذي تم تكوينه على خادم Mattermost الخاص بك. إصدار الخادم: {serverVersion}",
"renderer.components.configureServer.url.urlUpdated": "تم تحديث عنوان URL للخادم المقدم ليتوافق مع عنوان URL للموقع الذي تم تكوينه على خادم Mattermost الخاص بك. إصدار الخادم: {serverVersion}",
"renderer.components.configureServer.url.validating": "جاري التحقق...",
"renderer.components.errorView.cannotConnectToAppName": "لا يمكن الاتصال بـ{appName}",
"renderer.components.errorView.havingTroubleConnecting": "نواجه مشكلة في الاتصال بـ {appName} . سنستمر في محاولة إقامة اتصال.",
"renderer.components.errorView.refreshThenVerify": "إذا لم يعمل تحديث هذه الصفحة (Ctrl+R أو Command+R)، فيرجى التحقق من ما يلي:",
"renderer.components.errorView.troubleshooting.browserView.canReachFromBrowserWindow": "يمكنك الوصول إلى <link>{url}</link> من خلال نافذة متصفح.",
"renderer.components.errorView.troubleshooting.computerIsConnected": "جهازك متصل بالانترنت.",
"renderer.components.errorView.troubleshooting.urlIsCorrect.appNameIsCorrect": "إرتباط منصة {appName} <link>{url}</link> صحيح",
"renderer.components.extraBar.back": "رجوع",
"renderer.components.input.required": "هذه الخانة مطلوبة",
"renderer.components.mainPage.contextMenu.ariaLabel": "قائمة الخيارات",
"renderer.components.mainPage.titleBar": "{appName}",
"renderer.components.newServerModal.error.nameRequired": "الاسم مطلوب.",
"renderer.components.newServerModal.error.serverUrlExists": "يوجد بالفعل خادم بنفس عنوان URL.",
"renderer.components.newServerModal.error.urlIncorrectFormatting": "URL ليس صحيحاً.",
"renderer.components.newServerModal.error.urlRequired": "لم يتم ادخال الـURL.",
"renderer.components.newServerModal.permissions.geolocation": "الموقع",
"renderer.components.newServerModal.permissions.microphoneAndCamera": "المايكروفون والكاميرا",
"renderer.components.newServerModal.permissions.microphoneAndCamera.windowsCameraPermissions": "تم تعطيل الكاميرا في إعدادات Windows. انقر <link>هنا</link> لفتح إعدادات الكاميرا.",
"renderer.components.newServerModal.permissions.microphoneAndCamera.windowsMicrophoneaPermissions": "تم تعطيل الميكروفون في إعدادات Windows. انقر <link>هنا</link> لفتح إعدادات الميكروفون.",
"renderer.components.newServerModal.permissions.notifications": "التنبيهات",
"renderer.components.newServerModal.permissions.notifications.mac": "قد تحتاج أيضًا إلى تمكين الإشعارات في نظام التشغيل macOS لتطبيق Mattermost. انقر <link>هنا</link> لفتح تفضيلات النظام.",
"renderer.components.newServerModal.permissions.notifications.windows": "قد تحتاج أيضًا إلى تمكين الإشعارات في Windows لـ Mattermost. انقر <link>هنا</link> لفتح إعدادات الإشعارات.",
"renderer.components.newServerModal.permissions.screenShare": "مشاركة الشاشة",
"renderer.components.newServerModal.permissions.title": "الصلاحيات",
"renderer.components.newServerModal.serverDisplayName": "اسم الخادم",
"renderer.components.newServerModal.serverDisplayName.description": "اسم الخادم الى التطبيق.",
"renderer.components.newServerModal.serverURL": "URL الخادم",
"renderer.components.newServerModal.serverURL.description": "عنوان URL لخادم Mattermost الخاص بك. يجب أن يبدأ بـ http:// أو https://.",
"renderer.components.newServerModal.success.ok": "عنوان URL للخادم صالح. إصدار الخادم: {serverVersion}",
"renderer.components.newServerModal.title.add": "اضافة خادم",
"renderer.components.newServerModal.title.edit": "تعديل خادم",
"renderer.components.newServerModal.validating": "جاري التحقق...",
"renderer.components.newServerModal.warning.insecure": "من المحتمل أن يكون عنوان URL الخاص بخادمك غير آمن. للحصول على أفضل النتائج، استخدم عنوان URL مع بروتوكول HTTPS.",
"renderer.components.newServerModal.warning.notMattermost": "لا يبدو أن عنوان URL للخادم المقدم يشير إلى خادم Mattermost صالح. يرجى التحقق من عنوان URL والتحقق من اتصالك.",
"renderer.components.newServerModal.warning.urlNotMatched": "لا يتطابق عنوان URL للخادم مع عنوان URL للموقع الذي تم تكوينه على خادم Mattermost الخاص بك. إصدار الخادم: {serverVersion}",
"renderer.components.newServerModal.warning.urlUpdated": "تم تحديث عنوان URL للخادم المقدم ليتوافق مع عنوان URL للموقع الذي تم تكوينه على خادم Mattermost الخاص بك. إصدار الخادم: {serverVersion}",
"renderer.components.removeServerModal.body": "سيؤدي هذا إلى إزالة الخادم من تطبيق سطح المكتب الخاص بك ولكن لن يؤدي إلى حذف أي من بياناته - يمكنك إضافة الخادم مرة أخرى إلى التطبيق في أي وقت.",
"renderer.components.removeServerModal.confirm": "هل تريد تأكيد رغبتك في إزالة الخادم {serverName}؟",
"renderer.components.removeServerModal.title": "إزالة الخادم",
"renderer.components.saveButton.save": "حفظ",
"renderer.components.saveButton.saving": "جاري الحفظ",
"renderer.components.serverDropdownButton.noServersConfigured": "لا توجد خوادم تم تكوينها",
"renderer.components.settingsPage.afterRestart": "يسري مفعول الإعداد بعد إعادة تشغيل التطبيق.",
"renderer.components.settingsPage.appLanguage": "ضبط لغة التطبيق (اختباري)",
"renderer.components.settingsPage.appLanguage.description": "يختار اللغة التي سيستخدمها تطبيق سطح المكتب لعناصر القائمة والنوافذ المنبثقة. لا يزال التطبيق في مرحلة الإصدار التجريبي، وقد تفتقر بعض اللغات إلى سلاسل الترجمة.",
"renderer.components.settingsPage.appLanguage.useSystemDefault": "استخدم النظام الافتراضي",
"renderer.components.settingsPage.appOptions": "خيارات التطبيق",
"renderer.components.settingsPage.bounceIcon": "ارتد أيقونة Dock",
"renderer.components.settingsPage.bounceIcon.description": "إذا تم تمكين هذا الخيار، يرتد رمز Dock مرة واحدة أو حتى يفتح المستخدم التطبيق عند تلقي إشعار جديد.",
"renderer.components.settingsPage.bounceIcon.once": "مرة",
"renderer.components.settingsPage.bounceIcon.untilOpenApp": "حتى أفتح التطبيق",
"renderer.components.settingsPage.checkSpelling": "التحقق من الإملاء",
"renderer.components.settingsPage.checkSpelling.description": "قم بتمييز الكلمات المكتوبة بشكل خاطئ في رسائلك استنادًا إلى لغة نظامك أو تفضيلات اللغة.",
"renderer.components.settingsPage.checkSpelling.editSpellcheckUrl": "استخدم عنوان URL للقاموس البديل",
"renderer.components.settingsPage.checkSpelling.preferredLanguages": "اختر اللغة أو اللغات المرغوبة",
"renderer.components.settingsPage.checkSpelling.revertToDefault": "العودة إلى الوضع الافتراضي",
"renderer.components.settingsPage.checkSpelling.specifyURL": "حدد عنوان URL حيث يمكن استرداد تعريفات القاموس",
"renderer.components.settingsPage.downloadLocation": "مكان التنزيل",
"renderer.components.settingsPage.downloadLocation.description": "حدد المجلد الذي سيتم تنزيل الملفات فيه.",
"renderer.components.settingsPage.enableHardwareAcceleration": "استخدام تسريع أجهزة GPU",
"renderer.components.settingsPage.enableHardwareAcceleration.description": "إذا تم تمكينه، فسيتم عرض واجهة المستخدم {appName} بكفاءة أكبر ولكن قد يؤدي ذلك إلى انخفاض الاستقرار لبعض الأنظمة.",
"renderer.components.settingsPage.flashWindow": "اشعار بلون لأيقونة شريط المهام عند تلقي رسالة جديدة",
"renderer.components.settingsPage.flashWindow.description": "إذا تم تمكين هذا الخيار، فسوف يومض رمز شريط المهام لبضع ثوانٍ عند تلقي رسالة جديدة.",
"renderer.components.settingsPage.flashWindow.description.linuxFunctionality": "قد لا تعمل هذه الوظيفة مع جميع مديري النوافذ في Linux.",
"renderer.components.settingsPage.flashWindow.description.note": "ملاحظة: ",
"renderer.components.settingsPage.fullscreen": "فتح التطبيق في وضع ملء الشاشة",
"renderer.components.settingsPage.fullscreen.description": "إذا تم التمكين، فسيتم فتح تطبيق {appName} دائماً في وضع ملء الشاشة",
"renderer.components.settingsPage.header": "الاعدادات",
"renderer.components.settingsPage.launchAppMinimized": "تشغيل التطبيق المصغر",
"renderer.components.settingsPage.launchAppMinimized.description": "إذا تم تمكين هذا الخيار، سيبدأ التطبيق في system tray، ولن يعرض النافذة عند التشغيل.",
"renderer.components.settingsPage.loadingConfig": "جاري تحميل التكوين...",
"renderer.components.settingsPage.loggingLevel": "مستوى التسجيل",
"renderer.components.settingsPage.loggingLevel.description": "يعد التسجيل مفيدًا للمطورين والدعم لعزل المشكلات التي قد تواجهها مع تطبيق سطح المكتب.",
"renderer.components.settingsPage.loggingLevel.description.subtitle": "يؤدي زيادة مستوى السجل إلى زيادة استخدام مساحة القرص وقد يؤثر على الأداء. نوصي بزيادة مستوى السجل فقط إذا كنت تواجه مشكلات.",
"renderer.components.settingsPage.loggingLevel.level.debug": "تصحيح الأخطاء (debug)",
"renderer.components.settingsPage.loggingLevel.level.error": "الأخطاء (الخطأ)",
"renderer.components.settingsPage.loggingLevel.level.info": "معلومات (معلومات)",
"renderer.components.settingsPage.loggingLevel.level.silly": "أفضل (سخيف)",
"renderer.components.settingsPage.loggingLevel.level.verbose": "مطوّل (مطوّل)",
"renderer.components.settingsPage.loggingLevel.level.warn": "الأخطاء والتحذيرات (التحذير)",
"renderer.components.settingsPage.minimizeToTray": "ترك التطبيق قيد التشغيل في منطقة الإشعارات عند إغلاق نافذة التطبيق",
"renderer.components.settingsPage.minimizeToTray.description": "إذا تم تمكين هذا الخيار، فسيظل التطبيق قيد التشغيل في منطقة الإعلام بعد إغلاق نافذة التطبيق.",
"renderer.components.settingsPage.saving.error": "لا يمكن حفظ التغييرات. يرجى المحاولة مرة أخرى.",
"renderer.components.settingsPage.showUnreadBadge": "إظهار الشارة الحمراء على أيقونة {taskbar} للإشارة إلى الرسائل غير المقروءة",
"renderer.components.settingsPage.showUnreadBadge.description": "بغض النظر عن هذا الإعداد، تتم الإشارة إلى الإشارات دائمًا بشارة حمراء وعدد العناصر على أيقونة {taskbar}.",
"renderer.components.settingsPage.startAppOnLogin": "ابدأ تشغيل التطبيق عند تسجيل الدخول",
"renderer.components.settingsPage.startAppOnLogin.description": "إذا تم تمكين هذا الخيار، فسيتم تشغيل التطبيق تلقائيًا عند تسجيل الدخول إلى جهازك.",
"renderer.components.settingsPage.trayIcon.color": "لون الأيقونة: ",
"renderer.components.settingsPage.trayIcon.show": "إظهار الرمز في منطقة الإشعارات",
"renderer.components.settingsPage.trayIcon.show.darwin": "إظهار أيقونة {appName} في شريط القائمة",
"renderer.components.settingsPage.trayIcon.theme.dark": "داكن",
"renderer.components.settingsPage.trayIcon.theme.light": "فاتح",
"renderer.components.settingsPage.trayIcon.theme.systemDefault": "استخدم النظام الافتراضي",
"renderer.components.settingsPage.updates": "تحديثات",
"renderer.components.settingsPage.updates.automatic": "التحقق تلقائيًا من التحديثات",
"renderer.components.settingsPage.updates.automatic.description": "إذا تم تمكين هذا الخيار، فسيتم تنزيل التحديثات الخاصة بتطبيق سطح المكتب تلقائيًا وسيتم إعلامك عندما تصبح جاهزًا للتثبيت.",
"renderer.components.settingsPage.updates.checkNow": "التحقق من التحديثات الآن",
"renderer.components.showCertificateModal.algorithm": "خوارزمية",
"renderer.components.showCertificateModal.commonName": "الاسم الشائع",
"renderer.components.showCertificateModal.issuerName": "اسم المنشئ",
"renderer.components.showCertificateModal.noCertSelected": "لم يتم اختيار الشهادة",
"renderer.components.showCertificateModal.notValidAfter": "غير صالح بعد",
"renderer.components.showCertificateModal.notValidBefore": "غير صالح قبل",
"renderer.components.showCertificateModal.publicKeyInfo": "معلومات المفتاح العام",
"renderer.components.showCertificateModal.serialNumber": "الرقم التسلسلي",
"renderer.components.showCertificateModal.subjectName": "اسم العنوان",
"renderer.components.welcomeScreen.button.getStarted": "ابدأ هنا",
"renderer.components.welcomeScreen.slides.calls.subtitle": "عندما لا تكون الكتابة سريعة بما يكفي، يمكنك الانتقال بسلاسة من الدردشة إلى المكالمات الصوتية ومشاركة الشاشة دون الحاجة إلى تبديل الأدوات.",
"renderer.components.welcomeScreen.slides.calls.title": "ابدأ مكالمات آمنة على الفور",
"renderer.components.welcomeScreen.slides.collaborate.subtitle": "التواصل والتعاون بشكل فعال مع القنوات المستمرة ومشاركة الملفات ومقاطع التعليمات البرمجية وأتمتة سير العمل المصممة خصيصًا للفرق الفنية.",
"renderer.components.welcomeScreen.slides.collaborate.title": "التعاون في الوقت الحقيقي",
"renderer.components.welcomeScreen.slides.integrate.subtitle": "قم بتنفيذ سير العمل وأتمتته باستخدام تكاملات مرنة ومخصصة مع أدوات تقنية شائعة مثل GitHub وGitLab وServiceNow.",
"renderer.components.welcomeScreen.slides.integrate.title": "التكامل مع الأدوات التي تحبها",
"renderer.components.welcomeScreen.slides.welcome.subtitle": "Mattermost عبارة عن منصة تعاون مفتوحة المصدر للأعمال المهمة. آمنة ومرنة ومتكاملة مع الأدوات التي تحبها.",
"renderer.components.welcomeScreen.slides.welcome.title": "مرحباً",
"renderer.downloadsDropdown.ClearAll": "مسح الكل",
"renderer.downloadsDropdown.Downloads": "التنزيلات",
"renderer.downloadsDropdown.Update.ANewVersionIsAvailableToInstall": "يتوفر إصدار جديد من تطبيق سطح المكتب لـ{appName} (الإصدار {version}) للتثبيت.",
"renderer.downloadsDropdown.Update.DownloadUpdate": "تنزيل التحديث",
"renderer.downloadsDropdown.Update.MattermostVersionX": "{appName} الإصدار {version}",
"renderer.downloadsDropdown.Update.NewDesktopVersionAvailable": "إصدار سطح المكتب الجديد متاح",
"renderer.downloadsDropdown.Update.RestartAndUpdate": "إعادة التشغيل والتحديث",
"renderer.downloadsDropdown.remaining": "المتبقي",
"renderer.downloadsDropdownMenu.CancelDownload": "الغاء التنزيل",
"renderer.downloadsDropdownMenu.Clear": "مسح",
"renderer.downloadsDropdownMenu.Open": "فتح",
"renderer.downloadsDropdownMenu.ShowInFileExplorer": "إظهار في مستكشف الملفات",
"renderer.downloadsDropdownMenu.ShowInFileManager": "عرض في مدير الملفات",
"renderer.downloadsDropdownMenu.ShowInFinder": "عرض في Finder",
"renderer.downloadsDropdownMenu.ShowInFolder": "إظهار في المجلد",
"renderer.dropdown.addAServer": "اضافة خادم",
"renderer.dropdown.servers": "الخوادم",
"renderer.modals.certificate.certificateModal.certInfoButton": "معلومات الشهادة",
"renderer.modals.certificate.certificateModal.issuer": "المنشئ",
"renderer.modals.certificate.certificateModal.noCertsAvailable": "لا يوجد شهادة متاحة",
"renderer.modals.certificate.certificateModal.serial": "تسلسل",
"renderer.modals.certificate.certificateModal.subject": "العنوان",
"renderer.modals.certificate.certificateModal.subtitle": "حدد شهادة للتحقق من هويتك {url}",
"renderer.modals.certificate.certificateModal.title": "اختيار شهادة",
"renderer.modals.login.loginModal.message.proxy": "يتطلب الوكيل {host}:{port} اسم مستخدم وكلمة مرور.",
"renderer.modals.login.loginModal.message.server": "يتطلب الخادم {url} اسم مستخدم وكلمة مرور.",
"renderer.modals.login.loginModal.password": "كلمة المرور",
"renderer.modals.login.loginModal.title": "المصادقة مطلوبة",
"renderer.modals.login.loginModal.username": "اسم المستخدم",
"renderer.modals.permission.permissionModal.body": "يتطلب الموقع الذي لم يتم تضمينه في تكوين خادم Mattermost الخاص بك الوصول إلى {permission}.",
"renderer.modals.permission.permissionModal.requestOriginatedFromOrigin": "نشأ هذا الطلب من <link>{origin}</link>",
"renderer.modals.permission.permissionModal.title": "{permission} مطلوب",
"renderer.modals.permission.permissionModal.unknownOrigin": "مصدر غير معروف",
"renderer.time.hours": "ساعات",
"renderer.time.mins": "دقائق",
"renderer.time.sec": "ثواني"
}

27
i18n/be.json Normal file
View file

@ -0,0 +1,27 @@
{
"main.badge.unreadMentions": "У вас ёсць непрачытаныя згадкі ({mentionCount})",
"main.badge.unreadChannels": "У вас ёсць непрачытаныя каналы",
"main.app.utils.migrateMacAppStore.button.dontImport": "Не імпартаваць",
"main.app.initialize.downloadBox.allFiles": "Усе файлы",
"main.app.app.handleAppCertificateError.certNotTrusted.button.cancelConnection": "Адмяніць злучэнне",
"main.app.app.handleAppCertificateError.certError.dialog.title": "Памылка сертыфіката",
"common.tabs.TAB_PLAYBOOKS": "Playbooks",
"renderer.time.mins": "хвілін",
"renderer.time.hours": "гадзін",
"renderer.time.sec": "сек",
"main.app.app.handleAppCertificateError.certError.button.moreDetails": "Больш падрабязна",
"main.app.app.handleAppCertificateError.certError.button.cancelConnection": "Адмяніць злучэнне",
"label.yes": "Так",
"label.save": "Захаваць",
"label.remove": "Выдалiць",
"label.ok": "ОК",
"label.no": "Не",
"label.close": "Зачыніць",
"label.change": "Змяніць",
"label.cancel": "Скасаваць",
"label.add": "Дадаць",
"label.accept": "Прыняць",
"common.tabs.TAB_MESSAGING": "Channels",
"common.tabs.TAB_FOCALBOARD": "Boards",
"common.permissions.canBasicAuth": "Вэб-аўтэнтыфікацыя"
}

Some files were not shown because too many files have changed in this diff Show more