MM-62954 E2E/Playwright shared library (#30177)

* feat: Add package.json for Playwright library with dependencies

* feat: Add explicit exports for test.config in playwright-lib package

* feat: Add initialization setup for Mattermost E2E testing with admin and user client

* fix: Update package dependencies and resolve TypeScript build errors

* feat: Update package exports for test.config to support both CommonJS and ESM

* playwright shared library

* add README, fix pipeline

* keep file structures, move report up to playwright

* minimize API, use the prerelease versions of client and types

* bump version

* update package*.json

* resolve merge conflict

* update depedencies and merge conflicts

* update readme and fix ci

* remove unnecessary export and list all external packages

* fix import for Client4
Этот коммит содержится в:
Saturnino Abril
2025-04-01 08:52:56 +08:00
коммит произвёл GitHub
родитель ce9632cca3
Коммит a47269cfe2
163 изменённых файлов: 7203 добавлений и 5048 удалений

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

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

После

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

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

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {writeFile} from 'node:fs/promises';
import {Browser, BrowserContext, request} from '@playwright/test';
import {UserProfile} from '@mattermost/types/users';
import {testConfig} from './test_config';
import {pages} from './ui/pages';
export class TestBrowser {
readonly browser: Browser;
context: BrowserContext | null;
constructor(browser: Browser) {
this.browser = browser;
this.context = null;
}
async login(user: UserProfile) {
const options = {storageState: ''};
if (user) {
// Log in via API request and save user storage
const storagePath = await loginByAPI(user.username, user.password);
options.storageState = storagePath;
}
// Sign in a user in new browser context
const context = await this.browser.newContext(options);
const page = await context.newPage();
const channelsPage = new pages.ChannelsPage(page);
const systemConsolePage = new pages.SystemConsolePage(page);
const scheduledDraftPage = new pages.ScheduledDraftPage(page);
const draftPage = new pages.DraftPage(page);
this.context = context;
return {context, page, channelsPage, systemConsolePage, scheduledDraftPage, draftPage};
}
async close() {
if (this.context) {
await this.context.close();
}
}
}
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;
}

6
e2e-tests/playwright/lib/src/constant.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const appsPluginId = 'com.mattermost.apps';
export const callsPluginId = 'com.mattermost.calls';
export const playbooksPluginId = 'playbooks';

93
e2e-tests/playwright/lib/src/file.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
import fs from 'node:fs';
import mime from 'mime-types';
const commonAssetPath = path.resolve(__dirname, 'asset');
export const assetPath = path.resolve(process.cwd(), 'asset');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const availableFiles = ['mattermost-icon_128x128.png'] as const;
type AvailableFilename = (typeof availableFiles)[number];
/**
* Reads file data and creates a File object.
* @param filePath - The path to the file.
* @returns A File object containing the file data.
* @throws If the file does not exist.
*/
export function getFileData(filePath: string): File {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found at path: ${filePath}`);
}
const mimeType = mime.lookup(filePath) || undefined;
const fileName = path.basename(filePath);
const fileBuffer = fs.readFileSync(filePath);
return new File([fileBuffer], fileName, {type: mimeType});
}
/**
* Reads file data and creates a Blob object.
* @param filePath - The path to the file.
* @returns A Blob object containing the file data.
* @throws If the file does not exist.
*/
export function getBlobData(filePath: string): Blob {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found at path: ${filePath}`);
}
const mimeType = mime.lookup(filePath) || undefined;
const fileBuffer = fs.readFileSync(filePath);
return new Blob([fileBuffer], {type: mimeType});
}
/**
* Reads file data from the "asset" directory and creates a File object.
* @param filename - The name of the file in the "asset" directory.
* @returns An object containing a File object
*/
export function getFileFromAsset(filename: string) {
const filePath = path.join(assetPath, filename);
return getFileData(filePath);
}
/**
* Reads file data from the "asset" directory and creates a Blob object.
* @param filename - The name of the file in the "asset" directory.
* @returns An object containing a Blob object
*/
export function getBlobFromAsset(filename: string) {
const filePath = path.join(assetPath, filename);
return getBlobData(filePath);
}
/**
* Reads file data from the lib "asset" directory and creates a File object.
* @param filename - The name of the file in the "asset" directory.
* @returns An object containing a File object
*/
export function getFileFromCommonAsset(filename: AvailableFilename) {
const filePath = path.join(commonAssetPath, filename);
return getFileData(filePath);
}
/**
* Reads file data from the lib "asset" directory and creates a Blob object.
* @param filename - The name of the file in the "asset" directory.
* @returns An object containing a Blob object
*/
export function getBlobFromCommonAsset(filename: AvailableFilename) {
const filePath = path.join(commonAssetPath, filename);
return getBlobData(filePath);
}

88
e2e-tests/playwright/lib/src/flag.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,88 @@
// 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 {callsPluginId} from './constant';
import {getAdminClient} from './server/init';
export async function shouldHaveCallsEnabled(enabled = true) {
const {adminClient} = await getAdminClient();
const config = await adminClient.getConfig();
const callsEnabled = config.PluginSettings.PluginStates[callsPluginId].Enable;
const matched = callsEnabled === enabled;
expect(matched, matched ? '' : `Calls expect "${enabled}" but actual "${callsEnabled}"`).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 async function shouldRunInLinux() {
const platform = os.platform();
expect(platform, 'Run in Linux or Playwright docker image only').toBe('linux');
}
export async function ensureLicense() {
const {adminClient} = await getAdminClient();
let license = await adminClient.getClientLicenseOld();
if (license?.IsLicensed !== 'true') {
const config = await adminClient.getClientConfigOld();
expect(
config.ServiceEnvironment === 'dev',
'The trial license request fails in the local development environment. Please manually upload the test license.',
).toBeFalsy();
await requestTrialLicense();
license = await adminClient.getClientLicenseOld();
}
expect(license?.IsLicensed === 'true', 'Ensure server has license').toBeTruthy();
}
export async function requestTrialLicense() {
const {adminClient} = await getAdminClient();
const admin = await adminClient.getMe();
try {
await adminClient.requestTrialLicense({
receive_emails_accepted: true,
terms_accepted: true,
users: 100,
contact_name: admin.first_name + ' ' + admin.last_name,
contact_email: admin.email,
company_name: 'Mattermost Playwright E2E Tests',
company_size: '101-250',
company_country: 'United States',
});
} catch (error) {
expect(error, 'Failed to request trial license').toBeFalsy();
throw error;
}
}
export async function skipIfNoLicense() {
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(license.IsLicensed === 'false', 'Skipping test - server not licensed');
}
export async function skipIfFeatureFlagNotSet(name: string, value: string | boolean) {
const {adminClient} = await getAdminClient();
const cfg = await adminClient.getConfig();
test.skip(cfg.FeatureFlags[name] !== value, `Skipping test - Feature Flag ${name} needs to be set to ${value}`);
}

210
e2e-tests/playwright/lib/src/global_setup.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,210 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect} from '@playwright/test';
import {Client4} from '@mattermost/client';
import {UserProfile} from '@mattermost/types/users';
import {PluginManifest} from '@mattermost/types/plugins';
import {PreferenceType} from '@mattermost/types/preferences';
import {defaultTeam} from './util';
import {createRandomTeam, getAdminClient, getDefaultAdminUser, makeClient} from './server';
import {testConfig} from './test_config';
export async function baseGlobalSetup() {
let adminClient: Client4;
let adminUser: UserProfile | null;
({adminClient, adminUser} = await getAdminClient({skipLog: true}));
if (!adminUser) {
const firstClient = new Client4();
firstClient.setUrl(testConfig.baseURL);
const defaultAdmin = getDefaultAdminUser();
await firstClient.createUser(defaultAdmin, '', '');
({client: adminClient, user: adminUser} = await makeClient(defaultAdmin));
}
await sysadminSetup(adminClient, adminUser);
}
async function sysadminSetup(client: Client4, user: UserProfile | null) {
// Ensure admin's email is verified.
if (!user) {
await client.verifyUserEmail(client.token);
}
// 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)),
);
}
// Set default preferences
await savePreferences(client, user?.id ?? '');
// 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: Client4) {
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: Client4) {
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}
- TelemetryId = ${config.TelemetryId}
- ServiceEnvironment = ${config.ServiceEnvironment}`);
const {LogSettings, ServiceSettings} = await client.getConfig();
// eslint-disable-next-line no-console
console.log(`Notable Server Config:
- ServiceSettings.EnableSecurityFixAlert = ${ServiceSettings?.EnableSecurityFixAlert}
- LogSettings.EnableDiagnostics = ${LogSettings?.EnableDiagnostics}`);
}
export async function ensurePluginsLoaded(client: Client4) {
const pluginStatus = await client.getPluginStatuses();
const plugins = await client.getPlugins();
testConfig.ensurePluginsInstalled.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: PluginManifest) => 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.`);
}
});
}
async function printPluginDetails(client: Client4) {
const plugins = await client.getPlugins();
if (plugins.active.length) {
// eslint-disable-next-line no-console
console.log('Active plugins:');
}
plugins.active.forEach((plugin: PluginManifest) => {
// 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: PluginManifest) => {
// 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: Client4) {
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}`),
);
}
}
async function savePreferences(client: Client4, userId: UserProfile['id']) {
try {
if (!userId) {
throw new Error('userId is not defined');
}
const preferences: PreferenceType[] = [
{user_id: userId, category: 'tutorial_step', name: userId, value: '999'},
{user_id: userId, category: 'crt_thread_pane_step', name: userId, value: '999'},
];
await client.savePreferences(userId, preferences);
} catch (error) {
// eslint-disable-next-line no-console
console.log('Error saving preferences', error);
}
}

22
e2e-tests/playwright/lib/src/index.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {test, expect, PlaywrightExtended} from './test_fixture';
export {testConfig} from './test_config';
export {baseGlobalSetup} from './global_setup';
export {TestBrowser} from './browser_context';
export {getBlobFromAsset, getFileFromAsset} from './file';
export {duration, wait} from './util';
export {
ChannelsPage,
LandingLoginPage,
LoginPage,
ResetPasswordPage,
SignupPage,
ScheduledDraftPage,
SystemConsolePage,
DraftPage,
} from './ui/pages';
export {TestArgs, ScreenshotOptions} from './types';

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

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page} from '@playwright/test';
type NotificationData = {title: string} & NotificationOptions;
// Extend the Window interface to add custom properties
declare global {
interface Window {
_originalNotification: typeof Notification;
_notifications: NotificationData[];
getNotifications: () => NotificationData[];
}
}
/**
* `stubNotification` intercepts the Notification API to capture notifications.
*
* Note:
* - Works across browsers and devices, except in headless mode, where stubbing the Notification API is supported only in Firefox and WebKit.
* - An `Error: page.evaluate: window.getNotifications is not a function` may occur if the `stubNotification` function is called before the page has fully loaded.
*
* @param page Page object
* @param permission Permission setting for notifications, with possible values: "default" | "granted" | "denied". Note: A notification sound may still occur even when set to "denied", as the browser might attempt to trigger system notifications.
*/
export async function stubNotification(page: Page, permission: NotificationPermission) {
await page.evaluate((notificationPermission: NotificationPermission) => {
// Override the Notification.requestPermission method
window.Notification.requestPermission = () => Promise.resolve(permission);
// Copy the original Notification
if (!window._originalNotification) {
window._originalNotification = window.Notification;
}
// Initialize a list where to capture the notifications
window._notifications = [];
// Override the Notification constructor
class CustomNotification extends window._originalNotification {
constructor(title: string, options?: NotificationOptions) {
super(title, options);
const notification = {title, ...options};
window._notifications.push(notification);
}
}
// Set static properties and permission status
Object.defineProperties(CustomNotification, {
permission: {
get: () => notificationPermission,
},
requestPermission: {
value: () => Promise.resolve(notificationPermission),
},
});
// Replace the global Notification with the custom one
window.Notification = CustomNotification as unknown as typeof Notification;
// Method to get all notifications
window.getNotifications = () => window._notifications;
}, permission);
}
/**
* `waitForNotification` waits for a specified number of notifications to be received on the page within a given timeout.
* @param page Page object
* @param expectedCount Number of notifications to wait for before returning. (default: 1)
* @param timeout Wait time in milliseconds. (default: 5000ms)
* @returns An array of notifications received
*/
export async function waitForNotification(
page: Page,
expectedCount = 1,
timeout: number = 5000,
): Promise<NotificationData[]> {
const start = Date.now();
while (Date.now() - start < timeout) {
const notifications = await page.evaluate(() => window.getNotifications());
if (notifications.length >= expectedCount) {
return notifications;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
// eslint-disable-next-line no-console
console.error(`Notification not received within the timeout period of ${timeout}ms`);
return [];
}

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

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

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

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import {UserProfile} from '@mattermost/types/users';
import {testConfig} from '@/test_config';
// Variable to hold cache
const clients: Record<string, ClientCache> = {};
export async function makeClient(
userRequest?: UserRequest,
opts: {useCache?: boolean; skipLog?: boolean} = {useCache: true, skipLog: false},
): Promise<ClientCache> {
const client = new Client4();
client.setUrl(testConfig.baseURL);
try {
if (!userRequest) {
return {client, user: null};
}
const cacheKey = userRequest.username + userRequest.password;
if (opts?.useCache && clients[cacheKey] != null) {
return clients[cacheKey];
}
const userProfile = await client.login(userRequest.username, userRequest.password);
const user = {...userProfile, password: userRequest.password};
if (opts?.useCache) {
clients[cacheKey] = {client, user};
}
return {client, user};
} catch (err) {
if (!opts?.skipLog) {
// 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: Client4;
user: UserProfile | null;
};

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

@@ -0,0 +1,778 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import merge from 'deepmerge';
import {
AdminConfig,
ClusterSettings,
CollapsedThreads,
EmailSettings,
ExperimentalSettings,
LogSettings,
PasswordSettings,
PluginSettings,
ServiceSettings,
TeamSettings,
} from '@mattermost/types/config';
import {testConfig} from '@/test_config';
export function getOnPremServerConfig(): AdminConfig {
return merge<AdminConfig>(defaultServerConfig, onPremServerConfig() as AdminConfig);
}
type TestAdminConfig = {
ClusterSettings: Partial<ClusterSettings>;
EmailSettings: Partial<EmailSettings>;
ExperimentalSettings: Partial<ExperimentalSettings>;
LogSettings: Partial<LogSettings>;
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,
},
EmailSettings: {
PushNotificationServer: testConfig.pushNotificationServer,
},
LogSettings: {
EnableDiagnostics: false,
},
PasswordSettings: {
MinimumLength: 5,
Lowercase: false,
Number: false,
Uppercase: false,
Symbol: false,
EnableForgotLink: true,
},
PluginSettings: {
EnableUploads: true,
PluginStates: {
'com.mattermost.calls': {
Enable: false,
},
'com.mattermost.nps': {
Enable: false,
},
playbooks: {
Enable: true,
},
},
},
ServiceSettings: {
SiteURL: testConfig.baseURL,
EnableOnboardingFlow: false,
EnableSecurityFixAlert: false,
GiphySdkKey: 's0glxvzVg9azvPipKxcPLpXV0q1x1fVP',
EnableTesting: true,
},
TeamSettings: {
EnableOpenServer: true,
MaxUsersPerTeam: 2000,
},
};
};
// Should be based only from the generated default config from ./server via "make config-reset"
// Based on v10.5 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,
EnableOutgoingOAuthConnections: false,
EnableCommands: true,
OutgoingIntegrationRequestsTimeout: 30,
EnablePostUsernameOverride: false,
EnablePostIconOverride: false,
GoogleDeveloperKey: '',
EnableLinkPreviews: true,
EnablePermalinkPreviews: true,
RestrictLinkPreviews: '',
EnableTesting: false,
EnableDeveloper: false,
DeveloperFlags: '',
EnableClientPerformanceDebugging: false,
EnableSecurityFixAlert: true,
EnableInsecureOutgoingConnections: false,
AllowedUntrustedInternalConnections: '',
EnableMultifactorAuthentication: false,
EnforceMultifactorAuthentication: false,
EnableUserAccessTokens: false,
AllowCorsFrom: '',
CorsExposedHeaders: '',
CorsAllowCredentials: false,
CorsDebug: false,
AllowCookiesForSubdomains: false,
ExtendSessionLengthWithActivity: true,
TerminateSessionsOnPasswordChange: 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,
GiphySdkKey: '',
EnableCustomEmoji: true,
EnableEmojiPicker: true,
PostEditTimeLimit: -1,
TimeBetweenUserTypingUpdatesMilliseconds: 5000,
EnableCrossTeamSearch: true,
EnablePostSearch: true,
EnableFileSearch: true,
MinimumHashtagLength: 3,
EnableUserTypingMessages: true,
EnableChannelViewedMessages: true,
EnableUserStatuses: true,
ExperimentalEnableAuthenticationTransfer: true,
ClusterLogTimeoutMilliseconds: 2000,
EnableTutorial: true,
EnableOnboardingFlow: true,
ExperimentalEnableDefaultChannelLeaveJoinMessages: true,
ExperimentalGroupUnreadChannels: 'disabled',
EnableAPITeamDeletion: false,
EnableAPITriggerAdminNotifications: false,
EnableAPIUserDeletion: false,
EnableAPIPostDeletion: false,
EnableDesktopLandingPage: true,
ExperimentalEnableHardenedMode: false,
ExperimentalStrictCSRFEnforcement: false,
EnableEmailInvitations: false,
DisableBotsWhenOwnerIsDeactivated: true,
EnableBotAccountCreation: false,
EnableSVGs: false,
EnableLatex: false,
EnableInlineLatex: true,
PostPriority: true,
AllowPersistentNotifications: true,
AllowPersistentNotificationsForGuests: false,
PersistentNotificationIntervalMinutes: 5,
PersistentNotificationMaxCount: 6,
PersistentNotificationMaxRecipients: 5,
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,
AllowSyncedDrafts: true,
UniqueEmojiReactionLimitPerPost: 50,
RefreshPostStatsRunTime: '00:00',
MaximumPayloadSizeBytes: 300000,
MaximumURLLength: 2048,
ScheduledPosts: true,
},
TeamSettings: {
SiteName: 'Mattermost',
MaxUsersPerTeam: 50,
EnableJoinLeaveMessageByDefault: true,
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: [],
ReplicaMonitorIntervalSeconds: 5,
},
LogSettings: {
EnableConsole: true,
ConsoleLevel: 'DEBUG',
ConsoleJson: true,
EnableColor: false,
EnableFile: true,
FileLevel: 'INFO',
FileJson: true,
FileLocation: '',
EnableWebhookDebugging: true,
EnableDiagnostics: true,
VerboseDiagnostics: false,
EnableSentry: true,
AdvancedLoggingJSON: {},
MaxFieldSize: 2048,
},
ExperimentalAuditSettings: {
FileEnabled: false,
FileName: '',
FileMaxSizeMB: 100,
FileMaxAgeDays: 0,
FileMaxBackups: 0,
FileCompress: false,
FileMaxQueueSize: 1000,
AdvancedLoggingJSON: {},
},
NotificationLogSettings: {
EnableConsole: true,
ConsoleLevel: 'DEBUG',
ConsoleJson: true,
EnableColor: false,
EnableFile: true,
FileLevel: 'INFO',
FileJson: true,
FileLocation: '',
AdvancedLoggingJSON: {},
},
PasswordSettings: {
MinimumLength: 8,
Lowercase: false,
Number: false,
Uppercase: false,
Symbol: false,
EnableForgotLink: true,
},
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,
AmazonS3UploadPartSizeBytes: 5242880,
AmazonS3StorageClass: '',
DedicatedExportStore: false,
ExportDriverName: 'local',
ExportDirectory: './data/',
ExportAmazonS3AccessKeyId: '',
ExportAmazonS3SecretAccessKey: '',
ExportAmazonS3Bucket: '',
ExportAmazonS3PathPrefix: '',
ExportAmazonS3Region: '',
ExportAmazonS3Endpoint: 's3.amazonaws.com',
ExportAmazonS3SSL: true,
ExportAmazonS3SignV2: false,
ExportAmazonS3SSE: false,
ExportAmazonS3Trace: false,
ExportAmazonS3RequestTimeoutMilliseconds: 30000,
ExportAmazonS3PresignExpiresSeconds: 21600,
ExportAmazonS3UploadPartSizeBytes: 104857600,
ExportAmazonS3StorageClass: '',
},
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',
PushNotificationServerType: 'custom',
PushNotificationServerLocation: 'us',
PushNotificationContents: 'full',
PushNotificationBuffer: 1000,
EnableEmailBatching: false,
EmailBatchingBufferSize: 256,
EmailBatchingInterval: 30,
EnablePreviewModeBanner: true,
SkipServerCertificateVerification: false,
EmailNotificationContentsType: 'full',
LoginButtonColor: '#0000',
LoginButtonBorderColor: '#2389D7',
LoginButtonTextColor: '#2389D7',
},
RateLimitSettings: {
Enable: false,
PerSec: 10,
MaxBurst: 100,
MemoryStoreSize: 10000,
VaryByRemoteAddr: true,
VaryByUser: false,
VaryByHeader: '',
},
PrivacySettings: {
ShowEmailAddress: true,
ShowFullName: true,
},
SupportSettings: {
TermsOfServiceLink: 'https://mattermost.com/pl/terms-of-use/',
PrivacyPolicyLink: 'https://mattermost.com/pl/privacy-policy/',
AboutLink: 'https://mattermost.com/pl/about-mattermost',
HelpLink: 'https://mattermost.com/pl/help/',
ReportAProblemLink: 'https://mattermost.com/pl/report-a-bug',
ForgotPasswordLink: '',
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,
MaximumLoginAttempts: 10,
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',
},
ComplianceSettings: {
Enable: false,
Directory: './data/',
EnableDaily: false,
BatchSize: 30000,
},
LocalizationSettings: {
DefaultServerLocale: 'en',
DefaultClientLocale: 'en',
AvailableLocales: '',
EnableExperimentalLocales: false,
},
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/pl/download-apps',
AndroidAppDownloadLink: 'https://mattermost.com/pl/android-app/',
IosAppDownloadLink: 'https://mattermost.com/pl/ios-app/',
MobileExternalBrowser: false,
},
CacheSettings: {
CacheType: 'lru',
RedisAddress: '',
RedisPassword: '',
RedisDB: -1,
DisableClientCache: false,
},
ClusterSettings: {
Enable: false,
ClusterName: '',
OverrideHostname: '',
NetworkInterface: '',
BindAddress: '',
AdvertiseAddress: '',
UseIPAddress: true,
EnableGossipCompression: true,
EnableExperimentalGossipEncryption: false,
ReadOnlyConfig: true,
GossipPort: 8074,
},
MetricsSettings: {
Enable: false,
BlockProfileRate: 0,
ListenAddress: ':8067',
EnableClientMetrics: true,
EnableNotificationMetrics: true,
},
ExperimentalSettings: {
ClientSideCertEnable: false,
ClientSideCertCheck: 'secondary',
LinkMetadataTimeoutMilliseconds: 5000,
RestrictSystemAdmin: false,
EnableSharedChannels: false,
EnableRemoteClusterService: false,
DisableAppBar: false,
DisableRefetchingOnBrowserFocus: false,
DelayChannelAutocomplete: false,
DisableWakeUpReconnectHandler: false,
UsersStatusAndProfileFetchingPollIntervalMilliseconds: 3000,
YoutubeReferrerPolicy: false,
},
AnalyticsSettings: {
MaxUsersForStatistics: 2500,
},
ElasticsearchSettings: {
ConnectionURL: 'http://localhost:9200',
Backend: 'elasticsearch',
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: 10,
BatchSize: 10000,
RequestTimeoutSeconds: 30,
SkipTLSVerification: false,
CA: '',
ClientCert: '',
ClientKey: '',
Trace: '',
IgnoredPurgeIndexes: '',
},
BleveSettings: {
IndexDir: '',
EnableIndexing: false,
EnableSearching: false,
EnableAutocomplete: false,
BatchSize: 10000,
},
DataRetentionSettings: {
EnableMessageDeletion: false,
EnableFileDeletion: false,
EnableBoardsDeletion: false,
MessageRetentionDays: 365,
MessageRetentionHours: 0,
FileRetentionDays: 365,
FileRetentionHours: 0,
BoardsRetentionDays: 365,
DeletionJobStartTime: '02:00',
BatchSize: 3000,
TimeBetweenBatchesMilliseconds: 100,
RetentionIdsBatchSize: 100,
},
MessageExportSettings: {
EnableExport: false,
ExportFormat: 'actiance',
DailyRunTime: '01:00',
ExportFromTimestamp: 0,
BatchSize: 10000,
DownloadExportResults: false,
ChannelBatchSize: 100,
ChannelHistoryBatchSize: 10,
GlobalRelaySettings: {
CustomerType: 'A9',
SMTPUsername: '',
SMTPPassword: '',
EmailAddress: '',
SMTPServerTimeout: 1800,
CustomSMTPServerName: '',
CustomSMTPPort: '25',
},
},
JobSettings: {
RunJobs: true,
RunScheduler: true,
CleanupJobsThresholdDays: -1,
CleanupConfigThresholdDays: -1,
},
PluginSettings: {
Enable: true,
EnableUploads: false,
AllowInsecureDownloadURL: false,
EnableHealthCheck: true,
Directory: './plugins',
ClientDirectory: './client/plugins',
Plugins: {},
PluginStates: {
'com.mattermost.calls': {
Enable: true,
},
'com.mattermost.nps': {
Enable: true,
},
'mattermost-ai': {
Enable: true,
},
playbooks: {
Enable: true,
},
},
EnableMarketplace: true,
EnableRemoteMarketplace: true,
AutomaticPrepackagedPlugins: true,
RequirePluginSignature: false,
MarketplaceURL: 'https://api.integrations.mattermost.com',
SignaturePublicKeyFiles: [],
ChimeraOAuthProxyURL: '',
},
DisplaySettings: {
CustomURLSchemes: [],
MaxMarkdownNodes: 0,
},
GuestAccountsSettings: {
Enable: false,
HideTags: 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',
CWSMock: false,
Disable: false,
},
FeatureFlags: {
TestFeature: 'off',
TestBoolFeature: false,
EnableRemoteClusterService: false,
EnableSharedChannelsDMs: false,
AppsEnabled: false,
PermalinkPreviews: false,
NormalizeLdapDNs: false,
WysiwygEditor: false,
OnboardingTourTips: true,
DeprecateCloudFree: false,
EnableExportDirectDownload: false,
MoveThreadsEnabled: false,
StreamlinedMarketplace: true,
CloudIPFiltering: false,
ConsumePostHook: false,
CloudAnnualRenewals: false,
CloudDedicatedExportUI: false,
ChannelBookmarks: true,
WebSocketEventScope: true,
NotificationMonitoring: true,
ExperimentalAuditSettingsSystemConsoleUI: false,
CustomProfileAttributes: false,
},
ImportSettings: {
Directory: './import',
RetentionDays: 30,
},
ExportSettings: {
Directory: './export',
RetentionDays: 30,
},
WranglerSettings: {
PermittedWranglerRoles: [],
AllowedEmailDomain: [],
MoveThreadMaxCount: 100,
MoveThreadToAnotherTeamEnable: false,
MoveThreadFromPrivateChannelEnable: false,
MoveThreadFromDirectMessageChannelEnable: false,
MoveThreadFromGroupMessageChannelEnable: false,
},
ConnectedWorkspacesSettings: {
EnableSharedChannels: false,
EnableRemoteClusterService: false,
DisableSharedChannelsStatusSync: false,
MaxPostsPerSync: 50,
},
};

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

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

89
e2e-tests/playwright/lib/src/server/init.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect} from '@playwright/test';
import {PreferenceType} from '@mattermost/types/preferences';
import {makeClient} from './client';
import {getOnPremServerConfig} from './default_config';
import {createRandomTeam} from './team';
import {createRandomUser} from './user';
import {getFileFromCommonAsset} from '@/file';
import {testConfig} from '@/test_config';
export async function initSetup({
userPrefix = 'user',
teamPrefix = {name: 'team', displayName: 'Team'},
withDefaultProfileImage = true,
} = {}) {
try {
// Login the admin user via API
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Failed to setup admin: Admin user not found.');
}
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() as any);
// 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) {
const file = getFileFromCommonAsset('mattermost-icon_128x128.png');
await userClient.uploadProfileImage(user.id, file);
}
// Update user preference
const preferences: PreferenceType[] = [
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
{user_id: user.id, category: 'crt_thread_pane_step', name: user.id, value: '999'},
];
await userClient.savePreferences(user.id, preferences);
return {
adminClient,
adminUser,
adminConfig,
user,
userClient,
team,
offTopicUrl: getUrl(team.name, 'off-topic'),
townSquareUrl: getUrl(team.name, 'town-square'),
};
} catch (error) {
expect(error, 'Should not throw an error').toBeFalsy();
throw error;
}
}
export async function getAdminClient(opts: {skipLog: boolean} = {skipLog: false}) {
const {client: adminClient, user: adminUser} = await makeClient(
{
username: testConfig.adminUsername,
password: testConfig.adminPassword,
},
opts,
);
return {adminClient, adminUser};
}
function getUrl(teamName: string, channelName: string) {
return `/${teamName}/channels/${channelName}`;
}

33
e2e-tests/playwright/lib/src/server/post.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Post, PostMetadata} from '@mattermost/types/posts';
import {getRandomId} from '@/util';
export function createRandomPost(post?: Partial<Post>): Post {
if (post && post.channel_id && post.user_id) {
const time = Date.now();
const defaultPost = {
create_at: time,
user_id: post.user_id,
channel_id: post.channel_id,
root_id: post.root_id || '',
message: `${post?.message ?? ''}${getRandomId()}`,
pending_post_id: `${post.user_id}:${time}`,
props: post?.props || {},
file_ids: post?.file_ids || [],
metadata: {} as PostMetadata,
};
Reflect.deleteProperty(post, 'user_id');
Reflect.deleteProperty(post, 'channel_id');
Reflect.deleteProperty(post, 'message');
Reflect.deleteProperty(post, 'pending_post_id');
return {...defaultPost, ...post} as Post;
}
throw new Error('Post is missing channel_id or user_id or both');
}

18
e2e-tests/playwright/lib/src/server/team.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,18 @@
// 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 '@/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;
}

34
e2e-tests/playwright/lib/src/server/user.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {UserProfile} from '@mattermost/types/users';
import {getRandomId} from '@/util';
import {testConfig} from '@/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;
}

20
e2e-tests/playwright/lib/src/test_action.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, Page} from '@playwright/test';
export {waitUntil} from 'async-wait-until';
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)),
);
}

63
e2e-tests/playwright/lib/src/test_config.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as dotenv from 'dotenv';
dotenv.config();
// All process.env should be defined here
export class TestConfig {
baseURL: string;
adminUsername: string;
adminPassword: string;
adminEmail: string;
ensurePluginsInstalled: string[];
haClusterEnabled: boolean;
haClusterNodeCount: number;
haClusterName: string;
pushNotificationServer: string;
resetBeforeTest: boolean;
isCI: boolean;
headless: boolean;
slowMo: number;
workers: number;
snapshotEnabled: boolean;
percyEnabled: boolean;
constructor() {
// Server
this.baseURL = process.env.PW_BASE_URL || 'http://localhost:8065';
this.adminUsername = process.env.PW_ADMIN_USERNAME || 'sysadmin';
this.adminPassword = process.env.PW_ADMIN_PASSWORD || 'Sys@dmin-sample1';
this.adminEmail = process.env.PW_ADMIN_EMAIL || 'sysadmin@sample.mattermost.com';
this.ensurePluginsInstalled =
typeof process.env?.PW_ENSURE_PLUGINS_INSTALLED === 'string'
? process.env.PW_ENSURE_PLUGINS_INSTALLED.split(',').filter((plugin) => Boolean(plugin))
: [];
this.haClusterEnabled = parseBool(process.env.PW_HA_CLUSTER_ENABLED, false);
this.haClusterNodeCount = parseNumber(process.env.PW_HA_CLUSTER_NODE_COUNT, 2);
this.haClusterName = process.env.PW_HA_CLUSTER_NAME || 'mm_dev_cluster';
this.pushNotificationServer = process.env.PW_PUSH_NOTIFICATION_SERVER || 'https://push-test.mattermost.com';
this.resetBeforeTest = parseBool(process.env.PW_RESET_BEFORE_TEST, false);
// CI
this.isCI = !!process.env.CI;
// Playwright
this.headless = parseBool(process.env.PW_HEADLESS, true);
this.slowMo = parseNumber(process.env.PW_SLOWMO, 0);
this.workers = parseNumber(process.env.PW_WORKERS, 1);
// Visual tests
this.snapshotEnabled = parseBool(process.env.PW_SNAPSHOT_ENABLE, false);
this.percyEnabled = parseBool(process.env.PW_PERCY_ENABLE, false);
}
}
// Create a singleton instance
export const testConfig = new TestConfig();
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;
}

220
e2e-tests/playwright/lib/src/test_fixture.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,220 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Browser, Page, test as base} from '@playwright/test';
import {AxeResults} from 'axe-core';
import {AxeBuilder} from '@axe-core/playwright';
import {TestBrowser} from './browser_context';
import {
ensureLicense,
shouldHaveCallsEnabled,
shouldHaveFeatureFlag,
shouldRunInLinux,
skipIfFeatureFlagNotSet,
skipIfNoLicense,
} from './flag';
import {getBlobFromAsset, getFileFromAsset} from './file';
import {
createRandomChannel,
createRandomPost,
createRandomTeam,
createRandomUser,
getAdminClient,
initSetup,
} from './server';
import {hideDynamicChannelsContent, waitForAnimationEnd, waitUntil} from './test_action';
import {pages} from './ui/pages';
import {matchSnapshot} from './visual';
import {stubNotification, waitForNotification} from './mock_browser_api';
import {duration, getRandomId, simpleEmailRe, wait} from './util';
export {expect} from '@playwright/test';
export type ExtendedFixtures = {
axe: AxeBuilderExtended;
pw: PlaywrightExtended;
};
type AxeBuilderOptions = {
disableColorContrast?: boolean;
disableLinkInTextBlock?: boolean;
};
export const test = base.extend<ExtendedFixtures>({
// eslint-disable-next-line no-empty-pattern
axe: async ({}, use) => {
const ab = new AxeBuilderExtended();
await use(ab);
},
pw: async ({browser, page, isMobile}, use) => {
const pw = new PlaywrightExtended(browser, page, isMobile);
await use(pw);
await pw.testBrowser.close();
},
});
export class PlaywrightExtended {
// ./browser_context
readonly testBrowser;
// ./flag
readonly shouldHaveCallsEnabled;
readonly shouldHaveFeatureFlag;
readonly shouldRunInLinux;
readonly ensureLicense;
readonly skipIfNoLicense;
readonly skipIfFeatureFlagNotSet;
// ./file
readonly getBlobFromAsset;
readonly getFileFromAsset;
// ./server
readonly getAdminClient;
readonly initSetup;
// ./test_action
readonly hideDynamicChannelsContent;
readonly waitForAnimationEnd;
readonly waitUntil;
// ./mock_browser_api
readonly stubNotification;
readonly waitForNotification;
// ./visual
readonly matchSnapshot;
// ./util
readonly duration;
readonly simpleEmailRe;
readonly wait;
// random
readonly random;
// unauthenticated page
readonly loginPage;
readonly landingLoginPage;
readonly signupPage;
readonly resetPasswordPage;
readonly hasSeenLandingPage;
constructor(browser: Browser, page: Page, isMobile: boolean) {
// ./browser_context
this.testBrowser = new TestBrowser(browser);
// ./flag
this.shouldHaveCallsEnabled = shouldHaveCallsEnabled;
this.shouldHaveFeatureFlag = shouldHaveFeatureFlag;
this.shouldRunInLinux = shouldRunInLinux;
this.ensureLicense = ensureLicense;
this.skipIfNoLicense = skipIfNoLicense;
this.skipIfFeatureFlagNotSet = skipIfFeatureFlagNotSet;
// ./file
this.getBlobFromAsset = getBlobFromAsset;
this.getFileFromAsset = getFileFromAsset;
// ./server
this.initSetup = initSetup;
this.getAdminClient = getAdminClient;
// ./test_action
this.hideDynamicChannelsContent = hideDynamicChannelsContent;
this.waitForAnimationEnd = waitForAnimationEnd;
this.waitUntil = waitUntil;
// unauthenticated page
this.loginPage = new pages.LoginPage(page);
this.landingLoginPage = new pages.LandingLoginPage(page, isMobile);
this.signupPage = new pages.SignupPage(page);
this.resetPasswordPage = new pages.ResetPasswordPage(page);
// ./mock_browser_api
this.stubNotification = stubNotification;
this.waitForNotification = waitForNotification;
// ./visual
this.matchSnapshot = matchSnapshot;
// ./util
this.duration = duration;
this.wait = wait;
this.simpleEmailRe = simpleEmailRe;
this.random = {
id: getRandomId,
channel: createRandomChannel,
post: createRandomPost,
team: createRandomTeam,
user: createRandomUser,
};
this.hasSeenLandingPage = async () => {
// Visit the base URL to be able to set the localStorage
await page.goto('/');
return await waitUntilLocalStorageIsSet(page, '__landingPageSeen__', 'true');
};
}
}
export class AxeBuilderExtended {
readonly builder: (page: Page, options?: AxeBuilderOptions) => AxeBuilder;
// See https://github.com/dequelabs/axe-core/blob/master/doc/API.md#axe-core-tags
readonly tags: string[] = ['wcag2a', 'wcag2aa'];
constructor() {
this.builder = (page: Page, options: AxeBuilderOptions = {}) => {
// See https://github.com/dequelabs/axe-core/blob/master/doc/rule-descriptions.md#wcag-20-level-a--aa-rules
const disabledRules: string[] = [];
if (options.disableColorContrast) {
// Disabled in pages due to impact to overall theme of Mattermost.
// Option: make use of custom theme to improve color contrast.
disabledRules.push('color-contrast');
}
if (options.disableLinkInTextBlock) {
// Disabled in pages due to impact to overall theme of Mattermost.
// Option: make use of custom theme to improve color contrast.
disabledRules.push('link-in-text-block');
}
return new AxeBuilder({page}).withTags(this.tags).disableRules(disabledRules);
};
}
violationFingerprints(accessibilityScanResults: AxeResults) {
const fingerprints = accessibilityScanResults.violations.map((violation) => ({
rule: violation.id,
description: violation.description,
helpUrl: violation.helpUrl,
targets: violation.nodes.map((node) => {
return {target: node.target, impact: node.impact, html: node.html};
}),
}));
return JSON.stringify(fingerprints, null, 2);
}
}
async function waitUntilLocalStorageIsSet(page: Page, key: string, value: string, timeout = duration.ten_sec) {
await waitUntil(
() =>
page.evaluate(
({key, value}) => {
if (localStorage.getItem(key) === value) {
return true;
}
localStorage.setItem(key, value);
return false;
},
{key, value},
),
{timeout},
);
}

111
e2e-tests/playwright/lib/src/types.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, Page, ViewportSize} from '@playwright/test';
export type TestArgs = {
page: Page;
browserName: string;
viewport?: ViewportSize | null;
};
// Based on https://github.com/microsoft/playwright/blob/d6ec1ae3994f127e38b866a231a34efc6a4cac0d/packages/playwright/types/test.d.ts#L5692-L5759
export type ScreenshotOptions = {
/**
* When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different
* treatment depending on their duration:
* - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.
* - infinite animations are canceled to initial state, and then played over after the screenshot.
*
* Defaults to `"disabled"` that disables animations.
*/
animations?: 'disabled' | 'allow';
/**
* When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be
* changed. Defaults to `"hide"`.
*/
caret?: 'hide' | 'initial';
/**
* An object which specifies clipping of the resulting image.
*/
clip?: {
/**
* x-coordinate of top-left corner of clip area
*/
x: number;
/**
* y-coordinate of top-left corner of clip area
*/
y: number;
/**
* width of clipping area
*/
width: number;
/**
* height of clipping area
*/
height: number;
};
/**
* When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to
* `false`.
*/
fullPage?: boolean;
/**
* Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink
* box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box.
*/
mask?: Array<Locator>;
/**
* Specify the color of the overlay box for masked elements, in
* [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`.
*/
maskColor?: string;
/**
* An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1`. Default is
* configurable with `TestConfig.expect`. Unset by default.
*/
maxDiffPixelRatio?: number;
/**
* An acceptable amount of pixels that could be different. Default is configurable with `TestConfig.expect`. Unset by
* default.
*/
maxDiffPixels?: number;
/**
* Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
* Defaults to `false`.
*/
omitBackground?: boolean;
/**
* When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this
* will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so
* screenshots of high-dpi devices will be twice as large or even larger.
*
* Defaults to `"css"`.
*/
scale?: 'css' | 'device';
/**
* An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
* same pixel in compared images, between zero (strict) and one (lax), default is configurable with
* `TestConfig.expect`. Defaults to `0.2`.
*/
threshold?: number;
/**
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
*/
timeout?: number;
};

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

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

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

@@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
import ChannelsHeader from './header';
import ChannelsPostCreate from './post_create';
import ChannelsPostEdit from './post_edit';
import ChannelsPost from './post';
import {duration} from '@/util';
import {waitUntil} from '@/test_action';
export default class ChannelsCenterView {
readonly container: Locator;
readonly header;
readonly postCreate;
readonly scheduledDraftOptions;
readonly postBoxIndicator;
readonly scheduledDraftChannelIcon;
readonly scheduledDraftChannelInfoMessage;
readonly scheduledDraftChannelInfoMessageLocator;
readonly scheduledDraftChannelInfoMessageText;
readonly scheduledDraftSeeAllLink;
readonly postEdit;
readonly editedPostIcon;
constructor(container: Locator) {
this.container = container;
this.scheduledDraftChannelInfoMessageLocator = 'span:has-text("Message scheduled for")';
this.header = new ChannelsHeader(this.container.locator('.channel-header'));
this.postCreate = new ChannelsPostCreate(container.getByTestId('post-create'));
this.scheduledDraftOptions = new ChannelsPostCreate(container.locator('#dropdown_send_post_options'));
this.postEdit = new ChannelsPostEdit(container.locator('.post-edit__container'));
this.postBoxIndicator = container.locator('div.postBoxIndicator');
this.scheduledDraftChannelIcon = container.locator('#create_post i.icon-draft-indicator');
this.scheduledDraftChannelInfoMessage = container.locator('div.ScheduledPostIndicator span');
this.scheduledDraftChannelInfoMessageText = container.locator(this.scheduledDraftChannelInfoMessageLocator);
this.scheduledDraftSeeAllLink = container.locator('a:has-text("See all")');
this.editedPostIcon = (postID: string) => container.locator(`#postEdited_${postID}`);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await this.postCreate.toBeVisible();
}
/**
* Click on "See all scheduled messages"
*/
async clickOnSeeAllscheduledDrafts() {
await this.scheduledDraftSeeAllLink.isVisible();
await this.scheduledDraftSeeAllLink.click();
}
/**
* Return the first post in the Center
*/
async getFirstPost() {
const firstPost = this.container.getByTestId('postView').first();
await firstPost.waitFor();
return new ChannelsPost(firstPost);
}
/**
* Return the last post in the Center
*/
async getLastPost() {
const lastPost = this.container.getByTestId('postView').last();
await lastPost.waitFor();
return new ChannelsPost(lastPost);
}
/**
* Return the ID of the last post in the Center
*/
async getLastPostID() {
return this.container
.getByTestId('postView')
.last()
.getAttribute('id')
.then((id) => (id ? id.split('_')[1] : null));
}
/**
* Return the Nth post in the Center from the top
* @param index
* @returns
*/
async getNthPost(index: number) {
const nthPost = this.container.getByTestId('postView').nth(index);
await nthPost.waitFor();
return new ChannelsPost(nthPost);
}
/**
* Returns the Center post by post's id
* @param postId Just the ID without the prefix
*/
async getPostById(id: string) {
const postById = this.container.locator(`[id="post_${id}"]`);
await postById.waitFor();
return new ChannelsPost(postById);
}
async waitUntilLastPostContains(text: string, timeout = duration.ten_sec) {
await waitUntil(
async () => {
const post = await this.getLastPost();
const content = await post.container.textContent();
return content?.includes(text);
},
{timeout},
);
}
async waitUntilPostWithIdContains(id: string, text: string, timeout = duration.ten_sec) {
await waitUntil(
async () => {
const post = await this.getPostById(id);
const content = await post.container.textContent();
return content?.includes(text);
},
{timeout},
);
}
async verifyscheduledDraftChannelInfo() {
await this.postBoxIndicator.isVisible();
await this.scheduledDraftChannelIcon.isVisible();
const messageLocator = this.scheduledDraftChannelInfoMessage.first();
await expect(messageLocator).toContainText('Message scheduled for');
}
async clickOnLastEditedPost(postID: string | null) {
if (postID) {
await this.editedPostIcon(postID).click();
}
}
}

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

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class DeletePostConfirmationDialog {
readonly container: Locator;
readonly cancelButton;
readonly confirmButton;
constructor(container: Locator) {
this.container = container;
this.cancelButton = container.locator('button.btn.btn-tertiary');
this.confirmButton = container.locator('button#deletePostModalButton');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await expect(this.cancelButton).toBeVisible();
await expect(this.confirmButton).toBeVisible();
}
async notToBeVisible() {
await expect(this.container).not.toBeVisible();
await expect(this.cancelButton).not.toBeVisible();
await expect(this.confirmButton).not.toBeVisible();
}
async cancelDeletion() {
await this.cancelButton.click();
}
async confirmDeletion() {
await this.confirmButton.click();
}
}

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

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class DeletePostModal {
readonly container: Locator;
readonly confirmButton: Locator;
constructor(container: Locator) {
this.container = container;
this.confirmButton = container.locator('#deletePostModalButton');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async confirm() {
await this.confirmButton.waitFor();
await this.confirmButton.click();
// Wait for the modal to disappear
await expect(this.container).not.toBeVisible();
}
}

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

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class EmojiGifPicker {
readonly container: Locator;
readonly gifTab: Locator;
readonly gifSearchInput: Locator;
readonly gifPickerItems: Locator;
constructor(container: Locator) {
this.container = container;
this.gifTab = container.getByText('GIFs');
this.gifSearchInput = container.getByPlaceholder('Search GIPHY');
this.gifPickerItems = container.locator('.gif-picker__items');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async openGifTab() {
await expect(this.gifTab).toBeVisible();
await this.gifTab.click({force: true});
await expect(this.gifSearchInput).toBeVisible();
await expect(this.gifPickerItems).toBeVisible();
}
async searchGif(name: string) {
await this.gifSearchInput.fill(name);
await expect(this.gifSearchInput).toHaveValue(name);
}
async getNthGif(n: number) {
await expect(this.gifPickerItems).toBeVisible();
await this.gifPickerItems.locator('img').nth(n).waitFor();
const nthGif = this.gifPickerItems.locator('img').nth(n);
await expect(nthGif).toBeVisible();
const nthGifSrc = await nthGif.getAttribute('src');
const nthGifAlt = await nthGif.getAttribute('alt');
if (!nthGifSrc || !nthGifAlt) {
throw new Error('Gif src or alt is empty');
}
return {
src: nthGifSrc,
alt: nthGifAlt,
img: nthGif,
};
}
}

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

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class FindChannelsModal {
readonly container: Locator;
readonly input;
readonly searchList;
constructor(container: Locator) {
this.container = container;
this.input = container.getByRole('combobox', {name: 'quick switch input'});
this.searchList = container.locator('.suggestion-list__item');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}

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

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
/**
* This is the generic confirm modal that is used in the app.
* It has optional cancel button, optional checkbox and confirm button along with title and message body.
* It can present in different parts of the app such as channel, system console, etc and hence its constructor
* should be able to accept the page object of the app and an optional id to uniquely identify the modal.
*/
export default class GenericConfirmModal {
readonly container: Locator;
readonly confirmButton: Locator;
readonly cancelButton: Locator;
constructor(container: Locator) {
this.container = container;
this.confirmButton = container.locator('#confirmModalButton');
this.cancelButton = container.locator('#cancelModalButton');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async confirm() {
await this.confirmButton.waitFor();
await this.confirmButton.click();
// Wait for the modal to disappear
await expect(this.container).not.toBeVisible();
}
async cancel() {
await this.cancelButton.waitFor();
await this.cancelButton.click();
// Wait for the modal to disappear
await expect(this.container).not.toBeVisible();
}
}

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

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class ChannelsHeader {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}

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

@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class MessagePriority {
readonly container: Locator;
readonly priorityIcon: Locator;
readonly priorityMenu: Locator;
readonly standardPriorityOption: Locator;
readonly priorityDialog: Locator;
readonly dialogHeader: Locator;
constructor(container: Locator) {
this.container = container;
// Formatting bar priority icon
this.priorityIcon = container.locator('#messagePriority');
// Priority menu that opens when clicking the icon
this.priorityMenu = container.locator('[role="menu"]');
// Standard priority option in the menu (id comes from webapp implementation)
this.standardPriorityOption = this.priorityMenu.locator('#menu-item-priority-standard');
// Priority dialog elements
this.priorityDialog = container.page().getByRole('menu');
this.dialogHeader = container.page().locator('h4.modal-title');
}
async clickPriorityIcon() {
await this.priorityIcon.waitFor({state: 'visible'});
await this.priorityIcon.click();
}
async verifyPriorityIconVisible() {
await this.priorityIcon.waitFor({state: 'visible'});
await expect(this.priorityIcon).toBeVisible();
}
async verifyStandardPrioritySelected() {
await expect(this.priorityMenu).toBeVisible();
await expect(this.standardPriorityOption).toHaveAttribute('aria-checked', 'true');
}
async verifyPriorityMenuVisible() {
await expect(this.priorityMenu).toBeVisible();
// Look for beta text in header
await expect(this.priorityMenu.locator('text=Message Priority')).toBeVisible();
}
async closePriorityMenu() {
await this.priorityMenu.press('Escape');
await expect(this.priorityMenu).not.toBeVisible();
}
async verifyNoPriorityLabel(postText: string) {
const post = this.container.locator(`text=${postText}`);
await expect(post).toBeVisible();
// Verify no priority label exists
const priorityLabel = post.locator('[data-testid="post-priority-label"]');
await expect(priorityLabel).toHaveCount(0);
}
async verifyPriorityDialog() {
await expect(this.priorityDialog).toBeVisible();
await expect(this.dialogHeader).toHaveText('Message priority');
}
async verifyStandardOptionSelected() {
const standardOption = this.priorityDialog.getByRole('menuitemradio', {name: 'Standard'});
await expect(standardOption).toBeVisible();
await expect(standardOption.locator('svg.StyledCheckIcon-dFKfoY')).toBeVisible();
}
}

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

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
import PostMenu from './post_menu';
import ThreadFooter from './thread_footer';
export default class ChannelsPost {
readonly container: Locator;
readonly body;
readonly profileIcon;
readonly removePostButton;
readonly postMenu;
readonly threadFooter;
constructor(container: Locator) {
this.container = container;
this.body = container.locator('.post__body');
this.profileIcon = container.locator('.profile-icon');
this.removePostButton = container.locator('.post__remove');
this.postMenu = new PostMenu(container.locator('.post-menu'));
this.threadFooter = new ThreadFooter(container.locator('.ThreadFooter'));
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Hover over the post. Can be used for post menu to appear.
*/
async hover() {
await this.container.hover();
}
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 this.profileIcon.getByAltText(`${username} profile image`);
}
/**
* Clicks on the deleted post's remove 'x' button.
* Also verifies that the post is a deleted post.
*/
async remove() {
// Verify the post is a deleted post
await expect(this.container).toContainText(/\(message deleted\)/);
// Hover over the post and click on the remove post button
await this.container.hover();
await this.removePostButton.waitFor();
await this.removePostButton.click();
}
/**
* `toContainText` verifies if the post contains the specified text.
* @param text Text to be verified in the post
*/
async toContainText(text: string) {
await expect(this.container).toContainText(text);
}
}

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

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
import {Locator, expect} from '@playwright/test';
import {duration} from '@/util';
import {assetPath} from '@/file';
import {waitUntil} from '@/test_action';
export default class ChannelsPostCreate {
readonly container: Locator;
readonly input;
readonly attachmentButton;
readonly emojiButton;
readonly sendMessageButton;
readonly scheduleDraftMessageButton;
readonly priorityButton;
readonly suggestionList;
readonly filePreview;
constructor(container: Locator, isRHS = false) {
this.container = container;
if (!isRHS) {
this.input = container.getByTestId('post_textbox');
} else {
this.input = container.getByTestId('reply_textbox');
}
this.attachmentButton = container.locator('#fileUploadButton');
this.emojiButton = container.getByLabel('select an emoji');
this.sendMessageButton = container.getByTestId('SendMessageButton');
this.scheduleDraftMessageButton = container.getByLabel('Schedule message');
this.priorityButton = container.getByLabel('Message priority');
this.suggestionList = container.getByTestId('suggestionList');
this.filePreview = container.locator('.file-preview__container');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await this.input.waitFor();
await expect(this.input).toBeVisible();
}
/**
* It just writes the message in the input and doesn't send it
* @param message : Message to be written in the input
*/
async writeMessage(message: string) {
await this.input.waitFor();
await expect(this.input).toBeVisible();
await this.input.fill(message);
}
/**
* Returns the value of the message input
*/
async getInputValue() {
await expect(this.input).toBeVisible();
return await this.input.inputValue();
}
/**
* Sends the message already written in the input
*/
async sendMessage() {
await expect(this.input).toBeVisible();
const messageInputValue = await this.getInputValue();
expect(messageInputValue).not.toBe('');
await expect(this.sendMessageButton).toBeVisible();
await expect(this.sendMessageButton).toBeEnabled();
await this.sendMessageButton.click();
}
/**
* Click on Scheduled Draft button to open options
*/
async clickOnScheduleDraftDropdownButton() {
await expect(this.input).toBeVisible();
await expect(this.scheduleDraftMessageButton).toBeVisible();
await expect(this.scheduleDraftMessageButton).toBeEnabled();
await this.scheduleDraftMessageButton.click();
}
/**
* Opens the message priority menu
*/
async openPriorityMenu() {
await expect(this.priorityButton).toBeVisible();
await expect(this.priorityButton).toBeEnabled();
await this.priorityButton.click();
}
/**
* Composes and sends a message
*/
async postMessage(message: string, files?: string[]) {
await this.writeMessage(message);
if (files) {
const filePaths = files.map((file) => path.join(assetPath, file));
this.container.page().once('filechooser', async (fileChooser) => {
await fileChooser.setFiles(filePaths);
});
// Click on the attachment button
await this.attachmentButton.click();
// Wait until the file preview is displayed
await this.waitUntilFilePreviewContains(files);
}
await this.sendMessage();
}
async openEmojiPicker() {
await expect(this.emojiButton).toBeVisible();
await this.emojiButton.click();
}
async waitUntilFilePreviewContains(files: string[], timeout = duration.ten_sec) {
await waitUntil(
async () => {
const previews = this.filePreview.locator('.file-preview');
const details = this.filePreview.locator('.post-image__details');
const [previewsCount, detailsCount] = await Promise.all([previews.count(), details.count()]);
return previewsCount === files.length && detailsCount === files.length;
},
{timeout},
);
}
}

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

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class PostDotMenu {
readonly container: Locator;
readonly replyMenuItem;
readonly forwardMenuItem;
readonly followMessageMenuItem;
readonly markAsUnreadMenuItem;
readonly remindMenuItem;
readonly saveMenuItem;
readonly removeFromSavedMenuItem;
readonly pinToChannelMenuItem;
readonly unpinFromChannelMenuItem;
readonly moveThreadMenuItem;
readonly copyLinkMenuItem;
readonly editMenuItem;
readonly copyTextMenuItem;
readonly deleteMenuItem;
constructor(container: Locator) {
this.container = container;
const getMenuItem = (hasText: string) => container.getByRole('menuitem').filter({hasText});
this.replyMenuItem = getMenuItem('Reply');
this.forwardMenuItem = getMenuItem('Forward');
this.followMessageMenuItem = getMenuItem('Follow message');
this.markAsUnreadMenuItem = getMenuItem('Mark as Unread');
this.remindMenuItem = getMenuItem('Remind');
this.saveMenuItem = getMenuItem('Save');
this.removeFromSavedMenuItem = getMenuItem('Remove from Saved');
this.pinToChannelMenuItem = getMenuItem('Pin to Channel');
this.unpinFromChannelMenuItem = getMenuItem('Unpin from Channel');
this.moveThreadMenuItem = getMenuItem('Move Thread');
this.copyLinkMenuItem = getMenuItem('Copy Link');
this.editMenuItem = getMenuItem('Edit');
this.copyTextMenuItem = getMenuItem('Copy Text');
this.deleteMenuItem = getMenuItem('Delete');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}

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

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
import {Locator, expect} from '@playwright/test';
import DeletePostConfirmationDialog from './delete_post_confirmation_dialog';
import RestorePostConfirmationDialog from './restore_post_confirmation_dialog';
import {assetPath} from '@/file';
export default class ChannelsPostEdit {
readonly container: Locator;
readonly input;
readonly attachmentButton;
readonly emojiButton;
readonly sendMessageButton;
readonly deleteConfirmationDialog;
readonly restorePostConfirmationDialog;
constructor(container: Locator) {
this.container = container;
this.input = container.getByTestId('edit_textbox');
this.attachmentButton = container.locator('#fileUploadButton');
this.emojiButton = container.getByLabel('select an emoji');
this.sendMessageButton = container.locator('.save');
this.deleteConfirmationDialog = new DeletePostConfirmationDialog(container.page().locator('#deletePostModal'));
this.restorePostConfirmationDialog = new RestorePostConfirmationDialog(
container.page().locator('#restorePostModal'),
);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await this.input.waitFor();
await expect(this.input).toBeVisible();
}
async toNotBeVisible() {
await expect(this.input).not.toBeVisible();
}
async writeMessage(message: string) {
await this.input.waitFor();
await expect(this.input).toBeVisible();
await this.input.clear();
await this.input.fill(message);
}
async addFiles(files: string[]) {
const filePaths = files.map((file) => path.join(assetPath, file));
this.container.page().once('filechooser', async (fileChooser) => {
await fileChooser.setFiles(filePaths);
});
await this.attachmentButton.click();
}
async removeFile(fileName: string) {
const files = await this.container.locator(`.file-preview`).all();
for (let i = 0; i < files.length; i++) {
const textContent = await files[i].textContent();
if (textContent?.includes(fileName)) {
const removeButton = files[i].locator('.icon-close');
await removeButton.click();
break;
}
}
}
async sendMessage() {
await this.input.scrollIntoViewIfNeeded();
await expect(this.sendMessageButton).toBeVisible();
await expect(this.sendMessageButton).toBeEnabled();
await this.sendMessageButton.click();
}
async postMessage(message: string) {
await this.writeMessage(message);
await this.sendMessage();
}
async toContainText(text: string) {
await expect(this.container).toContainText(text);
}
}

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

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class PostMenu {
readonly container: Locator;
readonly plusOneEmojiButton;
readonly grinningEmojiButton;
readonly whiteCheckMarkEmojiButton;
readonly addReactionButton;
readonly saveButton;
readonly replyButton;
readonly actionsButton;
readonly dotMenuButton;
constructor(container: Locator) {
this.container = container;
this.plusOneEmojiButton = container.getByRole('button', {name: '+1 emoji'});
this.grinningEmojiButton = container.getByRole('button', {name: 'grinning emoji'});
this.whiteCheckMarkEmojiButton = container.getByRole('button', {name: 'white check mark emoji'});
this.addReactionButton = container.getByRole('button', {name: 'add reaction'});
this.saveButton = container.getByRole('button', {name: 'save'});
this.actionsButton = container.getByRole('button', {name: 'actions'});
this.replyButton = container.getByRole('button', {name: 'reply'});
this.dotMenuButton = container.getByRole('button', {name: 'more'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Clicks on the reply button from the post menu.
*/
async reply() {
await this.replyButton.waitFor();
await this.replyButton.click();
}
/**
* Clicks on the dot menu button from the post menu.
*/
async openDotMenu() {
await this.dotMenuButton.waitFor();
await this.dotMenuButton.click();
}
/**
* Clicks on dot menu button.
*/
async clickOnDotMenu() {
await this.dotMenuButton.click();
}
}

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

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class PostReminderMenu {
readonly container: Locator;
readonly thirtyMinsMenuItem;
readonly oneHourMenuItem;
readonly twoHoursMenuItem;
readonly tomorrowMenuItem;
readonly customMenuItem;
constructor(container: Locator) {
this.container = container;
const getMenuItem = (hasText: string) => container.getByRole('menuitem').filter({hasText});
this.thirtyMinsMenuItem = getMenuItem('30 mins');
this.oneHourMenuItem = getMenuItem('1 hour');
this.twoHoursMenuItem = getMenuItem('2 hours');
this.tomorrowMenuItem = getMenuItem('Tomorrow');
this.customMenuItem = getMenuItem('Custom');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}

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

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class RestorePostConfirmationDialog {
readonly container: Locator;
readonly cancelButton;
readonly confirmButton;
constructor(container: Locator) {
this.container = container;
this.cancelButton = container.locator('button.btn.btn-tertiary');
this.confirmButton = container.locator('button.GenericModal__button.btn.btn-primary.confirm');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await expect(this.cancelButton).toBeVisible();
await expect(this.confirmButton).toBeVisible();
}
async notToBeVisible() {
await expect(this.container).not.toBeVisible();
}
async confirmRestore() {
await this.confirmButton.click();
}
}

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

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class ScheduledDraftMenu {
readonly container: Locator;
readonly scheduleDraftMessageCustomTimeOption;
constructor(container: Locator) {
this.container = container;
this.scheduleDraftMessageCustomTimeOption = container.getByText('Choose a custom time');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async selectCustomTime() {
await this.scheduleDraftMessageCustomTimeOption.click();
}
}

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

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class ScheduledDraftModal {
readonly container: Locator;
readonly confirmButton;
readonly dateInput;
readonly timeLocator;
readonly timeDropdownOptions;
constructor(container: Locator) {
this.container = container;
this.confirmButton = container.locator('button.confirm');
this.dateInput = container.locator('div.Input_wrapper');
this.timeLocator = container.locator('div.dateTime__input');
this.timeDropdownOptions = container.locator('ul.dropdown-menu .MenuItem');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
getDaySuffix(day: number): string {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1:
return 'st';
case 2:
return 'nd';
case 3:
return 'rd';
default:
return 'th';
}
}
dateLocator(day: number, month: string, dayOfWeek: string) {
const daySuffix = this.getDaySuffix(day);
return this.container.locator(`button[aria-label*='${day}${daySuffix} ${month} (${dayOfWeek})']`);
}
async selectDay(dayFromToday: number = 0) {
await this.dateInput.click();
const pacificDate = this.getPacificDate();
const originDate = new Date(pacificDate.getTime());
// If dayFromToday is provided, add days to the current date
if (dayFromToday) {
pacificDate.setDate(pacificDate.getDate() + dayFromToday);
}
const day = pacificDate.getDate();
const month = pacificDate.toLocaleString('default', {month: 'long'});
const dayOfWeek = pacificDate.toLocaleDateString('en-US', {weekday: 'long'});
const dl = this.dateLocator(day, month, dayOfWeek);
// If the date is not visible and the month has changed, click the next month button
if (!(await dl.isVisible()) && pacificDate.getMonth() !== originDate.getMonth()) {
this.container.locator('button[aria-label="Go to next month"]').click();
}
await dl.click();
}
async confirm() {
await this.confirmButton.isVisible();
await this.confirmButton.click();
}
/**
* Selecting the First time option from the dropdown for
* scheduled_post_job to send the drafts out
*/
async selectTime() {
await this.timeLocator.click();
const timeButton = this.timeDropdownOptions.nth(2);
await expect(timeButton).toBeVisible();
await timeButton.click();
}
getPacificDate(): Date {
const currentDate = new Date();
// Convert the current date to Pacific Time
const utcTime = currentDate.getTime() + currentDate.getTimezoneOffset() * 60000;
const pacificOffset = -7 * 60; // Pacific Daylight Time (UTC-07:00)
const pacificTime = new Date(utcTime + pacificOffset * 60000);
return pacificTime;
}
}

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

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class SearchPopover {
readonly container: Locator;
readonly messagesButton;
readonly filesButton;
readonly searchInput;
readonly searchBoxClose;
readonly selectedSuggestion;
readonly searchHints;
readonly clearButton;
constructor(container: Locator) {
this.container = container;
this.messagesButton = container.getByRole('button', {name: 'Messages'});
this.filesButton = container.getByRole('button', {name: 'Files'});
this.searchInput = container.getByLabel('Search messages');
this.searchBoxClose = container.getByTestId('searchBoxClose');
this.selectedSuggestion = container.locator('.suggestion--selected').locator('.suggestion-list__main');
this.searchHints = container.locator('#searchHints');
this.clearButton = container.locator('.input-clear-x');
}
// clearIfPossible clears the search input if the clear button is visible. Returns true if the clear button was clicked.
async clearIfPossible() {
if (await this.clearButton.isVisible()) {
await this.clearButton.click();
return true;
}
return false;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
getSelectedSuggestion() {
return this.searchHints.locator('.suggestion--selected');
}
}

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

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
type NotificationSettingsSection = 'keysWithHighlight' | 'keysWithNotification';
export default class NotificationsSettings {
readonly container: Locator;
readonly keysWithHighlightDesc: Locator;
constructor(container: Locator) {
this.container = container;
this.keysWithHighlightDesc = container.locator('#keywordsAndHighlightDesc');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async expandSection(section: NotificationSettingsSection) {
if (section === 'keysWithHighlight') {
await this.container.getByText('Keywords That Get Highlighted (without notifications)').click();
await this.verifySectionIsExpanded('keysWithHighlight');
}
}
async verifySectionIsExpanded(section: NotificationSettingsSection) {
await expect(this.container.locator(`#${section}Edit`)).not.toBeVisible();
if (section === 'keysWithHighlight') {
await expect(
this.container.getByText(
'Enter non case-sensitive keywords, press Tab or use commas to separate them:',
),
).toBeVisible();
await expect(
this.container.getByText(
'These keywords will be shown to you with a highlight when anyone sends a message that includes them.',
),
).toBeVisible();
}
}
async getKeywordsInput() {
await expect(this.container.locator('input')).toBeVisible();
return this.container.locator('input');
}
async save() {
await expect(this.container.getByText('Save')).toBeVisible();
await this.container.getByText('Save').click();
}
}

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

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
import NotificationsSettings from './notification_settings';
export default class SettingsModal {
readonly container: Locator;
readonly notificationsSettingsTab;
readonly notificationsSettings;
constructor(container: Locator) {
this.container = container;
this.notificationsSettingsTab = container.locator('#notificationsButton');
this.notificationsSettings = new NotificationsSettings(container.locator('#notificationsSettings'));
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async openNotificationsTab() {
await expect(this.notificationsSettingsTab).toBeVisible();
await this.notificationsSettingsTab.click();
await this.notificationsSettings.toBeVisible();
}
async closeModal() {
await this.container.getByLabel('Close').click();
await expect(this.container).not.toBeVisible();
}
}

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

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class ChannelsSidebarLeft {
readonly container: Locator;
readonly findChannelButton;
readonly scheduledDraftCountonLHS;
constructor(container: Locator) {
this.container = container;
this.findChannelButton = container.getByRole('button', {name: 'Find Channels'});
this.scheduledDraftCountonLHS = container.locator('span.scheduledPostBadge');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async assertNoPendingScheduledDraft() {
await expect(this.scheduledDraftCountonLHS).not.toBeVisible();
}
async assertscheduledDraftCountLHS(count: string) {
await expect(this.scheduledDraftCountonLHS).toBeVisible();
await expect(this.scheduledDraftCountonLHS).toHaveText(count);
}
/**
* Clicks on the sidebar channel link with the given name.
* It can be any sidebar item name including channels, direct messages, or group messages, threads, etc.
* @param channelName
*/
async goToItem(channelName: string) {
const channel = this.container.locator(`#sidebarItem_${channelName}`);
await channel.waitFor();
await channel.click();
}
/**
* Verifies 'Drafts' as a sidebar link exists in LHS.
*/
async draftsVisible() {
const draftSidebarLink = this.container.getByText('Drafts', {exact: true});
await draftSidebarLink.waitFor();
await expect(draftSidebarLink).toBeVisible();
}
/**
* Verifies 'Drafts' as a sidebar link does not exist in LHS.
*/
async draftsNotVisible() {
const channel = this.container.getByText('Drafts', {exact: true});
await expect(channel).not.toBeVisible();
}
}

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

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
import ChannelsPostCreate from './post_create';
import ChannelsPostEdit from './post_edit';
import ChannelsPost from './post';
export default class ChannelsSidebarRight {
readonly container: Locator;
readonly closeButton;
readonly postCreate;
readonly rhsPostBody;
readonly postBoxIndicator;
readonly scheduledDraftChannelInfoMessage;
readonly scheduledDraftSeeAllLink;
readonly scheduledDraftChannelInfoMessageText;
readonly editTextbox;
readonly postEdit;
readonly currentVersionEditedPosttext;
readonly restorePreviousPostVersionIcon;
constructor(container: Locator) {
this.container = container;
this.postBoxIndicator = container.locator('div.postBoxIndicator');
this.scheduledDraftChannelInfoMessage = container.locator('div.ScheduledPostIndicator span');
this.scheduledDraftSeeAllLink = container.locator('a:has-text("See all")');
this.scheduledDraftChannelInfoMessageText = container.locator('span:has-text("Message scheduled for")');
this.rhsPostBody = container.locator('.post-message__text');
this.postCreate = new ChannelsPostCreate(container.getByTestId('comment-create'), true);
this.closeButton = container.locator('.sidebar--right__close');
this.editTextbox = container.locator('#edit_textbox');
this.postEdit = new ChannelsPostEdit(container.locator('.post-edit__container'));
this.currentVersionEditedPosttext = (postID: any) => container.locator(`#rhsPostMessageText_${postID} p`);
this.restorePreviousPostVersionIcon = container.locator(
'button[aria-label="Select to restore an old message."]',
);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Returns the RHS post by post id
* @param postId Just the ID without the prefix
*/
async getPostById(postId: string) {
const post = this.container.locator(`[id="rhsPost_${postId}"]`);
await post.waitFor();
return new ChannelsPost(post);
}
/**
* Return the last post in the RHS
*/
async getLastPost() {
const post = this.container.getByTestId('rhsPostView').last();
await post.waitFor();
return new ChannelsPost(post);
}
async getFirstPost() {
const post = this.container.getByTestId('rhsPostView').first();
await post.waitFor();
return new ChannelsPost(post);
}
/**
* Closes the RHS
*/
async close() {
await this.closeButton.waitFor();
await this.closeButton.click();
await expect(this.container).not.toBeVisible();
}
async clickOnSeeAllscheduledDrafts() {
await this.scheduledDraftSeeAllLink.isVisible();
await this.scheduledDraftSeeAllLink.click();
}
async toContainText(text: string) {
await expect(this.container).toContainText(text);
}
async verifyCurrentVersionPostMessage(postID: string | null, postMessageContent: string) {
expect(await this.currentVersionEditedPosttext(postID).textContent()).toBe(postMessageContent);
}
async restorePreviousPostVersion() {
await this.restorePreviousPostVersionIcon.isVisible();
await this.restorePreviousPostVersionIcon.click();
}
}

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

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class ThreadFooter {
readonly container: Locator;
readonly replyButton: Locator;
constructor(container: Locator) {
this.container = container;
this.replyButton = container.locator('.ReplyButton');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Clicks on the reply button in the thread footer to open the thread in RHS.
*/
async reply() {
await this.replyButton.waitFor();
await this.replyButton.click();
}
}

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

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class UserProfilePopover {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async close() {
await this.container.getByLabel('Close user profile popover').click();
}
}

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

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class Footer {
readonly container: Locator;
readonly copyright;
readonly aboutLink;
readonly privacyPolicyLink;
readonly termsLink;
readonly helpLink;
constructor(container: Locator) {
this.container = container;
this.copyright = container.locator('.footer-copyright');
this.aboutLink = container.locator('text=About');
this.privacyPolicyLink = container.locator('text=Privacy Policy');
this.termsLink = container.locator('text=Terms');
this.helpLink = container.locator('text=Help');
}
async toBeVisible() {
await expect(this.copyright).toBeVisible();
}
}

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

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class GlobalHeader {
readonly container: Locator;
readonly productSwitchMenu;
readonly recentMentionsButton;
readonly settingsButton;
readonly searchBox;
constructor(container: Locator) {
this.container = container;
this.productSwitchMenu = container.getByRole('button', {name: 'Product switch menu'});
this.recentMentionsButton = container.getByRole('button', {name: 'Recent mentions'});
this.settingsButton = container.getByRole('button', {name: 'Settings'});
this.searchBox = container.locator('#searchFormContainer');
}
async toBeVisible(name: string) {
await expect(this.container.getByRole('heading', {name})).toBeVisible();
}
async switchProduct(name: string) {
await this.productSwitchMenu.click();
await this.container.getByRole('link', {name}).click();
}
async openSettings() {
await expect(this.settingsButton).toBeVisible();
await this.settingsButton.click();
}
async openRecentMentions() {
await expect(this.recentMentionsButton).toBeVisible();
await this.recentMentionsButton.click();
}
async openSearch() {
await expect(this.searchBox).toBeVisible();
await this.searchBox.click();
}
async closeSearch() {
await expect(this.searchBox).toBeVisible();
await this.searchBox.getByTestId('searchBoxClose').click();
}
}

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

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ChannelsHeader from './channels/header';
import ChannelsAppBar from './channels/app_bar';
import ChannelsPostCreate from './channels/post_create';
import ChannelsPost from './channels/post';
import ChannelsCenterView from './channels/center_view';
import ChannelsSidebarLeft from './channels/sidebar_left';
import ChannelsSidebarRight from './channels/sidebar_right';
import DeletePostModal from './channels/delete_post_modal';
import FindChannelsModal from './channels/find_channels_modal';
import SettingsModal from './channels/settings/settings_modal';
import Footer from './footer';
import GlobalHeader from './global_header';
import SearchPopover from './channels/search_popover';
import MainHeader from './main_header';
import PostDotMenu from './channels/post_dot_menu';
import PostReminderMenu from './channels/post_reminder_menu';
import PostMenu from './channels/post_menu';
import ThreadFooter from './channels/thread_footer';
import EmojiGifPicker from './channels/emoji_gif_picker';
import GenericConfirmModal from './channels/generic_confirm_modal';
import MessagePriority from './channels/message_priority';
import ScheduledDraftMenu from './channels/scheduled_draft_menu';
import ScheduledDraftModal from './channels/scheduled_draft_modal';
import UserProfilePopover from './channels/user_profile_popover';
import SystemConsoleSidebar from './system_console/sidebar';
import SystemConsoleNavbar from './system_console/navbar';
import SystemUsers from './system_console/sections/system_users/system_users';
import SystemUsersFilterPopover from './system_console/sections/system_users/filter_popover';
import SystemUsersFilterMenu from './system_console/sections/system_users/filter_menu';
import SystemUsersColumnToggleMenu from './system_console/sections/system_users/column_toggle_menu';
import ChannelsPostEdit from './channels/post_edit';
import DeletePostConfirmationDialog from './channels/delete_post_confirmation_dialog';
import RestorePostConfirmationDialog from './channels/restore_post_confirmation_dialog';
import SystemConsoleFeatureDiscovery from './system_console/sections/system_users/feature_discovery';
import SystemConsoleMobileSecurity from './system_console/sections/system_users/mobile_security';
const components = {
GlobalHeader,
SearchPopover,
ChannelsCenterView,
ChannelsSidebarLeft,
ChannelsSidebarRight,
ChannelsAppBar,
ChannelsHeader,
ChannelsPostCreate,
ChannelsPostEdit,
ChannelsPost,
FindChannelsModal,
DeletePostModal,
SettingsModal,
PostDotMenu,
PostMenu,
ThreadFooter,
Footer,
MainHeader,
PostReminderMenu,
EmojiGifPicker,
GenericConfirmModal,
ScheduledDraftMenu,
ScheduledDraftModal,
SystemConsoleSidebar,
SystemConsoleNavbar,
SystemUsers,
SystemUsersFilterPopover,
SystemUsersFilterMenu,
SystemUsersColumnToggleMenu,
SystemConsoleFeatureDiscovery,
SystemConsoleMobileSecurity,
MessagePriority,
UserProfilePopover,
DeletePostConfirmationDialog,
RestorePostConfirmationDialog,
};
export {
components,
GlobalHeader,
ChannelsCenterView,
ChannelsSidebarLeft,
ChannelsSidebarRight,
ChannelsAppBar,
ChannelsHeader,
ChannelsPostCreate,
ChannelsPostEdit,
ChannelsPost,
FindChannelsModal,
DeletePostModal,
PostDotMenu,
PostMenu,
ThreadFooter,
MessagePriority,
DeletePostConfirmationDialog,
};

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

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class MainHeader {
readonly container: Locator;
readonly logo;
readonly backButton;
constructor(container: Locator) {
this.container = container;
this.logo = container.locator('.header-logo-link');
this.backButton = container.getByTestId('back_button');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}

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

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class SystemConsoleNavbar {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}

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

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class SystemUsersColumnToggleMenu {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Return the locator for the menu item with the given name.
*/
async getMenuItem(menuItem: string) {
const menuItemLocator = this.container.getByRole('menuitemcheckbox').filter({hasText: menuItem});
await menuItemLocator.waitFor();
return menuItemLocator;
}
/**
* Returns the list of locators for all the menu items.
*/
async getAllMenuItems() {
const menuItemLocators = this.container.getByRole('menuitemcheckbox');
return menuItemLocators;
}
/**
* Pass in the item name to check/uncheck the menu item.
*/
async clickMenuItem(menuItem: string) {
const menuItemLocator = await this.getMenuItem(menuItem);
await menuItemLocator.click();
}
/**
* Close column toggle menu.
*/
async close() {
await this.container.press('Escape');
await expect(this.container).not.toBeVisible();
}
}

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

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
/**
* System Console -> Feature Discovery
*/
export default class FeatureDiscovery {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toHaveTitle(title: string) {
await expect(this.container.getByTestId('featureDiscovery_title')).toHaveText(title);
}
}

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

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
/**
* The dropdown menu which appears for both Role and Status filter.
*/
export default class SystemUsersFilterMenu {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Return the locator for the menu item with the given name.
*/
async getMenuItem(menuItem: string) {
const menuItemLocator = this.container.getByText(menuItem);
await menuItemLocator.waitFor();
return menuItemLocator;
}
/**
* Clicks on the menu item with the given name.
*/
async clickMenuItem(menuItem: string) {
const menuItemLocator = await this.getMenuItem(menuItem);
await menuItemLocator.click();
}
/**
* Close the menu.
*/
async close() {
await this.container.press('Escape');
}
}

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

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class SystemUsersFilterPopover {
readonly container: Locator;
readonly teamMenuInput: Locator;
readonly roleMenuButton: Locator;
readonly statusMenuButton: Locator;
readonly applyButton: Locator;
constructor(container: Locator) {
this.container = container;
this.teamMenuInput = this.container.locator('#asyncTeamSelectInput');
this.roleMenuButton = this.container.locator('#DropdownInput_filterRole');
this.statusMenuButton = this.container.locator('#DropdownInput_filterStatus');
this.applyButton = this.container.getByText('Apply');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await expect(this.applyButton).toBeVisible();
}
/**
* Save the filter settings.
*/
async save() {
await this.applyButton.click();
}
/**
* Allows to type in the team filter for searching.
*/
async searchInTeamMenu(teamDisplayName: string) {
expect(this.teamMenuInput).toBeVisible();
await this.teamMenuInput.fill(teamDisplayName);
}
/**
* Opens the role filter menu.
*/
async openRoleMenu() {
expect(this.roleMenuButton).toBeVisible();
await this.roleMenuButton.click();
}
/**
* Opens the status filter menu.
*/
async openStatusMenu() {
expect(this.statusMenuButton).toBeVisible();
await this.statusMenuButton.click();
}
/**
* Closes the filter popover.
*/
async close() {
await this.container.press('Escape');
await expect(this.container).not.toBeVisible();
}
}

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

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
/**
* System Console -> Environment -> Mobile Security
*/
export default class MobileSecurity {
readonly container: Locator;
readonly enableBiometricAuthenticationToggleTrue: Locator;
readonly enableBiometricAuthenticationToggleFalse: Locator;
readonly preventScreenCaptureToggleTrue: Locator;
readonly preventScreenCaptureToggleFalse: Locator;
readonly jailbreakProtectionToggleTrue: Locator;
readonly jailbreakProtectionToggleFalse: Locator;
readonly saveButton: Locator;
constructor(container: Locator) {
this.container = container;
this.enableBiometricAuthenticationToggleTrue = this.container.getByTestId(
'NativeAppSettings.MobileEnableBiometricstrue',
);
this.enableBiometricAuthenticationToggleFalse = this.container.getByTestId(
'NativeAppSettings.MobileEnableBiometricsfalse',
);
this.preventScreenCaptureToggleTrue = this.container.getByTestId(
'NativeAppSettings.MobilePreventScreenCapturetrue',
);
this.preventScreenCaptureToggleFalse = this.container.getByTestId(
'NativeAppSettings.MobilePreventScreenCapturefalse',
);
this.jailbreakProtectionToggleTrue = this.container.getByTestId(
'NativeAppSettings.MobileJailbreakProtectiontrue',
);
this.jailbreakProtectionToggleFalse = this.container.getByTestId(
'NativeAppSettings.MobileJailbreakProtectionfalse',
);
this.saveButton = this.container.getByRole('button', {name: 'Save'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async clickEnableBiometricAuthenticationToggleTrue() {
await this.enableBiometricAuthenticationToggleTrue.click();
}
async clickEnableBiometricAuthenticationToggleFalse() {
await this.enableBiometricAuthenticationToggleFalse.click();
}
async clickPreventScreenCaptureToggleTrue() {
await this.preventScreenCaptureToggleTrue.click();
}
async clickPreventScreenCaptureToggleFalse() {
await this.preventScreenCaptureToggleFalse.click();
}
async clickJailbreakProtectionToggleTrue() {
await this.jailbreakProtectionToggleTrue.click();
}
async clickJailbreakProtectionToggleFalse() {
await this.jailbreakProtectionToggleFalse.click();
}
async clickSaveButton() {
await this.saveButton.click();
}
}

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

@@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
/**
* System Console -> User Management -> Users
*/
export default class SystemUsers {
readonly container: Locator;
readonly searchInput: Locator;
readonly saveRoleChange: Locator;
readonly columnToggleMenuButton: Locator;
readonly dateRangeSelectorMenuButton: Locator;
readonly exportButton: Locator;
readonly filterPopoverButton: Locator;
readonly actionMenuButtons: Locator[];
readonly loadingSpinner: Locator;
constructor(container: Locator) {
this.container = container;
this.searchInput = this.container.getByLabel('Search users');
this.saveRoleChange = this.container.locator('button.btn-primary:has-text("Save")');
this.columnToggleMenuButton = this.container.locator('#systemUsersColumnTogglerMenuButton');
this.dateRangeSelectorMenuButton = this.container.locator('#systemUsersDateRangeSelectorMenuButton');
this.exportButton = this.container.getByText('Export');
this.filterPopoverButton = this.container.getByText(/Filters \(\d+\)/);
this.actionMenuButtons = Array.from(Array(10).keys()).map((index) =>
this.container.locator(`#actionMenuButton-systemUsersTable-${index}`),
);
this.loadingSpinner = this.container.getByText('Loading');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async isLoadingComplete() {
await expect(this.loadingSpinner).toHaveCount(0);
}
/**
* Returns the locator for the header of the given column.
*/
async getColumnHeader(columnName: string) {
const columnHeader = this.container.getByRole('columnheader').filter({hasText: columnName});
return columnHeader;
}
/**
* Checks if given column exists in the table. By searching for the column header.
*/
async doesColumnExist(columnName: string) {
const columnHeader = await this.getColumnHeader(columnName);
return await columnHeader.isVisible();
}
/**
* Clicks on the column header of the given column for sorting.
*/
async clickSortOnColumn(columnName: string) {
const columnHeader = await this.getColumnHeader(columnName);
await columnHeader.waitFor();
await columnHeader.click();
}
/**
* Return the locator for the given row number. If '0' is passed, it will return the header row.
*/
async getNthRow(rowNumber: number) {
const row = this.container.getByRole('row').nth(rowNumber);
await row.waitFor();
return row;
}
/**
* Opens the Filter popover
*/
async openFilterPopover() {
expect(this.filterPopoverButton).toBeVisible();
await this.filterPopoverButton.click();
}
/**
* Open the column toggle menu
*/
async openColumnToggleMenu() {
expect(this.columnToggleMenuButton).toBeVisible();
await this.columnToggleMenuButton.click();
}
/**
* Open the date range selector menu
*/
async openDateRangeSelectorMenu() {
expect(this.dateRangeSelectorMenuButton).toBeVisible();
await this.dateRangeSelectorMenuButton.click();
}
/**
* Enter the given search term in the search input
*/
async enterSearchText(searchText: string) {
expect(this.searchInput).toBeVisible();
await this.searchInput.fill(`${searchText}`);
await this.isLoadingComplete();
}
/**
* Searches and verifies that the row with given text is found
*/
async verifyRowWithTextIsFound(text: string) {
const foundUser = this.container.getByText(text);
await foundUser.waitFor();
await expect(foundUser).toBeVisible();
}
/**
* Searches and verifies that the row with given text is not found
*/
async verifyRowWithTextIsNotFound(text: string) {
const foundUser = this.container.getByText(text);
await expect(foundUser).not.toBeVisible();
}
}

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

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Locator, expect} from '@playwright/test';
export default class SystemConsoleSidebar {
readonly container: Locator;
readonly searchInput: Locator;
constructor(container: Locator) {
this.container = container;
this.searchInput = container.getByPlaceholder('Find settings');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await expect(this.searchInput).toBeVisible();
}
/**
* Clicks on the sidebar section link with the given name. Pass the exact name of the section.
* @param sectionName
*/
async goToItem(sectionName: string) {
const section = this.container.getByText(sectionName, {exact: true});
await section.waitFor();
await section.click();
}
/**
* Searches for the given item in the sidebar search input.
* @param itemName
*/
async searchForItem(itemName: string) {
await this.searchInput.fill(itemName);
}
}

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

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page} from '@playwright/test';
import {components} from '@/ui/components';
export default class ChannelsPage {
readonly channels = 'Channels';
readonly page: Page;
readonly globalHeader;
readonly searchPopover;
readonly centerView;
readonly scheduledDraftDropdown;
readonly scheduledDraftModal;
readonly sidebarLeft;
readonly sidebarRight;
readonly appBar;
readonly userProfilePopover;
readonly messagePriority;
readonly findChannelsModal;
readonly deletePostModal;
readonly settingsModal;
readonly postContainer;
readonly postDotMenu;
readonly postReminderMenu;
readonly emojiGifPickerPopup;
constructor(page: Page) {
this.page = page;
// The main areas of the app
this.globalHeader = new components.GlobalHeader(page.locator('#global-header'));
this.searchPopover = new components.SearchPopover(page.locator('#searchPopover'));
this.centerView = new components.ChannelsCenterView(page.getByTestId('channel_view'));
this.sidebarLeft = new components.ChannelsSidebarLeft(page.locator('#SidebarContainer'));
this.sidebarRight = new components.ChannelsSidebarRight(page.locator('#sidebar-right'));
this.appBar = new components.ChannelsAppBar(page.locator('.app-bar'));
this.messagePriority = new components.MessagePriority(page.locator('body'));
// Modals
this.findChannelsModal = new components.FindChannelsModal(page.getByRole('dialog', {name: 'Find Channels'}));
this.deletePostModal = new components.DeletePostModal(page.locator('#deletePostModal'));
this.settingsModal = new components.SettingsModal(page.getByRole('dialog', {name: 'Settings'}));
// Menus
this.postDotMenu = new components.PostDotMenu(page.getByRole('menu', {name: 'Post extra options'}));
this.postReminderMenu = new components.PostReminderMenu(page.getByRole('menu', {name: 'Set a reminder for:'}));
// Popovers
this.emojiGifPickerPopup = new components.EmojiGifPicker(page.locator('#emojiGifPicker'));
this.scheduledDraftDropdown = new components.ScheduledDraftMenu(page.locator('#dropdown_send_post_options'));
this.scheduledDraftModal = new components.ScheduledDraftModal(page.locator('div.modal-content'));
this.userProfilePopover = new components.UserProfilePopover(page.locator('.user-profile-popover'));
// Posts
this.postContainer = page.locator('div.post-message__text');
}
async toBeVisible() {
await this.centerView.toBeVisible();
}
async getLastPost() {
return this.postContainer.last();
}
async goto(teamName = '', channelName = '') {
let channelsUrl = '/';
if (teamName) {
channelsUrl += `${teamName}`;
if (channelName) {
const prefix = channelName.startsWith('@') ? '/messages' : '/channels';
channelsUrl += `${prefix}/${channelName}`;
}
}
await this.page.goto(channelsUrl);
}
/**
* `postMessage` posts a message in the current channel
* @param message Message to post
*/
async postMessage(message: string) {
await this.centerView.postCreate.postMessage(message);
}
}

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

@@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page, expect} from '@playwright/test';
export default class DraftPage {
readonly page: Page;
readonly badgeCountOnScheduledTab;
readonly confirmbutton;
readonly datePattern;
readonly deleteIcon;
readonly deleteIconToolTip;
readonly noscheduledDraftIcon;
readonly scheduleIcon;
readonly rescheduleIconToolTip;
readonly draftBody;
readonly scheduledDraftPageInfo;
readonly scheduledDraftPanel;
readonly scheduledDraftSendNowButton;
readonly scheduledDraftSendNowButtonToolTip;
constructor(page: Page) {
this.page = page;
this.draftBody = page.locator('div.post__body');
this.scheduleIcon = page.locator('#draft_icon-clock-send-outline_reschedule');
this.datePattern =
/(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/;
this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-0 div.drafts_tab_title span.MuiBadge-badge');
// this.scheduledDraftPageInfo = page.locator('span:has-text("Send on")');
this.scheduledDraftPageInfo = page.locator('.PanelHeader__info');
this.scheduledDraftPanel = (messageContent: string) =>
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
this.deleteIcon = page.locator('#draft_icon-trash-can-outline_delete');
this.deleteIconToolTip = page.locator('text=Delete scheduled post');
this.rescheduleIconToolTip = page.locator('text=Schedule draft');
this.noscheduledDraftIcon = page.locator('.no-results__wrapper');
this.scheduledDraftSendNowButton = page.locator('#draft_icon-send-outline_sendNow');
this.scheduledDraftSendNowButtonToolTip = page.locator('text=Send now');
this.confirmbutton = this.page.locator('button.btn-primary');
}
async goTo(teamName: string) {
await this.page.goto(`/${teamName}/drafts`);
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
await expect(this.page).toHaveURL(/.*drafts/);
}
async assertBadgeCountOnTab(badgeCount: string) {
await this.badgeCountOnScheduledTab.isVisible();
await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount);
}
async assertDraftBody(draftMessage: string) {
await expect(this.draftBody).toBeVisible();
await expect(this.draftBody).toHaveText(draftMessage);
}
async verifyOnHoverActionItems(messageContent: string) {
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.verifyDeleteIcon();
await this.verifyScheduleIcon(messageContent);
await this.verifySendNowIcon();
}
async verifyDeleteIcon() {
await this.deleteIcon.isVisible();
await this.deleteIcon.hover();
await expect(this.deleteIconToolTip).toBeVisible();
await expect(this.deleteIconToolTip).toHaveText('Delete scheduled post');
}
async verifyScheduleIcon(messageContent: string) {
await this.scheduledDraftPanel(messageContent).hover();
await expect(this.scheduleIcon).toBeVisible();
await this.scheduleIcon.hover();
await expect(this.rescheduleIconToolTip).toBeVisible();
await expect(this.rescheduleIconToolTip).toHaveText('Schedule draft');
}
async verifySendNowIcon() {
await this.scheduledDraftSendNowButton.isVisible();
await this.scheduledDraftSendNowButton.hover();
await expect(this.scheduledDraftSendNowButtonToolTip).toBeVisible();
await expect(this.scheduledDraftSendNowButtonToolTip).toHaveText('Send now');
}
async getTimeStampOfMessage(messageContent: string) {
await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded();
await this.scheduledDraftPanel(messageContent).isVisible();
return this.scheduledDraftPanel(messageContent).locator(this.scheduledDraftPageInfo).innerHTML();
}
async openScheduleModal(messageContent: string) {
await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded();
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.scheduleIcon.hover();
await expect(this.rescheduleIconToolTip).toBeVisible();
await expect(this.rescheduleIconToolTip).toHaveText('Schedule draft');
await this.scheduleIcon.click();
}
async deleteScheduledMessage(messageContent: string) {
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.verifyDeleteIcon();
await this.deleteIcon.click();
expect(await this.confirmbutton.textContent()).toEqual('Yes, delete');
await this.confirmbutton.click();
}
async sendScheduledMessage(messageContent: string) {
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.verifySendNowIcon();
await this.scheduledDraftSendNowButton.click();
expect(await this.confirmbutton.textContent()).toEqual('Yes, send now');
await this.confirmbutton.click();
}
}

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

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ChannelsPage from './channels';
import LandingLoginPage from './landing_login';
import LoginPage from './login';
import ResetPasswordPage from './reset_password';
import SignupPage from './signup';
import SystemConsolePage from './system_console';
import ScheduledDraftPage from './scheduled_draft';
import DraftPage from './drafts';
const pages = {
ChannelsPage,
LandingLoginPage,
LoginPage,
ResetPasswordPage,
SignupPage,
ScheduledDraftPage,
SystemConsolePage,
DraftPage,
};
export {
pages,
ChannelsPage,
LandingLoginPage,
LoginPage,
ResetPasswordPage,
SignupPage,
ScheduledDraftPage,
SystemConsolePage,
DraftPage,
};

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

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page, expect} from '@playwright/test';
export default class LandingLoginPage {
readonly page: Page;
readonly isMobile?: boolean;
readonly viewInAppButton;
readonly viewInDesktopAppButton;
readonly viewInBrowserButton;
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');
}
}

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

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page, expect} from '@playwright/test';
import {UserProfile} from '@mattermost/types/users';
import {components} from '@/ui/components';
export default class LoginPage {
readonly page: Page;
readonly title;
readonly subtitle;
readonly bodyCard;
readonly loginInput;
readonly loginPlaceholder;
readonly loginWithAdLdapPlaceholder;
readonly passwordInput;
readonly passwordToggleButton;
readonly signInButton;
readonly createAccountLink;
readonly forgotPasswordLink;
readonly userErrorLabel;
readonly fieldWithError;
readonly formContainer;
readonly header;
readonly footer;
constructor(page: Page) {
this.page = page;
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-content');
this.loginInput = page.locator('#input_loginId');
this.loginPlaceholder = page.locator(`[placeholder="Email or Username"]`);
this.loginWithAdLdapPlaceholder = page.locator(`[placeholder="Email, Username or AD/LDAP Username"]`);
this.passwordInput = page.locator('#input_password-input');
this.passwordToggleButton = page.getByRole('button', {name: 'Show or hide password'});
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');
this.header = new components.MainHeader(page.locator('.hfroute-header'));
this.footer = new components.Footer(page.locator('.hfroute-footer'));
}
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()]);
}
}

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

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page, expect} from '@playwright/test';
import {components} from '@/ui/components';
export default class ResetPasswordPage {
readonly page: Page;
readonly title;
readonly subtitle;
readonly emailInput;
readonly resetButton;
readonly formContainer;
readonly header;
readonly footer;
constructor(page: Page) {
this.page = page;
this.title = page.locator('h1:has-text("Password Reset")');
this.subtitle = page.locator('text=To reset your password, enter the email address you used to sign up');
this.emailInput = page.locator(`[placeholder="Email"]`);
this.resetButton = page.locator('#passwordResetButton');
this.formContainer = page.locator('.signup-team__container');
this.header = new components.MainHeader(page.locator('.signup-header'));
this.footer = new components.Footer(page.locator('#footer_section'));
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
await expect(this.title).toBeVisible();
await expect(this.subtitle).toBeVisible();
await expect(this.emailInput).toBeVisible();
await expect(this.resetButton).toBeVisible();
}
async goto() {
await this.page.goto('/reset_password');
}
async reset(email: string) {
await this.emailInput.fill(email);
await Promise.all([this.page.waitForNavigation(), this.resetButton.click()]);
}
}

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

@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page, expect} from '@playwright/test';
export default class ScheduledDraftPage {
readonly page: Page;
readonly badgeCountOnScheduledTab;
readonly confirmbutton;
readonly copyIcon;
readonly copyIconToolTip;
readonly datePattern;
readonly deleteIcon;
readonly deleteIconToolTip;
readonly noscheduledDraftIcon;
readonly rescheduleIcon;
readonly rescheduleIconToolTip;
readonly scheduledDraftBody;
readonly scheduledDraftPageInfo;
readonly scheduledDraftPanel;
readonly scheduledDraftSendNowButton;
readonly scheduledDraftSendNowButtonToolTip;
readonly editIcon;
readonly editBox;
readonly editorSaveButton;
constructor(page: Page) {
this.page = page;
this.datePattern =
/(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/;
this.scheduledDraftBody = page.locator('div.post__body');
this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-1 div.drafts_tab_title span.MuiBadge-badge');
this.scheduledDraftPageInfo = page.locator('.PanelHeader__info');
this.scheduledDraftPanel = (messageContent: string) =>
page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`);
this.deleteIcon = page.locator('#draft_icon-trash-can-outline_delete');
this.deleteIconToolTip = page.locator('text=Delete scheduled post');
this.copyIcon = page.locator('#draft_icon-content-copy_copy_text');
this.copyIconToolTip = page.locator('text=Copy text');
this.rescheduleIcon = page.locator('#draft_icon-clock-send-outline_reschedule');
this.rescheduleIconToolTip = page.locator('text=Reschedule post');
this.noscheduledDraftIcon = page.locator('.no-results__wrapper');
this.scheduledDraftSendNowButton = page.locator('#draft_icon-send-outline_sendNow');
this.scheduledDraftSendNowButtonToolTip = page.locator('text=Send now');
this.confirmbutton = this.page.locator('button.btn-primary');
this.editIcon = page.locator('#draft_icon-pencil-outline_edit');
this.editBox = page.locator('textarea#edit_textbox');
this.editorSaveButton = page.locator('button.save');
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
await expect(this.page).toHaveURL(/.*scheduled_posts/);
}
async assertBadgeCountOnTab(badgeCount: string) {
await this.badgeCountOnScheduledTab.isVisible();
await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount);
}
async assertscheduledDraftBody(draftMessage: string) {
await expect(this.scheduledDraftBody).toBeVisible();
await expect(this.scheduledDraftBody).toHaveText(draftMessage);
}
async verifyOnHoverActionItems(messageContent: string) {
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.verifyDeleteIcon();
await this.verifyCopyIcon();
await this.verifyRescheduleIcon();
await this.verifySendNowIcon();
}
async verifyDeleteIcon() {
await this.deleteIcon.isVisible();
await this.deleteIcon.hover();
await expect(this.deleteIconToolTip).toBeVisible();
await expect(this.deleteIconToolTip).toHaveText('Delete scheduled post');
}
async verifyCopyIcon() {
await this.copyIcon.isVisible();
await this.copyIcon.hover();
await expect(this.copyIconToolTip).toBeVisible();
await expect(this.copyIconToolTip).toHaveText('Copy text');
}
async verifyRescheduleIcon() {
await expect(this.rescheduleIcon).toBeVisible();
await this.rescheduleIcon.hover();
await expect(this.rescheduleIconToolTip).toBeVisible();
await expect(this.rescheduleIconToolTip).toHaveText('Reschedule post');
}
async verifySendNowIcon() {
await this.scheduledDraftSendNowButton.isVisible();
await this.scheduledDraftSendNowButton.hover();
await expect(this.scheduledDraftSendNowButtonToolTip).toBeVisible();
await expect(this.scheduledDraftSendNowButtonToolTip).toHaveText('Send now');
}
async getTimeStampOfMessage(messageContent: string) {
await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded();
await this.scheduledDraftPanel(messageContent).isVisible();
return this.scheduledDraftPanel(messageContent).locator(this.scheduledDraftPageInfo).innerHTML();
}
async openRescheduleModal(messageContent: string) {
await this.scheduledDraftPanel(messageContent).scrollIntoViewIfNeeded();
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.rescheduleIcon.hover();
await expect(this.rescheduleIconToolTip).toBeVisible();
await expect(this.rescheduleIconToolTip).toHaveText('Reschedule post');
await this.rescheduleIcon.click();
}
async deleteScheduledMessage(messageContent: string) {
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.verifyDeleteIcon();
await this.deleteIcon.click();
expect(await this.confirmbutton.textContent()).toEqual('Yes, delete');
await this.confirmbutton.click();
}
async sendScheduledMessage(messageContent: string) {
await this.scheduledDraftPanel(messageContent).isVisible();
await this.scheduledDraftPanel(messageContent).hover();
await this.verifySendNowIcon();
await this.scheduledDraftSendNowButton.click();
expect(await this.confirmbutton.textContent()).toEqual('Yes, send now');
await this.confirmbutton.click();
}
async goTo(teamName: string) {
await this.page.goto(`/${teamName}/scheduled_posts`);
}
async editText(newText: string) {
await this.editIcon.click();
await this.editBox.isVisible();
await this.editBox.fill(newText);
await this.editorSaveButton.isVisible();
await this.editorSaveButton.click();
await this.editBox.isHidden();
await this.scheduledDraftPanel(newText).isVisible();
}
async copyScheduledMessage(draftMessage: string) {
await this.scheduledDraftPanel(draftMessage).isVisible();
await this.scheduledDraftPanel(draftMessage).hover();
await this.verifyCopyIcon();
await this.copyIcon.click();
}
}

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

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page, expect} from '@playwright/test';
import {duration, wait} from '@/util';
import {components} from '@/ui/components';
export default class SignupPage {
readonly page: Page;
readonly title;
readonly subtitle;
readonly bodyCard;
readonly emailInput;
readonly usernameInput;
readonly passwordInput;
readonly passwordToggleButton;
readonly newsLetterCheckBox;
readonly newsLetterPrivacyPolicyLink;
readonly newsLetterUnsubscribeLink;
readonly agreementTermsOfUseLink;
readonly agreementPrivacyPolicyLink;
readonly createAccountButton;
readonly loginLink;
readonly emailError;
readonly usernameError;
readonly passwordError;
readonly header;
readonly footer;
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-content');
this.loginLink = page.locator('text=Log in');
this.emailInput = page.locator('#input_email');
this.usernameInput = page.locator('#input_name');
this.passwordInput = page.locator('#input_password-input');
this.passwordToggleButton = page.getByRole('button', {name: 'Show or hide password'});
this.createAccountButton = page.locator('button:has-text("Create Account")');
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-72 characters long.');
const newsletterBlock = page.locator('.check-input');
this.newsLetterCheckBox = newsletterBlock.getByRole('checkbox', {name: 'newsletter checkbox'});
this.newsLetterPrivacyPolicyLink = newsletterBlock.locator('text=Privacy Policy');
this.newsLetterUnsubscribeLink = newsletterBlock.locator('text=unsubscribe');
const agreementBlock = page.locator('.signup-body-card-agreement');
this.agreementTermsOfUseLink = agreementBlock.locator('text=Terms of Use');
this.agreementPrivacyPolicyLink = agreementBlock.locator('text=Privacy Policy');
this.header = new components.MainHeader(page.locator('.hfroute-header'));
this.footer = new components.Footer(page.locator('.hfroute-footer'));
}
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_user_complete');
}
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();
}
}
}

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

@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Page} from '@playwright/test';
import {components} from '@/ui/components';
export default class SystemConsolePage {
readonly page: Page;
readonly sidebar;
readonly navbar;
/**
* System Console -> User Management -> Users
*/
readonly systemUsers;
readonly systemUsersFilterPopover;
readonly systemUsersRoleMenu;
readonly systemUsersStatusMenu;
readonly systemUsersDateRangeMenu;
readonly systemUsersColumnToggleMenu;
readonly systemUsersActionMenus;
readonly mobileSecurity;
readonly featureDiscovery;
// modal
readonly confirmModal;
readonly exportModal;
readonly saveChangesModal;
constructor(page: Page) {
this.page = page;
// Areas of the page
this.navbar = new components.SystemConsoleNavbar(page.locator('.backstage-navbar'));
this.sidebar = new components.SystemConsoleSidebar(page.locator('.admin-sidebar'));
// Sections and sub-sections
this.systemUsers = new components.SystemUsers(page.getByTestId('systemUsersSection'));
this.mobileSecurity = new components.SystemConsoleMobileSecurity(
page.getByTestId('sysconsole_section_MobileSecuritySettings'),
);
this.featureDiscovery = new components.SystemConsoleFeatureDiscovery(page.getByTestId('featureDiscovery'));
// Menus & Popovers
this.systemUsersFilterPopover = new components.SystemUsersFilterPopover(
page.locator('#systemUsersFilterPopover'),
);
this.systemUsersRoleMenu = new components.SystemUsersFilterMenu(page.locator('#DropdownInput_filterRole'));
this.systemUsersStatusMenu = new components.SystemUsersFilterMenu(page.locator('#DropdownInput_filterStatus'));
this.systemUsersColumnToggleMenu = new components.SystemUsersColumnToggleMenu(
page.locator('#systemUsersColumnTogglerMenu'),
);
this.systemUsersDateRangeMenu = new components.SystemUsersFilterMenu(
page.locator('#systemUsersDateRangeSelectorMenu'),
);
this.systemUsersActionMenus = Array.from(Array(10).keys()).map(
(index) => new components.SystemUsersFilterMenu(page.locator(`#actionMenu-systemUsersTable-${index}`)),
);
this.confirmModal = new components.GenericConfirmModal(page.locator('#confirmModal'));
this.exportModal = new components.GenericConfirmModal(page.getByRole('dialog', {name: 'Export user data'}));
this.saveChangesModal = new components.SystemUsers(page.locator('div.modal-content'));
}
async toBeVisible() {
await this.page.waitForLoadState('networkidle');
await this.sidebar.toBeVisible();
await this.navbar.toBeVisible();
}
async goto() {
await this.page.goto('/admin_console');
}
async saveRoleChange() {
await this.saveChangesModal.container.locator('button.btn-primary:has-text("Save")').click();
}
async clickResetButton() {
await this.saveChangesModal.container.locator('button.btn-primary:has-text("Reset")').click();
}
}

48
e2e-tests/playwright/lib/src/util.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {v4 as uuidv4} from 'uuid';
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 const simpleEmailRe = /\S+@\S+\.\S+/;

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

@@ -0,0 +1,45 @@
// 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 {TestInfo, expect} from '@playwright/test';
import snapshotWithPercy from './percy';
import {duration, illegalRe, wait} from '@/util';
import {testConfig} from '@/test_config';
import {ScreenshotOptions, TestArgs} from '@/types';
export async function matchSnapshot(testInfo: TestInfo, testArgs: TestArgs, options: ScreenshotOptions = {}) {
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 || testConfig.percyEnabled) {
await testArgs.page.waitForLoadState('networkidle');
await testArgs.page.waitForLoadState('domcontentloaded');
await wait(duration.half_sec);
}
if (testConfig.snapshotEnabled) {
// Visual test with built-in snapshot
const filename = testInfo.title.trim().replace(illegalRe, '').replace(/\s/g, '-').trim().toLowerCase();
await expect(testArgs.page).toHaveScreenshot(`${filename}.png`, {fullPage: true, ...options});
}
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);
}
}

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

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import percySnapshot from '@percy/playwright';
import {testConfig} from '@/test_config';
import {TestArgs} from '@/types';
export default async function snapshotWithPercy(name: string, testArgs: TestArgs) {
if (testArgs.browserName === 'chromium' && testConfig.percyEnabled && testArgs.viewport) {
const {page, viewport} = testArgs;
try {
await percySnapshot(page, name, {widths: [viewport.width], minHeight: viewport.height});
} catch (error) {
// log an error for debugging
// eslint-disable-next-line no-console
console.log(`${error}\nIn addition, check if token is properly set by "export PERCY_TOKEN=<change_me>"`);
}
}
}