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
This commit is contained in:
Saturnino Abril 2025-04-01 08:52:56 +08:00 committed by GitHub
parent ce9632cca3
commit a47269cfe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
163 changed files with 7203 additions and 5048 deletions

View file

@ -70,7 +70,7 @@ jobs:
SERVER: "${{ steps.generate.outputs.SERVER }}"
ENABLED_DOCKER_SERVICES: "${{ steps.generate.outputs.ENABLED_DOCKER_SERVICES }}"
TEST_FILTER_CYPRESS: "${{ steps.generate.outputs.TEST_FILTER_CYPRESS }}"
TEST_FILTER_PLAYWRIGHT: "tests/ --project=chrome" # Note: Run on chrome but eventually will enable to all projects which include firefox and ipad.
TEST_FILTER_PLAYWRIGHT: "tests"
BUILD_ID: "${{ steps.generate.outputs.BUILD_ID }}"
TM4J_ENABLE: "${{ steps.generate.outputs.TM4J_ENABLE }}"
REPORT_TYPE: "${{ steps.generate.outputs.REPORT_TYPE }}"

View file

@ -98,7 +98,7 @@ case "${TEST:-$TEST_DEFAULT}" in
cypress )
export TEST_FILTER_DEFAULT='--stage=@prod --group=@smoke' ;;
playwright )
export TEST_FILTER_DEFAULT='tests/functional/system_console/system_users/actions.spec.ts --project=chrome' ;;
export TEST_FILTER_DEFAULT='functional/system_console/system_users/actions.spec.ts' ;;
* )
export TEST_FILTER_DEFAULT='' ;;
esac

View file

@ -105,7 +105,7 @@ case "$TEST" in
if [ -n "$WEBHOOK_URL" ]; then
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm i
# Utilize environment data and report files to generate the webhook body
./report.webhookgen.js | curl -X POST -fsSL -H 'Content-Type: application/json' -d @- "$WEBHOOK_URL"
node report.webhookgen.js | curl -X POST -fsSL -H 'Content-Type: application/json' -d @- "$WEBHOOK_URL"
fi
;;
esac

View file

@ -276,7 +276,7 @@ $(if mme2e_is_token_in_list "webhook-interactions" "$ENABLED_DOCKER_SERVICES"; t
$(if mme2e_is_token_in_list "playwright" "$ENABLED_DOCKER_SERVICES"; then
echo '
playwright:
image: mcr.microsoft.com/playwright:v1.49.1-noble
image: mcr.microsoft.com/playwright:v1.51.1-noble
entrypoint: ["/bin/bash", "-c"]
command: ["until [ -f /var/run/mm_terminate ]; do sleep 5; done"]
env_file:

View file

@ -37,13 +37,18 @@ EOF
# Run Playwright test
# NB: do not exit the script if some testcases fail
${MME2E_DC_SERVER} exec -i -u "$MME2E_UID" -- playwright bash -c "cd e2e-tests/playwright && npm run test -- ${TEST_FILTER}" | tee ../playwright/logs/playwright.log || true
${MME2E_DC_SERVER} exec -i -u "$MME2E_UID" -- playwright bash -c "cd e2e-tests/playwright && npm run test:ci -- ${TEST_FILTER}" | tee ../playwright/logs/playwright.log || true
# Collect run results
# Documentation on the results.json file: https://playwright.dev/docs/api/class-testcase#test-case-expected-status
# NB: the following line is needed only for compatibility reasons, to support RollingRelease tests for versions prior to v10.1.0
# It can be removed after releases <=v10.0.x are phased out
mv -v ../playwright/playwright-report/results.json ../playwright/results/reporter/results.json 2>/dev/null || true
# NB: the following line is needed only for compatibility reasons, to support RollingRelease tests for versions prior to v10.6.0
# It can be removed once 10.5 (ESR) is no longer supported
mv -v ../playwright/test/results/ ../playwright/ 2>/dev/null || true
jq -f /dev/stdin ../playwright/results/reporter/results.json > ../playwright/results/summary.json <<EOF
{
passed: .stats.expected,

16
e2e-tests/playwright/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# playwright tests
test/logs
test/node_modules
test/playwright-report
test/test-results
test/results
test/storage_state
test/specs-results
test/specs/**/*-darwin.png
test/specs/**/*-window.png
test/specs/accessibility/**/*-snapshots
test/.eslintcache
# build
dist
*.tsbuildinfo

View file

@ -5,6 +5,7 @@ import path from 'node:path';
import {fileURLToPath} from 'node:url';
import js from '@eslint/js';
import {FlatCompat} from '@eslint/eslintrc';
import eslintPluginHeader from 'eslint-plugin-header';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -14,18 +15,23 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
});
eslintPluginHeader.rules.header.meta.schema = false;
export default [
{
ignores: ['**/node_modules', '**/playwright-report', '**/test-results', '**/results'],
ignores: ['**/node_modules', '**/dist', '**/playwright-report', '**/test-results', '**/results'],
},
...compat.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended').map((config) => ({
...config,
files: ['**/*.ts', '**/*.s'],
})),
...compat
.extends('eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:import/recommended')
.map((config) => ({
...config,
files: ['**/*.ts', '**/*.js'],
})),
{
files: ['**/*.ts', '**/*.s'],
files: ['**/*.ts', '**/*.js'],
plugins: {
'@typescript-eslint': typescriptEslint,
header: eslintPluginHeader,
},
languageOptions: {
globals: {
@ -35,11 +41,31 @@ export default [
ecmaVersion: 5,
sourceType: 'module',
},
settings: {
'import/resolver': {
typescript: true,
node: true,
},
},
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'no-console': 'error',
'header/header': [
'error',
'line',
' Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.\n See LICENSE.txt for license information.',
2,
],
'import/order': [
'error',
{
'newlines-between': 'always',
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
},
],
'import/no-unresolved': 'off',
},
},
];

View file

@ -0,0 +1,214 @@
# @mattermost/playwright-lib
A comprehensive end-to-end testing library for Mattermost web, desktop and plugin applications using Playwright.
## Overview
This library provides:
- Pre-built page objects and components for common Mattermost UI elements
- Server configuration and initialization utilities
- Test fixtures and helpers
- Visual testing support with Percy integration
- Accessibility testing support with [axe-core](https://github.com/dequelabs/axe-core)
- Browser notification mocking
- File handling utilities
- Common test actions and assertions
## Installation
```bash
npm install @mattermost/playwright-lib
```
## Usage
Basic example of logging in and posting a message:
```typescript
import {test, expect} from '@mattermost/playwright-lib';
test('user can post message', async ({pw}) => {
// # Create and login a new user
const {user} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(user);
// # Navigate and post a message
await channelsPage.goto();
const message = 'Hello World!';
await channelsPage.postMessage(message);
// * Verify message appears
const lastPost = await channelsPage.getLastPost();
await expect(lastPost).toHaveText(message);
});
```
## Key Components
### Page Objects
Ready-to-use page objects for common Mattermost pages:
- Login
- Signup
- Channels
- System Console
- And more...
### UI Components
Reusable component objects for UI elements:
- Headers
- Posts
- Menus
- Modals
- And more...
### Test Utilities
Helper functions for common testing needs:
- Server setup and configuration
- User/team creation
- File handling
- Visual testing
- And more...
## Configuration
The library can be configured via optional environment variables:
### Environment Variables
All environment variables are optional with sensible defaults.
#### Server Configuration
| Variable | Description | Default |
| ----------------------------- | ------------------------------------------ | -------------------------------- |
| `PW_BASE_URL` | Server URL | `http://localhost:8065` |
| `PW_ADMIN_USERNAME` | Admin username | `sysadmin` |
| `PW_ADMIN_PASSWORD` | Admin password | `Sys@dmin-sample1` |
| `PW_ADMIN_EMAIL` | Admin email | `sysadmin@sample.mattermost.com` |
| `PW_ENSURE_PLUGINS_INSTALLED` | Comma-separated list of plugins to install | `[]` |
| `PW_RESET_BEFORE_TEST` | Reset server before test | `false` |
#### High Availability Cluster Settings
| Variable | Description | Default |
| -------------------------- | ----------------------- | ---------------- |
| `PW_HA_CLUSTER_ENABLED` | Enable HA cluster | `false` |
| `PW_HA_CLUSTER_NODE_COUNT` | Number of cluster nodes | `2` |
| `PW_HA_CLUSTER_NAME` | Cluster name | `mm_dev_cluster` |
#### Push Notifications
| Variable | Description | Default |
| ----------------------------- | ---------------------------- | ---------------------------------- |
| `PW_PUSH_NOTIFICATION_SERVER` | Push notification server URL | `https://push-test.mattermost.com` |
#### Playwright Settings
| Variable | Description | Default |
| ------------- | ------------------------------- | ------- |
| `PW_HEADLESS` | Run tests headless | `true` |
| `PW_SLOWMO` | Add delay between actions in ms | `0` |
| `PW_WORKERS` | Number of parallel workers | `1` |
#### Visual Testing
| Variable | Description | Default |
| -------------------- | --------------------------- | ------- |
| `PW_SNAPSHOT_ENABLE` | Enable snapshot testing | `false` |
| `PW_PERCY_ENABLE` | Enable Percy visual testing | `false` |
#### CI Settings
| Variable | Description | Default |
| -------- | ------------------------------------ | ------- |
| `CI` | Set automatically in CI environments | N/A |
## Accessibility Testing
The library includes built-in accessibility testing using [axe-core](https://github.com/dequelabs/axe-core):
```typescript
import {test, expect} from '@mattermost/playwright-lib';
test('verify login page accessibility', async ({page, axe}) => {
// # Navigate to login page
await page.goto('/login');
// # Run accessibility scan
const results = await axe.builder(page).analyze();
// * Verify no accessibility violations
expect(results.violations).toHaveLength(0);
});
```
The axe-core integration:
- Runs WCAG 2.0 Level A & AA rules by default
- Provides detailed violation reports
- Supports rule customization
- Can be configured per-test or globally
## Visual Testing
The library supports visual testing through [Playwright's built-in visual comparisons](https://playwright.dev/docs/test-snapshots) and [Percy](https://www.browserstack.com/percy) integration:
```typescript
import {test, expect} from '@mattermost/playwright-lib';
test('verify channel header appearance', async ({pw, browserName, viewport}, testInfo) => {
// # Setup and login
const {user} = await pw.initSetup();
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Navigate and prepare page
await channelsPage.goto();
await expect(channelsPage.appBar.playbooksIcon).toBeVisible();
await pw.hideDynamicChannelsContent(page);
// * Take and verify snapshot
await pw.matchSnapshot(testInfo, {page, browserName, viewport});
});
```
## Browser Notifications
Mock and verify browser notifications:
```typescript
import {test, expect} from '@mattermost/playwright-lib';
test('verify notification on mention', async ({pw}) => {
// # Setup users and team
const {team, adminUser, user} = await pw.initSetup();
// # Setup admin browser with notifications
const {page: adminPage, channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, 'town-square');
await pw.stubNotification(adminPage, 'granted');
// # Setup user browser and post mention
const {channelsPage: userChannelsPage} = await pw.testBrowser.login(user);
await userChannelsPage.goto(team.name, 'off-topic');
await userChannelsPage.postMessage(`@ALL good morning, ${team.name}!`);
// * Verify notification received
const notifications = await pw.waitForNotification(adminPage);
expect(notifications.length).toBe(1);
});
```
## Contributing
See [CONTRIBUTING.md](https://github.com/mattermost/mattermost/blob/master/CONTRIBUTING.md) for development setup and guidelines.
## License
See [LICENSE.txt](https://github.com/mattermost/mattermost/blob/master/LICENSE.txt) for license information.

View file

@ -0,0 +1,68 @@
{
"name": "@mattermost/playwright-lib",
"version": "10.6.1",
"description": "A comprehensive end-to-end testing library for Mattermost web, desktop and plugin applications using Playwright",
"repository": {
"type": "git",
"url": "git+https://github.com/mattermost/mattermost.git"
},
"author": "mattermost",
"license": "MIT",
"bugs": {
"url": "https://github.com/mattermost/mattermost/issues"
},
"homepage": "https://github.com/mattermost/mattermost/tree/master/e2e-tests/playwright/lib#readme",
"type": "commonjs",
"files": [
"dist"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"keywords": [
"mattermost",
"e2e",
"playwright",
"test-automation"
],
"scripts": {
"build": "rollup -c --bundleConfigAsCjs",
"build:watch": "npm run build -- --watch",
"build-tsc": "tsc --build --verbose",
"build-tsc:watch": "tsc --watch --preserveWatchOutput",
"clean": "rm -rf dist node_modules *.tsbuildinfo",
"tsc": "tsc -b"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@axe-core/playwright": "4.10.1",
"@mattermost/client": "file:../../../webapp/platform/client",
"@mattermost/types": "file:../../../webapp/platform/types",
"@percy/cli": "1.30.7",
"@percy/playwright": "1.0.7",
"async-wait-until": "2.0.23",
"axe-core": "4.10.3",
"deepmerge": "4.3.1",
"dotenv": "16.4.7",
"mime-types": "3.0.1",
"uuid": "11.1.0"
},
"devDependencies": {
"@rollup/plugin-typescript": "12.1.2",
"@types/mime-types": "2.1.4",
"@types/node": "22.13.14",
"@types/react": "19.0.12",
"rollup": "4.38.0",
"rollup-plugin-copy": "3.5.0"
},
"peerDependencies": {
"@playwright/test": "1.51.1"
}
}

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import typescript from '@rollup/plugin-typescript';
import copy from 'rollup-plugin-copy';
export default {
input: 'src/index.ts',
output: [
{
dir: 'dist',
format: 'cjs', // CommonJS for Playwright
sourcemap: true,
preserveModules: true, // Keep file structure
preserveModulesRoot: 'src',
},
],
plugins: [
typescript(),
copy({
targets: [{src: 'src/asset/**/*', dest: 'dist/asset'}], // Copy assets to dist/
}),
],
external: [
'@playwright/test',
'@mattermost/client',
'@mattermost/types/config',
'@axe-core/playwright',
'@percy/playwright',
'dotenv',
'node:fs/promises',
'node:path',
'node:fs',
'node:os',
'mime-types',
'uuid',
'async-wait-until',
'chalk',
'deepmerge',
],
};

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -3,11 +3,11 @@
import {writeFile} from 'node:fs/promises';
import {request, Browser, BrowserContext} from '@playwright/test';
import {Browser, BrowserContext, request} from '@playwright/test';
import {UserProfile} from '@mattermost/types/users';
import testConfig from '@e2e-test.config';
import pages from '@e2e-support/ui/pages';
import {testConfig} from './test_config';
import {pages} from './ui/pages';
export class TestBrowser {
readonly browser: Browser;

View file

@ -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);
}

View file

@ -3,9 +3,8 @@
import os from 'node:os';
import {expect} from '@playwright/test';
import {expect, test} from '@playwright/test';
import {test} from './test_fixture';
import {callsPluginId} from './constant';
import {getAdminClient} from './server/init';
@ -56,12 +55,17 @@ export async function ensureLicense() {
export async function requestTrialLicense() {
const {adminClient} = await getAdminClient();
const admin = await adminClient.getMe();
try {
// @ts-expect-error This may fail requesting for trial license
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();

View file

@ -2,21 +2,22 @@
// 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 {Client, createRandomTeam, getAdminClient, getDefaultAdminUser, makeClient} from './support/server';
import {defaultTeam} from './support/util';
import testConfig from './test.config';
import {defaultTeam} from './util';
import {createRandomTeam, getAdminClient, getDefaultAdminUser, makeClient} from './server';
import {testConfig} from './test_config';
async function globalSetup() {
let adminClient: Client;
export async function baseGlobalSetup() {
let adminClient: Client4;
let adminUser: UserProfile | null;
({adminClient, adminUser} = await getAdminClient({skipLog: true}));
if (!adminUser) {
const firstClient = new Client();
const firstClient = new Client4();
firstClient.setUrl(testConfig.baseURL);
const defaultAdmin = getDefaultAdminUser();
await firstClient.createUser(defaultAdmin, '', '');
@ -25,13 +26,9 @@ async function globalSetup() {
}
await sysadminSetup(adminClient, adminUser);
return function () {
// placeholder for teardown setup
};
}
async function sysadminSetup(client: Client, user: UserProfile | null) {
async function sysadminSetup(client: Client4, user: UserProfile | null) {
// Ensure admin's email is verified.
if (!user) {
await client.verifyUserEmail(client.token);
@ -79,7 +76,7 @@ async function sysadminSetup(client: Client, user: UserProfile | null) {
await ensureServerDeployment(client);
}
async function printLicenseInfo(client: Client) {
async function printLicenseInfo(client: Client4) {
const license = await client.getClientLicenseOld();
// eslint-disable-next-line no-console
console.log(`Server License:
@ -91,7 +88,7 @@ async function printLicenseInfo(client: Client) {
- Users = ${license.Users}`);
}
async function printClientInfo(client: Client) {
async function printClientInfo(client: Client4) {
const config = await client.getClientConfigOld();
// eslint-disable-next-line no-console
console.log(`Build Info:
@ -111,7 +108,7 @@ async function printClientInfo(client: Client) {
- LogSettings.EnableDiagnostics = ${LogSettings?.EnableDiagnostics}`);
}
export async function ensurePluginsLoaded(client: Client) {
export async function ensurePluginsLoaded(client: Client4) {
const pluginStatus = await client.getPluginStatuses();
const plugins = await client.getPlugins();
@ -123,7 +120,7 @@ export async function ensurePluginsLoaded(client: Client) {
return;
}
const isActive = plugins.active.some((plugin) => plugin.id === pluginId);
const isActive = plugins.active.some((plugin: PluginManifest) => plugin.id === pluginId);
if (!isActive) {
await client.enablePlugin(pluginId);
@ -136,7 +133,7 @@ export async function ensurePluginsLoaded(client: Client) {
});
}
async function printPluginDetails(client: Client) {
async function printPluginDetails(client: Client4) {
const plugins = await client.getPlugins();
if (plugins.active.length) {
@ -144,7 +141,7 @@ async function printPluginDetails(client: Client) {
console.log('Active plugins:');
}
plugins.active.forEach((plugin) => {
plugins.active.forEach((plugin: PluginManifest) => {
// eslint-disable-next-line no-console
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
});
@ -154,7 +151,7 @@ async function printPluginDetails(client: Client) {
console.log('Inactive plugins:');
}
plugins.inactive.forEach((plugin) => {
plugins.inactive.forEach((plugin: PluginManifest) => {
// eslint-disable-next-line no-console
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
});
@ -163,7 +160,7 @@ async function printPluginDetails(client: Client) {
console.log('');
}
async function ensureServerDeployment(client: Client) {
async function ensureServerDeployment(client: Client4) {
if (testConfig.haClusterEnabled) {
const {haClusterNodeCount, haClusterName} = testConfig;
@ -194,7 +191,7 @@ async function ensureServerDeployment(client: Client) {
}
}
async function savePreferences(client: Client, userId: UserProfile['id']) {
async function savePreferences(client: Client4, userId: UserProfile['id']) {
try {
if (!userId) {
throw new Error('userId is not defined');
@ -211,5 +208,3 @@ async function savePreferences(client: Client, userId: UserProfile['id']) {
console.log('Error saving preferences', error);
}
}
export default globalSetup;

View file

@ -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';

View file

@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getRandomId} from '@e2e-support/util';
import {Channel, ChannelType} from '@mattermost/types/channels';
import {getRandomId} from '@/util';
type ChannelInput = {
teamId: string;
name: string;

View file

@ -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;
};

View file

@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import merge from 'deepmerge';
import {
AdminConfig,
ClusterSettings,
@ -15,7 +14,8 @@ import {
ServiceSettings,
TeamSettings,
} from '@mattermost/types/config';
import testConfig from '@e2e-test.config';
import {testConfig} from '@/test_config';
export function getOnPremServerConfig(): AdminConfig {
return merge<AdminConfig>(defaultServerConfig, onPremServerConfig() as AdminConfig);

View file

@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {Client, makeClient} from './client';
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';

View file

@ -2,16 +2,16 @@
// See LICENSE.txt for license information.
import {expect} from '@playwright/test';
import {PreferenceType} from '@mattermost/types/preferences';
import testConfig from '@e2e-test.config';
import {getFileDataFromAsset} from '@e2e-support/file';
import {makeClient} from '.';
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'},
@ -30,7 +30,7 @@ export async function initSetup({
}
// Reset server config
const adminConfig = await adminClient.updateConfig(getOnPremServerConfig());
const adminConfig = await adminClient.updateConfig(getOnPremServerConfig() as any);
// Create new team
const team = await adminClient.createTeam(createRandomTeam(teamPrefix.name, teamPrefix.displayName));
@ -45,7 +45,7 @@ export async function initSetup({
const {client: userClient} = await makeClient(user);
if (withDefaultProfileImage) {
const {file} = getFileDataFromAsset('mattermost-icon_128x128.png', 'image/png');
const file = getFileFromCommonAsset('mattermost-icon_128x128.png');
await userClient.uploadProfileImage(user.id, file);
}

View file

@ -2,7 +2,8 @@
// See LICENSE.txt for license information.
import {Post, PostMetadata} from '@mattermost/types/posts';
import {getRandomId} from '@e2e-support/util';
import {getRandomId} from '@/util';
export function createRandomPost(post?: Partial<Post>): Post {
if (post && post.channel_id && post.user_id) {

View file

@ -2,7 +2,8 @@
// See LICENSE.txt for license information.
import {Team, TeamType} from '@mattermost/types/teams';
import {getRandomId} from '@e2e-support/util';
import {getRandomId} from '@/util';
export function createRandomTeam(name = 'team', displayName = 'Team', type: TeamType = 'O', unique = true): Team {
const randomSuffix = getRandomId();

View file

@ -2,8 +2,9 @@
// See LICENSE.txt for license information.
import {UserProfile} from '@mattermost/types/users';
import {getRandomId} from '@e2e-support/util';
import testConfig from '@e2e-test.config';
import {getRandomId} from '@/util';
import {testConfig} from '@/test_config';
export function createRandomUser(prefix = 'user') {
const randomId = getRandomId();

View file

@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import {Locator, Page} from '@playwright/test';
export {waitUntil} from 'async-wait-until';
const visibilityHidden = 'visibility: hidden !important;';
@ -19,7 +18,3 @@ export async function waitForAnimationEnd(locator: Locator) {
Promise.all(element.getAnimations({subtree: true}).map((animation) => animation.finished)),
);
}
export async function moveMouseToCenter(page: Page) {
await page.mouse.move(0, 0);
}

View file

@ -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;
}

View file

@ -1,26 +1,37 @@
import {test as base, Browser, Page} from '@playwright/test';
// 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 {AxeBuilder} from '@axe-core/playwright';
import {TestBrowser} from './browser_context';
import {
ensureLicense,
shouldHaveCallsEnabled,
shouldHaveFeatureFlag,
shouldRunInLinux,
ensureLicense,
skipIfNoLicense,
skipIfFeatureFlagNotSet,
skipIfNoLicense,
} from './flag';
import {initSetup, getAdminClient} from './server';
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 {pages} from './ui/pages';
import {matchSnapshot} from './visual';
import {stubNotification, waitForNotification} from './mock_browser_api';
import {duration} from './util';
import {duration, getRandomId, simpleEmailRe, wait} from './util';
export {expect} from '@playwright/test';
type ExtendedFixtures = {
export type ExtendedFixtures = {
axe: AxeBuilderExtended;
pw: PlaywrightExtended;
};
@ -43,7 +54,7 @@ export const test = base.extend<ExtendedFixtures>({
},
});
class PlaywrightExtended {
export class PlaywrightExtended {
// ./browser_context
readonly testBrowser;
@ -55,6 +66,10 @@ class PlaywrightExtended {
readonly skipIfNoLicense;
readonly skipIfFeatureFlagNotSet;
// ./file
readonly getBlobFromAsset;
readonly getFileFromAsset;
// ./server
readonly getAdminClient;
readonly initSetup;
@ -64,19 +79,27 @@ class PlaywrightExtended {
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;
// ./visual
readonly matchSnapshot;
// ./mock_browser_api
readonly stubNotification;
readonly waitForNotification;
readonly hasSeenLandingPage;
constructor(browser: Browser, page: Page, isMobile: boolean) {
@ -91,6 +114,10 @@ class PlaywrightExtended {
this.skipIfNoLicense = skipIfNoLicense;
this.skipIfFeatureFlagNotSet = skipIfFeatureFlagNotSet;
// ./file
this.getBlobFromAsset = getBlobFromAsset;
this.getFileFromAsset = getFileFromAsset;
// ./server
this.initSetup = initSetup;
this.getAdminClient = getAdminClient;
@ -106,13 +133,26 @@ class PlaywrightExtended {
this.signupPage = new pages.SignupPage(page);
this.resetPasswordPage = new pages.ResetPasswordPage(page);
// ./visual
this.matchSnapshot = matchSnapshot;
// ./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('/');
@ -121,7 +161,7 @@ class PlaywrightExtended {
}
}
class AxeBuilderExtended {
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

View file

@ -9,29 +9,6 @@ export type TestArgs = {
viewport?: ViewportSize | null;
};
export type TestConfig = {
// Server
baseURL: string;
adminUsername: string;
adminPassword: string;
adminEmail: string;
ensurePluginsInstalled: string[];
resetBeforeTest: boolean;
haClusterEnabled: boolean;
haClusterNodeCount: number;
haClusterName: string;
pushNotificationServer: string;
// CI
isCI: boolean;
// Playwright
headless: boolean;
slowMo: number;
workers: number;
// Visual tests
snapshotEnabled: boolean;
percyEnabled: boolean;
};
// Based on https://github.com/microsoft/playwright/blob/d6ec1ae3994f127e38b866a231a34efc6a4cac0d/packages/playwright/types/test.d.ts#L5692-L5759
export type ScreenshotOptions = {
/**

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class ChannelsAppBar {
readonly container: Locator;
@ -18,5 +18,3 @@ export default class ChannelsAppBar {
await expect(this.container).toBeVisible();
}
}
export {ChannelsAppBar};

View file

@ -1,11 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
import {components} from '@e2e-support/ui/components';
import {waitUntil} from '@e2e-support/test_action';
import {duration} from '@e2e-support/util';
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;
@ -25,12 +29,10 @@ export default class ChannelsCenterView {
constructor(container: Locator) {
this.container = container;
this.scheduledDraftChannelInfoMessageLocator = 'span:has-text("Message scheduled for")';
this.header = new components.ChannelsHeader(this.container.locator('.channel-header'));
this.postCreate = new components.ChannelsPostCreate(container.getByTestId('post-create'));
this.scheduledDraftOptions = new components.ChannelsPostCreate(
container.locator('#dropdown_send_post_options'),
);
this.postEdit = new components.ChannelsPostEdit(container.locator('.post-edit__container'));
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');
@ -58,7 +60,7 @@ export default class ChannelsCenterView {
async getFirstPost() {
const firstPost = this.container.getByTestId('postView').first();
await firstPost.waitFor();
return new components.ChannelsPost(firstPost);
return new ChannelsPost(firstPost);
}
/**
@ -67,7 +69,7 @@ export default class ChannelsCenterView {
async getLastPost() {
const lastPost = this.container.getByTestId('postView').last();
await lastPost.waitFor();
return new components.ChannelsPost(lastPost);
return new ChannelsPost(lastPost);
}
/**
@ -89,7 +91,7 @@ export default class ChannelsCenterView {
async getNthPost(index: number) {
const nthPost = this.container.getByTestId('postView').nth(index);
await nthPost.waitFor();
return new components.ChannelsPost(nthPost);
return new ChannelsPost(nthPost);
}
/**
@ -99,7 +101,7 @@ export default class ChannelsCenterView {
async getPostById(id: string) {
const postById = this.container.locator(`[id="post_${id}"]`);
await postById.waitFor();
return new components.ChannelsPost(postById);
return new ChannelsPost(postById);
}
async waitUntilLastPostContains(text: string, timeout = duration.ten_sec) {
@ -138,5 +140,3 @@ export default class ChannelsCenterView {
}
}
}
export {ChannelsCenterView};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class DeletePostConfirmationDialog {
readonly container: Locator;
@ -36,5 +36,3 @@ export default class DeletePostConfirmationDialog {
await this.confirmButton.click();
}
}
export {DeletePostConfirmationDialog};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class DeletePostModal {
readonly container: Locator;
@ -24,5 +24,3 @@ export default class DeletePostModal {
await expect(this.container).not.toBeVisible();
}
}
export {DeletePostModal};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class EmojiGifPicker {
readonly container: Locator;
@ -57,5 +57,3 @@ export default class EmojiGifPicker {
};
}
}
export {EmojiGifPicker};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class FindChannelsModal {
readonly container: Locator;
@ -19,5 +19,3 @@ export default class FindChannelsModal {
await expect(this.container).toBeVisible();
}
}
export {FindChannelsModal};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
/**
* This is the generic confirm modal that is used in the app.
@ -41,5 +41,3 @@ export default class GenericConfirmModal {
await expect(this.container).not.toBeVisible();
}
}
export {GenericConfirmModal};

View file

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

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class MessagePriority {
readonly container: Locator;
@ -74,5 +74,3 @@ export default class MessagePriority {
await expect(standardOption.locator('svg.StyledCheckIcon-dFKfoY')).toBeVisible();
}
}
export {MessagePriority};

View file

@ -1,9 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
import {components} from '@e2e-support/ui/components';
import PostMenu from './post_menu';
import ThreadFooter from './thread_footer';
export default class ChannelsPost {
readonly container: Locator;
@ -25,8 +26,8 @@ export default class ChannelsPost {
this.removePostButton = container.locator('.post__remove');
this.postMenu = new components.PostMenu(container.locator('.post-menu'));
this.threadFooter = new components.ThreadFooter(container.locator('.ThreadFooter'));
this.postMenu = new PostMenu(container.locator('.post-menu'));
this.threadFooter = new ThreadFooter(container.locator('.ThreadFooter'));
}
async toBeVisible() {
@ -72,5 +73,3 @@ export default class ChannelsPost {
await expect(this.container).toContainText(text);
}
}
export {ChannelsPost};

View file

@ -1,9 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import path from 'node:path';
import {waitUntil} from '@e2e-support/test_action';
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;
@ -15,6 +19,7 @@ export default class ChannelsPostCreate {
readonly scheduleDraftMessageButton;
readonly priorityButton;
readonly suggestionList;
readonly filePreview;
constructor(container: Locator, isRHS = false) {
this.container = container;
@ -31,6 +36,7 @@ export default class ChannelsPostCreate {
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() {
@ -101,18 +107,16 @@ export default class ChannelsPostCreate {
await this.writeMessage(message);
if (files) {
const filePaths = files.map((file) => path.join(path.resolve(__dirname), '../../../asset', file));
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 for all files to be uploaded
await waitUntil(async () => {
const attachment = await this.container.locator('.file-preview').count();
return attachment === files.length;
});
// Wait until the file preview is displayed
await this.waitUntilFilePreviewContains(files);
}
await this.sendMessage();
@ -122,6 +126,18 @@ export default class ChannelsPostCreate {
await expect(this.emojiButton).toBeVisible();
await this.emojiButton.click();
}
}
export {ChannelsPostCreate};
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},
);
}
}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class PostDotMenu {
readonly container: Locator;
@ -46,5 +46,3 @@ export default class PostDotMenu {
await expect(this.container).toBeVisible();
}
}
export {PostDotMenu};

View file

@ -1,9 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import path from 'node:path';
import {components} from '@e2e-support/ui/components';
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;
@ -23,10 +28,8 @@ export default class ChannelsPostEdit {
this.attachmentButton = container.locator('#fileUploadButton');
this.emojiButton = container.getByLabel('select an emoji');
this.sendMessageButton = container.locator('.save');
this.deleteConfirmationDialog = new components.DeletePostConfirmationDialog(
container.page().locator('#deletePostModal'),
);
this.restorePostConfirmationDialog = new components.RestorePostConfirmationDialog(
this.deleteConfirmationDialog = new DeletePostConfirmationDialog(container.page().locator('#deletePostModal'));
this.restorePostConfirmationDialog = new RestorePostConfirmationDialog(
container.page().locator('#restorePostModal'),
);
}
@ -51,7 +54,7 @@ export default class ChannelsPostEdit {
}
async addFiles(files: string[]) {
const filePaths = files.map((file) => path.join(path.resolve(__dirname), '../../../asset', file));
const filePaths = files.map((file) => path.join(assetPath, file));
this.container.page().once('filechooser', async (fileChooser) => {
await fileChooser.setFiles(filePaths);
});
@ -90,5 +93,3 @@ export default class ChannelsPostEdit {
await expect(this.container).toContainText(text);
}
}
export {ChannelsPostEdit};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class PostMenu {
readonly container: Locator;
@ -55,5 +55,3 @@ export default class PostMenu {
await this.dotMenuButton.click();
}
}
export {PostMenu};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class PostReminderMenu {
readonly container: Locator;
@ -28,5 +28,3 @@ export default class PostReminderMenu {
await expect(this.container).toBeVisible();
}
}
export {PostReminderMenu};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class RestorePostConfirmationDialog {
readonly container: Locator;
@ -30,5 +30,3 @@ export default class RestorePostConfirmationDialog {
await this.confirmButton.click();
}
}
export {RestorePostConfirmationDialog};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class ScheduledDraftMenu {
readonly container: Locator;
@ -22,5 +22,3 @@ export default class ScheduledDraftMenu {
await this.scheduleDraftMessageCustomTimeOption.click();
}
}
export {ScheduledDraftMenu};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class ScheduledDraftModal {
readonly container: Locator;
@ -93,5 +93,3 @@ export default class ScheduledDraftModal {
return pacificTime;
}
}
export {ScheduledDraftModal};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class SearchPopover {
readonly container: Locator;
@ -43,5 +43,3 @@ export default class SearchPopover {
return this.searchHints.locator('.suggestion--selected');
}
}
export {SearchPopover};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
type NotificationSettingsSection = 'keysWithHighlight' | 'keysWithNotification';
@ -52,5 +52,3 @@ export default class NotificationsSettings {
await this.container.getByText('Save').click();
}
}
export {NotificationsSettings};

View file

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
import {NotificationsSettings} from './notification_settings';
import NotificationsSettings from './notification_settings';
export default class SettingsModal {
readonly container: Locator;
@ -35,5 +35,3 @@ export default class SettingsModal {
await expect(this.container).not.toBeVisible();
}
}
export {SettingsModal};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class ChannelsSidebarLeft {
readonly container: Locator;
@ -56,5 +56,3 @@ export default class ChannelsSidebarLeft {
await expect(channel).not.toBeVisible();
}
}
export {ChannelsSidebarLeft};

View file

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
import {components} from '@e2e-support/ui/components';
import ChannelsPostCreate from './post_create';
import ChannelsPostEdit from './post_edit';
import ChannelsPost from './post';
export default class ChannelsSidebarRight {
readonly container: Locator;
@ -28,11 +30,11 @@ export default class ChannelsSidebarRight {
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 components.ChannelsPostCreate(container.getByTestId('comment-create'), true);
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 components.ChannelsPostEdit(container.locator('.post-edit__container'));
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."]',
@ -50,7 +52,7 @@ export default class ChannelsSidebarRight {
async getPostById(postId: string) {
const post = this.container.locator(`[id="rhsPost_${postId}"]`);
await post.waitFor();
return new components.ChannelsPost(post);
return new ChannelsPost(post);
}
/**
@ -59,13 +61,13 @@ export default class ChannelsSidebarRight {
async getLastPost() {
const post = this.container.getByTestId('rhsPostView').last();
await post.waitFor();
return new components.ChannelsPost(post);
return new ChannelsPost(post);
}
async getFirstPost() {
const post = this.container.getByTestId('rhsPostView').first();
await post.waitFor();
return new components.ChannelsPost(post);
return new ChannelsPost(post);
}
/**
@ -96,5 +98,3 @@ export default class ChannelsSidebarRight {
await this.restorePreviousPostVersionIcon.click();
}
}
export {ChannelsSidebarRight};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class ThreadFooter {
readonly container: Locator;
@ -26,5 +26,3 @@ export default class ThreadFooter {
await this.replyButton.click();
}
}
export {ThreadFooter};

View file

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

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class Footer {
readonly container: Locator;
@ -26,5 +26,3 @@ export default class Footer {
await expect(this.copyright).toBeVisible();
}
}
export {Footer};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class GlobalHeader {
readonly container: Locator;
@ -49,5 +49,3 @@ export default class GlobalHeader {
await this.searchBox.getByTestId('searchBoxClose').click();
}
}
export {GlobalHeader};

View file

@ -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,
};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class MainHeader {
readonly container: Locator;
@ -20,5 +20,3 @@ export default class MainHeader {
await expect(this.container).toBeVisible();
}
}
export {MainHeader};

View file

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

View file

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

View file

@ -21,5 +21,3 @@ export default class FeatureDiscovery {
await expect(this.container.getByTestId('featureDiscovery_title')).toHaveText(title);
}
}
export {FeatureDiscovery};

View file

@ -1,12 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
/**
* The dropdown menu which appears for both Role and Status filter.
*/
class SystemUsersFilterMenu {
export default class SystemUsersFilterMenu {
readonly container: Locator;
constructor(container: Locator) {
@ -42,5 +42,3 @@ class SystemUsersFilterMenu {
await this.container.press('Escape');
}
}
export {SystemUsersFilterMenu};

View file

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
class SystemUsersFilterPopover {
export default class SystemUsersFilterPopover {
readonly container: Locator;
readonly teamMenuInput: Locator;
@ -66,5 +66,3 @@ class SystemUsersFilterPopover {
await expect(this.container).not.toBeVisible();
}
}
export {SystemUsersFilterPopover};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
/**
* System Console -> User Management -> Users
@ -130,5 +130,3 @@ export default class SystemUsers {
await expect(foundUser).not.toBeVisible();
}
}
export {SystemUsers};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Locator} from '@playwright/test';
import {Locator, expect} from '@playwright/test';
export default class SystemConsoleSidebar {
readonly container: Locator;
@ -37,5 +37,3 @@ export default class SystemConsoleSidebar {
await this.searchInput.fill(itemName);
}
}
export {SystemConsoleSidebar};

View file

@ -3,7 +3,7 @@
import {Page} from '@playwright/test';
import {components} from '@e2e-support/ui/components';
import {components} from '@/ui/components';
export default class ChannelsPage {
readonly channels = 'Channels';
@ -19,6 +19,7 @@ export default class ChannelsPage {
readonly sidebarRight;
readonly appBar;
readonly userProfilePopover;
readonly messagePriority;
readonly findChannelsModal;
readonly deletePostModal;
@ -40,6 +41,7 @@ export default class ChannelsPage {
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'}));
@ -88,5 +90,3 @@ export default class ChannelsPage {
await this.centerView.postCreate.postMessage(message);
}
}
export {ChannelsPage};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Page} from '@playwright/test';
import {Page, expect} from '@playwright/test';
export default class DraftPage {
readonly page: Page;
@ -124,5 +124,3 @@ export default class DraftPage {
await this.confirmbutton.click();
}
}
export {DraftPage};

View file

@ -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,
};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Page} from '@playwright/test';
import {Page, expect} from '@playwright/test';
export default class LandingLoginPage {
readonly page: Page;
@ -37,5 +37,3 @@ export default class LandingLoginPage {
await this.page.goto('/landing#/login');
}
}
export {LandingLoginPage};

View file

@ -1,11 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Page} from '@playwright/test';
import {Page, expect} from '@playwright/test';
import {UserProfile} from '@mattermost/types/users';
import {components} from '@e2e-support/ui/components';
import {components} from '@/ui/components';
export default class LoginPage {
readonly page: Page;
@ -67,5 +66,3 @@ export default class LoginPage {
await Promise.all([this.page.waitForNavigation(), this.signInButton.click()]);
}
}
export {LoginPage};

View file

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Page} from '@playwright/test';
import {Page, expect} from '@playwright/test';
import {components} from '@e2e-support/ui/components';
import {components} from '@/ui/components';
export default class ResetPasswordPage {
readonly page: Page;
@ -47,5 +47,3 @@ export default class ResetPasswordPage {
await Promise.all([this.page.waitForNavigation(), this.resetButton.click()]);
}
}
export {ResetPasswordPage};

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Page} from '@playwright/test';
import {Page, expect} from '@playwright/test';
export default class ScheduledDraftPage {
readonly page: Page;
@ -157,5 +157,3 @@ export default class ScheduledDraftPage {
await this.copyIcon.click();
}
}
export {ScheduledDraftPage};

View file

@ -1,10 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, Page} from '@playwright/test';
import {Page, expect} from '@playwright/test';
import {duration, wait} from '@e2e-support/util';
import {components} from '@e2e-support/ui/components';
import {duration, wait} from '@/util';
import {components} from '@/ui/components';
export default class SignupPage {
readonly page: Page;
@ -86,5 +86,3 @@ export default class SignupPage {
}
}
}
export {SignupPage};

View file

@ -2,9 +2,10 @@
// See LICENSE.txt for license information.
import {Page} from '@playwright/test';
import {components} from '../components';
class SystemConsolePage {
import {components} from '@/ui/components';
export default class SystemConsolePage {
readonly page: Page;
readonly sidebar;
@ -39,10 +40,10 @@ class SystemConsolePage {
// Sections and sub-sections
this.systemUsers = new components.SystemUsers(page.getByTestId('systemUsersSection'));
this.mobileSecurity = new components.MobileSecurity(
this.mobileSecurity = new components.SystemConsoleMobileSecurity(
page.getByTestId('sysconsole_section_MobileSecuritySettings'),
);
this.featureDiscovery = new components.FeatureDiscovery(page.getByTestId('featureDiscovery'));
this.featureDiscovery = new components.SystemConsoleFeatureDiscovery(page.getByTestId('featureDiscovery'));
// Menus & Popovers
this.systemUsersFilterPopover = new components.SystemUsersFilterPopover(
@ -84,5 +85,3 @@ class SystemConsolePage {
await this.saveChangesModal.container.locator('button.btn-primary:has-text("Reset")').click();
}
}
export {SystemConsolePage};

View file

@ -4,14 +4,14 @@
import os from 'node:os';
import chalk from 'chalk';
import {expect, TestInfo} from '@playwright/test';
import {duration, illegalRe, wait} from '@e2e-support/util';
import testConfig from '@e2e-test.config';
import {ScreenshotOptions, TestArgs} from '@e2e-types';
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

View file

@ -3,8 +3,8 @@
import percySnapshot from '@percy/playwright';
import testConfig from '@e2e-test.config';
import {TestArgs} from '@e2e-types';
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) {

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"outDir": "dist",
"target": "ES2022",
"esModuleInterop": true,
"strict": true,
"declaration": true,
"moduleResolution": "node",
"module": "esnext",
"resolveJsonModule": true,
"skipLibCheck": true,
"downlevelIteration": true,
"baseUrl": "src",
"paths": {
"@/*": ["*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,34 @@
{
"scripts": {
"test": "cross-env PW_SNAPSHOT_ENABLE=true playwright test",
"test:update-snapshots": "cross-env PW_SNAPSHOT_ENABLE=true playwright test --update-snapshots",
"percy": "cross-env PERCY_TOKEN=$PERCY_TOKEN PW_PERCY_ENABLE=true percy exec -- playwright test --project=chrome --project=ipad",
"tsc": "tsc -b",
"lint": "eslint .",
"prettier": "prettier . --check",
"prettier:fix": "prettier --write .",
"check": "npm run tsc && npm run lint && npm run prettier",
"codegen": "cross-env playwright codegen $PW_BASE_URL",
"playwright-ui": "cross-env playwright test --ui",
"test-slomo": "cross-env PW_SNAPSHOT_ENABLE=true PW_SLOWMO=1000 playwright test",
"show-report": "npx playwright show-report",
"postinstall": "script/post_install.sh"
},
"dependencies": {
"@axe-core/playwright": "4.10.1",
"@percy/cli": "1.30.6",
"@percy/playwright": "1.0.7",
"@playwright/test": "1.49.1",
"async-wait-until": "2.0.18",
"axe-core": "4.10.2",
"chalk": "4.1.2",
"dayjs": "1.11.13",
"deepmerge": "4.3.1",
"dotenv": "16.4.7",
"form-data-encoder": "4.0.2",
"formdata-node": "6.0.3",
"uuid": "11.0.5",
"zod": "3.24.1"
},
"devDependencies": {
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.20.0",
"@typescript-eslint/parser": "8.20.0",
"cross-env": "7.0.3",
"eslint": "9.18.0",
"prettier": "3.4.2",
"typescript": "5.7.3"
}
"name": "mattermost-playwright",
"private": true,
"workspaces": [
"lib",
"test"
],
"scripts": {
"postinstall": "npm run build",
"build": "npm run build --workspaces --if-present",
"build:watch": "npm run build:watch --workspaces --if-present",
"clean": "rm -rf node_modules logs results test-results && npm run clean --workspaces --if-present",
"tsc": "npm run tsc --workspaces --if-present",
"lint": "eslint .",
"prettier": "prettier . --check",
"prettier:fix": "prettier --write .",
"check": "npm run lint && npm run prettier && npm run tsc",
"test": "npm run test --workspace=test",
"test:ci": "npm run test:ci --workspace=test"
},
"dependencies": {
"dayjs": "1.11.13",
"@playwright/test": "1.51.1"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "8.28.0",
"eslint": "9.23.0",
"eslint-import-resolver-typescript": "4.2.7",
"eslint-plugin-header": "3.1.1",
"eslint-plugin-import": "2.31.0",
"prettier": "3.5.3",
"typescript": "5.7.3"
}
}

View file

@ -1,6 +1,10 @@
#!/usr/bin/env node
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('fs');
const dayjs = require('dayjs');
const duration = require('dayjs/plugin/duration');
dayjs.extend(duration);
@ -90,5 +94,5 @@ function generateWebhookBody() {
};
}
let webhookBody = generateWebhookBody();
const webhookBody = generateWebhookBody();
process.stdout.write(JSON.stringify(webhookBody));

View file

@ -1,65 +0,0 @@
// 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';
const assetPath = path.resolve(__dirname, 'asset');
/**
* Reads file data and creates a File object.
* @param filePath - The path to the file.
* @param mimeType - The MIME type of the file.
* @returns A File object containing the file data.
* @throws If the file does not exist.
*/
export function getFileData(filePath: string, mimeType: string): File {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found at path: ${filePath}`);
}
const fileName = path.basename(filePath);
const fileBuffer = fs.readFileSync(filePath);
return new File([fileBuffer], fileName, {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.
* @param mimeType - The MIME type of the file.
* @returns An object containing a File object and the filename.
*/
export function getFileDataFromAsset(filename: string, mimeType: string) {
const filePath = path.join(assetPath, filename);
return {file: getFileData(filePath, mimeType), filename: path.basename(filePath)};
}
/**
* Reads file data and creates a Blob object.
* @param filePath - The path to the file.
* @param mimeType - The MIME type of the file.
* @returns A Blob object containing the file data.
* @throws If the file does not exist.
*/
export function getBlobData(filePath: string, mimeType: string): Blob {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found at path: ${filePath}`);
}
const fileBuffer = fs.readFileSync(filePath);
return new Blob([fileBuffer], {type: mimeType});
}
/**
* Reads file data from the "asset" directory and creates a Blob object.
* @param filename - The name of the file in the "asset" directory.
* @param mimeType - The MIME type of the file.
* @returns An object containing a Blob object and the filename.
*/
export function getBlobDataFromAsset(filename: string, mimeType: string) {
const filePath = path.join(assetPath, filename);
return {blob: getBlobData(filePath, mimeType), filename: path.basename(filePath)};
}

View file

@ -1,162 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This is based on "webapp/platform/client/src/client4.ts". Modified for node client.
// Update should be made in comparison with the base Client4.
import fs from 'node:fs';
import path from 'node:path';
import stream from 'stream';
import {FormData} from 'formdata-node';
import {FormDataEncoder} from 'form-data-encoder';
import testConfig from '@e2e-test.config';
import Client4 from '@mattermost/client/client4';
import {Options, StatusOK} from '@mattermost/types/client4';
import {License} from '@mattermost/types/config';
import {CustomEmoji} from '@mattermost/types/emojis';
import {PluginManifest} from '@mattermost/types/plugins';
import {UserProfile} from '@mattermost/types/users';
export default class Client extends Client4 {
getFormDataOptions = (formData: FormData): Options => {
const encoder = new FormDataEncoder(formData);
return {
method: 'post',
body: stream.Readable.from(encoder.encode()),
headers: encoder.headers,
duplex: 'half',
};
};
readFileAsFormData = (filePath: string, key: string, additionalFields: Record<string, any> = {}) => {
const fileData = fs.readFileSync(filePath);
const formData = new FormData();
formData.append(key, new Blob([fileData]), path.basename(filePath));
// Append additional fields if provided
for (const [field, value] of Object.entries(additionalFields)) {
formData.append(field, value);
}
return formData;
};
uploadProfileImageX = (userId: string, filePath: string) => {
const formData = this.readFileAsFormData(filePath, 'image');
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getUserRoute(userId)}/image`, options);
};
setTeamIconX = (teamId: string, filePath: string) => {
const formData = this.readFileAsFormData(filePath, 'image');
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getTeamRoute(teamId)}/image`, options);
};
createCustomEmojiX = (emoji: CustomEmoji, filePath: string) => {
const formData = this.readFileAsFormData(filePath, 'image', {emoji: JSON.stringify(emoji)});
const options = this.getFormDataOptions(formData);
return this.doFetch<CustomEmoji>(`${this.getEmojisRoute()}`, options);
};
uploadBrandImageX = (filePath: string) => {
const formData = this.readFileAsFormData(filePath, 'image');
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(`${this.getBrandRoute()}/image`, options);
};
uploadCertificateX = (filePath: string, route: string) => {
const formData = this.readFileAsFormData(filePath, 'certificate');
const options = this.getFormDataOptions(formData);
return this.doFetch<StatusOK>(route, options);
};
uploadPublicSamlCertificateX = (filePath: string) => {
return this.uploadCertificateX(filePath, `${this.getBaseRoute()}/saml/certificate/public`);
};
uploadPrivateSamlCertificateX = (filePath: string) => {
return this.uploadCertificateX(filePath, `${this.getBaseRoute()}/saml/certificate/private`);
};
uploadPublicLdapCertificateX = (filePath: string) => {
return this.uploadCertificateX(filePath, `${this.getBaseRoute()}/ldap/certificate/public`);
};
uploadPrivateLdapCertificateX = (filePath: string) => {
return this.uploadCertificateX(filePath, `${this.getBaseRoute()}/ldap/certificate/private`);
};
uploadIdpSamlCertificateX = (filePath: string) => {
return this.uploadCertificateX(filePath, `${this.getBaseRoute()}/saml/certificate/idp`);
};
uploadLicenseX = (filePath: string) => {
const formData = this.readFileAsFormData(filePath, 'license');
const options = this.getFormDataOptions(formData);
return this.doFetch<License>(`${this.getBaseRoute()}/license`, options);
};
uploadPluginX = async (filePath: string, force = false) => {
const additionalFields = force ? {force: 'true'} : {};
const formData = this.readFileAsFormData(filePath, 'plugin', additionalFields);
const options = this.getFormDataOptions(formData);
return this.doFetch<PluginManifest>(this.getPluginsRoute(), options);
};
}
// Variable to hold cache
const clients: Record<string, ClientCache> = {};
async function makeClient(
userRequest?: UserRequest,
opts: {useCache?: boolean; skipLog?: boolean} = {useCache: true, skipLog: false},
): Promise<ClientCache> {
const client = new Client();
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: Client;
user: UserProfile | null;
};
export {Client, makeClient};

View file

@ -1,98 +0,0 @@
// 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 '@e2e-support/ui/components/channels/post_edit';
import DeletePostConfirmationDialog from '@e2e-support/ui/components/channels/delete_post_confirmation_dialog';
import RestorePostConfirmationDialog from '@e2e-support/ui/components/channels/restore_post_confirmation_dialog';
import MobileSecurity from './system_console/sections/system_users/mobile_security';
import FeatureDiscovery from './system_console/sections/system_users/feature_discovery';
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,
MessagePriority,
UserProfilePopover,
DeletePostConfirmationDialog,
RestorePostConfirmationDialog,
MobileSecurity,
FeatureDiscovery,
};
export {
components,
GlobalHeader,
ChannelsCenterView,
ChannelsSidebarLeft,
ChannelsSidebarRight,
ChannelsAppBar,
ChannelsHeader,
ChannelsPostCreate,
ChannelsPostEdit,
ChannelsPost,
FindChannelsModal,
DeletePostModal,
PostDotMenu,
PostMenu,
ThreadFooter,
MessagePriority,
DeletePostConfirmationDialog,
};

View file

@ -1,25 +0,0 @@
// 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 {ChannelsPage, LandingLoginPage, LoginPage, SignupPage, ScheduledDraftPage, DraftPage};
export default pages;

View file

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

View file

@ -40,10 +40,10 @@ npm run test
#### 1. Run docker container using latest focal version
Change to the root directory, then run the docker container. (See https://playwright.dev/docs/docker for reference.)
Change to the `e2e-tests/playwright` directory, then run the docker container. (See https://playwright.dev/docs/docker for reference.)
```
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.49.1-noble /bin/bash
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.51.1-noble /bin/bash
```
#### 2. Inside the docker container
@ -51,23 +51,26 @@ docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwr
```
export PW_BASE_URL=http://host.docker.internal:8065
export PW_HEADLESS=true
cd mattermost/e2e-tests/playwright
cd mattermost
# Install npm packages. Use "npm ci" to match the automated environment
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci
# Run specific test. See https://playwright.dev/docs/test-cli.
npm run test -- login --project=chrome
npm run test -- -- login --project=chrome
# Or run all tests
npm run test
# Update snapshots
npm run test -- login --update-snapshots
# Run visual tests
npm run test -- -- visual
# Update snapshots of visual tests
npm run test -- -- visual --update-snapshots
```
## Page/Component Object Model
See https://playwright.dev/docs/test-pom.
Page and component abstractions are located at `./support/ui`. They should be established before writing a spec file so that any future changes in the DOM structure will be made in one place only. No static UI text or fixed locator should be written in the spec file.
Page and component abstractions are in shared library located at `./lib/src/ui`. They should be established before writing a spec file so that any future changes in the DOM structure will be made in one place only. No static UI text or fixed locator should be written in the spec file.

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {baseGlobalSetup, testConfig} from '@mattermost/playwright-lib';
async function globalSetup() {
try {
await baseGlobalSetup();
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error(error);
throw new Error(
`Global setup failed.\n\tEnsure the server at ${testConfig.baseURL} is running and accessible.\n\tPlease check the logs for more details.`,
);
}
return function () {
// placeholder for teardown setup
};
}
export default globalSetup;

View file

@ -0,0 +1,26 @@
{
"name": "@mattermost/playwright-test",
"version": "0.0.1",
"scripts": {
"test": "cross-env PW_SNAPSHOT_ENABLE=true playwright test",
"test:ci": "cross-env PW_SNAPSHOT_ENABLE=true playwright test --project=chrome",
"test:update-snapshots": "cross-env PW_SNAPSHOT_ENABLE=true playwright test --update-snapshots",
"percy": "cross-env PERCY_TOKEN=$PERCY_TOKEN PW_PERCY_ENABLE=true percy exec -- playwright test --project=chrome --project=ipad",
"tsc": "tsc -b",
"codegen": "cross-env playwright codegen $PW_BASE_URL",
"playwright-ui": "cross-env playwright test --ui",
"test-slomo": "cross-env PW_SNAPSHOT_ENABLE=true PW_SLOWMO=1000 playwright test",
"show-report": "npx playwright show-report",
"postinstall": "script/post_install.sh",
"clean": "rm -rf dist node_modules *.tsbuildinfo logs results storage_state test-results"
},
"dependencies": {
"@mattermost/client": "file:../../../webapp/platform/client",
"@mattermost/playwright-lib": "*",
"@mattermost/types": "file:../../../webapp/platform/types",
"zod": "3.24.2"
},
"devDependencies": {
"cross-env": "7.0.3"
}
}

View file

@ -2,16 +2,14 @@
// See LICENSE.txt for license information.
import {defineConfig, devices} from '@playwright/test';
import {duration} from '@e2e-support/util';
import testConfig from '@e2e-test.config';
import {duration, testConfig} from '@mattermost/playwright-lib';
export default defineConfig({
globalSetup: require.resolve('./global_setup'),
globalSetup: './global_setup.ts',
forbidOnly: testConfig.isCI,
outputDir: './results/output',
retries: testConfig.isCI ? 2 : 0,
testDir: 'tests',
testDir: 'specs',
timeout: duration.one_min,
workers: testConfig.workers,
expect: {

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@e2e-support/test_fixture';
import {expect, test} from '@mattermost/playwright-lib';
test.fixme('Base channel accessibility', async ({pw, axe}) => {
// # Create and sign in a new user

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