Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

2
e2e/playwright/.eslintignore Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
results
node_modules

14
e2e/playwright/.eslintrc.json Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"plugins": ["@typescript-eslint"],
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module"
},
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"no-console": "error"
}
}

3
e2e/playwright/.percy.yml Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
version: 2
percy:
defer-uploads: true

5
e2e/playwright/.prettierignore Обычный файл
Просмотреть файл

@@ -0,0 +1,5 @@
node_modules
package.json
package-lock.json
playwright-report
storage_state

6
e2e/playwright/.prettierrc.json Обычный файл
Просмотреть файл

@@ -0,0 +1,6 @@
{
"bracketSpacing": false,
"printWidth": 120,
"singleQuote": true,
"tabWidth": 4
}

61
e2e/playwright/README.md Обычный файл
Просмотреть файл

@@ -0,0 +1,61 @@
## Local development
#### 1. Start local server in a separate terminal.
#### 2. Install dependencies and run the test.
```
# Install npm packages
npm i
# Install browser binaries as prompted if Playwright is just installed or updated
# See https://playwright.dev/docs/browsers
npx playwright install
# Run specific test of all projects -- chrome, firefox, iphone and ipad.
# See https://playwright.dev/docs/test-cli.
npm run test -- login
# Run specific test of a project
npm run test -- login --project=chrome
# Or run all tests
npm run test
```
#### 3. Inspect test results at `/test-results` folder when something failed unexpectedly.
## Updating screenshots is strictly via Playwright's docker container for consistency
#### 1. Run docker container using latest focal version
Change to root directory, run docker container
```
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.30.0-focal /bin/bash
```
#### 2. Inside the docker container
```
export PW_BASE_URL=http://host.docker.internal:8065
cd mattermost/e2e/playwright
# Install npm packages. Use "npm ci" to match the automated environment
npm ci
# Run specific test. See https://playwright.dev/docs/test-cli.
npm run test -- login --project=chrome
# Or run all tests
npm run test
# Update snapshots
npm run test -- login --update-snapshots
```
## Page/Component Object Model
See https://playwright.dev/docs/test-pom.
Page and component abstractions are located at `./support/ui`. It should be first class before writing a spec file so that any future change in DOM structure will be done in one place only. No static UI text and fixed locator should be written in the spec file.

226
e2e/playwright/global_setup.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,226 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect} from '@playwright/test';
import {AdminConfig} from '@mattermost/types/config';
import {UserProfile} from '@mattermost/types/users';
import {PluginsResponse} from '@mattermost/types/plugins';
import {
Client,
createRandomTeam,
getAdminClient,
getDefaultAdminUser,
getOnPremServerConfig,
makeClient,
} from './support/server';
import {defaultTeam} from './support/util';
import testConfig from './test.config';
async function globalSetup() {
let adminClient: Client;
let adminUser: UserProfile | null;
({adminClient, adminUser} = await getAdminClient());
if (!adminUser) {
const {client: firstClient} = await makeClient();
const defaultAdmin = getDefaultAdminUser();
await firstClient.createUser(defaultAdmin, '', '');
({client: adminClient, user: adminUser} = await makeClient(defaultAdmin));
}
await sysadminSetup(adminClient, adminUser);
return function () {
// placeholder for teardown setup
};
}
async function sysadminSetup(client: Client, user: UserProfile | null) {
// Ensure admin's email is verified.
if (!user) {
await client.verifyUserEmail(client.token);
}
// Update default server config
const adminConfig = await client.updateConfig(getOnPremServerConfig());
// Log license and config info
await printLicenseInfo(client);
await printClientInfo(client);
// Create default team if not present.
// Otherwise, create other teams and channels other than the default team cna channels (town-square and off-topic).
const myTeams = await client.getMyTeams();
const myDefaultTeam = myTeams && myTeams.length > 0 && myTeams.find((team) => team.name === defaultTeam.name);
if (!myDefaultTeam) {
await client.createTeam(createRandomTeam(defaultTeam.name, defaultTeam.displayName, 'O', false));
} else if (myDefaultTeam && testConfig.resetBeforeTest) {
await Promise.all(
myTeams.filter((team) => team.name !== defaultTeam.name).map((team) => client.deleteTeam(team.id))
);
const myChannels = await client.getMyChannels(myDefaultTeam.id);
await Promise.all(
myChannels
.filter((channel) => {
return (
channel.team_id === myDefaultTeam.id &&
channel.name !== 'town-square' &&
channel.name !== 'off-topic'
);
})
.map((channel) => client.deleteChannel(channel.id))
);
}
// Log boards product status
printBoardsProductStatus(adminConfig);
// Ensure all products as plugin are installed and active.
await ensurePluginsLoaded(client);
// Log plugin details
await printPluginDetails(client);
// Ensure server deployment type is as expected
await ensureServerDeployment(client);
}
async function printLicenseInfo(client: Client) {
const license = await client.getClientLicenseOld();
// eslint-disable-next-line no-console
console.log(`Server License:
- IsLicensed = ${license.IsLicensed}
- IsTrial = ${license.IsTrial}
- SkuName = ${license.SkuName}
- SkuShortName = ${license.SkuShortName}
- Cloud = ${license.Cloud}
- Users = ${license.Users}`);
}
async function printClientInfo(client: Client) {
const config = await client.getClientConfigOld();
// eslint-disable-next-line no-console
console.log(`Build Info:
- BuildNumber = ${config.BuildNumber}
- BuildDate = ${config.BuildDate}
- Version = ${config.Version}
- BuildHash = ${config.BuildHash}
- BuildHashEnterprise = ${config.BuildHashEnterprise}
- BuildEnterpriseReady = ${config.BuildEnterpriseReady}
- BuildHashBoards = ${config.BuildHashBoards}
- BuildBoards = ${config.BuildBoards}
- BuildHashPlaybooks = ${config.BuildHashPlaybooks}
- FeatureFlagAppsEnabled = ${config.FeatureFlagAppsEnabled}
- FeatureFlagBoardsProduct = ${config.FeatureFlagBoardsProduct}
- FeatureFlagCallsEnabled = ${config.FeatureFlagCallsEnabled}
- TelemetryId = ${config.TelemetryId}`);
}
function getProductsAsPlugin() {
const productsAsPlugin = ['com.mattermost.calls', 'playbooks'];
if (!testConfig.boardsProductEnabled) {
productsAsPlugin.push('focalboard');
}
return productsAsPlugin;
}
async function ensurePluginsLoaded(client: Client) {
const pluginStatus = await client.getPluginStatuses();
const plugins = (await client.getPlugins()) as PluginsResponse;
getProductsAsPlugin().forEach(async (pluginId) => {
const isInstalled = pluginStatus.some((plugin) => plugin.plugin_id === pluginId);
if (!isInstalled) {
// eslint-disable-next-line no-console
console.log(`${pluginId} is not installed. Related visual test will fail.`);
return;
}
const isActive = plugins.active.some((plugin) => plugin.id === pluginId);
if (!isActive) {
await client.enablePlugin(pluginId);
// eslint-disable-next-line no-console
console.log(`${pluginId} is installed and has been activated.`);
} else {
// eslint-disable-next-line no-console
console.log(`${pluginId} is installed and active.`);
}
});
}
function printBoardsProductStatus(config: AdminConfig) {
// Ensure boards as product is enabled
if (!config.FeatureFlags.BoardsProduct) {
// eslint-disable-next-line no-console
console.log('FeatureFlags.BoardsProduct is disabled. Related visual test will fail.');
} else {
// eslint-disable-next-line no-console
console.log('FeatureFlags.BoardsProduct is enabled.');
}
}
async function printPluginDetails(client: Client) {
const plugins = (await client.getPlugins()) as PluginsResponse;
if (plugins.active.length) {
// eslint-disable-next-line no-console
console.log('Active plugins:');
}
plugins.active.forEach((plugin) => {
// eslint-disable-next-line no-console
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
});
if (plugins.inactive.length) {
// eslint-disable-next-line no-console
console.log('Inactive plugins:');
}
plugins.inactive.forEach((plugin) => {
// eslint-disable-next-line no-console
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
});
// eslint-disable-next-line no-console
console.log('');
}
async function ensureServerDeployment(client: Client) {
if (testConfig.haClusterEnabled) {
const {haClusterNodeCount, haClusterName} = testConfig;
const {Enable, ClusterName} = (await client.getConfig()).ClusterSettings;
expect(Enable, Enable ? '' : 'Should have cluster enabled').toBe(true);
const sameClusterName = ClusterName === haClusterName;
expect(
sameClusterName,
sameClusterName
? ''
: `Should have cluster name set and as expected. Got "${ClusterName}" but expected "${haClusterName}"`
).toBe(true);
const clusterInfo = await client.getClusterStatus();
const sameCount = clusterInfo?.length === haClusterNodeCount;
expect(
sameCount,
sameCount
? ''
: `Should match number of nodes in a cluster as expected. Got "${clusterInfo?.length}" but expected "${haClusterNodeCount}"`
).toBe(true);
clusterInfo.forEach((info) =>
// eslint-disable-next-line no-console
console.log(`hostname: ${info.hostname}, version: ${info.version}, config_hash: ${info.config_hash}`)
);
}
}
export default globalSetup;

4007
e2e/playwright/package-lock.json сгенерированный Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

32
e2e/playwright/package.json Обычный файл
Просмотреть файл

@@ -0,0 +1,32 @@
{
"scripts": {
"test": "PW_SNAPSHOT_ENABLE=true playwright test",
"percy": "PERCY_TOKEN=$PERCY_TOKEN PW_PERCY_ENABLE=true percy exec -- playwright test --project=chrome --project=iphone --project=ipad",
"tsc": "tsc -b",
"lint": "eslint . --ext .js,.ts",
"prettier": "prettier --write .",
"check": "npm run tsc && npm run lint && npm run prettier",
"codegen": "playwright codegen $PW_BASE_URL",
"test-slomo": "PW_SNAPSHOT_ENABLE=true PW_HEADLESS=false PW_SLOWMO=1000 playwright test",
"show-report": "npx playwright show-report"
},
"dependencies": {
"@percy/cli": "1.18.0",
"@percy/playwright": "1.0.4",
"@playwright/test": "1.30.0",
"chalk": "4.1.2",
"deepmerge": "4.3.0",
"dotenv": "16.0.3",
"form-data": "4.0.0",
"isomorphic-unfetch": "4.0.2",
"uuid": "9.0.0"
},
"devDependencies": {
"@types/uuid": "9.0.0",
"@typescript-eslint/eslint-plugin": "5.51.0",
"@typescript-eslint/parser": "5.51.0",
"eslint": "8.34.0",
"prettier": "2.8.4",
"typescript": "4.9.5"
}
}

85
e2e/playwright/playwright.config.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineConfig, devices} from '@playwright/test';
import {duration} from '@e2e-support/util';
import testConfig from '@e2e-test.config';
const defaultOutputFolder = 'playwright-report';
export default defineConfig({
globalSetup: require.resolve('./global_setup'),
forbidOnly: testConfig.isCI,
outputDir: './test-results',
testDir: 'tests',
timeout: duration.one_min,
workers: testConfig.workers,
expect: {
timeout: duration.ten_sec,
toMatchSnapshot: {
threshold: 0.4,
maxDiffPixelRatio: 0.0001,
},
},
use: {
baseURL: testConfig.baseURL,
headless: testConfig.headless,
locale: 'en-US',
launchOptions: {
slowMo: testConfig.slowMo,
},
screenshot: 'only-on-failure',
timezoneId: 'America/Los_Angeles',
trace: 'off',
video: 'on-first-retry',
actionTimeout: duration.half_min,
storageState: {
cookies: [],
origins: [
{
origin: testConfig.baseURL,
localStorage: [{name: '__landingPageSeen__', value: 'true'}],
},
],
},
},
projects: [
{
name: 'iphone',
use: {
browserName: 'chromium',
...devices['iPhone 13 Pro'],
},
},
{
name: 'ipad',
use: {
browserName: 'chromium',
...devices['iPad Pro 11'],
},
},
{
name: 'chrome',
use: {
browserName: 'chromium',
permissions: ['notifications'],
viewport: {width: 1280, height: 1024},
},
},
{
name: 'firefox',
use: {
browserName: 'firefox',
permissions: ['notifications'],
viewport: {width: 1280, height: 1024},
},
},
],
reporter: [
['html', {open: 'never', outputFolder: defaultOutputFolder}],
['json', {outputFile: `${defaultOutputFolder}/results.json`}],
['junit', {outputFile: `${defaultOutputFolder}/results.xml`}],
['list'],
],
});

Двоичные данные
e2e/playwright/support/asset/mattermost-icon_128x128.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 13 KiB

65
e2e/playwright/support/browser_context.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {writeFile} from 'node:fs/promises';
import {request, Browser} from '@playwright/test';
import {UserProfile} from '@mattermost/types/users';
import testConfig from '@e2e-test.config';
export class TestBrowser {
readonly browser: Browser;
constructor(browser: Browser) {
this.browser = browser;
}
async login(user: UserProfile) {
// Log in via API request and save user storage
const storagePath = await loginByAPI(user.username, user.password);
// Sign in a user in new browser context
const context = await this.browser.newContext({storageState: storagePath});
const page = await context.newPage();
return {context, page};
}
}
export async function loginByAPI(loginId: string, password: string, token = '', ldapOnly = false) {
const requestContext = await request.newContext();
const data: any = {
login_id: loginId,
password,
token,
deviceId: '',
};
if (ldapOnly) {
data.ldap_only = 'true';
}
// Log in via API
await requestContext.post(`${testConfig.baseURL}/api/v4/users/login`, {
data,
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
// Save signed-in state to a folder
const storagePath = `storage_state/${Date.now()}_${loginId}_${password}${token ? '_' + token : ''}${
ldapOnly ? '_ldap' : ''
}.json`;
const storageState = await requestContext.storageState({path: storagePath});
await requestContext.dispose();
// Append origins to bypass seeing landing page then write to file
storageState.origins.push({
origin: testConfig.baseURL,
localStorage: [{name: '__landingPageSeen__', value: 'true'}],
});
await writeFile(storagePath, JSON.stringify(storageState));
return storagePath;
}

41
e2e/playwright/support/flag.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import os from 'node:os';
import {expect, test} from '@playwright/test';
import {getAdminClient} from './server/init';
import {isSmallScreen} from './util';
export async function shouldHaveBoardsEnabled(enabled = true) {
const {adminClient} = await getAdminClient();
const config = await adminClient.getConfig();
const boardsEnabled =
(typeof config.FeatureFlags.BoardsProduct === 'boolean' && config.FeatureFlags.BoardsProduct) ||
config.PluginSettings.PluginStates['focalboard'].Enable;
const matched = boardsEnabled === enabled;
expect(matched, matched ? '' : `Boards expect "${enabled}" but actual "${boardsEnabled}"`).toBeTruthy();
}
export async function shouldHaveFeatureFlag(name: string, value: string | boolean) {
const {adminClient} = await getAdminClient();
const config = await adminClient.getConfig();
const matched = config.FeatureFlags[name] === value;
expect(
matched,
matched ? '' : `FeatureFlags["${name}'] expect "${value}" but actual "${config.FeatureFlags[name]}"`
).toBeTruthy();
}
export function shouldSkipInSmallScreen() {
test.skip(({viewport}) => isSmallScreen(viewport), 'Not applicable to mobile device');
}
export async function shouldRunInLinux() {
const platform = os.platform();
await expect(platform, 'Run in Linux or Playwright docker image only').toBe('linux');
}

28
e2e/playwright/support/server/channel.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getRandomId} from '@e2e-support/util';
import {Channel, ChannelType} from '@mattermost/types/channels';
export function createRandomChannel(
teamId: string,
name: string,
displayName: string,
type: ChannelType = 'O',
purpose = '',
header = '',
unique = true
): Channel {
const randomSuffix = getRandomId();
const channel = {
team_id: teamId,
name: unique ? `${name}-${randomSuffix}` : name,
display_name: unique ? `${displayName} ${randomSuffix}` : displayName,
type,
purpose,
header,
};
return channel as Channel;
}

213
e2e/playwright/support/server/client.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,213 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This is based on "packages/client/src/client4.ts". Modified for node client.
// Update should be made in comparison with the base Client4.
import fs from 'node:fs';
import path from 'node:path';
import FormData from 'form-data';
import 'isomorphic-unfetch';
import testConfig from '@e2e-test.config';
import Client4 from '@mattermost/client/client4';
import {Options, StatusOK} from '@mattermost/types/client4';
import {License} from '@mattermost/types/config';
import {CustomEmoji} from '@mattermost/types/emojis';
import {PluginManifest} from '@mattermost/types/plugins';
import {UserProfile} from '@mattermost/types/users';
export default class Client extends Client4 {
getFormDataOptions = (formData: FormData): Options => {
return {
method: 'post',
body: formData,
headers: {
'Content-Type': `multipart/form-data; boundary=${formData.getBoundary()}`,
},
};
};
uploadProfileImageX = (userId: string, filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('image', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getUserRoute(userId)}/image`, options);
};
setTeamIconX = (teamId: string, filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('image', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getTeamRoute(teamId)}/image`, options);
};
createCustomEmojiX = (emoji: CustomEmoji, filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('image', fileData, path.basename(filePath));
formData.append('emoji', JSON.stringify(emoji));
const options = this.getFormDataOptions(formData);
return this.doFetch<CustomEmoji>(`${this.getEmojisRoute()}`, options);
};
uploadBrandImageX = (filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('image', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getBrandRoute()}/image`, options);
};
uploadPublicSamlCertificateX = (filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('certificate', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getBaseRoute()}/saml/certificate/public`, options);
};
uploadPrivateSamlCertificateX = (filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('certificate', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getBaseRoute()}/saml/certificate/private`, options);
};
uploadPublicLdapCertificateX = (filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('certificate', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getBaseRoute()}/ldap/certificate/public`, options);
};
uploadPrivateLdapCertificateX = (filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('certificate', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getBaseRoute()}/ldap/certificate/private`, options);
};
uploadIdpSamlCertificateX = (filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('certificate', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getBaseRoute()}/saml/certificate/idp`, options);
};
uploadLicenseX = (filePath: string) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append('license', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<License>(`${this.getBaseRoute()}/license`, options);
};
uploadPluginX = async (filePath: string, force = false) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
if (force) {
formData.append('force', 'true');
}
formData.append('plugin', fileData, path.basename(filePath));
const options = this.getFormDataOptions(formData);
return this.doFetch<PluginManifest>(this.getPluginsRoute(), options);
};
// *****************************************************************************
// Boards client
// based on https://github.com/mattermost/focalboard/blob/main/webapp/src/octoClient.ts
// *****************************************************************************
async patchUserConfig(userID: string, patch: UserConfigPatch): Promise<UserPreference[] | undefined> {
const path = `/users/${encodeURIComponent(userID)}/config`;
const options = {
method: 'put',
body: JSON.stringify(patch),
};
return this.doFetch<UserPreference[]>(this.getBoardsRoute() + path, options);
}
}
// Variable to hold cache
const clients: Record<string, ClientCache> = {};
async function makeClient(userRequest?: UserRequest, useCache = true): Promise<ClientCache> {
const client = new Client();
client.setUrl(testConfig.baseURL);
try {
if (!userRequest) {
return {client, user: null};
}
const cacheKey = userRequest.username + userRequest.password;
if (useCache && clients[cacheKey] != null) {
return clients[cacheKey];
}
const userProfile = await client.login(userRequest.username, userRequest.password);
const user = {...userProfile, password: userRequest.password};
const config = await client.getClientConfigOld();
client.setUseBoardsProduct(config.FeatureFlagBoardsProduct === 'true');
if (useCache) {
clients[cacheKey] = {client, user};
}
return {client, user};
} catch (err) {
// log an error for debugging
// eslint-disable-next-line no-console
console.log('makeClient', err);
return {client, user: null};
}
}
// Client types
type UserRequest = {
username: string;
email?: string;
password: string;
};
type ClientCache = {
client: Client;
user: UserProfile | null;
};
// Boards types
interface UserPreference {
user_id: string;
category: string;
name: string;
value: any;
}
interface UserConfigPatch {
updatedFields?: Record<string, string>;
deletedFields?: string[];
}
export {Client, makeClient};

Просмотреть файл

@@ -0,0 +1,713 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import merge from 'deepmerge';
import {
AdminConfig,
ExperimentalSettings,
FeatureFlags,
PasswordSettings,
ServiceSettings,
TeamSettings,
PluginSettings,
ClusterSettings,
CollapsedThreads,
} from '@mattermost/types/config';
import testConfig from '@e2e-test.config';
export function getOnPremServerConfig(): AdminConfig {
return merge<AdminConfig>(defaultServerConfig, onPremServerConfig() as AdminConfig);
}
type TestAdminConfig = {
ClusterSettings: Partial<ClusterSettings>;
ExperimentalSettings: Partial<ExperimentalSettings>;
FeatureFlags: Partial<FeatureFlags>;
PasswordSettings: Partial<PasswordSettings>;
PluginSettings: Partial<PluginSettings>;
ServiceSettings: Partial<ServiceSettings>;
TeamSettings: Partial<TeamSettings>;
};
// On-prem setting that is different from the default
const onPremServerConfig = (): Partial<TestAdminConfig> => {
return {
ClusterSettings: {
Enable: testConfig.haClusterEnabled,
ClusterName: testConfig.haClusterName,
},
ExperimentalSettings: {
EnableAppBar: true,
},
FeatureFlags: {
BoardsProduct: testConfig.boardsProductEnabled,
},
PasswordSettings: {
MinimumLength: 5,
Lowercase: false,
Number: false,
Uppercase: false,
Symbol: false,
},
PluginSettings: {
EnableUploads: true,
Plugins: {
'com.mattermost.calls': {
defaultenabled: true,
},
},
PluginStates: {
focalboard: {
Enable: !testConfig.boardsProductEnabled,
},
},
},
ServiceSettings: {
SiteURL: testConfig.baseURL,
EnableOnboardingFlow: false,
},
TeamSettings: {
EnableOpenServer: true,
},
};
};
// Should be based only from the generated default config from mattermost-server via "make config-reset"
// Based on v7.9 server
const defaultServerConfig: AdminConfig = {
ServiceSettings: {
SiteURL: '',
WebsocketURL: '',
LicenseFileLocation: '',
ListenAddress: ':8065',
ConnectionSecurity: '',
TLSCertFile: '',
TLSKeyFile: '',
TLSMinVer: '1.2',
TLSStrictTransport: false,
TLSStrictTransportMaxAge: 63072000,
TLSOverwriteCiphers: [],
UseLetsEncrypt: false,
LetsEncryptCertificateCacheFile: './config/letsencrypt.cache',
Forward80To443: false,
TrustedProxyIPHeader: [],
ReadTimeout: 300,
WriteTimeout: 300,
IdleTimeout: 60,
MaximumLoginAttempts: 10,
GoroutineHealthThreshold: -1,
EnableOAuthServiceProvider: true,
EnableIncomingWebhooks: true,
EnableOutgoingWebhooks: true,
EnableCommands: true,
EnablePostUsernameOverride: false,
EnablePostIconOverride: false,
GoogleDeveloperKey: '',
EnableLinkPreviews: true,
EnablePermalinkPreviews: true,
RestrictLinkPreviews: '',
EnableTesting: false,
EnableDeveloper: false,
DeveloperFlags: '',
EnableClientPerformanceDebugging: false,
EnableOpenTracing: false,
EnableSecurityFixAlert: true,
EnableInsecureOutgoingConnections: false,
AllowedUntrustedInternalConnections: '',
EnableMultifactorAuthentication: false,
EnforceMultifactorAuthentication: false,
EnableUserAccessTokens: false,
AllowCorsFrom: '',
CorsExposedHeaders: '',
CorsAllowCredentials: false,
CorsDebug: false,
AllowCookiesForSubdomains: false,
ExtendSessionLengthWithActivity: true,
SessionLengthWebInDays: 30,
SessionLengthWebInHours: 720,
SessionLengthMobileInDays: 30,
SessionLengthMobileInHours: 720,
SessionLengthSSOInDays: 30,
SessionLengthSSOInHours: 720,
SessionCacheInMinutes: 10,
SessionIdleTimeoutInMinutes: 43200,
WebsocketSecurePort: 443,
WebsocketPort: 80,
WebserverMode: 'gzip',
EnableGifPicker: true,
GfycatAPIKey: '2_KtH_W5',
GfycatAPISecret: '3wLVZPiswc3DnaiaFoLkDvB4X0IV6CpMkj4tf2inJRsBY6-FnkT08zGmppWFgeof',
EnableCustomEmoji: true,
EnableEmojiPicker: true,
PostEditTimeLimit: -1,
TimeBetweenUserTypingUpdatesMilliseconds: 5000,
EnablePostSearch: true,
EnableFileSearch: true,
MinimumHashtagLength: 3,
EnableUserTypingMessages: true,
EnableChannelViewedMessages: true,
EnableUserStatuses: true,
ExperimentalEnableAuthenticationTransfer: true,
ClusterLogTimeoutMilliseconds: 2000,
EnablePreviewFeatures: true,
EnableTutorial: true,
EnableOnboardingFlow: true,
ExperimentalEnableDefaultChannelLeaveJoinMessages: true,
ExperimentalGroupUnreadChannels: 'disabled',
EnableAPITeamDeletion: false,
EnableAPITriggerAdminNotifications: false,
EnableAPIUserDeletion: false,
ExperimentalEnableHardenedMode: false,
ExperimentalStrictCSRFEnforcement: false,
EnableEmailInvitations: false,
DisableBotsWhenOwnerIsDeactivated: true,
EnableBotAccountCreation: false,
EnableSVGs: false,
EnableLatex: false,
EnableInlineLatex: true,
PostPriority: true,
EnableAPIChannelDeletion: false,
EnableLocalMode: false,
LocalModeSocketLocation: '/var/tmp/mattermost_local.socket',
EnableAWSMetering: false,
SplitKey: '',
FeatureFlagSyncIntervalSeconds: 30,
DebugSplit: false,
ThreadAutoFollow: true,
CollapsedThreads: CollapsedThreads.ALWAYS_ON,
ManagedResourcePaths: '',
EnableCustomGroups: true,
SelfHostedPurchase: true,
AllowSyncedDrafts: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
SelfHostedExpansion: false,
},
TeamSettings: {
SiteName: 'Mattermost',
MaxUsersPerTeam: 50,
EnableUserCreation: true,
EnableOpenServer: false,
EnableUserDeactivation: false,
RestrictCreationToDomains: '',
EnableCustomUserStatuses: true,
EnableCustomBrand: false,
CustomBrandText: '',
CustomDescriptionText: '',
RestrictDirectMessage: 'any',
EnableLastActiveTime: true,
UserStatusAwayTimeout: 300,
MaxChannelsPerTeam: 2000,
MaxNotificationsPerChannel: 1000,
EnableConfirmNotificationsToChannel: true,
TeammateNameDisplay: 'username',
ExperimentalViewArchivedChannels: true,
ExperimentalEnableAutomaticReplies: false,
LockTeammateNameDisplay: false,
ExperimentalPrimaryTeam: '',
ExperimentalDefaultChannels: [],
},
ClientRequirements: {
AndroidLatestVersion: '',
AndroidMinVersion: '',
IosLatestVersion: '',
IosMinVersion: '',
},
SqlSettings: {
DriverName: 'postgres',
DataSource:
'postgres://mmuser:mostest@localhost/mattermost_test?sslmode=disable\u0026connect_timeout=10\u0026binary_parameters=yes',
DataSourceReplicas: [],
DataSourceSearchReplicas: [],
MaxIdleConns: 20,
ConnMaxLifetimeMilliseconds: 3600000,
ConnMaxIdleTimeMilliseconds: 300000,
MaxOpenConns: 300,
Trace: false,
AtRestEncryptKey: '',
QueryTimeout: 30,
DisableDatabaseSearch: false,
MigrationsStatementTimeoutSeconds: 100000,
ReplicaLagSettings: [],
},
LogSettings: {
EnableConsole: true,
ConsoleLevel: 'DEBUG',
ConsoleJson: true,
EnableColor: false,
EnableFile: true,
FileLevel: 'INFO',
FileJson: true,
FileLocation: '',
EnableWebhookDebugging: true,
EnableDiagnostics: true,
VerboseDiagnostics: false,
EnableSentry: true,
AdvancedLoggingConfig: '',
},
ExperimentalAuditSettings: {
FileEnabled: false,
FileName: '',
FileMaxSizeMB: 100,
FileMaxAgeDays: 0,
FileMaxBackups: 0,
FileCompress: false,
FileMaxQueueSize: 1000,
AdvancedLoggingConfig: '',
},
NotificationLogSettings: {
EnableConsole: true,
ConsoleLevel: 'DEBUG',
ConsoleJson: true,
EnableColor: false,
EnableFile: true,
FileLevel: 'INFO',
FileJson: true,
FileLocation: '',
AdvancedLoggingConfig: '',
},
PasswordSettings: {
MinimumLength: 8,
Lowercase: false,
Number: false,
Uppercase: false,
Symbol: false,
},
FileSettings: {
EnableFileAttachments: true,
EnableMobileUpload: true,
EnableMobileDownload: true,
MaxFileSize: 104857600,
MaxImageResolution: 33177600,
MaxImageDecoderConcurrency: -1,
DriverName: 'local',
Directory: './data/',
EnablePublicLink: false,
ExtractContent: true,
ArchiveRecursion: false,
PublicLinkSalt: '',
InitialFont: 'nunito-bold.ttf',
AmazonS3AccessKeyId: '',
AmazonS3SecretAccessKey: '',
AmazonS3Bucket: '',
AmazonS3PathPrefix: '',
AmazonS3Region: '',
AmazonS3Endpoint: 's3.amazonaws.com',
AmazonS3SSL: true,
AmazonS3SignV2: false,
AmazonS3SSE: false,
AmazonS3Trace: false,
AmazonS3RequestTimeoutMilliseconds: 30000,
},
EmailSettings: {
EnableSignUpWithEmail: true,
EnableSignInWithEmail: true,
EnableSignInWithUsername: true,
SendEmailNotifications: true,
UseChannelInEmailNotifications: false,
RequireEmailVerification: false,
FeedbackName: '',
FeedbackEmail: 'test@example.com',
ReplyToAddress: 'test@example.com',
FeedbackOrganization: '',
EnableSMTPAuth: false,
SMTPUsername: '',
SMTPPassword: '',
SMTPServer: 'localhost',
SMTPPort: '10025',
SMTPServerTimeout: 10,
ConnectionSecurity: '',
SendPushNotifications: true,
PushNotificationServer: 'https://push-test.mattermost.com',
PushNotificationContents: 'full',
PushNotificationBuffer: 1000,
EnableEmailBatching: false,
EmailBatchingBufferSize: 256,
EmailBatchingInterval: 30,
EnablePreviewModeBanner: true,
SkipServerCertificateVerification: false,
EmailNotificationContentsType: 'full',
LoginButtonColor: '#0000',
LoginButtonBorderColor: '#2389D7',
LoginButtonTextColor: '#2389D7',
EnableInactivityEmail: true,
},
RateLimitSettings: {
Enable: false,
PerSec: 10,
MaxBurst: 100,
MemoryStoreSize: 10000,
VaryByRemoteAddr: true,
VaryByUser: false,
VaryByHeader: '',
},
PrivacySettings: {
ShowEmailAddress: true,
ShowFullName: true,
},
SupportSettings: {
TermsOfServiceLink: 'https://mattermost.com/terms-of-use/',
PrivacyPolicyLink: 'https://mattermost.com/privacy-policy/',
AboutLink: 'https://docs.mattermost.com/about/product.html/',
HelpLink: 'https://mattermost.com/default-help/',
ReportAProblemLink: 'https://mattermost.com/default-report-a-problem/',
SupportEmail: '',
CustomTermsOfServiceEnabled: false,
CustomTermsOfServiceReAcceptancePeriod: 365,
EnableAskCommunityLink: true,
},
AnnouncementSettings: {
EnableBanner: false,
BannerText: '',
BannerColor: '#f2a93b',
BannerTextColor: '#333333',
AllowBannerDismissal: true,
AdminNoticesEnabled: true,
UserNoticesEnabled: true,
NoticesURL: 'https://notices.mattermost.com/',
NoticesFetchFrequency: 3600,
NoticesSkipCache: false,
},
ThemeSettings: {
EnableThemeSelection: true,
DefaultTheme: 'default',
AllowCustomThemes: true,
AllowedThemes: [],
},
GitLabSettings: {
Enable: false,
Secret: '',
Id: '',
Scope: '',
AuthEndpoint: '',
TokenEndpoint: '',
UserAPIEndpoint: '',
DiscoveryEndpoint: '',
ButtonText: '',
ButtonColor: '',
},
GoogleSettings: {
Enable: false,
Secret: '',
Id: '',
Scope: 'profile email',
AuthEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
TokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token',
UserAPIEndpoint:
'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses,nicknames,metadata',
DiscoveryEndpoint: '',
ButtonText: '',
ButtonColor: '',
},
Office365Settings: {
Enable: false,
Secret: '',
Id: '',
Scope: 'User.Read',
AuthEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
TokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
UserAPIEndpoint: 'https://graph.microsoft.com/v1.0/me',
DiscoveryEndpoint: '',
DirectoryId: '',
},
OpenIdSettings: {
Enable: false,
Secret: '',
Id: '',
Scope: 'profile openid email',
AuthEndpoint: '',
TokenEndpoint: '',
UserAPIEndpoint: '',
DiscoveryEndpoint: '',
ButtonText: '',
ButtonColor: '#145DBF',
},
LdapSettings: {
Enable: false,
EnableSync: false,
LdapServer: '',
LdapPort: 389,
ConnectionSecurity: '',
BaseDN: '',
BindUsername: '',
BindPassword: '',
UserFilter: '',
GroupFilter: '',
GuestFilter: '',
EnableAdminFilter: false,
AdminFilter: '',
GroupDisplayNameAttribute: '',
GroupIdAttribute: '',
FirstNameAttribute: '',
LastNameAttribute: '',
EmailAttribute: '',
UsernameAttribute: '',
NicknameAttribute: '',
IdAttribute: '',
PositionAttribute: '',
LoginIdAttribute: '',
PictureAttribute: '',
SyncIntervalMinutes: 60,
SkipCertificateVerification: false,
PublicCertificateFile: '',
PrivateKeyFile: '',
QueryTimeout: 60,
MaxPageSize: 0,
LoginFieldName: '',
LoginButtonColor: '#0000',
LoginButtonBorderColor: '#2389D7',
LoginButtonTextColor: '#2389D7',
Trace: false,
},
ComplianceSettings: {
Enable: false,
Directory: './data/',
EnableDaily: false,
BatchSize: 30000,
},
LocalizationSettings: {
DefaultServerLocale: 'en',
DefaultClientLocale: 'en',
AvailableLocales: '',
},
SamlSettings: {
Enable: false,
EnableSyncWithLdap: false,
EnableSyncWithLdapIncludeAuth: false,
IgnoreGuestsLdapSync: false,
Verify: true,
Encrypt: true,
SignRequest: false,
IdpURL: '',
IdpDescriptorURL: '',
IdpMetadataURL: '',
ServiceProviderIdentifier: '',
AssertionConsumerServiceURL: '',
SignatureAlgorithm: 'RSAwithSHA1',
CanonicalAlgorithm: 'Canonical1.0',
ScopingIDPProviderId: '',
ScopingIDPName: '',
IdpCertificateFile: '',
PublicCertificateFile: '',
PrivateKeyFile: '',
IdAttribute: '',
GuestAttribute: '',
EnableAdminAttribute: false,
AdminAttribute: '',
FirstNameAttribute: '',
LastNameAttribute: '',
EmailAttribute: '',
UsernameAttribute: '',
NicknameAttribute: '',
LocaleAttribute: '',
PositionAttribute: '',
LoginButtonText: 'SAML',
LoginButtonColor: '#34a28b',
LoginButtonBorderColor: '#2389D7',
LoginButtonTextColor: '#ffffff',
},
NativeAppSettings: {
AppCustomURLSchemes: ['mmauth://', 'mmauthbeta://'],
AppDownloadLink: 'https://mattermost.com/download/#mattermostApps',
AndroidAppDownloadLink: 'https://mattermost.com/mattermost-android-app/',
IosAppDownloadLink: 'https://mattermost.com/mattermost-ios-app/',
},
ClusterSettings: {
Enable: false,
ClusterName: '',
OverrideHostname: '',
NetworkInterface: '',
BindAddress: '',
AdvertiseAddress: '',
UseIPAddress: true,
EnableGossipCompression: true,
EnableExperimentalGossipEncryption: false,
ReadOnlyConfig: true,
GossipPort: 8074,
StreamingPort: 8075,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 128,
IdleConnTimeoutMilliseconds: 90000,
},
MetricsSettings: {
Enable: false,
BlockProfileRate: 0,
ListenAddress: ':8067',
},
ExperimentalSettings: {
ClientSideCertEnable: false,
ClientSideCertCheck: 'secondary',
LinkMetadataTimeoutMilliseconds: 5000,
RestrictSystemAdmin: false,
UseNewSAMLLibrary: false,
EnableSharedChannels: false,
EnableRemoteClusterService: false,
EnableAppBar: false,
PatchPluginsReactDOM: false,
},
AnalyticsSettings: {
MaxUsersForStatistics: 2500,
},
ElasticsearchSettings: {
ConnectionURL: 'http://localhost:9200',
Username: 'elastic',
Password: 'changeme',
EnableIndexing: false,
EnableSearching: false,
EnableAutocomplete: false,
Sniff: true,
PostIndexReplicas: 1,
PostIndexShards: 1,
ChannelIndexReplicas: 1,
ChannelIndexShards: 1,
UserIndexReplicas: 1,
UserIndexShards: 1,
AggregatePostsAfterDays: 365,
PostsAggregatorJobStartTime: '03:00',
IndexPrefix: '',
LiveIndexingBatchSize: 1,
BatchSize: 10000,
RequestTimeoutSeconds: 30,
SkipTLSVerification: false,
CA: '',
ClientCert: '',
ClientKey: '',
Trace: '',
},
BleveSettings: {
IndexDir: '',
EnableIndexing: false,
EnableSearching: false,
EnableAutocomplete: false,
BatchSize: 10000,
},
DataRetentionSettings: {
EnableMessageDeletion: false,
EnableFileDeletion: false,
EnableBoardsDeletion: false,
MessageRetentionDays: 365,
FileRetentionDays: 365,
BoardsRetentionDays: 365,
DeletionJobStartTime: '02:00',
BatchSize: 3000,
},
MessageExportSettings: {
EnableExport: false,
ExportFormat: 'actiance',
DailyRunTime: '01:00',
ExportFromTimestamp: 0,
BatchSize: 10000,
DownloadExportResults: false,
GlobalRelaySettings: {
CustomerType: 'A9',
SMTPUsername: '',
SMTPPassword: '',
EmailAddress: '',
SMTPServerTimeout: 1800,
},
},
JobSettings: {
RunJobs: true,
RunScheduler: true,
CleanupJobsThresholdDays: -1,
CleanupConfigThresholdDays: -1,
},
ProductSettings: {
EnablePublicSharedBoards: false,
},
PluginSettings: {
Enable: true,
EnableUploads: false,
AllowInsecureDownloadURL: false,
EnableHealthCheck: true,
Directory: './plugins',
ClientDirectory: './client/plugins',
Plugins: {},
PluginStates: {
'com.mattermost.apps': {
Enable: true,
},
'com.mattermost.calls': {
Enable: true,
},
'com.mattermost.nps': {
Enable: true,
},
focalboard: {
Enable: false,
},
playbooks: {
Enable: true,
},
},
EnableMarketplace: true,
EnableRemoteMarketplace: true,
AutomaticPrepackagedPlugins: true,
RequirePluginSignature: false,
MarketplaceURL: 'https://api.integrations.mattermost.com',
SignaturePublicKeyFiles: [],
ChimeraOAuthProxyURL: '',
},
DisplaySettings: {
CustomURLSchemes: [],
ExperimentalTimezone: true,
},
GuestAccountsSettings: {
Enable: false,
AllowEmailAccounts: true,
EnforceMultifactorAuthentication: false,
RestrictCreationToDomains: '',
},
ImageProxySettings: {
Enable: false,
ImageProxyType: 'local',
RemoteImageProxyURL: '',
RemoteImageProxyOptions: '',
},
CloudSettings: {
CWSURL: 'https://customers.mattermost.com',
CWSAPIURL: 'https://portal.internal.prod.cloud.mattermost.com',
},
FeatureFlags: {
TestFeature: 'off',
TestBoolFeature: false,
EnableRemoteClusterService: false,
AppsEnabled: true,
PluginPlaybooks: '',
PluginApps: '',
PluginFocalboard: '',
PluginCalls: '',
PermalinkPreviews: true,
CallsEnabled: true,
BoardsFeatureFlags: '',
BoardsDataRetention: false,
NormalizeLdapDNs: false,
EnableInactivityCheckJob: true,
UseCaseOnboarding: true,
GraphQL: false,
InsightsEnabled: true,
CommandPalette: false,
BoardsProduct: true,
SendWelcomePost: true,
WorkTemplate: false,
PostPriority: true,
WysiwygEditor: false,
PeopleProduct: false,
AnnualSubscription: false,
ReduceOnBoardingTaskList: false,
OnboardingAutoShowLinkedBoard: true,
ThreadsEverywhere: false,
GlobalDrafts: true,
OnboardingTourTips: true,
},
ImportSettings: {
Directory: './import',
RetentionDays: 30,
},
ExportSettings: {
Directory: './export',
RetentionDays: 30,
},
};

9
e2e/playwright/support/server/index.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {Client, makeClient} from './client';
export {createRandomChannel} from './channel';
export {getOnPremServerConfig} from './default_config';
export {initSetup, getAdminClient} from './init';
export {createRandomTeam} from './team';
export {createRandomUser, getDefaultAdminUser} from './user';

100
e2e/playwright/support/server/init.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
import {expect} from '@playwright/test';
import {PreferenceType} from '@mattermost/types/preferences';
import testConfig from '@e2e-test.config';
import {makeClient} from '.';
import {getOnPremServerConfig} from './default_config';
import {createRandomTeam} from './team';
import {createRandomUser} from './user';
const boardsUserConfigPatch = {
updatedFields: {
welcomePageViewed: '1',
onboardingTourStep: '999',
tourCategory: 'board',
version72MessageCanceled: 'true',
},
};
export async function initSetup({
userPrefix = 'user',
teamPrefix = {name: 'team', displayName: 'Team'},
withDefaultProfileImage = true,
skipBoardsUserConfig = true,
} = {}) {
try {
// Login the admin user via API
const {adminClient, adminUser} = await getAdminClient();
if (!adminClient) {
throw new Error(
"Failed to setup admin: Check that you're able to access the server using the same admin credential."
);
}
// Reset server config
const adminConfig = await adminClient.updateConfig(getOnPremServerConfig());
// Create new team
const team = await adminClient.createTeam(createRandomTeam(teamPrefix.name, teamPrefix.displayName));
// Create new user and add to newly created team
const randomUser = createRandomUser(userPrefix);
const user = await adminClient.createUser(randomUser, '', '');
user.password = randomUser.password;
await adminClient.addToTeam(team.id, user.id);
// Log in new user via API
const {client: userClient} = await makeClient(user);
if (withDefaultProfileImage) {
// Set user profile image
const fullPath = path.join(path.resolve(__dirname), '../', 'asset/mattermost-icon_128x128.png');
await userClient.uploadProfileImageX(user.id, fullPath);
}
// Update user preference
const preferences: PreferenceType[] = [
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
];
await userClient.savePreferences(user.id, preferences);
if (skipBoardsUserConfig) {
await userClient.patchUserConfig(user.id, boardsUserConfigPatch);
}
return {
adminClient,
adminUser,
adminConfig,
user,
userClient,
team,
offTopicUrl: getUrl(team.name, 'off-topic'),
townSquareUrl: getUrl(team.name, 'town-square'),
};
} catch (err) {
// log an error for debugging
// eslint-disable-next-line no-console
console.log(err);
expect(err, 'Should not throw an error').toBeFalsy();
throw err;
}
}
export async function getAdminClient() {
const {client: adminClient, user: adminUser} = await makeClient({
username: testConfig.adminUsername,
password: testConfig.adminPassword,
});
return {adminClient, adminUser};
}
function getUrl(teamName: string, channelName: string) {
return `/${teamName}/channels/${channelName}`;
}

17
e2e/playwright/support/server/team.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Team, TeamType} from '@mattermost/types/teams';
import {getRandomId} from '@e2e-support/util';
export function createRandomTeam(name = 'team', displayName = 'Team', type: TeamType = 'O', unique = true): Team {
const randomSuffix = getRandomId();
const team = {
name: unique ? `${name}-${randomSuffix}` : name,
display_name: unique ? `${displayName} ${randomSuffix}` : displayName,
type,
};
return team as Team;
}

33
e2e/playwright/support/server/user.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {UserProfile} from '@mattermost/types/users';
import {getRandomId} from '@e2e-support/util';
import testConfig from '@e2e-test.config';
export function createRandomUser(prefix = 'user') {
const randomId = getRandomId();
const user = {
email: `${prefix}${randomId}@sample.mattermost.com`,
username: `${prefix}${randomId}`,
password: 'passwd',
first_name: `First${randomId}`,
last_name: `Last${randomId}`,
nickname: `Nickname${randomId}`,
};
return user as UserProfile;
}
export function getDefaultAdminUser() {
const admin = {
username: testConfig.adminUsername,
password: testConfig.adminPassword,
first_name: 'Kenneth',
last_name: 'Moreno',
email: testConfig.adminEmail,
};
return admin as UserProfile;
}

19
e2e/playwright/support/test_action.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, Page} from '@playwright/test';
const visibilityHidden = 'visibility: hidden !important;';
const hideTeamHeader = `.test-team-header {${visibilityHidden}} `;
const hidePostHeaderTime = `.post__time {${visibilityHidden}} `;
const hidePostProfileIcon = `.profile-icon {${visibilityHidden}} `;
export async function hideDynamicChannelsContent(page: Page) {
await page.addStyleTag({content: hideTeamHeader + hidePostHeaderTime + hidePostProfileIcon});
}
export async function waitForAnimationEnd(locator: Locator) {
return locator.evaluate((element) =>
Promise.all(element.getAnimations({subtree: true}).map((animation) => animation.finished))
);
}

76
e2e/playwright/support/test_fixture.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,76 @@
import {test as base, Browser} from '@playwright/test';
import {TestBrowser} from './browser_context';
import {shouldHaveBoardsEnabled, shouldHaveFeatureFlag, shouldSkipInSmallScreen, shouldRunInLinux} from './flag';
import {initSetup, getAdminClient} from './server';
import {hideDynamicChannelsContent, waitForAnimationEnd} from './test_action';
import {pages} from './ui/pages';
import {matchSnapshot} from './visual';
export {expect} from '@playwright/test';
type ExtendedFixtures = {
pw: PlaywrightExtended;
pages: typeof pages;
};
export const test = base.extend<ExtendedFixtures>({
pw: async ({browser}, use) => {
const pw = new PlaywrightExtended(browser);
await use(pw);
},
// eslint-disable-next-line no-empty-pattern
pages: async ({}, use) => {
await use(pages);
},
});
class PlaywrightExtended {
// ./browser_context
readonly testBrowser: TestBrowser;
// ./flag
readonly shouldHaveBoardsEnabled: typeof shouldHaveBoardsEnabled;
readonly shouldHaveFeatureFlag: typeof shouldHaveFeatureFlag;
readonly shouldSkipInSmallScreen: typeof shouldSkipInSmallScreen;
readonly shouldRunInLinux: typeof shouldRunInLinux;
// ./server
readonly getAdminClient: typeof getAdminClient;
readonly initSetup: typeof initSetup;
// ./test_action
readonly hideDynamicChannelsContent: typeof hideDynamicChannelsContent;
readonly waitForAnimationEnd: typeof waitForAnimationEnd;
// ./ui/pages
readonly pages: typeof pages;
// ./visual
readonly matchSnapshot: typeof matchSnapshot;
constructor(browser: Browser) {
// ./browser_context
this.testBrowser = new TestBrowser(browser);
// ./flag
this.shouldHaveBoardsEnabled = shouldHaveBoardsEnabled;
this.shouldHaveFeatureFlag = shouldHaveFeatureFlag;
this.shouldSkipInSmallScreen = shouldSkipInSmallScreen;
this.shouldRunInLinux = shouldRunInLinux;
// ./server
this.initSetup = initSetup;
this.getAdminClient = getAdminClient;
// ./test_action
this.hideDynamicChannelsContent = hideDynamicChannelsContent;
this.waitForAnimationEnd = waitForAnimationEnd;
// ./ui/pages
this.pages = pages;
// ./visual
this.matchSnapshot = matchSnapshot;
}
}

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class BoardsCreateModal {
readonly locator: Locator;
readonly productSwitchMenu: Locator;
constructor(locator: Locator) {
this.locator = locator;
this.productSwitchMenu = locator.getByRole('button', {name: 'Product switch menu'});
}
async switchProduct(name: string) {
await this.productSwitchMenu.click();
await this.locator.getByRole('link', {name: `${name}`}).click();
}
async toBeVisible(name: string) {
await expect(this.locator.getByRole('heading', {name})).toBeVisible();
}
}
export {BoardsCreateModal};

Просмотреть файл

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator} from '@playwright/test';
export default class BoardsSidebar {
readonly container: Locator;
readonly plusButton: Locator;
readonly createNewBoardMenuItem: Locator;
readonly createNewCategoryMenuItem: Locator;
readonly titles: Locator;
constructor(container: Locator) {
this.container = container;
this.plusButton = container.locator('.add-board-icon');
this.createNewBoardMenuItem = container.getByRole('button', {name: 'Create new board'});
this.createNewCategoryMenuItem = container.getByRole('button', {name: 'Create New Category'});
this.titles = container.locator('.SidebarBoardItem > .octo-sidebar-title');
}
async waitForTitle(name: string) {
await this.container.getByRole('button', {name: `${name}`}).waitFor({state: 'visible'});
}
}
export {BoardsSidebar};

Просмотреть файл

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class ChannelsAppBar {
readonly container: Locator;
readonly playbooksIcon: Locator;
constructor(container: Locator) {
this.container = container;
this.playbooksIcon = container.locator('#app-bar-icon-playbooks').getByRole('img');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}
export {ChannelsAppBar};

Просмотреть файл

@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class ChannelsPost {
readonly container: Locator;
readonly profileIcon: Locator;
constructor(container: Locator) {
this.container = container;
this.profileIcon = container.locator('.profile-icon');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async getId() {
const id = await this.container.getAttribute('id');
expect(id, 'No post ID found.').toBeTruthy();
return (id || '').substring('post_'.length);
}
async getProfileImage(username: string) {
return await this.profileIcon.getByAltText(`${username} profile image`);
}
}
export {ChannelsPost};

Просмотреть файл

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class ChannelsPostCreate {
readonly container: Locator;
readonly input: Locator;
readonly attachmentButton: Locator;
readonly emojiButton: Locator;
constructor(container: Locator) {
this.container = container;
this.input = container.getByTestId('post_textbox');
this.attachmentButton = container.getByLabel('attachment');
this.emojiButton = container.getByLabel('select an emoji');
}
async postMessage(message: string) {
await this.input.fill(message);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await expect(this.input).toBeVisible();
}
}
export {ChannelsPostCreate};

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
export default class GlobalHeader {
readonly container: Locator;
readonly productSwitchMenu: Locator;
constructor(container: Locator) {
this.container = container;
this.productSwitchMenu = container.getByRole('button', {name: 'Product switch menu'});
}
async switchProduct(name: string) {
await this.productSwitchMenu.click();
await this.container.getByRole('link', {name: `${name}`}).click();
}
async toBeVisible(name: string) {
await expect(this.container.getByRole('heading', {name})).toBeVisible();
}
}
export {GlobalHeader};

Просмотреть файл

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BoardsSidebar} from './boards/sidebar';
import {ChannelsAppBar} from './channels/app_bar';
import {ChannelsPostCreate} from './channels/post_create';
import {ChannelsPost} from './channels/post';
import {GlobalHeader} from './global_header';
const components = {
BoardsSidebar,
ChannelsAppBar,
ChannelsPostCreate,
ChannelsPost,
GlobalHeader,
};
export {components, BoardsSidebar, ChannelsAppBar, ChannelsPostCreate, ChannelsPost, GlobalHeader};

Просмотреть файл

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator, Page} from '@playwright/test';
import {GlobalHeader} from '@e2e-support/ui/components';
export default class BoardsCreatePage {
readonly boards = 'Boards';
readonly page: Page;
readonly globalHeader: GlobalHeader;
readonly createBoardHeading: Locator;
readonly createEmptyBoardButton: Locator;
readonly useTemplateButton: Locator;
constructor(page: Page) {
this.page = page;
this.globalHeader = new GlobalHeader(this.page.locator('#global-header'));
this.createBoardHeading = page.getByRole('heading', {name: 'Create a board'});
this.createEmptyBoardButton = page.getByRole('button', {name: ' Create an empty board'});
this.useTemplateButton = page.getByRole('button', {name: 'Use this template'});
}
async goto(teamId = '') {
let boardsUrl = '/boards';
if (teamId) {
boardsUrl += `/team/${teamId}`;
}
await this.page.goto(boardsUrl);
}
async toBeVisible() {
await this.globalHeader.toBeVisible(this.boards);
await expect(this.createEmptyBoardButton).toBeVisible();
await expect(this.useTemplateButton).toBeVisible();
await expect(this.createBoardHeading).toBeVisible();
}
async createEmptyBoard() {
await this.createEmptyBoardButton.click();
}
}
export {BoardsCreatePage};

Просмотреть файл

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator, Page} from '@playwright/test';
import {BoardsSidebar, GlobalHeader} from '@e2e-support/ui/components';
export default class BoardsViewPage {
readonly boards = 'Boards';
readonly page: Page;
readonly sidebar: BoardsSidebar;
readonly globalHeader: GlobalHeader;
readonly topHead: Locator;
readonly editableTitle: Locator;
readonly shareButton: Locator;
constructor(page: Page) {
this.page = page;
this.sidebar = new BoardsSidebar(page.locator('.octo-sidebar'));
this.globalHeader = new GlobalHeader(this.page.locator('#global-header'));
this.topHead = page.locator('.top-head');
this.editableTitle = this.topHead.getByPlaceholder('Untitled board');
this.shareButton = page.getByRole('button', {name: '󰍁 Share'});
}
async goto(teamId = '', boardId = '', viewId = '', cardId = '') {
let boardsUrl = '/boards';
if (teamId) {
boardsUrl += `/team/${teamId}`;
if (boardId) {
boardsUrl += `/${boardId}`;
if (viewId) {
boardsUrl += `/${viewId}`;
if (cardId) {
boardsUrl += `/${cardId}`;
}
}
}
}
await this.page.goto(boardsUrl);
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
await this.globalHeader.toBeVisible(this.boards);
await expect(this.shareButton).toBeVisible();
await expect(this.topHead).toBeVisible();
}
async shouldHaveUntitledBoard() {
await this.editableTitle.isVisible();
expect(await this.editableTitle.getAttribute('value')).toBe('');
await expect(this.page.getByTitle('(Untitled Board)')).toBeVisible();
}
}
export {BoardsViewPage};

72
e2e/playwright/support/ui/pages/channels.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page} from '@playwright/test';
import {ChannelsAppBar, ChannelsPost, ChannelsPostCreate, GlobalHeader} from '@e2e-support/ui/components';
import {isSmallScreen} from '@e2e-support/util';
export default class ChannelsPage {
readonly channels = 'Channels';
readonly page: Page;
readonly postCreate: ChannelsPostCreate;
readonly globalHeader: GlobalHeader;
readonly appBar: ChannelsAppBar;
constructor(page: Page) {
this.page = page;
this.postCreate = new ChannelsPostCreate(page.locator('#post-create'));
this.globalHeader = new GlobalHeader(this.page.locator('#global-header'));
this.appBar = new ChannelsAppBar(page.locator('.app-bar'));
}
async goto(teamName = '', channelName = '') {
let channelsUrl = '/';
if (teamName) {
channelsUrl += `/${teamName}`;
if (channelName) {
channelsUrl += `/${channelName}`;
}
}
await this.page.goto(channelsUrl);
}
async toBeVisible() {
if (!isSmallScreen(this.page.viewportSize())) {
await this.globalHeader.toBeVisible(this.channels);
}
await this.postCreate.toBeVisible();
}
async postMessage(message: string) {
await this.postCreate.input.waitFor();
await this.postCreate.postMessage(message);
}
async getFirstPost() {
await this.page.getByTestId('postView').first().waitFor();
const post = await this.page.getByTestId('postView').first();
return new ChannelsPost(post);
}
async getLastPost() {
await this.page.getByTestId('postView').last().waitFor();
const post = await this.page.getByTestId('postView').last();
return new ChannelsPost(post);
}
async getNthPost(index: number) {
await this.page.getByTestId('postView').nth(index).waitFor();
const post = await this.page.getByTestId('postView').nth(index);
return new ChannelsPost(post);
}
async getPostById(id: string) {
await this.page.locator(`[id="post_${id}"]`).waitFor();
const post = await this.page.locator(`[id="post_${id}"]`);
return new ChannelsPost(post);
}
}
export {ChannelsPage};

20
e2e/playwright/support/ui/pages/index.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BoardsCreatePage} from './boards_create';
import {BoardsViewPage} from './boards_view';
import {ChannelsPage} from './channels';
import {LandingLoginPage} from './landing_login';
import {LoginPage} from './login';
import {SignupPage} from './signup';
const pages = {
BoardsCreatePage,
BoardsViewPage,
ChannelsPage,
LandingLoginPage,
LoginPage,
SignupPage,
};
export {pages, BoardsCreatePage, BoardsViewPage, ChannelsPage, LandingLoginPage, LoginPage, SignupPage};

Просмотреть файл

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator, Page} from '@playwright/test';
export default class LandingLoginPage {
readonly page: Page;
readonly isMobile?: boolean;
readonly viewInAppButton: Locator;
readonly viewInDesktopAppButton: Locator;
readonly viewInBrowserButton: Locator;
constructor(page: Page, isMobile?: boolean) {
this.page = page;
this.isMobile = isMobile;
this.viewInAppButton = page.locator('text=View in App');
this.viewInDesktopAppButton = page.locator('text=View in Desktop App');
this.viewInBrowserButton = page.locator('text=View in Browser');
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
if (this.isMobile) {
await expect(this.viewInAppButton).toBeVisible();
} else {
await expect(this.viewInDesktopAppButton).toBeVisible();
}
await expect(this.viewInBrowserButton).toBeVisible();
}
async goto() {
await this.page.goto('/landing#/login');
}
}
export {LandingLoginPage};

66
e2e/playwright/support/ui/pages/login.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator, Page} from '@playwright/test';
import {AdminConfig} from '@mattermost/types/config';
import {UserProfile} from '@mattermost/types/users';
export default class LoginPage {
readonly adminConfig: AdminConfig;
readonly page: Page;
readonly title: Locator;
readonly subtitle: Locator;
readonly bodyCard: Locator;
readonly loginInput: Locator;
readonly loginPlaceholder: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly createAccountLink: Locator;
readonly forgotPasswordLink: Locator;
readonly userErrorLabel: Locator;
readonly fieldWithError: Locator;
readonly formContainer: Locator;
constructor(page: Page, adminConfig: AdminConfig) {
this.page = page;
this.adminConfig = adminConfig;
const loginInputPlaceholder = adminConfig.LdapSettings.Enable
? 'Email, Username or AD/LDAP Username'
: 'Email or Username';
this.title = page.locator('h1:has-text("Log in to your account")');
this.subtitle = page.locator('text=Collaborate with your team in real-time');
this.bodyCard = page.locator('.login-body-card');
this.loginInput = page.locator('#input_loginId');
this.loginPlaceholder = page.locator(`[placeholder="${loginInputPlaceholder}"]`);
this.passwordInput = page.locator('#input_password-input');
this.signInButton = page.locator('button:has-text("Log in")');
this.createAccountLink = page.locator("text=Don't have an account?");
this.forgotPasswordLink = page.locator('text=Forgot your password?');
this.userErrorLabel = page.locator('text=Please enter your email or username');
this.fieldWithError = page.locator('.with-error');
this.formContainer = page.locator('.signup-team__container');
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
await expect(this.title).toBeVisible();
await expect(this.loginInput).toBeVisible();
await expect(this.passwordInput).toBeVisible();
}
async goto() {
await this.page.goto('/login');
}
async login(user: UserProfile, useUsername = true) {
await this.loginInput.fill(useUsername ? user.username : user.email);
await this.passwordInput.fill(user.password);
await Promise.all([this.page.waitForNavigation(), this.signInButton.click()]);
}
}
export {LoginPage};

66
e2e/playwright/support/ui/pages/signup.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator, Page} from '@playwright/test';
import {duration, wait} from '@e2e-support/util';
export default class SignupPage {
readonly page: Page;
readonly title: Locator;
readonly subtitle: Locator;
readonly bodyCard: Locator;
readonly emailInput: Locator;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly createAccountButton: Locator;
readonly loginLink: Locator;
readonly emailError: Locator;
readonly usernameError: Locator;
readonly passwordError: Locator;
constructor(page: Page) {
this.page = page;
this.title = page.locator('h1:has-text("Lets get started")');
this.subtitle = page.locator('text=Create your Mattermost account to start collaborating with your team');
this.bodyCard = page.locator('.signup-body-card');
this.emailInput = page.locator('#input_email');
this.usernameInput = page.locator('#input_name');
this.passwordInput = page.locator('#input_password-input');
this.createAccountButton = page.locator('button:has-text("Create Account")');
this.loginLink = page.locator('text=Click here to sign in.');
this.emailError = page.locator('text=Please enter a valid email address');
this.usernameError = page.locator(
'text=Usernames have to begin with a lowercase letter and be 3-22 characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.'
);
this.passwordError = page.locator('text=Must be 5-64 characters long.');
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
await this.page.waitForLoadState('domcontentloaded');
await wait(duration.half_sec);
await expect(this.title).toBeVisible();
await expect(this.emailInput).toBeVisible();
await expect(this.usernameInput).toBeVisible();
await expect(this.passwordInput).toBeVisible();
}
async goto() {
await this.page.goto('/signup_email');
}
async create(user: {email: string; username: string; password: string}, waitForRedirect = true) {
await this.emailInput.fill(user.email);
await this.usernameInput.fill(user.username);
await this.passwordInput.fill(user.password);
await this.createAccountButton.click();
if (waitForRedirect) {
await this.page.waitForNavigation();
}
}
}
export {SignupPage};

52
e2e/playwright/support/util.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {v4 as uuidv4} from 'uuid';
import {ViewportSize} from '@playwright/test';
const second = 1000;
const minute = 60 * 1000;
export const duration = {
half_sec: second / 2,
one_sec: second,
two_sec: second * 2,
four_sec: second * 4,
ten_sec: second * 10,
half_min: minute / 2,
one_min: minute,
two_min: minute * 2,
four_min: minute * 4,
};
/**
* Explicit `wait` should not normally used but made available for special cases.
* @param {number} ms - duration in millisecond
* @return {Promise} promise with timeout
*/
export const wait = async (ms = 0) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
/**
* @param {Number} length - length on random string to return, e.g. 7 (default)
* @return {String} random string
*/
export function getRandomId(length = 7): string {
const MAX_SUBSTRING_INDEX = 27;
return uuidv4()
.replace(/-/g, '')
.substring(MAX_SUBSTRING_INDEX - length, MAX_SUBSTRING_INDEX);
}
// Default team is meant for sysadmin's primary team,
// selected for compatibility with existing local development.
// It should not be used for testing.
export const defaultTeam = {name: 'ad-1', displayName: 'eligendi', type: 'O'};
export const illegalRe = /[/?<>\\:*|":&();]/g;
export function isSmallScreen(viewport?: ViewportSize | {width: number; height: number} | null) {
return viewport?.width ? Boolean(viewport?.width <= 390) : true;
}

38
e2e/playwright/support/visual/index.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import os from 'node:os';
import chalk from 'chalk';
import {expect, TestInfo} from '@playwright/test';
import {illegalRe} from '@e2e-support/util';
import testConfig, {TestArgs} from '@e2e-test.config';
import snapshotWithPercy from './percy';
export async function matchSnapshot(testInfo: TestInfo, testArgs: TestArgs) {
if (os.platform() !== 'linux') {
// eslint-disable-next-line no-console
console.log(
chalk.yellow(
`^ Warning: No visual test performed. Run in Linux or Playwright docker image to match snapshot.`
)
);
return;
}
if (testConfig.snapshotEnabled) {
// Visual test with built-in snapshot
const filename = testInfo.title.replace(illegalRe, '').replace(/\s/g, '-').trim().toLowerCase();
expect(await testArgs.page.screenshot({fullPage: true})).toMatchSnapshot(`${filename}.png`);
}
if (testConfig.percyEnabled) {
// Used to easily identify the screenshot when viewing from third-party service provider.
const name = `[${testInfo.project.name}, ${testArgs?.viewport?.width}px] > ${testInfo.file} > ${testInfo.title}`;
// Visual test with Percy
await snapshotWithPercy(name, testArgs);
}
}

22
e2e/playwright/support/visual/percy.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import percySnapshot from '@percy/playwright';
import testConfig, {TestArgs} from '@e2e-test.config';
export default async function snapshotWithPercy(name: string, testArgs: TestArgs) {
if (testArgs.browserName === 'chromium' && testConfig.percyEnabled && testArgs.viewport) {
if (!testConfig.percyToken) {
// eslint-disable-next-line no-console
console.error('Error: Token is missing! Please set using: "export PERCY_TOKEN=<change_me>"');
}
const {page, viewport} = testArgs;
// Ignore since percy is using Playwright.Page
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await percySnapshot(page, name, {widths: [viewport.width], minHeight: viewport.height});
}
}

69
e2e/playwright/test.config.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page, ViewportSize} from '@playwright/test';
import * as dotenv from 'dotenv';
dotenv.config();
export type TestArgs = {
page: Page;
browserName: string;
viewport?: ViewportSize | null;
};
export type TestConfig = {
// Server
baseURL: string;
adminUsername: string;
adminPassword: string;
adminEmail: string;
boardsProductEnabled: boolean;
resetBeforeTest: boolean;
haClusterEnabled: boolean;
haClusterNodeCount: number;
haClusterName: string;
// CI
isCI: boolean;
// Playwright
headless: boolean;
slowMo: number;
workers: number;
// Visual tests
snapshotEnabled: boolean;
percyEnabled: boolean;
percyToken?: string;
};
// All process.env should be defined here
const config: TestConfig = {
// Server
baseURL: process.env.PW_BASE_URL || 'http://localhost:8065',
adminUsername: process.env.PW_ADMIN_USERNAME || 'sysadmin',
adminPassword: process.env.PW_ADMIN_PASSWORD || 'Sys@dmin-sample1',
adminEmail: process.env.PW_ADMIN_EMAIL || 'sysadmin@sample.mattermost.com',
boardsProductEnabled: parseBool(process.env.PW_BOARDS_PRODUCT_ENABLED, true),
haClusterEnabled: parseBool(process.env.PW_HA_CLUSTER_ENABLED, false),
haClusterNodeCount: parseNumber(process.env.PW_HA_CLUSTER_NODE_COUNT, 2),
haClusterName: process.env.PW_HA_CLUSTER_NAME || 'mm_dev_cluster',
resetBeforeTest: parseBool(process.env.PW_RESET_BEFORE_TEST, false),
// CI
isCI: !!process.env.CI,
// Playwright
headless: parseBool(process.env.PW_HEADLESS, false),
slowMo: parseNumber(process.env.PW_SLOWMO, 0),
workers: parseNumber(process.env.PW_WORKERS, 1),
// Visual tests
snapshotEnabled: parseBool(process.env.PW_SNAPSHOT_ENABLE, false),
percyEnabled: parseBool(process.env.PW_PERCY_ENABLE, false),
percyToken: process.env.PERCY_TOKEN,
};
function parseBool(actualValue: string | undefined, defaultValue: boolean) {
return actualValue ? actualValue === 'true' : defaultValue;
}
function parseNumber(actualValue: string | undefined, defaultValue: number) {
return actualValue ? parseInt(actualValue, 10) : defaultValue;
}
export default config;

Просмотреть файл

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@e2e-support/test_fixture';
import {shouldSkipInSmallScreen} from '@e2e-support/flag';
shouldSkipInSmallScreen();
test('MM-T4274 Create an Empty Board', async ({pw, pages}) => {
await pw.shouldHaveBoardsEnabled();
// Create and sign in a new user
const {user} = await pw.initSetup();
// Log in a user in new browser context
const {page} = await pw.testBrowser.login(user);
// Visit a default channel page
const channelsPage = new pages.ChannelsPage(page);
await channelsPage.goto();
await channelsPage.toBeVisible();
// Switch to Boards page
await channelsPage.globalHeader.switchProduct('Boards');
// Should have redirected to boards create page
const boardsCreatePage = new pages.BoardsCreatePage(page);
await boardsCreatePage.toBeVisible();
// Create empty board
await boardsCreatePage.createEmptyBoard();
// Should have redirected to boards view page
const boardsViewPage = new pages.BoardsViewPage(page);
await boardsViewPage.toBeVisible();
await boardsViewPage.shouldHaveUntitledBoard();
// Type new title and hit enter
const title = 'Testing';
await boardsViewPage.editableTitle.fill(title);
await boardsViewPage.editableTitle.press('Enter');
// Should update the title in heading and in sidebar
expect(await boardsViewPage.editableTitle.getAttribute('value')).toBe(title);
await boardsViewPage.sidebar.waitForTitle(title);
});

Просмотреть файл

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@e2e-support/test_fixture';
import {shouldSkipInSmallScreen} from '@e2e-support/flag';
shouldSkipInSmallScreen();
test('Board template', async ({pw, pages, browserName, viewport}, testInfo) => {
await pw.shouldHaveBoardsEnabled();
// Create and sign in a new user
const {user} = await pw.initSetup();
// Log in a user in new browser context
const {page} = await pw.testBrowser.login(user);
// Should have redirected to boards create page
const boardsCreatePage = new pages.BoardsCreatePage(page);
await boardsCreatePage.goto();
await boardsCreatePage.toBeVisible();
// Match snapshot of create board page
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
});

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 129 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 184 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 246 KiB

Просмотреть файл

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@e2e-support/test_fixture';
import {shouldSkipInSmallScreen} from '@e2e-support/flag';
shouldSkipInSmallScreen();
test('View untitled board', async ({pw, pages, browserName, viewport}, testInfo) => {
await pw.shouldHaveBoardsEnabled();
// Create and sign in a new user
const {user} = await pw.initSetup();
// Log in a user in new browser context
const {page} = await pw.testBrowser.login(user);
// Should have redirected to boards create page
const boardsCreatePage = new pages.BoardsCreatePage(page);
await boardsCreatePage.goto();
await boardsCreatePage.toBeVisible();
// Create empty board
await boardsCreatePage.createEmptyBoard();
// Should have redirected to boards view page
const boardsViewPage = new pages.BoardsViewPage(page);
await boardsViewPage.toBeVisible();
await boardsViewPage.shouldHaveUntitledBoard();
// Match snapshot of create board page
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
});

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 38 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 65 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 85 KiB

Просмотреть файл

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@e2e-support/test_fixture';
import {duration, isSmallScreen, wait} from '@e2e-support/util';
test('Intro to channel as regular user', async ({pw, pages, browserName, viewport}, testInfo) => {
// Create and sign in a new user
const {user} = await pw.initSetup();
// Log in a user in new browser context
const {page} = await pw.testBrowser.login(user);
// Visit a default channel page
const channelsPage = new pages.ChannelsPage(page);
await channelsPage.goto();
await channelsPage.toBeVisible();
// Wait for Boards' bot image to be loaded
await pw.shouldHaveFeatureFlag('OnboardingAutoShowLinkedBoard', true);
const boardsWelcomePost = await channelsPage.getFirstPost();
await expect(await boardsWelcomePost.getProfileImage('boards')).toBeVisible();
await wait(duration.one_sec);
// Wait for Playbooks icon to be loaded in App bar, except in iphone
if (!isSmallScreen(viewport)) {
await expect(channelsPage.appBar.playbooksIcon).toBeVisible();
}
// Hide dynamic elements of Channels page
await pw.hideDynamicChannelsContent(page);
// Match snapshot of channel intro page
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
});

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 77 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 113 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 183 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 178 KiB

Просмотреть файл

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@e2e-support/test_fixture';
test('/landing#/login', async ({pw, pages, page, isMobile, browserName, viewport}, testInfo) => {
// Go to landing login page
const landingLoginPage = new pages.LandingLoginPage(page, isMobile);
await landingLoginPage.goto();
await landingLoginPage.toBeVisible();
// Match snapshot of landing page
await pw.matchSnapshot(testInfo, {page, browserName, viewport});
});

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 105 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 169 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 212 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 157 KiB

Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@e2e-support/test_fixture';
test('/login', async ({pw, pages, page, browserName, viewport}, testInfo) => {
// Go to login page
const {adminClient} = await pw.getAdminClient();
const adminConfig = await adminClient.getConfig();
const loginPage = new pages.LoginPage(page, adminConfig);
await loginPage.goto();
await loginPage.toBeVisible();
// Click to other element to remove focus from email input
await loginPage.title.click();
// Match snapshot of login page
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
// Click sign in button without entering user credential
await loginPage.signInButton.click();
await loginPage.userErrorLabel.waitFor();
await pw.waitForAnimationEnd(loginPage.bodyCard);
// Match snapshot of login page with error
await pw.matchSnapshot({...testInfo, title: `${testInfo.title} error`}, testArgs);
});

Двоичные данные
e2e/playwright/tests/visual/common/login.spec.ts-snapshots/login-chrome-linux.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 150 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 147 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 270 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 312 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 241 KiB

Двоичные данные
e2e/playwright/tests/visual/common/login.spec.ts-snapshots/login-firefox-linux.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 276 KiB

Двоичные данные
e2e/playwright/tests/visual/common/login.spec.ts-snapshots/login-ipad-linux.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 297 KiB

Двоичные данные
e2e/playwright/tests/visual/common/login.spec.ts-snapshots/login-iphone-linux.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 230 KiB

Просмотреть файл

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@e2e-support/test_fixture';
test('/signup_email', async ({pw, pages, page, browserName, viewport}, testInfo) => {
// Go to login page
const {adminClient} = await pw.getAdminClient();
const adminConfig = await adminClient.getConfig();
const loginPage = new pages.LoginPage(page, adminConfig);
await loginPage.goto();
await loginPage.toBeVisible();
// Create an account
await loginPage.createAccountLink.click();
// Should have redirected to signup page
const signupPage = new pw.pages.SignupPage(page);
await signupPage.toBeVisible();
// Click to other element to remove focus from email input
await signupPage.title.click();
// Match snapshot of signup_email page
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
// Click sign in button without entering user credential
const invalidUser = {email: 'invalid', username: 'a', password: 'b'};
await signupPage.create(invalidUser, false);
await signupPage.emailError.waitFor();
await signupPage.usernameError.waitFor();
await signupPage.passwordError.waitFor();
await pw.waitForAnimationEnd(signupPage.bodyCard);
// Match snapshot of signup_email page
await pw.matchSnapshot({...testInfo, title: `${testInfo.title} error`}, testArgs);
});

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 156 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 160 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 285 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 368 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 276 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 280 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 348 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 256 KiB

17
e2e/playwright/tsconfig.json Обычный файл
Просмотреть файл

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"esModuleInterop": true,
"strict": true,
"strictNullChecks": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@mattermost/client/*": ["../../webapp/platform/client/lib/*"],
"@mattermost/types/*": ["../../webapp/platform/types/lib/*"],
"@e2e-support/*": ["support/*"],
"@e2e-test.config": ["test.config.ts"]
}
},
"include": ["./**/*"]
}