snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
# @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
```
### Peer Dependencies
This library requires `@playwright/test` version 1.55.0 or higher:
```bash
npm install @playwright/test@^1.55.0
```
## 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...
Global setup leaves only the plugins a fresh install has active (Calls, Playbooks, Agents, NPS) and
deactivates the rest, so a plugin one spec enabled cannot alter the webapp for the next one — the
demo plugin, for instance, replaces the attachment button with its own upload menu. A spec needing
another plugin enables it itself with `pw.installAndEnablePlugin()` or `pw.ensurePluginsLoaded()`.
`PW_ENSURE_PLUGINS_INSTALLED` is honoured and left active.
## 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` |
| `PW_SMTP_URL` | Inbucket HTTP API URL | `http://localhost:9001` |
#### 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 |
#### Testcontainers
Selects `testcontainers` mode — Playwright brings up the server + dependencies itself via [Testcontainers](https://node.testcontainers.org/) — instead of the `external` mode default, which expects a server already running at `PW_BASE_URL`.
| Variable | Description | Default |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `PW_USE_TESTCONTAINERS` | Selects `testcontainers` mode | `false` |
| `PW_TESTCONTAINERS_SERVICES` | Comma-separated additional services to start (`openldap`, `keycloak`, `elasticsearch`, `opensearch`, `minio`, `azurite`); set to an empty string to start none | `minio,openldap,keycloak,elasticsearch` |
| `PW_TESTCONTAINERS_REUSE` | Reuse containers across repeated local runs instead of recreating them; tear down with `npm run testcontainers:down` (requires `testcontainers.reuse.enable=true` in `~/.testcontainers.properties`) | `true` |
| `PW_TESTCONTAINERS_CONTAINER_RUNNER` | Set when the Playwright process itself runs inside a Docker container (e.g. CI), to join it to the Testcontainers network | `false` |
| `SERVER_IMAGE` | Prebuilt Mattermost server image `testcontainers` mode starts — also the upgrade tests' to-image | `mattermostdevelopment/mattermost-enterprise-edition:master` |
| `PW_UPGRADE_FROM_SERVER_IMAGE` | Older server image `test:upgrade:from` boots fresh on — use a `release-X.Y` tag (see `script/resolve_upgrade_matrix.mjs`). Required; if `.env.testcontainers` already exists, run `npm run testcontainers:down` first | none — required for `--project=upgrade-from` |
| `MM_ENV` | Comma-separated `KEY=VALUE` server config overrides, merged over the test baseline | none |
| `PW_LDAP_HOST` / `PW_LDAP_PORT` | OpenLDAP host/port (only used when `openldap` is started) | `localhost` / `389` |
| `PW_KEYCLOAK_URL` | Keycloak URL (only used when `keycloak` is started) | `http://localhost:8484` |
| `PW_ELASTICSEARCH_URL` | Elasticsearch URL (only used when `elasticsearch` is started) | `http://localhost:9200` |
| `PW_OPENSEARCH_URL` | OpenSearch URL (only used when `opensearch` is started) | `http://localhost:9201` |
| `PW_MINIO_URL` | Minio URL (only used when `minio` is started) | `http://localhost:9000` |
| `PW_AZURITE_URL` | Azurite URL (only used when `azurite` is started) | `http://localhost:10000` |
In `testcontainers` mode, these host/port defaults are never actually used — Testcontainers always assigns its own dynamic port or network alias per run, so a `testcontainers`-mode run can never collide with a same-machine `external`-mode session using the fixed defaults above. Tear a reused stack down explicitly with `npm run testcontainers:down` (from the `playwright/` package).
## 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,76 @@
{
"name": "@mattermost/playwright-lib",
"version": "11.11.0",
"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": "module",
"files": [
"dist"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"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.13.0",
"@azure/storage-blob": "12.33.0",
"@mattermost/client": "file:../../../webapp/platform/client",
"@mattermost/types": "file:../../../webapp/platform/types",
"@percy/cli": "1.32.6",
"@percy/playwright": "1.1.2",
"@testcontainers/postgresql": "12.1.0",
"async-wait-until": "2.0.31",
"axe-core": "4.13.0",
"chalk": "6.0.0",
"deepmerge": "4.3.1",
"dotenv": "17.4.2",
"ldapts": "9.0.0",
"luxon": "3.7.2",
"mime-types": "3.0.2",
"minio": "8.0.7",
"testcontainers": "12.1.0",
"uuid": "14.0.1"
},
"devDependencies": {
"@rollup/plugin-typescript": "12.3.0",
"@types/luxon": "3.7.4",
"@types/mime-types": "3.0.1",
"@types/node": "26.2.0",
"rollup": "4.62.4",
"rollup-plugin-copy": "3.5.0"
},
"peerDependencies": {
"@playwright/test": ">=1.59.0"
}
}

View File

@@ -0,0 +1,53 @@
// 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: 'esm',
sourcemap: true,
preserveModules: true, // Keep file structure
preserveModulesRoot: 'src',
},
],
plugins: [
typescript(),
copy({
targets: [
{src: 'src/asset/**/*', dest: 'dist/asset'}, // Copy assets to dist/
{src: 'src/containers/assets/**/*', dest: 'dist/containers/assets'},
],
}),
],
external: [
'@playwright/test',
'@mattermost/client',
'@mattermost/types/config',
'@axe-core/playwright',
'@azure/storage-blob',
'@percy/playwright',
'@testcontainers/postgresql',
'dotenv',
'ldapts',
'luxon',
'minio',
'node:child_process',
'node:path',
'node:fs',
'node:fs/promises',
'node:os',
'node:url',
'node:util',
'mime-types',
'testcontainers',
'uuid',
'async-wait-until',
'chalk',
'deepmerge',
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
export type EnableAutotranslationOptions = {
mockBaseUrl: string;
targetLanguages?: string[];
};
/**
* Enable autotranslation in server config with LibreTranslate provider pointing at the mock URL.
* Requires an enterprise license for the feature to be available.
*/
export async function enableAutotranslationConfig(
adminClient: Client4,
options: EnableAutotranslationOptions,
): Promise<void> {
await adminClient.patchConfig({
FeatureFlags: {
AutoTranslation: true,
},
AutoTranslationSettings: {
Enable: true,
Provider: 'libretranslate',
LibreTranslate: {
URL: options.mockBaseUrl,
APIKey: '',
},
TargetLanguages: options.targetLanguages ?? ['en', 'es'],
RestrictDMAndGM: false,
Workers: 4,
TimeoutMs: 5000,
},
} as any);
}
/**
* Disable autotranslation in server config.
* Uses patchConfig for the same reasons as enableAutotranslationConfig.
*/
export async function disableAutotranslationConfig(adminClient: Client4): Promise<void> {
await adminClient.patchConfig({
FeatureFlags: {
AutoTranslation: false,
},
AutoTranslationSettings: {
Enable: false,
TargetLanguages: [],
Workers: 0,
Provider: '',
LibreTranslate: {
URL: '',
APIKey: '',
},
TimeoutMs: 0,
RestrictDMAndGM: false,
},
} as any);
}
/**
* Enable autotranslation for a channel (requires permission and feature available).
*/
export async function enableChannelAutotranslation(adminClient: Client4, channelId: string): Promise<void> {
await adminClient.patchChannel(channelId, {autotranslation: true} as any);
}
/**
* Disable autotranslation for a channel.
*/
export async function disableChannelAutotranslation(adminClient: Client4, channelId: string): Promise<void> {
await adminClient.patchChannel(channelId, {autotranslation: false} as any);
}
export async function setMockSourceLanguage(mockBaseUrl: string, language: string): Promise<void> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${mockBaseUrl}/__control/source`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({language}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
// 404 or other error likely means real LibreTranslate (no control endpoint)
if (res.status === 404) {
// Silently ignore - using real LibreTranslate with automatic language detection
return;
}
throw new Error(`Mock detect failed: ${res.status}`);
}
} finally {
clearTimeout(timeoutId);
}
} catch {
// Silently ignore all errors - could be mock server down or real LibreTranslate without control endpoint
// Real LibreTranslate uses automatic language detection from message content anyway
// Expected errors: AbortError (timeout), network errors, 404 from real LibreTranslate
// All are handled gracefully by the retry logic above
}
}
/**
* Set autotranslation opt-in for the current user in a channel.
* The client must be logged in as the user whose setting is being changed (e.g. userClient).
*/
export async function setUserChannelAutotranslation(
client: Client4,
channelId: string,
enabled: boolean,
): Promise<void> {
await client.setMyChannelAutotranslation(channelId, enabled);
}
const AUTO_TRANSLATION_PERMISSIONS = [
'manage_public_channel_auto_translation',
'manage_private_channel_auto_translation',
];
/**
* Call this after enableAutotranslationConfig and before any UI test that relies on
* the autotranslation toggle appearing in Channel Settings.
*/
export async function ensureAutotranslationPermissions(adminClient: Client4): Promise<void> {
const roleNames = ['channel_admin', 'team_admin', 'system_admin'];
await Promise.all(
roleNames.map(async (roleName) => {
const role = await adminClient.getRoleByName(roleName);
const missing = AUTO_TRANSLATION_PERMISSIONS.filter((p) => !role.permissions.includes(p));
if (missing.length > 0) {
await adminClient.patchRole(role.id, {
permissions: [...role.permissions, ...missing],
});
}
}),
);
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {writeFile} from 'node:fs/promises';
import path from 'node:path';
import fs from 'node:fs';
import type {Browser, BrowserContext} from '@playwright/test';
import {request} from '@playwright/test';
import type {UserProfile} from '@mattermost/types/users';
import {testConfig} from './test_config';
import {pages} from './ui/pages';
import {resolvePlaywrightPath} from './util';
export class TestBrowser {
readonly browser: Browser;
private contexts: BrowserContext[] = [];
constructor(browser: Browser) {
this.browser = browser;
}
async login(user: UserProfile) {
const options = {storageState: ''};
if (user) {
// Log in via API request and save user storage
const storagePath = await loginByAPI(user.username, user.password);
options.storageState = storagePath;
}
// Sign in a user in new browser context
const context = await this.browser.newContext(options);
await routeInternalBaseUrlToHost(context);
const page = await context.newPage();
const channelsPage = new pages.ChannelsPage(page);
const systemConsolePage = new pages.SystemConsolePage(page);
const scheduledPostsPage = new pages.ScheduledPostsPage(page);
const draftsPage = new pages.DraftsPage(page);
const recapsPage = new pages.RecapsPage(page);
const threadsPage = new pages.ThreadsPage(page);
const contentReviewPage = new pages.ContentReviewPage(page);
this.contexts.push(context);
return {
context,
page,
channelsPage,
systemConsolePage,
scheduledPostsPage,
draftsPage,
recapsPage,
threadsPage,
contentReviewPage,
};
}
/**
* Switch the auth state of an existing context to a different user
* without creating a new context. After switching, pages in the context
* should be reloaded to pick up the new auth state.
*/
async switchUser(context: BrowserContext, user: UserProfile) {
const storagePath = await loginByAPI(user.username, user.password);
await (context as any).setStorageState(storagePath);
}
async close() {
for (const context of this.contexts) {
await context.close();
}
this.contexts = [];
}
}
/**
* Plugin webapp bundles build absolute URLs from ServiceSettings.SiteURL, which in `testcontainers`
* mode is a Docker network alias that only other containers can resolve. Left alone those fetches
* hang from the host browser, so anything waiting on network idle never settles. Rewrite them onto
* the host-mapped URL, which serves the same server. No-op in `external` mode, where the two match.
*/
async function routeInternalBaseUrlToHost(context: BrowserContext) {
const {internalBaseURL, baseURL} = testConfig;
if (internalBaseURL === baseURL) {
return;
}
await context.route(`${internalBaseURL}/**`, async (route) => {
await route.continue({url: route.request().url().replace(internalBaseURL, baseURL)});
});
}
export async function loginByAPI(loginId: string, password: string, token = '', ldapOnly = false) {
const requestContext = await request.newContext();
const data: any = {
login_id: loginId,
password,
token,
deviceId: '',
};
if (ldapOnly) {
data.ldap_only = 'true';
}
// Log in via API
await requestContext.post(`${testConfig.baseURL}/api/v4/users/login`, {
data,
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
// Save signed-in state to a folder
const storageStateDir = resolvePlaywrightPath('storage_state');
// Ensure storage_state directory exists
if (!fs.existsSync(storageStateDir)) {
fs.mkdirSync(storageStateDir, {recursive: true});
}
const filename = `${Date.now()}_${loginId}_${password}${token ? '_' + token : ''}${ldapOnly ? '_ldap' : ''}.json`;
const storagePath = path.join(storageStateDir, filename);
const storageState = await requestContext.storageState({path: storagePath});
await requestContext.dispose();
// Append origins to bypass seeing landing page then write to file
storageState.origins.push({
origin: testConfig.baseURL,
localStorage: [{name: '__landingPageSeen__', value: 'true'}],
});
await writeFile(storagePath, JSON.stringify(storageState));
return storagePath;
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const aiPluginId = 'mattermost-ai';
export const appsPluginId = 'com.mattermost.apps';
export const callsPluginId = 'com.mattermost.calls';
export const npsPluginId = 'com.mattermost.nps';
export const playbooksPluginId = 'playbooks';
// License SKU short names — mirrored from webapp/channels/src/utils/constants.tsx LicenseSkus
export const LicenseSkus = {
E10: 'E10',
E20: 'E20',
Starter: 'starter',
Professional: 'professional',
Enterprise: 'enterprise',
EnterpriseAdvanced: 'advanced',
Entry: 'entry',
} as const;
// Remote users hour limit taken from webapp/channels/src/utils/constants.ts
export const REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY = 22;
export const REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY = 6;

View File

@@ -0,0 +1,5 @@
# Copied from server/build/Dockerfile.elasticsearch on 2026-07-19.
ARG ELASTICSEARCH_VERSION=9.0.0
FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION}
RUN /usr/share/elasticsearch/bin/elasticsearch-plugin install --batch analysis-icu analysis-nori analysis-kuromoji analysis-smartcn

View File

@@ -0,0 +1,5 @@
# Copied from server/build/Dockerfile.opensearch on 2026-07-19.
ARG OPENSEARCH_VERSION=3.0.0
FROM opensearchproject/opensearch:$OPENSEARCH_VERSION
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu analysis-nori analysis-kuromoji analysis-smartcn

View File

@@ -0,0 +1,13 @@
# Vendored assets
These files are copied from `server/build/` in the Mattermost monorepo so that
`@mattermost/playwright-lib`'s Testcontainers support is self-contained once published to npm
(it can't reach outside its own package at runtime). If the source files change, these copies
need to be refreshed manually — they are not symlinked or build-generated.
| File | Copied from | Date |
| ---------------------------- | ------------------------------------------------ | ---------- |
| `postgres.conf` | `server/build/docker/postgres.conf` | 2026-07-19 |
| `keycloak-realm-export.json` | `server/build/docker/keycloak/realm-export.json` | 2026-07-19 |
| `Dockerfile.elasticsearch` | `server/build/Dockerfile.elasticsearch` | 2026-07-19 |
| `Dockerfile.opensearch` | `server/build/Dockerfile.opensearch` | 2026-07-19 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
# Copied from server/build/docker/postgres.conf on 2026-07-19.
max_connections = 500
listen_addresses = '*'
fsync = off
full_page_writes = off
default_text_search_config = 'pg_catalog.english'
commit_delay=1000
logging_collector=off
password_encryption = 'scram-sha-256'

View File

@@ -0,0 +1,20 @@
# webhook_serve.js, utils/webhook_utils.js, and tests/plugins/post_message_as.js are vendored
# copies of e2e-tests/cypress's files of the same name (shared sidecar, also used by Cypress) —
# copied on 2026-07-20. Dependencies match e2e-tests/.ci/server.generate.sh's "playwright" Compose
# service, just installed at build time instead of on every container start.
FROM node:24-alpine
WORKDIR /webhook
RUN npm init -y >/dev/null && \
npm install express@5.1.0 axios@1.11.0 client-oauth2@github:larkox/js-client-oauth2#e24e2eb5dfcbbbb3a59d095e831dbe0012b0ac49
COPY webhook_serve.js ./webhook_serve.js
COPY utils/ ./utils/
COPY tests/plugins/post_message_as.js ./tests/plugins/post_message_as.js
RUN chown -R node:node /webhook
USER node
EXPOSE 3000
CMD ["node", "webhook_serve.js"]

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const axios = require('axios');
module.exports = async ({sender, message, channelId, rootId, createAt = 0, baseUrl}) => {
const loginResponse = await axios({
url: `${baseUrl}/api/v4/users/login`,
headers: {'X-Requested-With': 'XMLHttpRequest'},
method: 'post',
data: {login_id: sender.username, password: sender.password},
});
const setCookie = loginResponse.headers['set-cookie'];
let cookieString = '';
setCookie.forEach((cookie) => {
const nameAndValue = cookie.split(';')[0];
cookieString += nameAndValue + ';';
});
let response;
try {
response = await axios({
url: `${baseUrl}/api/v4/posts`,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Cookie: cookieString,
},
method: 'post',
data: {
channel_id: channelId,
message,
type: '',
create_at: createAt,
root_id: rootId,
},
});
} catch (err) {
expect(Boolean(err)).to.equal(false);
}
return {status: response.status, data: response.data};
};

View File

@@ -0,0 +1,843 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Helper function to create dialog base structure
function createDialog(triggerId, webhookBaseUrl, dialogConfig) {
const baseDialog = {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: dialogConfig.callback_id,
title: dialogConfig.title,
submit_label: dialogConfig.submit_label || 'Submit',
notify_on_cancel: true,
...dialogConfig.dialog_props,
elements: dialogConfig.elements || [],
},
};
if (dialogConfig.icon_url) {
baseDialog.dialog.icon_url = dialogConfig.icon_url;
}
if (dialogConfig.introduction_text) {
baseDialog.dialog.introduction_text = dialogConfig.introduction_text;
}
if (dialogConfig.state) {
baseDialog.dialog.state = dialogConfig.state;
}
if (dialogConfig.source_url) {
baseDialog.dialog.source_url = dialogConfig.source_url;
}
return baseDialog;
}
// Helper function to create form response structure
function createFormResponse(formConfig) {
return {
callback_id: formConfig.callback_id,
title: formConfig.title,
submit_label: formConfig.submit_label || 'Submit',
notify_on_cancel: true,
elements: formConfig.elements || [],
...formConfig.form_props,
};
}
// Helper function to create common form elements
function createElement(type, config) {
const baseElement = {
display_name: config.display_name,
name: config.name,
type,
optional: config.optional || false,
};
if (config.placeholder) {
baseElement.placeholder = config.placeholder;
}
if (config.help_text) {
baseElement.help_text = config.help_text;
}
if (config.default) {
baseElement.default = config.default;
}
if (config.subtype) {
baseElement.subtype = config.subtype;
}
if (config.min_length) {
baseElement.min_length = config.min_length;
}
if (config.max_length) {
baseElement.max_length = config.max_length;
}
if (config.data_source) {
baseElement.data_source = config.data_source;
}
if (config.options) {
baseElement.options = config.options;
}
if (config.refresh) {
baseElement.refresh = config.refresh;
}
if (config.action_button) {
baseElement.action_button = config.action_button;
}
return baseElement;
}
// Standard icon URL
const STANDARD_ICON = 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png';
// Dialog configurations
const DIALOG_CONFIGS = {
full: {
callback_id: 'somecallbackid',
title: 'Title for Full Dialog Test',
icon_url: STANDARD_ICON,
elements: [
createElement('text', {
display_name: 'Display Name',
name: 'realname',
default: 'default text',
placeholder: 'placeholder',
help_text: 'This a regular input in an interactive dialog triggered by a test integration.',
}),
createElement('text', {
display_name: 'Email',
name: 'someemail',
subtype: 'email',
placeholder: 'placeholder@bladekick.com',
help_text: 'This a regular email input in an interactive dialog triggered by a test integration.',
}),
createElement('text', {display_name: 'Number', name: 'somenumber', subtype: 'number'}),
createElement('text', {
display_name: 'Password',
name: 'somepassword',
subtype: 'password',
default: 'p@ssW0rd',
placeholder: 'placeholder',
help_text: 'This a password input in an interactive dialog triggered by a test integration.',
optional: true,
}),
createElement('textarea', {
display_name: 'Display Name Long Text Area',
name: 'realnametextarea',
placeholder: 'placeholder',
optional: true,
min_length: 5,
max_length: 100,
}),
createElement('select', {
display_name: 'User Selector',
name: 'someuserselector',
placeholder: 'Select a user...',
data_source: 'users',
}),
createElement('select', {
display_name: 'Channel Selector',
name: 'somechannelselector',
placeholder: 'Select a channel...',
help_text: 'Choose a channel from the list.',
data_source: 'channels',
optional: true,
}),
createElement('select', {
display_name: 'Option Selector',
name: 'someoptionselector',
placeholder: 'Select an option...',
options: [
{text: 'Option1', value: 'opt1'},
{text: 'Option2', value: 'opt2'},
{text: 'Option3', value: 'opt3'},
],
}),
createElement('radio', {
display_name: 'Radio Option Selector',
name: 'someradiooptions',
help_text: '',
options: [
{text: 'Engineering', value: 'engineering'},
{text: 'Sales', value: 'sales'},
],
}),
createElement('bool', {
display_name: 'Boolean Selector',
name: 'boolean_input',
placeholder: 'Was this modal helpful?',
default: 'True',
optional: true,
help_text: 'This is the help text',
}),
],
dialog_props: {state: 'somestate'},
},
simple: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test without elements',
icon_url: STANDARD_ICON,
submit_label: 'Submit Test',
dialog_props: {state: 'somestate'},
},
userAndChannel: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with user and channel element',
icon_url: STANDARD_ICON,
submit_label: 'Submit Test',
elements: [
createElement('select', {
display_name: 'User Selector',
name: 'someuserselector',
placeholder: 'Select a user...',
data_source: 'users',
}),
createElement('select', {
display_name: 'Channel Selector',
name: 'somechannelselector',
placeholder: 'Select a channel...',
help_text: 'Choose a channel from the list.',
data_source: 'channels',
optional: true,
}),
],
dialog_props: {state: 'somestate'},
},
boolean: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with boolean element',
icon_url: STANDARD_ICON,
submit_label: 'Submit Test',
elements: [
createElement('bool', {
display_name: 'Boolean Selector',
name: 'boolean_input',
placeholder: 'Was this modal helpful?',
default: 'True',
optional: true,
help_text: 'This is the help text',
}),
],
dialog_props: {state: 'somestate'},
},
fieldRefresh: {
callback_id: 'field_refresh_callback',
title: 'Field Refresh Demo',
introduction_text: 'Enter project name then select type to see different fields',
elements: [
createElement('text', {
display_name: 'Project Name',
name: 'project_name',
placeholder: 'Enter project name',
}),
createElement('select', {
display_name: 'Project Type',
name: 'project_type',
refresh: true,
placeholder: 'Select project type...',
options: [
{text: 'Web Application', value: 'web'},
{text: 'Mobile App', value: 'mobile'},
{text: 'API Service', value: 'api'},
],
}),
],
},
multistepStep1: {
callback_id: 'multistep_callback',
title: 'Step 1 - Personal Info',
introduction_text: 'Multi-step registration - Step 1 of 3',
submit_label: 'Next Step',
elements: [
createElement('text', {
display_name: 'First Name',
name: 'first_name',
placeholder: 'Enter your first name',
}),
createElement('text', {
display_name: 'Email',
name: 'email',
subtype: 'email',
placeholder: 'Enter your email address',
}),
],
dialog_props: {state: 'step1'},
},
multistepStep2: {
callback_id: 'multistep_callback',
title: 'Step 2 - Work Info',
introduction_text: 'Multi-step registration - Step 2 of 3',
submit_label: 'Next Step',
elements: [
createElement('select', {
display_name: 'Department',
name: 'department',
placeholder: 'Select department...',
options: [
{text: 'Engineering', value: 'engineering'},
{text: 'Marketing', value: 'marketing'},
{text: 'Sales', value: 'sales'},
],
}),
createElement('radio', {
display_name: 'Experience Level',
name: 'experience_level',
options: [
{text: 'Junior', value: 'junior'},
{text: 'Mid-level', value: 'mid'},
{text: 'Senior', value: 'senior'},
],
}),
],
form_props: {state: 'step2'},
},
multistepStep3: {
callback_id: 'multistep_callback',
title: 'Step 3 - Final Details',
introduction_text: 'Multi-step registration - Step 3 of 3',
submit_label: 'Complete Registration',
elements: [
createElement('textarea', {
display_name: 'Comments',
name: 'comments',
placeholder: 'Any additional comments...',
optional: true,
}),
createElement('bool', {display_name: 'Terms & Conditions', name: 'terms_accepted'}),
],
form_props: {state: 'step3'},
},
actionButtonParent: {
callback_id: 'action_button_parent_callback',
title: 'Parent Dialog with Action Button',
elements: [],
},
actionButtonChild: {
callback_id: 'child_callback',
title: 'Child Dialog',
elements: [
createElement('text', {
display_name: 'Child Input',
name: 'child_input',
placeholder: 'Enter value',
optional: true,
}),
],
},
};
// Public API functions
function getFullDialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.full);
}
function getSimpleDialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.simple);
}
function getUserAndChannelDialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.userAndChannel);
}
function getBooleanDialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.boolean);
}
function getFieldRefreshDialog(triggerId, webhookBaseUrl) {
const config = {...DIALOG_CONFIGS.fieldRefresh};
config.source_url = `${webhookBaseUrl}/field_refresh_source`;
return createDialog(triggerId, webhookBaseUrl, config);
}
function getMultistepStep1Dialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.multistepStep1);
}
function getMultistepStep2Dialog(triggerId, webhookBaseUrl) {
const config = {...DIALOG_CONFIGS.multistepStep2};
config.dialog_props = {url: `${webhookBaseUrl}/dialog_submit`, ...config.form_props};
return createFormResponse(config);
}
function getMultistepStep3Dialog(triggerId, webhookBaseUrl) {
const config = {...DIALOG_CONFIGS.multistepStep3};
config.dialog_props = {url: `${webhookBaseUrl}/dialog_submit`, ...config.form_props};
return createFormResponse(config);
}
function getMultiSelectDialog(triggerId, webhookBaseUrl, includeDefaults = false) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with multiselect elements',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
submit_label: 'Submit Multiselect Test',
notify_on_cancel: true,
state: 'somestate',
elements: [
{
display_name: 'Multi Option Selector',
name: 'multiselect_options',
type: 'select',
multiselect: true,
default: includeDefaults ? 'opt1,opt3' : '',
placeholder: 'Select multiple options...',
help_text: 'You can select multiple options from this list.',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [
{
text: 'Engineering',
value: 'opt1',
},
{
text: 'Sales',
value: 'opt2',
},
{
text: 'Marketing',
value: 'opt3',
},
{
text: 'Support',
value: 'opt4',
},
{
text: 'Product',
value: 'opt5',
},
],
},
{
display_name: 'Multi User Selector',
name: 'multiselect_users',
type: 'select',
multiselect: true,
default: '',
placeholder: 'Select multiple users...',
help_text: 'Choose multiple users from the team.',
optional: false,
min_length: 0,
max_length: 0,
data_source: 'users',
options: null,
},
{
display_name: 'Single Option Selector',
name: 'single_select_options',
type: 'select',
multiselect: false,
default: includeDefaults ? 'single2' : '',
placeholder: 'Select one option...',
help_text: 'This is a regular single-select for comparison.',
optional: false,
min_length: 0,
max_length: 0,
data_source: '',
options: [
{
text: 'Single Option 1',
value: 'single1',
},
{
text: 'Single Option 2',
value: 'single2',
},
{
text: 'Single Option 3',
value: 'single3',
},
],
},
],
},
};
}
function getDynamicSelectDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with dynamic select element',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
submit_label: 'Submit Dynamic Select Test',
notify_on_cancel: true,
state: 'somestate',
elements: [
{
display_name: 'Dynamic Role Selector',
name: 'dynamic_role_selector',
type: 'select',
data_source: 'dynamic',
data_source_url: `${webhookBaseUrl}/dynamic_select_source`,
default: '',
placeholder: 'Search for a role...',
help_text: 'Start typing to search for available roles. Options are loaded dynamically.',
optional: false,
min_length: 0,
max_length: 0,
},
{
display_name: 'Optional Dynamic Selector',
name: 'optional_dynamic_selector',
type: 'select',
data_source: 'dynamic',
data_source_url: `${webhookBaseUrl}/dynamic_select_source`,
default: 'backend_eng',
placeholder: 'Search for another role...',
help_text: 'This field is optional and has a default value.',
optional: true,
min_length: 0,
max_length: 0,
},
],
},
};
}
// Basic date field test - MM-T2530A
function getBasicDateDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/datetime_dialog_submit`,
dialog: {
callback_id: 'basic_date_callback',
title: 'DateTime Fields Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
elements: [
{
display_name: 'Event Date',
name: 'event_date',
type: 'date',
default: '',
placeholder: 'Select a date',
help_text: 'Select the date for your event',
optional: false,
},
],
submit_label: 'Submit',
notify_on_cancel: true,
state: 'datetime_state',
},
};
}
// Basic datetime field test - MM-T2530B
function getBasicDateTimeDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/datetime_dialog_submit`,
dialog: {
callback_id: 'basic_datetime_callback',
title: 'DateTime Fields Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
elements: [
{
display_name: 'Event Date',
name: 'event_date',
type: 'date',
default: '',
placeholder: 'Select a date',
help_text: 'Select the date for your event',
optional: false,
},
{
display_name: 'Meeting Time',
name: 'meeting_time',
type: 'datetime',
default: '',
placeholder: 'Select date and time',
help_text: 'Select the date and time for your meeting',
optional: false,
datetime_config: {
time_interval: 60,
},
},
],
submit_label: 'Submit',
notify_on_cancel: true,
state: 'datetime_state',
},
};
}
// Date field with min_date constraint - MM-T2530C
function getMinDateConstraintDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/datetime_dialog_submit`,
dialog: {
callback_id: 'mindate_callback',
title: 'DateTime Fields Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
elements: [
{
display_name: 'Future Date Only',
name: 'future_date',
type: 'date',
default: '',
placeholder: 'Select a future date',
help_text: 'Must be today or later',
optional: true,
datetime_config: {
min_date: 'today',
},
},
],
submit_label: 'Submit',
notify_on_cancel: true,
state: 'datetime_state',
},
};
}
// DateTime field with custom time interval - MM-T2530D
function getCustomIntervalDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/datetime_dialog_submit`,
dialog: {
callback_id: 'interval_callback',
title: 'DateTime Fields Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
elements: [
{
display_name: 'Custom Interval Time',
name: 'interval_time',
type: 'datetime',
default: '',
placeholder: 'Select time (30min intervals)',
help_text: 'Time picker with 30-minute intervals',
optional: true,
datetime_config: {
time_interval: 30,
},
},
],
submit_label: 'Submit',
notify_on_cancel: true,
state: 'datetime_state',
},
};
}
// Relative date values test - MM-T2530F
function getRelativeDateDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/datetime_dialog_submit`,
dialog: {
callback_id: 'relative_callback',
title: 'DateTime Fields Test',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
elements: [
{
display_name: 'Relative Date Example',
name: 'relative_date',
type: 'date',
default: 'today',
placeholder: 'Today by default',
help_text: 'Defaults to today using relative date',
optional: true,
},
{
display_name: 'Relative DateTime Example',
name: 'relative_datetime',
type: 'datetime',
default: '+1d',
placeholder: 'Tomorrow by default',
help_text: 'Defaults to tomorrow using relative date',
optional: true,
},
],
submit_label: 'Submit',
notify_on_cancel: true,
state: 'datetime_state',
},
};
}
// Legacy function for backward compatibility - returns basic datetime dialog
function getDateTimeDialog(triggerId, webhookBaseUrl) {
return getBasicDateTimeDialog(triggerId, webhookBaseUrl);
}
function getTimezoneManualDialog(triggerId, webhookBaseUrl) {
return createDialog(triggerId, webhookBaseUrl, {
callback_id: 'timezone_manual',
title: 'Timezone & Manual Entry Demo',
introduction_text:
'**Timezone & Manual Entry Demo**\n\n' +
'This dialog demonstrates timezone support and manual time entry features.',
elements: [
{
display_name: 'Your Local Time (Manual Entry)',
name: 'local_manual',
type: 'datetime',
help_text: 'Type any time: 9am, 14:30, 3:45pm - no rounding',
datetime_config: {
manual_time_entry: true,
},
optional: true,
},
{
display_name: 'London Office Hours (Dropdown)',
name: 'london_dropdown',
type: 'datetime',
help_text: 'Times shown in GMT - select from 60 min intervals',
datetime_config: {
location_timezone: 'Europe/London',
time_interval: 60,
},
optional: true,
},
{
display_name: 'London Office Hours (Manual Entry)',
name: 'london_manual',
type: 'datetime',
help_text: 'Type time in GMT: 9am, 14:30, 3:45pm - no rounding',
datetime_config: {
location_timezone: 'Europe/London',
manual_time_entry: true,
},
optional: true,
},
],
});
}
function getFileUploadDialog(triggerId, webhookBaseUrl) {
return {
trigger_id: triggerId,
url: `${webhookBaseUrl}/dialog_submit`,
dialog: {
callback_id: 'somecallbackid',
title: 'Title for Dialog Test with file upload element',
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
submit_label: 'Submit File Upload Test',
notify_on_cancel: true,
state: 'somestate',
elements: [
{
display_name: 'Upload Single Document',
name: 'single_document',
type: 'file',
placeholder: 'Select one document...',
help_text: 'Upload a single document (replaces previous selection).',
optional: false,
},
{
display_name: 'Upload Multiple Files',
name: 'multiple_files',
type: 'file',
allow_multiple: true,
placeholder: 'Select multiple files...',
help_text: 'Upload multiple files (can select and add more).',
optional: false,
},
{
display_name: 'Description',
name: 'description',
type: 'textarea',
subtype: '',
default: '',
placeholder: 'Describe the uploaded files...',
help_text: 'Provide a description for the uploaded files.',
optional: true,
min_length: 0,
max_length: 500,
data_source: '',
options: null,
},
],
},
};
}
function getActionButtonParentDialog(triggerId, webhookBaseUrl) {
const config = {
...DIALOG_CONFIGS.actionButtonParent,
elements: [
createElement('text', {
display_name: 'Your Name',
name: 'your_name',
placeholder: 'Enter your name',
optional: true,
}),
// Two action buttons on the same dialog. Each carries a distinct
// context.source so the child dialog can reflect which one was pressed.
createElement('action_button', {
display_name: 'Open Details',
name: 'open_details',
action_button: {
url: `${webhookBaseUrl}/dialog/open_child`,
context: {source: 'Details'},
},
}),
createElement('action_button', {
display_name: 'Open Summary',
name: 'open_summary',
action_button: {
url: `${webhookBaseUrl}/dialog/open_child`,
context: {source: 'Summary'},
},
}),
],
};
return createDialog(triggerId, webhookBaseUrl, config);
}
// `source` comes from the pressed action button's context.source and is reflected
// in the child dialog's title and introduction text, so a test can verify which
// button opened it.
function getActionButtonChildDialog(triggerId, webhookBaseUrl, source) {
const label = source || 'Unknown';
const config = {
...DIALOG_CONFIGS.actionButtonChild,
title: `${label} Dialog`,
introduction_text: `This child dialog was opened from the "${label}" action button.`,
};
return createDialog(triggerId, webhookBaseUrl, config);
}
module.exports = {
getFullDialog,
getSimpleDialog,
getUserAndChannelDialog,
getBooleanDialog,
getFieldRefreshDialog,
getMultistepStep1Dialog,
getMultistepStep2Dialog,
getMultistepStep3Dialog,
getMultiSelectDialog,
getDynamicSelectDialog,
getDateTimeDialog,
getBasicDateDialog,
getBasicDateTimeDialog,
getMinDateConstraintDialog,
getCustomIntervalDialog,
getRelativeDateDialog,
getTimezoneManualDialog,
getFileUploadDialog,
getActionButtonParentDialog,
getActionButtonChildDialog,
};

View File

@@ -0,0 +1,770 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable no-console */
const express = require('express');
const axios = require('axios');
const ClientOAuth2 = require('client-oauth2');
const webhookUtils = require('./utils/webhook_utils');
const postMessageAs = require('./tests/plugins/post_message_as');
const port = 3000;
const server = express();
server.use(express.json());
server.use(express.urlencoded({extended: true}));
process.title = process.argv[2];
server.get('/', ping);
server.post('/setup', doSetup);
server.post('/message_menus', postMessageMenus);
server.post('/dialog_request', onDialogRequest);
server.post('/simple_dialog_request', onSimpleDialogRequest);
server.post('/user_and_channel_dialog_request', onUserAndChannelDialogRequest);
server.post('/dialog_submit', onDialogSubmit);
server.post('/boolean_dialog_request', onBooleanDialogRequest);
server.post('/multiselect_dialog_request', onMultiSelectDialogRequest);
server.post('/dynamic_select_dialog_request', onDynamicSelectDialogRequest);
server.post('/file_upload_dialog_request', onFileUploadDialogRequest);
server.post('/dynamic_select_source', onDynamicSelectSource);
server.post('/dialog/field-refresh', onFieldRefreshDialogRequest);
server.post('/dialog/multistep', onMultistepDialogRequest);
server.post('/dialog/action_button_request', onActionButtonDialogRequest);
server.post('/dialog/open_child', onOpenChildDialog);
server.post('/field_refresh_source', onFieldRefreshSource);
server.post('/datetime_dialog_request', onDateTimeDialogRequest);
server.post('/datetime_dialog_submit', onDateTimeDialogSubmit);
server.post('/slack_compatible_message_response', postSlackCompatibleMessageResponse);
server.post('/mm_blocks_integration', postMmBlocksIntegration);
server.post('/mm_blocks_integration_update', postMmBlocksIntegrationUpdate);
server.post('/mm_blocks_integration_static_select', postMmBlocksIntegrationStaticSelect);
server.post('/mm_blocks_integration_echo_query', postMmBlocksIntegrationEchoQuery);
server.post('/mm_blocks_integration_echo_context', postMmBlocksIntegrationEchoContext);
server.post('/send_message_to_channel', postSendMessageToChannel);
server.post('/post_outgoing_webhook', postOutgoingWebhook);
server.post('/send_oauth_credentials', postSendOauthCredentials);
server.get('/start_oauth', getStartOAuth);
server.get('/complete_oauth', getCompleteOauth);
server.post('/post_oauth_message', postOAuthMessage);
server.listen(port, (err) => {
if (err) {
console.error(err);
throw err;
}
console.log(`Webhook test server listening on port ${port}!`);
});
function ping(req, res) {
return res.json({
message: "I'm alive!",
endpoints: [
'GET /',
'POST /setup',
'POST /message_menus',
'POST /dialog_request',
'POST /simple_dialog_request',
'POST /user_and_channel_dialog_request',
'POST /dialog_submit',
'POST /boolean_dialog_request',
'POST /multiselect_dialog_request',
'POST /dynamic_select_dialog_request',
'POST /file_upload_dialog_request',
'POST /dynamic_select_source',
'POST /dialog/field-refresh',
'POST /dialog/multistep',
'POST /dialog/action_button_request',
'POST /dialog/open_child',
'POST /field_refresh_source',
'POST /datetime_dialog_request',
'POST /datetime_dialog_submit',
'POST /slack_compatible_message_response',
'POST /mm_blocks_integration',
'POST /mm_blocks_integration_update',
'POST /mm_blocks_integration_static_select',
'POST /mm_blocks_integration_echo_query',
'POST /mm_blocks_integration_echo_context',
'POST /send_message_to_channel',
'POST /post_outgoing_webhook',
'POST /send_oauth_credentials',
'GET /start_oauth',
'GET /complete_oauth',
'POST /post_oauth_message',
],
});
}
// Set base URLs and credential to be accessible by any endpoint
let baseUrl;
let webhookBaseUrl;
let adminUsername;
let adminPassword;
function doSetup(req, res) {
baseUrl = req.body.baseUrl;
webhookBaseUrl = req.body.webhookBaseUrl;
adminUsername = req.body.adminUsername;
adminPassword = req.body.adminPassword;
return res.status(201).send('Successfully setup the new base URLs and credential.');
}
let client;
let authedUser;
function postSendOauthCredentials(req, res) {
const {appID, appSecret} = req.body;
client = new ClientOAuth2({
clientId: appID,
clientSecret: appSecret,
authorizationUri: `${baseUrl}/oauth/authorize`,
accessTokenUri: `${baseUrl}/oauth/access_token`,
redirectUri: `${webhookBaseUrl}/complete_oauth`,
});
return res.status(200).send('OK');
}
function getStartOAuth(req, res) {
return res.redirect(client.code.getUri());
}
function getCompleteOauth(req, res) {
client.code
.getToken(req.originalUrl)
.then((user) => {
authedUser = user;
return res.status(200).send('OK');
})
.catch((reason) => {
return res.status(reason.status).send(reason);
});
}
async function postOAuthMessage(req, res) {
const {channelId, message, rootId, createAt} = req.body;
const apiUrl = `${baseUrl}/api/v4/posts`;
authedUser.sign({
method: 'post',
url: apiUrl,
});
try {
await axios({
url: apiUrl,
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
Authorization: 'Bearer ' + authedUser.accessToken,
},
method: 'post',
data: {
channel_id: channelId,
message,
type: '',
create_at: createAt,
root_id: rootId,
},
});
} catch {
// Do nothing
}
return res.status(200).send('OK');
}
function postSlackCompatibleMessageResponse(req, res) {
const {spoiler, skipSlackParsing} = req.body.context;
res.setHeader('Content-Type', 'application/json');
return res.json({
ephemeral_text: spoiler,
skip_slack_parsing: skipSlackParsing,
});
}
/**
* Mattermost mm_blocks external actions POST the same integration envelope as legacy message buttons.
* @see model.PostActionIntegrationResponse
*/
function postMmBlocksIntegration(req, res) {
const userName = req.body && req.body.user_name ? req.body.user_name : 'unknown';
res.setHeader('Content-Type', 'application/json');
return res.status(200).json({
ephemeral_text: `Playwright mm_blocks integration OK (user: ${userName}).`,
skip_slack_parsing: true,
});
}
/**
* Returns a PostActionIntegrationResponse update so the interactive post is edited in-place
* (persisted webhook post or ephemeral mm_blocks post).
*/
function postMmBlocksIntegrationUpdate(req, res) {
res.setHeader('Content-Type', 'application/json');
return res.status(200).json({
update: {
message: 'E2E mm_blocks post updated (message field).',
props: {
mm_blocks: [
{
type: 'text',
text: 'PLAYWRIGHT_MM_BLOCKS_UPDATED',
},
],
},
},
skip_slack_parsing: true,
});
}
/** Echoes URL query parameters Mattermost merged onto the integration request (action query + block query). */
function postMmBlocksIntegrationEchoQuery(req, res) {
const entries = Object.keys(req.query || {})
.sort()
.map((k) => `${k}=${String(req.query[k])}`);
const summary = entries.join('&');
res.setHeader('Content-Type', 'application/json');
return res.status(200).json({
ephemeral_text: `Playwright mm_blocks query OK (${summary})`,
skip_slack_parsing: true,
});
}
/** Echoes `context.test_marker` from the Mattermost integration POST body for mm_blocks external actions. */
function postMmBlocksIntegrationEchoContext(req, res) {
const ctx = (req.body && req.body.context) || {};
const marker = typeof ctx.test_marker === 'string' ? ctx.test_marker : JSON.stringify(ctx.test_marker ?? null);
res.setHeader('Content-Type', 'application/json');
return res.status(200).json({
ephemeral_text: `Playwright mm_blocks context OK (test_marker: ${marker}).`,
skip_slack_parsing: true,
});
}
/** Echoes `context.selected_option` from the Mattermost integration POST for mm_blocks static_select. */
function postMmBlocksIntegrationStaticSelect(req, res) {
const selected = req.body && req.body.context && req.body.context.selected_option;
const label = typeof selected === 'string' ? selected : JSON.stringify(selected ?? null);
res.setHeader('Content-Type', 'application/json');
return res.status(200).json({
ephemeral_text: `Playwright mm_blocks static_select OK (selected_option: ${label}).`,
skip_slack_parsing: true,
});
}
function postMessageMenus(req, res) {
let responseData = {};
const {body} = req;
if (body && body.context.action === 'do_something') {
responseData = {
ephemeral_text: `Ephemeral | ${body.type} ${body.data_source} option: ${body.context.selected_option}`,
};
}
res.setHeader('Content-Type', 'application/json');
return res.json(responseData);
}
async function openDialog(dialog) {
// Callers invoke this fire-and-forget (no await/catch), so any rejection here
// would become an unhandled rejection and crash the whole webhook process.
// Guard against a missing baseUrl (set by /setup) and swallow request errors.
if (!baseUrl) {
console.error('openDialog called before /setup ran — baseUrl is not set; skipping dialog open');
return;
}
try {
await axios({
method: 'post',
url: `${baseUrl}/api/v4/actions/dialogs/open`,
data: dialog,
});
} catch (err) {
const status = err.response && err.response.status;
const body = err.response && err.response.data;
console.error(
'openDialog request failed:',
status || err.code || err.message,
body ? JSON.stringify(body) : '',
);
}
}
function onDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getFullDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Full dialog triggered via slash command!'});
}
function onSimpleDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getSimpleDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Simple dialog triggered via slash command!'});
}
function onUserAndChannelDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getUserAndChannelDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Simple dialog triggered via slash command!'});
}
function onBooleanDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getBooleanDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Simple dialog triggered via slash command!'});
}
function onMultiSelectDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
// Check URL parameters or body for includeDefaults flag
const includeDefaults = req.query.includeDefaults === 'true' || req.query.includeDefaults === true;
const dialog = webhookUtils.getMultiSelectDialog(body.trigger_id, webhookBaseUrl, includeDefaults);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Multiselect dialog triggered via slash command!'});
}
function onDynamicSelectDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getDynamicSelectDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Dynamic select dialog triggered via slash command!'});
}
function onFileUploadDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getFileUploadDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'File upload dialog triggered via slash command!'});
}
function onDynamicSelectSource(req, res) {
const {body} = req;
// Simulate dynamic options based on search text
const searchText = (body.submission.query || '').toLowerCase();
const allOptions = [
{text: 'Backend Engineer', value: 'backend_eng'},
{text: 'Frontend Engineer', value: 'frontend_eng'},
{text: 'Full Stack Engineer', value: 'fullstack_eng'},
{text: 'DevOps Engineer', value: 'devops_eng'},
{text: 'QA Engineer', value: 'qa_eng'},
{text: 'Product Manager', value: 'product_mgr'},
{text: 'Engineering Manager', value: 'eng_mgr'},
{text: 'Senior Backend Engineer', value: 'sr_backend_eng'},
{text: 'Senior Frontend Engineer', value: 'sr_frontend_eng'},
{text: 'Principal Engineer', value: 'principal_eng'},
{text: 'Staff Engineer', value: 'staff_eng'},
{text: 'Technical Lead', value: 'tech_lead'},
];
// Filter options based on search text
const filteredOptions = searchText
? allOptions.filter(
(option) =>
option.text.toLowerCase().includes(searchText) || option.value.toLowerCase().includes(searchText),
)
: allOptions.slice(0, 6); // Limit to first 6 if no search
res.setHeader('Content-Type', 'application/json');
return res.json({
items: filteredOptions,
});
}
function onDateTimeDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
let dialog;
const command = body.text ? body.text.trim() : '';
// Use focused dialog functions based on command parameter
switch (command) {
case 'basic':
dialog = webhookUtils.getBasicDateDialog(body.trigger_id, webhookBaseUrl);
break;
case 'mindate':
dialog = webhookUtils.getMinDateConstraintDialog(body.trigger_id, webhookBaseUrl);
break;
case 'interval':
dialog = webhookUtils.getCustomIntervalDialog(body.trigger_id, webhookBaseUrl);
break;
case 'relative':
dialog = webhookUtils.getRelativeDateDialog(body.trigger_id, webhookBaseUrl);
break;
case 'timezone-manual':
dialog = webhookUtils.getTimezoneManualDialog(body.trigger_id, webhookBaseUrl);
break;
default:
// Default to basic datetime dialog for backward compatibility
dialog = webhookUtils.getBasicDateTimeDialog(body.trigger_id, webhookBaseUrl);
break;
}
console.log('Opening DateTime dialog', dialog.dialog.title);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'DateTime dialog triggered via slash command!'});
}
function onDateTimeDialogSubmit(req, res) {
console.log('DateTime dialog submit handler called!');
const {body} = req;
res.setHeader('Content-Type', 'application/json');
// Log the submitted datetime values for debugging
console.log('DateTime dialog submission:', JSON.stringify(body, null, 2));
// Extract datetime values from submission
const submission = body.submission || {};
const eventDate = submission.event_date;
const meetingTime = submission.meeting_time;
const relativeDate = submission.relative_date;
const relativeDateTime = submission.relative_datetime;
// Create a success message with the submitted values
let message = 'Form submitted successfully! ';
if (eventDate || meetingTime || relativeDate || relativeDateTime) {
message += 'Submitted values: ';
if (eventDate) {
message += `Event Date: ${eventDate}, `;
}
if (meetingTime) {
message += `Meeting Time: ${meetingTime}, `;
}
if (relativeDate) {
message += `Relative Date: ${relativeDate}, `;
}
if (relativeDateTime) {
message += `Relative DateTime: ${relativeDateTime}, `;
}
message = message.slice(0, -2); // Remove trailing comma and space
}
// Send success response that will appear as a post in the channel
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
function onDialogSubmit(req, res) {
const {body} = req;
res.setHeader('Content-Type', 'application/json');
let message;
if (body.cancelled) {
message = 'Dialog cancelled';
console.log('[WEBHOOK] Dialog cancelled');
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Check if this is a multistep submission
if (body.callback_id === 'multistep_callback') {
const currentState = body.state || '';
// Determine next step based on current state
if (currentState === 'step1') {
// Move to step 2
const nextForm = webhookUtils.getMultistepStep2Dialog(null, webhookBaseUrl);
return res.json({
type: 'form',
form: nextForm,
});
} else if (currentState === 'step2') {
// Move to step 3
const nextForm = webhookUtils.getMultistepStep3Dialog(null, webhookBaseUrl);
return res.json({
type: 'form',
form: nextForm,
});
}
// Final step - complete the multistep
const submission = body.submission || {};
message = `Multistep completed successfully! Final step values: ${JSON.stringify(submission, null, 2)}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Check if this is a field refresh dialog submission
if (body.callback_id === 'field_refresh_callback') {
const submission = body.submission || {};
message = `Field refresh dialog submitted successfully! Values: ${JSON.stringify(submission, null, 2)}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
// Regular dialog submission
// Format submission data for the channel message
const sanitize = (str) => String(str).replace(/[<>&"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
const submissionData = Object.entries(body.submission || {})
.map(([key, value]) => `**${sanitize(key)}**: ${sanitize(value)}`)
.join('\n');
message = `Dialog submitted successfully!\n\n**Submission Data:**\n${submissionData}`;
sendSysadminResponse(message, body.channel_id);
return res.json({text: message});
}
/**
* @route "POST /send_message_to_channel?type={messageType}&channel_id={channelId}"
* @query type - message type of empty string for regular message if not provided (default), "system_message", etc
* @query channel_id - channel where to send the message
*/
function postSendMessageToChannel(req, res) {
const channelId = req.query.channel_id;
const response = {
response_type: 'in_channel',
text: 'Extra response 2',
channel_id: channelId,
extra_responses: [
{
response_type: 'in_channel',
text: 'Hello World',
channel_id: channelId,
},
],
};
if (req.query.type) {
response.type = req.query.type;
}
res.json(response);
}
// Convenient way to send response in a channel by using sysadmin account
function sendSysadminResponse(message, channelId) {
postMessageAs({
sender: {
username: adminUsername,
password: adminPassword,
},
message,
channelId,
baseUrl,
});
}
const responseTypes = ['in_channel', 'comment'];
function getWebhookResponse(body, {responseType, username, iconUrl}) {
const payload = Object.entries(body)
.map(([key, value]) => `- ${key}: "${value}"`)
.join('\n');
return `
\`\`\`
#### Outgoing Webhook Payload
${payload}
#### Webhook override to Mattermost instance
- response_type: "${responseType}"
- type: ""
- username: "${username}"
- icon_url: "${iconUrl}"
\`\`\`
`;
}
/**
* @route "POST /post_outgoing_webhook?override_username={username}&override_icon_url={iconUrl}&response_type={comment}"
* @query override_username - the user name that overrides the user name defined by the outgoing webhook
* @query override_icon_url - the user icon url that overrides the user icon url defined by the outgoing webhook
* @query response_type - "in_channel" (default) or "comment"
*/
function postOutgoingWebhook(req, res) {
const {body, query} = req;
if (!body) {
res.status(404).send({error: 'Invalid data'});
}
const responseType = query.response_type || responseTypes[0];
const username = query.override_username || '';
const iconUrl = query.override_icon_url || '';
const response = {
text: getWebhookResponse(body, {responseType, username, iconUrl}),
username,
icon_url: iconUrl,
type: '',
response_type: responseType,
};
res.status(200).send(response);
}
function onFieldRefreshDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getFieldRefreshDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Field refresh dialog triggered via slash command!'});
}
function onMultistepDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getMultistepStep1Dialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Multistep dialog triggered via slash command!'});
}
function onActionButtonDialogRequest(req, res) {
const {body} = req;
if (body.trigger_id) {
const dialog = webhookUtils.getActionButtonParentDialog(body.trigger_id, webhookBaseUrl);
openDialog(dialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({text: 'Action button dialog triggered!'});
}
async function onOpenChildDialog(req, res) {
const {body} = req;
// context.source identifies which action button on the parent dialog was
// pressed; it is forwarded by the server in the PostActionIntegrationRequest.
const source = (body.context && body.context.source) || 'Unknown';
console.log('onOpenChildDialog called with trigger_id:', body.trigger_id, 'source:', source);
if (body.trigger_id) {
const childDialog = webhookUtils.getActionButtonChildDialog(body.trigger_id, webhookBaseUrl, source);
// Await the dialog open before responding. The server's /execute call
// (DoActionRequest) waits for this response, so awaiting here ensures the
// child's WS open_dialog event is published before the browser's
// executeDialogAction promise resolves — removing the render race in tests.
await openDialog(childDialog);
}
res.setHeader('Content-Type', 'application/json');
return res.json({});
}
function onFieldRefreshSource(req, res) {
const {body} = req;
const submission = body.submission || {};
const projectType = submission.project_type;
const projectName = submission.project_name || '';
res.setHeader('Content-Type', 'application/json');
// Return updated form based on project type selection
const elements = [
{
display_name: 'Project Name',
name: 'project_name',
type: 'text',
placeholder: 'Enter project name',
default: projectName,
optional: false,
},
{
display_name: 'Project Type',
name: 'project_type',
type: 'select',
refresh: true,
placeholder: 'Select project type...',
default: projectType,
options: [
{text: 'Web Application', value: 'web'},
{text: 'Mobile App', value: 'mobile'},
{text: 'API Service', value: 'api'},
],
},
];
// Add different fields based on project type
if (projectType === 'web') {
elements.push({
display_name: 'Framework',
name: 'framework',
type: 'select',
placeholder: 'Select framework...',
options: [
{text: 'React', value: 'react'},
{text: 'Vue', value: 'vue'},
{text: 'Angular', value: 'angular'},
],
});
} else if (projectType === 'mobile') {
elements.push({
display_name: 'Platform',
name: 'platform',
type: 'select',
placeholder: 'Select platform...',
options: [
{text: 'iOS', value: 'ios'},
{text: 'Android', value: 'android'},
{text: 'React Native', value: 'react-native'},
],
});
} else if (projectType === 'api') {
elements.push({
display_name: 'Language',
name: 'language',
type: 'select',
placeholder: 'Select language...',
options: [
{text: 'Go', value: 'go'},
{text: 'Node.js', value: 'nodejs'},
{text: 'Python', value: 'python'},
],
});
}
return res.json({
type: 'form',
form: {
title: 'Field Refresh Demo',
introduction_text: 'Enter project name then select type to see different fields',
submit_label: 'Submit',
elements,
},
});
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {AZURITE_ALIAS, AZURITE_BLOB_PORT, TESTCONTAINERS_LABELS} from './constants';
import {AZURITE_IMAGE} from './default_images';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
// An alternative to Minio for blob storage.
export async function startAzuriteContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('azurite', async () => {
let builder = new GenericContainer(AZURITE_IMAGE)
.withCommand([
'azurite-blob',
'--blobHost',
'0.0.0.0',
'--blobPort',
String(AZURITE_BLOB_PORT),
'--skipApiVersionCheck',
])
.withExposedPorts(AZURITE_BLOB_PORT)
.withNetwork(network)
.withNetworkAliases(AZURITE_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withWaitStrategy(Wait.forListeningPorts());
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
// Single source of truth for the fixed values every container/helper needs to agree on.
// Kept separate from test_config.ts because these are not overridable — they're either
// Testcontainers network aliases (only meaningful inside the Testcontainers network) or
// fixed test-only credentials for a throwaway local service.
export const POSTGRES_ALIAS = 'postgres';
export const POSTGRES_PORT = 5432;
export const POSTGRES_DB = 'mattermost_test';
export const POSTGRES_USER = 'mmuser';
export const POSTGRES_PASSWORD = 'mostest_password';
export const INBUCKET_ALIAS = 'inbucket';
export const INBUCKET_WEB_PORT = 9001;
export const INBUCKET_SMTP_PORT = 10025;
export const INBUCKET_POP3_PORT = 10110;
export const MATTERMOST_ALIAS = 'server';
export const MATTERMOST_PORT = 8065;
// Interactive-message/dialog callback sidecar shared with Cypress. Always started, like
// postgres/inbucket — not gated behind testcontainersServices.
export const WEBHOOK_ALIAS = 'webhook';
export const WEBHOOK_PORT = 3000;
export const OPENLDAP_ALIAS = 'openldap';
export const OPENLDAP_PORT = 389;
export const OPENLDAP_ADMIN_DN = 'cn=admin,dc=mm,dc=test,dc=com';
export const OPENLDAP_ADMIN_PASSWORD = 'mostest_password';
export const OPENLDAP_BASE_DN = 'dc=mm,dc=test,dc=com';
export const KEYCLOAK_ALIAS = 'keycloak';
export const KEYCLOAK_PORT = 8080;
export const KEYCLOAK_REALM = 'mattermost';
export const KEYCLOAK_ADMIN_USER = 'admin';
export const KEYCLOAK_ADMIN_PASSWORD = 'admin';
export const ELASTICSEARCH_ALIAS = 'elasticsearch';
export const ELASTICSEARCH_PORT = 9200;
export const MINIO_ALIAS = 'minio';
export const MINIO_PORT = 9000;
export const MINIO_ACCESS_KEY = 'minioaccesskey';
export const MINIO_SECRET_KEY = 'miniosecretkey';
export const MINIO_BUCKET = 'mattermost-test';
// Alternative to Minio for blob storage.
export const AZURITE_ALIAS = 'azurite';
export const AZURITE_BLOB_PORT = 10000;
// Azurite's well-known default emulator account — published by Microsoft's own docs, not a secret.
export const AZURITE_ACCOUNT_NAME = 'devstoreaccount1';
export const AZURITE_ACCOUNT_KEY =
'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==';
export const AZURITE_CONTAINER = 'mattermost-test';
// Alternative to Elasticsearch for search.
export const OPENSEARCH_ALIAS = 'opensearch';
export const OPENSEARCH_PORT = 9201;
export const OPENSEARCH_ADMIN_PASSWORD = 'Test@dmin_123';
// Applied to every container this module starts, so `npm run testcontainers:down` can find and
// remove them from a fresh process — the in-memory `started` state in stack.ts only exists in
// the process that created it.
export const TESTCONTAINERS_LABEL_KEY = 'mm-playwright-testcontainers';
export const TESTCONTAINERS_LABEL_VALUE = 'true';
export const TESTCONTAINERS_LABELS = {[TESTCONTAINERS_LABEL_KEY]: TESTCONTAINERS_LABEL_VALUE};
// Bind-mounted into every Mattermost container at /mattermost/data (mattermost_container.ts) so
// local-disk file storage survives restartMattermostContainer()'s docker rm -f, which would
// otherwise discard the anonymous VOLUME Docker creates for it. Fixed and repo-relative (not an
// os.tmpdir()) so every process — including a later npm-script invocation or CI loop iteration —
// resolves the same path independently, with nothing to persist through .env.testcontainers.
export const MATTERMOST_DATA_DIR = path.resolve(process.cwd(), '.mattermost_data');

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Single place to check and bump every image.
// The Mattermost server's default is overridable via the SERVER_IMAGE env var (testConfig.serverImage).
export const MATTERMOST_SERVER_IMAGE = 'mattermostdevelopment/mattermost-enterprise-edition:master';
export const POSTGRES_IMAGE = 'postgres:15';
export const INBUCKET_IMAGE = 'inbucket/inbucket:3.1.1';
export const OPENLDAP_IMAGE = 'osixia/openldap:1.4.0';
export const KEYCLOAK_IMAGE = 'quay.io/keycloak/keycloak:23.0.7';
export const MINIO_IMAGE = 'cgr.dev/chainguard/minio:latest';
export const AZURITE_IMAGE = 'mcr.microsoft.com/azure-storage/azurite:3.34.0';
// Built from a Dockerfile on top of docker.elastic.co/elasticsearch/elasticsearch, rather than
// pulled as a fixed image — so only the version is fixed here.
export const ELASTICSEARCH_VERSION = '9.0.0';
// Built from a Dockerfile on top of opensearchproject/opensearch, rather than pulled as a fixed
// image — so only the version is fixed here.
export const OPENSEARCH_VERSION = '3.0.0';

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {ELASTICSEARCH_ALIAS, ELASTICSEARCH_PORT, TESTCONTAINERS_LABELS} from './constants';
import {ELASTICSEARCH_VERSION as DEFAULT_ELASTICSEARCH_VERSION} from './default_images';
import {containerAssetPath} from './paths';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
// Built from a vendored Dockerfile, not the generic @testcontainers/elasticsearch wrapper — it
// installs the CJK analysis plugins (analysis-icu/nori/kuromoji/smartcn) real search tests rely on.
export async function startElasticsearchContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('elasticsearch', async () => {
// deleteOnExit: false — otherwise the built image bakes in this session's Ryuk id, so Ryuk
// reaps the "reused" container the moment this session ends, defeating withReuse() below.
const image = await GenericContainer.fromDockerfile(containerAssetPath(), 'Dockerfile.elasticsearch')
.withBuildArgs({
ELASTICSEARCH_VERSION: process.env.ELASTICSEARCH_VERSION || DEFAULT_ELASTICSEARCH_VERSION,
})
.build(undefined, {deleteOnExit: false});
let builder = image
.withExposedPorts(ELASTICSEARCH_PORT)
.withNetwork(network)
.withNetworkAliases(ELASTICSEARCH_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withEnvironment({
'http.host': '0.0.0.0',
'http.port': String(ELASTICSEARCH_PORT),
'xpack.security.enabled': 'false',
'action.destructive_requires_name': 'false',
'transport.host': '127.0.0.1',
ES_JAVA_OPTS: '-Xms512m -Xmx512m',
})
.withStartupTimeout(3 * 60_000)
.withWaitStrategy(Wait.forHttp('/_cluster/health', ELASTICSEARCH_PORT).forStatusCode(200));
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Test-oriented MM_* config the Mattermost server container starts with by default,
// merged under testConfig.serverEnv (MM_ENV) so callers can still override any of it.
export const SERVER_ENV_BASELINE: Record<string, string> = {
MM_SERVICEENVIRONMENT: 'test',
MM_CLUSTERSETTINGS_READONLYCONFIG: 'false',
MM_CONNECTEDWORKSPACESSETTINGS_ENABLEREMOTECLUSTERSERVICE: 'true',
MM_CONNECTEDWORKSPACESSETTINGS_ENABLESHAREDWORKSPACES: 'true',
MM_LOGSETTINGS_CONSOLELEVEL: 'DEBUG',
MM_LOGSETTINGS_ENABLEDIAGNOSTICS: 'false',
MM_PLUGINSETTINGS_ENABLEUPLOADS: 'true',
MM_SERVICESETTINGS_ALLOWCORSFROM: '*',
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: 'keycloak elasticsearch opensearch minio azurite webhook',
MM_SERVICESETTINGS_ENABLELOCALMODE: 'true',
MM_SERVICESETTINGS_ENABLESECURITYFIXALERT: 'false',
MM_SERVICESETTINGS_ENABLETESTING: 'true',
// Feature flags this test suite needs on, off by default in the server
MM_FEATUREFLAGS_ATTRIBUTEVALUEMASKING: 'true',
MM_FEATUREFLAGS_PERMISSIONPOLICIES: 'true',
MM_FEATUREFLAGS_PROPERTYFIELDRANK: 'true',
MM_FEATUREFLAGS_RECURRINGSCHEDULEDPOSTS: 'true',
MM_FEATUREFLAGS_RESOURCEATTRIBUTESINPOLICIES: 'true',
MM_FEATUREFLAGS_TEAMMEMBERSHIPACCESSCONTROL: 'true',
MM_FEATUREFLAGS_WYSIWYGEDITOR: 'true',
};

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {
INBUCKET_ALIAS,
INBUCKET_POP3_PORT,
INBUCKET_SMTP_PORT,
INBUCKET_WEB_PORT,
TESTCONTAINERS_LABELS,
} from './constants';
import {INBUCKET_IMAGE} from './default_images';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
export async function startInbucketContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('inbucket', async () => {
let builder = new GenericContainer(INBUCKET_IMAGE)
.withExposedPorts(INBUCKET_WEB_PORT, INBUCKET_SMTP_PORT, INBUCKET_POP3_PORT)
.withNetwork(network)
.withNetworkAliases(INBUCKET_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withEnvironment({
INBUCKET_WEB_ADDR: `0.0.0.0:${INBUCKET_WEB_PORT}`,
INBUCKET_POP3_ADDR: `0.0.0.0:${INBUCKET_POP3_PORT}`,
INBUCKET_SMTP_ADDR: `0.0.0.0:${INBUCKET_SMTP_PORT}`,
})
.withWaitStrategy(Wait.forListeningPorts());
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {startStack, stopStack} from './stack';

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {
KEYCLOAK_ADMIN_PASSWORD,
KEYCLOAK_ADMIN_USER,
KEYCLOAK_ALIAS,
KEYCLOAK_PORT,
TESTCONTAINERS_LABELS,
} from './constants';
import {KEYCLOAK_IMAGE} from './default_images';
import {containerAssetPath} from './paths';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
export async function startKeycloakContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('keycloak', async () => {
let builder = new GenericContainer(KEYCLOAK_IMAGE)
.withEntrypoint(['/opt/keycloak/bin/kc.sh'])
.withCommand(['start', '--import-realm'])
.withExposedPorts(KEYCLOAK_PORT)
.withNetwork(network)
.withNetworkAliases(KEYCLOAK_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withEnvironment({
KEYCLOAK_ADMIN: KEYCLOAK_ADMIN_USER,
KEYCLOAK_ADMIN_PASSWORD,
KC_HOSTNAME_STRICT: 'false',
KC_HOSTNAME_STRICT_HTTPS: 'false',
KC_HTTP_ENABLED: 'true',
})
.withCopyFilesToContainer([
{
source: containerAssetPath('keycloak-realm-export.json'),
target: '/opt/keycloak/data/import/realm-export.json',
},
])
.withStartupTimeout(3 * 60_000)
.withWaitStrategy(Wait.forHttp('/realms/master', KEYCLOAK_PORT).forStatusCode(200));
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import chalk from 'chalk';
const PREFIX = chalk.cyan('[testcontainers]');
export function logTestcontainers(message: string): void {
// eslint-disable-next-line no-console
console.log(`${PREFIX} ${message}`);
}
export function warnTestcontainers(message: string): void {
// eslint-disable-next-line no-console
console.warn(`${PREFIX} ${message}`);
}
export function errorTestcontainers(message: string): void {
// eslint-disable-next-line no-console
console.error(`${PREFIX} ${message}`);
}

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedTestContainer} from 'testcontainers';
import {isUpgradeFromProjectSelected, isUpgradeToPhaseProjectSelected} from '../upgrade_env';
import {
INBUCKET_ALIAS,
INBUCKET_SMTP_PORT,
MATTERMOST_DATA_DIR,
MATTERMOST_ALIAS,
MATTERMOST_PORT,
POSTGRES_ALIAS,
POSTGRES_DB,
POSTGRES_PASSWORD,
POSTGRES_PORT,
POSTGRES_USER,
TESTCONTAINERS_LABELS,
} from './constants';
import {SERVER_ENV_BASELINE} from './env_baseline';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
// Env this container computes itself from the stack Testcontainers just built. Must win over any
// stray testConfig.serverEnv (MM_ENV) entry and over testConfig.bootEnvOverrides (passed in as
// `extraEnv` by restartMattermostContainer()), or a stray key collision there could break the
// server's own connectivity. Deliberately does NOT know about any additional service (LDAP/
// Keycloak/Elasticsearch/OpenSearch/Minio/Azurite) — those are each spec's own responsibility via
// pw.ensure<Service>(), which points the already-running server at them through patchConfig.
//
// MM_LICENSE (if set) is passed straight through: the server reads it directly at startup
// (platform.LoadLicense), so it boots already licensed instead of needing an authenticated upload
// call after the fact.
export function resolveMattermostBootEnv(extraEnv: Record<string, string> = {}): Record<string, string> {
return {
...SERVER_ENV_BASELINE,
...testConfig.serverEnv,
...extraEnv,
...structuralEnv(),
};
}
const POSTGRES_DSN = `postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_ALIAS}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable&connect_timeout=10&binary_parameters=yes`;
function structuralEnv(): Record<string, string> {
return {
// Config lives in Postgres (config.NewStoreFromDSN's DatabaseStore) rather than
// /mattermost/config/config.json, which is how a production HA deployment runs and is what
// makes the config outlive the container. The Dockerfile declares /mattermost/config as a
// VOLUME, so each container Docker creates gets its own anonymous one seeded from the image
// — a file-backed config would be silently factory-reset by every
// restartMattermostContainer() and by the upgrade swap, leaving the to-image running its own
// shipped defaults instead of the config it inherited. In the database it is preserved and
// migrated across versions by the server itself, like the rest of the data.
MM_CONFIG: POSTGRES_DSN,
MM_SQLSETTINGS_DRIVERNAME: 'postgres',
MM_SQLSETTINGS_DATASOURCE: POSTGRES_DSN,
MM_EMAILSETTINGS_SMTPSERVER: INBUCKET_ALIAS,
MM_EMAILSETTINGS_SMTPPORT: String(INBUCKET_SMTP_PORT),
// Required at boot so prepackaged plugins (Agents, etc.) can activate without waiting for a later patchConfig.
MM_SERVICESETTINGS_SITEURL: `http://${MATTERMOST_ALIAS}:${MATTERMOST_PORT}`,
...(process.env.MM_LICENSE ? {MM_LICENSE: process.env.MM_LICENSE} : {}),
// Replaces the baseline for this key: appends mock file-server hosts (host.docker.internal
// and the bridge gateway IP, set by startStack() once the network is up).
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: [
SERVER_ENV_BASELINE.MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS,
'host.docker.internal',
testConfig.testcontainersNetworkGatewayIp,
]
.filter(Boolean)
.join(' '),
};
}
// Readiness requires both the /api/v4/system/ping health check AND the permissions-migration job
// scheduler's "All migrations are complete." log line (scheduler.go, jobs/migrations package).
// Ping alone isn't enough: MigrationKeyAdvancedPermissionsPhase2 runs as an async job whose
// scheduler deliberately delays its first tick 60s after startup — a real window a spec's very
// first API call can otherwise land inside, tripping IsPhase2MigrationCompleted() gates with
// "required migrations have not yet completed" (confirmed in practice: a permissions-page spec
// hit exactly this, with the "Edit Scheme" link stuck disabled, when this wait was dropped).
// Requires MM_LOGSETTINGS_CONSOLELEVEL=DEBUG (env_baseline.ts) since the scheduler logs that line
// at Debug. Only paid on a genuinely fresh boot — a reused/adopted stack skips this entirely.
//
// upgrade-from and upgrade-swap-to skip the log wait for the same reason: API-only upgrade
// harnesses, and restarts/reuse can miss the log line after the old container is removed.
function mattermostWaitStrategy() {
const ping = Wait.forHttp('/api/v4/system/ping', MATTERMOST_PORT).forStatusCode(200);
if (isUpgradeFromProjectSelected() || isUpgradeToPhaseProjectSelected()) {
return ping;
}
return Wait.forAll([ping, Wait.forLogMessage(/All migrations are complete\./, 1)]);
}
// Joins the network by name (withNetworkMode) rather than a StartedNetwork object: also called
// from restartMattermostContainer(), which runs in a worker process that never holds the actual
// StartedNetwork handle — only the network's name (threaded through testConfig) is available
// there.
export async function startMattermostContainer(
networkName: string,
extraEnv: Record<string, string> = {},
): Promise<StartedTestContainer> {
const env = resolveMattermostBootEnv(extraEnv);
return startWithRetry('server', async () => {
let builder = new GenericContainer(testConfig.serverImage)
.withPlatform('linux/amd64') // The published server images are amd64-only.
.withNetworkMode(networkName)
.withNetworkAliases(MATTERMOST_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
// Ensures host.docker.internal resolves to the Docker host (via host-gateway).
.withExtraHosts([{host: 'host.docker.internal', ipAddress: 'host-gateway'}])
.withExposedPorts(MATTERMOST_PORT)
.withEnvironment(env)
// Bind-mounted unconditionally so local-disk FileSettings data (server/build/Dockerfile's
// VOLUME ["/mattermost/data", ...]) survives restartMattermostContainer()'s docker rm -f
// instead of being discarded with the anonymous volume Docker would otherwise create.
// Harmless — just an unused empty directory — when a different FileSettings backend
// (Minio/Azurite) is active.
.withBindMounts([{source: MATTERMOST_DATA_DIR, target: '/mattermost/data', mode: 'rw'}])
.withStartupTimeout(5 * 60_000)
.withWaitStrategy(mattermostWaitStrategy());
// Upgrade-swap-to skips reuse: the old container was rm -f'd and reuse can reattach to an
// unrelated leftover on the same image tag.
//
// upgrade-from must still use withReuse() when PW_TESTCONTAINERS_REUSE=true — otherwise
// Ryuk reaps the server when the from-phase process exits and upgrade-to cannot adopt the
// stack. assertUpgradeFromFreshStart() already requires testcontainers:down before from.
if (testConfig.testcontainersReuse && !isUpgradeToPhaseProjectSelected()) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {MINIO_ACCESS_KEY, MINIO_ALIAS, MINIO_PORT, MINIO_SECRET_KEY, TESTCONTAINERS_LABELS} from './constants';
import {MINIO_IMAGE} from './default_images';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
export async function startMinioContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('minio', async () => {
let builder = new GenericContainer(MINIO_IMAGE)
.withCommand(['server', '/data', '--console-address', ':9002'])
.withExposedPorts(MINIO_PORT)
.withNetwork(network)
.withNetworkAliases(MINIO_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withEnvironment({
MINIO_ROOT_USER: MINIO_ACCESS_KEY,
MINIO_ROOT_PASSWORD: MINIO_SECRET_KEY,
})
.withWaitStrategy(Wait.forHttp('/minio/health/live', MINIO_PORT).forStatusCode(200));
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {GenericContainer} from 'testcontainers';
import type {WaitStrategy} from 'testcontainers';
import {MATTERMOST_ALIAS, MATTERMOST_PORT, TESTCONTAINERS_LABELS} from './constants';
import {testConfig} from '@/test_config';
const execFileAsync = promisify(execFile);
const MMCTL_ENTRYPOINT = '/mattermost/bin/mmctl';
const MMCTL_CONFIG_DIR = '/tmp/mmctl-xdg';
const MMCTL_CREDENTIALS_NAME = 'e2e';
export type MmctlResult = {
exitCode: number;
output: string;
};
// Matches the Credentials/CredentialsList shape mmctl reads (server/cmd/mmctl/commands/auth_utils.go).
// Written directly instead of via `mmctl auth login`, since that needs a password file and this
// image has no shell to create one. AuthMethod "T" treats authToken as a plain bearer token, which
// mmctl reads into Client4.AuthToken.
function buildCredentialsFileContent(username: string, authToken: string): string {
const credentialsList = {
[MMCTL_CREDENTIALS_NAME]: {
name: MMCTL_CREDENTIALS_NAME,
username,
authToken,
authMethod: 'T',
instanceUrl: `http://${MATTERMOST_ALIAS}:${MATTERMOST_PORT}`,
active: true,
},
};
return JSON.stringify(credentialsList);
}
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf-8');
}
// Wait.forOneShotStartup() throws on any non-zero exit code, but a non-zero exit from mmctl is a
// meaningful result to inspect, not a startup failure. This strategy ignores the exit code;
// completion is awaited afterward via `docker wait`.
class NoOpWaitStrategy implements WaitStrategy {
private startupTimeoutMs = 0;
async waitUntilReady(): Promise<void> {
// No-op — completion is awaited by the caller via `docker wait`.
}
withStartupTimeout(startupTimeoutMs: number): this {
this.startupTimeoutMs = startupTimeoutMs;
return this;
}
isStartupTimeoutSet(): boolean {
return true;
}
getStartupTimeout(): number {
return this.startupTimeoutMs;
}
}
async function waitForExitCode(containerId: string): Promise<number> {
// Bounded so a hung mmctl command can't stall cleanup indefinitely.
const {stdout} = await execFileAsync('docker', ['wait', containerId], {timeout: 60_000});
return parseInt(stdout.trim(), 10);
}
/**
* Runs a single mmctl command in its own throwaway container built from the server image, acting
* as a real remote client rather than the `--local` unix-socket mode used for the server's healthcheck.
*
* Joins the network by name (withNetworkMode), not via getNetwork() — this runs in the Playwright
* worker process, a different OS process from the one that created the network, so getNetwork()'s
* in-process cache would create a second, unrelated network instead of finding the real one.
*/
export async function runMmctl(args: string[], username: string, authToken: string): Promise<MmctlResult> {
if (!testConfig.testcontainersNetworkName) {
throw new Error(
'No Testcontainers network name available (PW_TESTCONTAINERS_NETWORK_NAME) — is PW_USE_TESTCONTAINERS=true?',
);
}
const container = await new GenericContainer(testConfig.serverImage)
.withPlatform('linux/amd64') // The published server images are amd64-only.
.withNetworkMode(testConfig.testcontainersNetworkName)
.withLabels(TESTCONTAINERS_LABELS)
.withEnvironment({XDG_CONFIG_HOME: MMCTL_CONFIG_DIR})
.withCopyContentToContainer([
{
content: buildCredentialsFileContent(username, authToken),
target: `${MMCTL_CONFIG_DIR}/mmctl/config`,
},
])
.withEntrypoint([MMCTL_ENTRYPOINT])
.withCommand(args)
.withWaitStrategy(new NoOpWaitStrategy())
.withStartupTimeout(60_000)
.start();
try {
const exitCode = await waitForExitCode(container.getId());
const output = await streamToString(await container.logs());
return {exitCode, output};
} finally {
await container.stop({remove: true});
}
}
/**
* Runs mmctl inside the already-running server container over its `--local` unix socket
* (enabled by SERVER_ENV_BASELINE's MM_SERVICESETTINGS_ENABLELOCALMODE).
*
* Unlike runMmctl(), this needs no admin session, which makes it the only way to change server
* config before global setup creates the first user.
*/
export async function runMmctlLocal(args: string[]): Promise<MmctlResult> {
if (!testConfig.mattermostContainerId) {
throw new Error('No running Mattermost container to exec mmctl --local in.');
}
const execArgs = ['exec', testConfig.mattermostContainerId, MMCTL_ENTRYPOINT, '--local', ...args];
try {
const {stdout, stderr} = await execFileAsync('docker', execArgs, {timeout: 60_000});
return {exitCode: 0, output: `${stdout}${stderr}`};
} catch (error) {
const failure = error as {code?: unknown; stdout?: string; stderr?: string};
const output = `${failure.stdout ?? ''}${failure.stderr ?? ''}`;
return {exitCode: typeof failure.code === 'number' ? failure.code : 1, output: output || String(error)};
}
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {Network} from 'testcontainers';
import type {StartedNetwork} from 'testcontainers';
import {logTestcontainers, warnTestcontainers} from './log';
const execFileAsync = promisify(execFile);
// One bridge network per Playwright invocation, shared by every container it starts. When
// Playwright itself runs inside a container, that container also joins this network so it can
// reach everything by alias instead of a mapped port.
let startedNetwork: StartedNetwork | undefined;
export async function getNetwork(): Promise<StartedNetwork> {
if (!startedNetwork) {
logTestcontainers('creating network...');
startedNetwork = await new Network().start();
logTestcontainers('network created.');
}
return startedNetwork;
}
/**
* The bridge network's gateway IP (e.g. 172.18.0.1) — a real address bound to an interface on the
* Docker host itself, so a process listening on 0.0.0.0 on the host is reachable both from the
* host directly and from any container on this network, without a network alias or mapped port.
* Takes a network id/name rather than a StartedNetwork object so it also works from
* reuseExistingStack(), which only has testConfig's network name.
*/
export async function getNetworkGatewayIp(network: string): Promise<string> {
const {stdout} = await execFileAsync('docker', [
'network',
'inspect',
network,
'--format',
'{{(index .IPAM.Config 0).Gateway}}',
]);
return stdout.trim();
}
export async function stopNetwork(): Promise<void> {
if (!startedNetwork) {
return;
}
const network = startedNetwork;
startedNetwork = undefined;
// A container a worker process swapped in via restartMattermostContainer() (a different OS
// process, invisible to this one) can still be mid-detach from the network at this exact
// moment, which Docker reports as "has active endpoints". Ryuk removes the network anyway
// once that settles, so retry briefly rather than surfacing a scary but harmless error.
const attempts = 5;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
await network.stop();
return;
} catch (error) {
if (attempt === attempts) {
warnTestcontainers(
`could not remove network ${network.getId()} (Ryuk will remove it shortly): ${String(error)}`,
);
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {OPENLDAP_ADMIN_PASSWORD, OPENLDAP_ALIAS, OPENLDAP_PORT, TESTCONTAINERS_LABELS} from './constants';
import {OPENLDAP_IMAGE} from './default_images';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
// osixia/openldap is known to occasionally fail its first boot under load — startWithRetry
// retries a bounded number of times rather than letting a flaky first attempt fail the whole run.
export async function startOpenldapContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('openldap', async () => {
let builder = new GenericContainer(OPENLDAP_IMAGE)
.withExposedPorts(OPENLDAP_PORT)
.withNetwork(network)
.withNetworkAliases(OPENLDAP_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withEnvironment({
LDAP_TLS_VERIFY_CLIENT: 'never',
LDAP_ORGANISATION: 'Mattermost Test',
LDAP_DOMAIN: 'mm.test.com',
LDAP_ADMIN_PASSWORD: OPENLDAP_ADMIN_PASSWORD,
})
.withStartupTimeout(2 * 60_000)
.withWaitStrategy(Wait.forListeningPorts());
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {OPENSEARCH_ADMIN_PASSWORD, OPENSEARCH_ALIAS, OPENSEARCH_PORT, TESTCONTAINERS_LABELS} from './constants';
import {OPENSEARCH_VERSION as DEFAULT_OPENSEARCH_VERSION} from './default_images';
import {containerAssetPath} from './paths';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
// Built from a vendored Dockerfile (installs the same CJK analysis plugins as the
// Elasticsearch container) — an alternative to Elasticsearch for search.
export async function startOpensearchContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('opensearch', async () => {
// deleteOnExit: false — otherwise the built image bakes in this session's Ryuk id, so Ryuk
// reaps the "reused" container the moment this session ends, defeating withReuse() below.
const image = await GenericContainer.fromDockerfile(containerAssetPath(), 'Dockerfile.opensearch')
.withBuildArgs({OPENSEARCH_VERSION: process.env.OPENSEARCH_VERSION || DEFAULT_OPENSEARCH_VERSION})
.build(undefined, {deleteOnExit: false});
let builder = image
.withExposedPorts(OPENSEARCH_PORT)
.withNetwork(network)
.withNetworkAliases(OPENSEARCH_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withEnvironment({
'http.port': String(OPENSEARCH_PORT),
'discovery.type': 'single-node',
'plugins.security.disabled': 'true',
DISABLE_INSTALL_DEMO_CONFIG: 'true',
OPENSEARCH_INITIAL_ADMIN_PASSWORD: OPENSEARCH_ADMIN_PASSWORD,
OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m',
})
.withStartupTimeout(3 * 60_000)
.withWaitStrategy(Wait.forHttp('/_cluster/health', OPENSEARCH_PORT).forStatusCode(200));
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
// Resolved relative to this module's own location (not the caller's cwd or the monorepo), so it
// keeps working once `@mattermost/playwright-lib` is installed as an npm package with no access
// to the rest of the repo. `preserveModules` keeps `containers/assets` alongside this file's
// compiled output in `dist`, same as `src`.
//
// `__dirname` rather than `import.meta.url`: despite `"type": "module"`, Playwright loads this
// package via `require()`, not `import()` — and Node's require()-of-ESM interop disallows
// `import.meta` (throws "Cannot use 'import.meta' outside a module"), while `__dirname` still
// resolves since Node wraps the module as CommonJS to support that require() call.
const assetsDir = path.join(__dirname, 'assets');
export function containerAssetPath(...segments: string[]): string {
return path.join(assetsDir, ...segments);
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PostgreSqlContainer} from '@testcontainers/postgresql';
import type {StartedPostgreSqlContainer} from '@testcontainers/postgresql';
import {Wait} from 'testcontainers';
import type {StartedNetwork} from 'testcontainers';
import {POSTGRES_ALIAS, POSTGRES_DB, POSTGRES_PASSWORD, POSTGRES_USER, TESTCONTAINERS_LABELS} from './constants';
import {POSTGRES_IMAGE} from './default_images';
import {containerAssetPath} from './paths';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
export async function startPostgresContainer(network: StartedNetwork): Promise<StartedPostgreSqlContainer> {
return startWithRetry('postgres', async () => {
let builder = new PostgreSqlContainer(POSTGRES_IMAGE)
.withDatabase(POSTGRES_DB)
.withUsername(POSTGRES_USER)
.withPassword(POSTGRES_PASSWORD)
.withNetwork(network)
.withNetworkAliases(POSTGRES_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withCopyFilesToContainer([
{
source: containerAssetPath('postgres.conf'),
target: '/etc/postgresql/postgresql.conf',
},
])
.withCommand(['postgres', '-c', 'config_file=/etc/postgresql/postgresql.conf'])
.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 1));
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {startAzuriteContainer} from './azurite_container';
import {startElasticsearchContainer} from './elasticsearch_container';
import {startKeycloakContainer} from './keycloak_container';
import {startMinioContainer} from './minio_container';
import {startOpenldapContainer} from './openldap_container';
import {startOpensearchContainer} from './opensearch_container';
import type {TestContainersServiceName} from '@/test_config';
// The single place to extend when a new additional service is needed. `stack.ts` starts these
// only for the names in testConfig.testcontainersServices.
export const ADDITIONAL_SERVICE_STARTERS: Record<
TestContainersServiceName,
(network: StartedNetwork) => Promise<StartedTestContainer>
> = {
openldap: startOpenldapContainer,
keycloak: startKeycloakContainer,
elasticsearch: startElasticsearchContainer,
opensearch: startOpensearchContainer,
minio: startMinioContainer,
azurite: startAzuriteContainer,
};

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {errorTestcontainers} from './log';
import {duration, wait} from '@/util';
const DEFAULT_ATTEMPTS = 3;
// Wraps a container's build+start so transient failures (image pull/build over a flaky network,
// occasional first-boot flakiness) get a bounded, backed-off retry instead of failing the whole
// run — and so any failure, at any attempt, is unambiguous about which container/image caused it,
// rather than surfacing as testcontainers' own generic "Failed to build image"/"Failed to start
// container" with no name attached.
export async function startWithRetry<T>(
label: string,
start: () => Promise<T>,
attempts = DEFAULT_ATTEMPTS,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await start();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
errorTestcontainers(`"${label}" failed on attempt ${attempt}/${attempts}: ${message}`);
if (attempt < attempts) {
await wait(duration.two_sec * attempt);
}
}
}
throw new Error(`Failed to start "${label}" container after ${attempts} attempts: ${String(lastError)}`, {
cause: lastError,
});
}

View File

@@ -0,0 +1,771 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {execFile} from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {promisify} from 'node:util';
import {test} from '@playwright/test';
import type {StartedPostgreSqlContainer} from '@testcontainers/postgresql';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {
AZURITE_ALIAS,
AZURITE_BLOB_PORT,
ELASTICSEARCH_ALIAS,
ELASTICSEARCH_PORT,
INBUCKET_ALIAS,
INBUCKET_WEB_PORT,
KEYCLOAK_ALIAS,
KEYCLOAK_PORT,
MATTERMOST_DATA_DIR,
MATTERMOST_ALIAS,
MATTERMOST_PORT,
MINIO_ALIAS,
MINIO_PORT,
OPENLDAP_ALIAS,
OPENLDAP_PORT,
OPENSEARCH_ALIAS,
OPENSEARCH_PORT,
POSTGRES_ALIAS,
POSTGRES_DB,
POSTGRES_PASSWORD,
POSTGRES_PORT,
POSTGRES_USER,
WEBHOOK_ALIAS,
WEBHOOK_PORT,
} from './constants';
import {
AZURITE_IMAGE,
ELASTICSEARCH_VERSION,
INBUCKET_IMAGE,
KEYCLOAK_IMAGE,
MINIO_IMAGE,
OPENLDAP_IMAGE,
OPENSEARCH_VERSION,
POSTGRES_IMAGE,
} from './default_images';
import {startInbucketContainer} from './inbucket_container';
import {logTestcontainers} from './log';
import {resolveMattermostBootEnv, startMattermostContainer} from './mattermost_container';
import {getNetwork, getNetworkGatewayIp, stopNetwork} from './network';
import {startPostgresContainer} from './postgres_container';
import {ADDITIONAL_SERVICE_STARTERS} from './requirements';
import {startWebhookContainer} from './webhook_container';
import {clearClientCache} from '@/server/client';
import {defaultBootEnv, testConfig} from '@/test_config';
import type {TestContainersServiceName} from '@/test_config';
import {isUpgradeToPhaseProjectSelected, upgradeToStackNotRunningError} from '@/upgrade_env';
import {duration} from '@/util';
const execFileAsync = promisify(execFile);
const ENV_FILE_PATH = path.resolve(process.cwd(), '.env.testcontainers');
const LOG_DIR = path.resolve(process.cwd(), 'logs');
type StartedStack = {
network: StartedNetwork;
postgres: StartedPostgreSqlContainer;
inbucket: StartedTestContainer;
webhook: StartedTestContainer;
mattermost: StartedTestContainer;
additional: Partial<Record<TestContainersServiceName, StartedTestContainer>>;
};
let started: StartedStack | undefined;
// True if this process is running against a stack an EARLIER process created (via
// reuseExistingStack()) rather than one it started itself — there are no real Testcontainers
// handles to hold in this case, only values .env.testcontainers resolved into testConfig via
// dotenv. A reusing process never owns the stack's lifecycle, so stopStack() must leave both
// the containers and the env file untouched for whatever other process still needs them.
let reused = false;
/**
* Brings up a bridge network, Postgres, Inbucket, the Mattermost server, and whichever
* additional services testConfig.testcontainersServices names. No-op if `testcontainers` mode isn't
* selected (PW_USE_TESTCONTAINERS unset), already started (repeated calls within the same
* process, e.g. a stray double-invocation, are harmless), or already reused. Otherwise first
* checks whether an earlier process's stack is still alive and reuses it instead — see
* reuseExistingStack().
*/
export async function startStack(): Promise<void> {
if (!testConfig.useTestContainers || started || reused) {
return;
}
if (await reuseExistingStack()) {
return;
}
if (isUpgradeToPhaseProjectSelected()) {
throw upgradeToStackNotRunningError();
}
// reuseExistingStack() found nothing live to reuse — any bootEnvOverrides read from a stale
// .env.testcontainers (e.g. left behind by a manual `docker rm` or a crashed prior process)
// no longer describes anything real. Reset to the genuine defaults the container about to be
// created will actually boot with, or a later restart could wrongly believe some stale
// setting is already active and skip a restart it actually needs.
testConfig.bootEnvOverrides = defaultBootEnv();
// The bind-mounted data dir persists across container restarts; reset only on a fresh boot.
// Mattermost runs as UID 2000 and creates 0750 dirs (see makeMattermostDataHostReadable). The
// host workspace is a different user, so this empty mount must be world-writable or the
// server cannot create those dirs. Host chown to 2000 is not reliable (non-root CI, rootless
// Docker, Docker Desktop UID mapping).
await resetLocalStorageDir();
const network = await getNetwork();
if (testConfig.containerRunner) {
await joinSelfToNetwork(network.getId());
}
// Stored so a host-side mock file server can be reached from both the browser and containers
// on this network (see getNetworkGatewayIp).
testConfig.testcontainersNetworkGatewayIp = await getNetworkGatewayIp(network.getId());
const additionalNames = testConfig.testcontainersServices;
logTestcontainers(
`pulling/starting images: server, postgres, inbucket, webhook${additionalNames.length ? `, ${additionalNames.join(', ')}` : ''}`,
);
await logServerImageAge(testConfig.serverImage);
// Tracks every container that actually comes up, independent of whether the group as a whole
// (or the mattermost start after it) ultimately succeeds — so a failure partway through still
// knows exactly what to tear down instead of leaking whatever already started.
const startedContainers: StartedTestContainer[] = [];
const trackAndLog = <T extends StartedTestContainer>(name: string, promise: Promise<T>): Promise<T> => {
const startedAt = Date.now();
// The biggest blind spot is the server: its own wait strategy alone can take minutes
// (see mattermost_container.ts), during which nothing else prints — so ping every 30s to
// make clear the run hasn't stalled.
const heartbeat = setInterval(() => {
logTestcontainers(`still waiting on ${name} (${elapsedSeconds(startedAt)}s elapsed)...`);
}, duration.half_min);
return promise.then(
(container) => {
clearInterval(heartbeat);
startedContainers.push(container);
logTestcontainers(`${name} ready in ${elapsedSeconds(startedAt)}s.`);
return container;
},
(error) => {
clearInterval(heartbeat);
throw error;
},
);
};
try {
const [postgres, inbucket, webhook, ...additionalContainers] = await Promise.all([
trackAndLog('postgres', startPostgresContainer(network)),
trackAndLog('inbucket', startInbucketContainer(network)),
trackAndLog('webhook', startWebhookContainer(network)),
...additionalNames.map((name) => trackAndLog(name, ADDITIONAL_SERVICE_STARTERS[name](network))),
]);
const additional: Partial<Record<TestContainersServiceName, StartedTestContainer>> = {};
additionalNames.forEach((name, index) => {
additional[name] = additionalContainers[index];
});
const mattermost = await trackAndLog('server', startMattermostContainer(network.getName()));
started = {network, postgres, inbucket, webhook, mattermost, additional};
} catch (error) {
await Promise.allSettled(startedContainers.map((container) => container.stop()));
await stopNetwork();
throw error;
}
applyResolvedConfig(started);
resetEnvFile('initial boot');
logStackStarted(started);
}
/**
* True if .env.testcontainers points at a Mattermost container that's still running — i.e. some
* OTHER process already brought up a stack this process should reuse instead of duplicating.
* Always a different process (the only channel between them is the env file, not runtime IPC);
* covers PW_TESTCONTAINERS_REUSE=true across separate local invocations, and a CI dispatcher that
* starts the server once per worker job and runs one spec per process against it.
*
* Deliberately does not gate on testConfig.testcontainersReuse: that flag governs whether the
* OWNING process leaves the stack running on its own exit — a different decision from whether
* THIS process should reuse a stack it finds already alive. Reuse always applies once liveness
* is confirmed.
*/
async function reuseExistingStack(): Promise<boolean> {
if (!testConfig.mattermostContainerId || !(await isContainerRunning(testConfig.mattermostContainerId))) {
return false;
}
reused = true;
if (testConfig.containerRunner) {
await joinSelfToNetwork(testConfig.testcontainersNetworkName);
}
logTestcontainers(
'reusing already-running server (with PW_TESTCONTAINERS_REUSE=true), see .env.testcontainers for stack information',
);
logStackReused();
return true;
}
// Same shape of summary as logStackStarted(), but built from testConfig's resolved fields
// instead of live StartedTestContainer handles — reusing never gets those (see `reused`'s
// declaration above), only whatever an EARLIER process's startStack() persisted to
// .env.testcontainers and this process's dotenv.config() read back into testConfig.
function logStackReused(): void {
const lines: string[] = [
` - ${'server'.padEnd(13)} = ${testConfig.baseURL}`,
` - ${'postgres'.padEnd(13)} = ${testConfig.postgresUrl}`,
` - ${'inbucket'.padEnd(13)} = ${testConfig.smtpURL}`,
` - ${'webhook'.padEnd(13)} = ${testConfig.webhookBaseUrl}`,
];
const additionalUrls: Record<TestContainersServiceName, string> = {
openldap: `${testConfig.ldapHost}:${testConfig.ldapPort}`,
keycloak: testConfig.keycloakUrl,
elasticsearch: testConfig.elasticsearchUrl,
opensearch: testConfig.opensearchUrl,
minio: testConfig.minioUrl,
azurite: testConfig.azuriteUrl,
};
testConfig.testcontainersServices.forEach((name) => {
lines.push(` - ${name.padEnd(13)} = ${additionalUrls[name]}`);
});
// eslint-disable-next-line no-console
console.log(
`Testcontainers (reused, network ${testConfig.testcontainersNetworkName}, tear down with: "npm run testcontainers:down"):\n${lines.join('\n')}\n${formatServerEnvSummary()}\n`,
);
}
async function isContainerRunning(containerId: string): Promise<boolean> {
try {
const {stdout} = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}}', containerId]);
return stdout.trim() === 'true';
} catch {
return false;
}
}
/**
* Tears down the stack: always collects logs and removes the generated env file; only actually
* stops containers when reuse isn't enabled (PW_TESTCONTAINERS_REUSE=true leaves them running
* for the next invocation — local or a CI dispatcher's next spec — to reuse).
*
* A no-op beyond clearing the local flag when this process reused rather than created the stack:
* it never owned the containers or the env file, so it must leave both exactly as it found them
* for whichever process (or later dispatch) still depends on them.
*/
export async function stopStack(options: {force?: boolean} = {}): Promise<void> {
if (!testConfig.useTestContainers) {
return;
}
if (reused) {
reused = false;
logTestcontainers('this process reused an existing server — leaving it untouched.');
return;
}
if (!started) {
return;
}
const stack = started;
await collectLogs(stack);
const shouldStop = options.force || !testConfig.testcontainersReuse;
if (shouldStop) {
await Promise.allSettled([
stack.mattermost.stop(),
stack.inbucket.stop(),
stack.webhook.stop(),
stack.postgres.stop(),
...Object.values(stack.additional).map((container) => container?.stop()),
]);
await stopNetwork();
logStackStopped(stack);
archiveEnvFile();
removeEnvFile();
} else {
logStackLeftRunning(stack);
}
started = undefined;
}
/**
* True if every key in `env` already has the given value in testConfig.bootEnvOverrides — i.e.
* the currently-running Mattermost container was already booted this way, so a pw.ensure*() can
* skip restartMattermostContainer() for these settings.
*/
export function bootEnvMatches(env: Record<string, string>): boolean {
return Object.entries(env).every(([key, value]) => testConfig.bootEnvOverrides[key] === value);
}
/**
* Stops the current Mattermost container and starts a fresh one with additional env merged in —
* used for settings like FileSettings.DriverName, ElasticsearchSettings.Backend, or
* MM_FEATUREFLAGS_* which are never re-read from a running server, so patchConfig alone can't
* change them.
*
* Safe to call from a Playwright worker process, unlike startStack()/stopStack(): it works from
* testConfig's container id and network name rather than the in-process StartedStack, since
* global setup (which owns that in-process state) and worker processes are different OS
* processes.
*
* `env` is merged into testConfig.bootEnvOverrides (not replaced) so an earlier pw.ensure*()
* call's settings survive a later, unrelated one restarting the same container again. The merged
* result, new container id, and new baseURL are appended to .env.testcontainers so any other
* process picks up the real current state instead of a stale or default one.
*
* Always performs the restart without checking whether `env` is already active — callers (e.g.
* ensureMinio()/ensureAzurite()) own that decision via bootEnvMatches().
*/
export async function restartMattermostContainer(env: Record<string, string>): Promise<void> {
if (!testConfig.useTestContainers) {
throw new Error('restartMattermostContainer requires PW_USE_TESTCONTAINERS=true.');
}
if (!testConfig.mattermostContainerId || !testConfig.testcontainersNetworkName) {
throw new Error(
'No running Testcontainers stack to restart (missing Mattermost container id or network name).',
);
}
extendTimeoutForRestart();
testConfig.bootEnvOverrides = {...testConfig.bootEnvOverrides, ...env};
await execFileAsync('docker', ['rm', '-f', testConfig.mattermostContainerId]);
const mattermost = await startMattermostContainer(
testConfig.testcontainersNetworkName,
testConfig.bootEnvOverrides,
);
testConfig.baseURL = resolveUrl(mattermost, MATTERMOST_PORT, MATTERMOST_ALIAS);
testConfig.mattermostContainerId = mattermost.getId();
clearClientCache();
const redactedEnv = redactServerEnv(env);
appendEnvFile(`restart requested by ${describeCurrentTest()} — env ${JSON.stringify(redactedEnv)}`);
logTestcontainers(`restarted server (${testConfig.serverImage}) with ${JSON.stringify(redactedEnv)}.`);
await logServerImageAge(testConfig.serverImage);
}
// Empties the server's bind-mounted data directory on a fresh boot. The server writes as UID 2000
// into 0750 dirs, so on Linux the host user cannot delete them — which failed global setup on
// every second boot, and so on every retest. Falls back to clearing from a container, as root.
async function resetLocalStorageDir(): Promise<void> {
try {
fs.rmSync(MATTERMOST_DATA_DIR, {recursive: true, force: true});
} catch (error) {
// Not matched on errno: EACCES on Linux, ENOTEMPTY on macOS, same cause.
const code = (error as NodeJS.ErrnoException).code ?? 'unknown';
logTestcontainers(`server data dir not removable as this user (${code}); clearing it in a container`);
try {
await clearLocalStorageInContainer();
} catch (containerError) {
// Starting dirty would carry a previous run's uploads into this run's assertions.
throw new Error(
`Could not clear ${MATTERMOST_DATA_DIR} (host: ${code}, container: ` +
`${(containerError as Error).message}). Remove it manually and re-run.`,
);
}
}
fs.mkdirSync(MATTERMOST_DATA_DIR, {recursive: true});
fs.chmodSync(MATTERMOST_DATA_DIR, 0o777);
}
// Clears the mount's contents (not the mount point) as root, which the host uid mapping does not
// restrict. Same shape as makeMattermostDataHostReadable(): Postgres image, already pulled per run.
// `find -delete` needs no shell — the server image ships none.
async function clearLocalStorageInContainer(): Promise<void> {
fs.mkdirSync(MATTERMOST_DATA_DIR, {recursive: true});
await execFileAsync('docker', [
'run',
'--rm',
'--user',
'0',
'-v',
`${MATTERMOST_DATA_DIR}:/data`,
POSTGRES_IMAGE,
'find',
'/data',
'-mindepth',
'1',
'-delete',
]);
}
// Identifies whichever spec/test is currently driving a restart, so .env.testcontainers's history
// shows why the server ended up in its current state. restartMattermostContainer() is always
// called from inside a running test, so test.info() should resolve; the fallback only guards a
// future caller that isn't.
function describeCurrentTest(): string {
try {
const info = test.info();
return `${path.relative(process.cwd(), info.file)} > ${info.title}`;
} catch {
return 'unknown caller (not running inside a test)';
}
}
// startMattermostContainer()'s wait strategy blocks on a scheduler log line whose first tick is
// deliberately delayed 60s after startup (see that function's comment), so a restart alone can
// exceed the suite's default 60s test timeout before the test has done any of its own work.
// Playwright's timeout wouldn't cancel the still-in-flight restart, so a timed-out retry can race
// it and hit a container mid-swap. Raising (not just extending) the timeout avoids ratcheting it
// down if a later restart in the same test calls this again after some budget is already spent.
function extendTimeoutForRestart(): void {
try {
const info = test.info();
info.setTimeout(Math.max(info.timeout, duration.four_min));
} catch {
// Not running inside a test (e.g. called from a script) — nothing to extend.
}
}
// containerRunner mode: join the calling `playwright` container to the same network so its own
// connections can use aliases too, instead of mapped ports. Relies on Docker setting the
// container's hostname to its own container ID by default, and on the `docker` CLI being
// present alongside the mounted socket.
//
// Takes a network name/ID string rather than a StartedNetwork object: reuseExistingStack() only
// has testConfig.testcontainersNetworkName (read from .env.testcontainers) to work with, not a
// live handle — and the docker CLI resolves either form the same way, so the freshly-created path
// below just passes network.getId() instead.
async function joinSelfToNetwork(networkId: string): Promise<void> {
const selfContainerId = os.hostname();
try {
await execFileAsync('docker', ['network', 'connect', networkId, selfContainerId]);
} catch (error) {
// A CI dispatcher running one spec per process reuses the same stack (and this same
// runner container) on every invocation, so this join is attempted again every time —
// already-connected isn't a failure, it's the expected steady state after the first.
if (String(error).includes('already exists in network')) {
return;
}
throw new Error(
'containerRunner mode (PW_TESTCONTAINERS_CONTAINER_RUNNER=true) requires the calling container ' +
'to join the Testcontainers network, but "docker network connect" failed for container ' +
`"${selfContainerId}": ${String(error)}. Ensure /var/run/docker.sock is mounted and the docker ` +
'CLI is installed in this image.',
);
}
}
function resolveUrl(container: StartedTestContainer, port: number, alias: string): string {
if (testConfig.containerRunner) {
return `http://${alias}:${port}`;
}
return `http://${container.getHost()}:${container.getMappedPort(port)}`;
}
function resolveHostAndPort(container: StartedTestContainer, port: number, alias: string): [string, number] {
if (testConfig.containerRunner) {
return [alias, port];
}
return [container.getHost(), container.getMappedPort(port)];
}
// Same containerRunner-aware resolution as resolveUrl()/resolveHostAndPort() above: the alias in
// containerRunner mode (the test process is on the Testcontainers network), the host-mapped port
// otherwise — direct-DB specs are just another client connecting from wherever the test process
// runs.
function resolvePostgresUrl(postgres: StartedPostgreSqlContainer): string {
const [host, port] = resolveHostAndPort(postgres, POSTGRES_PORT, POSTGRES_ALIAS);
return `postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${host}:${port}/${POSTGRES_DB}?sslmode=disable&connect_timeout=10&binary_parameters=yes`;
}
type ContainerMetadata = {alias: string; port: number; image: string};
const ADDITIONAL_CONTAINER_METADATA: Record<TestContainersServiceName, ContainerMetadata> = {
openldap: {alias: OPENLDAP_ALIAS, port: OPENLDAP_PORT, image: OPENLDAP_IMAGE},
keycloak: {alias: KEYCLOAK_ALIAS, port: KEYCLOAK_PORT, image: KEYCLOAK_IMAGE},
elasticsearch: {
alias: ELASTICSEARCH_ALIAS,
port: ELASTICSEARCH_PORT,
image: `built, Elasticsearch ${ELASTICSEARCH_VERSION}`,
},
opensearch: {alias: OPENSEARCH_ALIAS, port: OPENSEARCH_PORT, image: `built, OpenSearch ${OPENSEARCH_VERSION}`},
minio: {alias: MINIO_ALIAS, port: MINIO_PORT, image: MINIO_IMAGE},
azurite: {alias: AZURITE_ALIAS, port: AZURITE_BLOB_PORT, image: AZURITE_IMAGE},
};
function containerEntries(stack: StartedStack): Array<[string, StartedTestContainer, ContainerMetadata]> {
const base: Array<[string, StartedTestContainer, ContainerMetadata]> = [
['server', stack.mattermost, {alias: MATTERMOST_ALIAS, port: MATTERMOST_PORT, image: testConfig.serverImage}],
['postgres', stack.postgres, {alias: POSTGRES_ALIAS, port: POSTGRES_PORT, image: POSTGRES_IMAGE}],
['inbucket', stack.inbucket, {alias: INBUCKET_ALIAS, port: INBUCKET_WEB_PORT, image: INBUCKET_IMAGE}],
['webhook', stack.webhook, {alias: WEBHOOK_ALIAS, port: WEBHOOK_PORT, image: 'built, webhook sidecar'}],
];
const additional = Object.entries(stack.additional)
.filter((entry): entry is [TestContainersServiceName, StartedTestContainer] => entry[1] !== undefined)
.map((entry): [string, StartedTestContainer, ContainerMetadata] => [
entry[0],
entry[1],
ADDITIONAL_CONTAINER_METADATA[entry[0]],
]);
return [...base, ...additional];
}
function formatContainerLine(name: string, container: StartedTestContainer, metadata: ContainerMetadata): string {
const host = `${container.getHost()}:${container.getMappedPort(metadata.port)}`;
return ` - ${name.padEnd(13)} = ${metadata.image} (network: ${metadata.alias}:${metadata.port}, host: ${host})`;
}
function elapsedSeconds(startedAt: number): string {
return ((Date.now() - startedAt) / 1000).toFixed(1);
}
// `master`/`release-*` tags get rebuilt continuously, so a cached copy can silently go stale;
// pinned version tags (e.g. `:11.10.0`) never change, so they're excluded.
const MUTABLE_IMAGE_TAG_PATTERN = /:(master|release-.+)$/;
async function logServerImageAge(image: string): Promise<void> {
let created: Date;
try {
const {stdout} = await execFileAsync('docker', ['image', 'inspect', image, '--format', '{{.Created}}']);
created = new Date(stdout.trim());
} catch {
// Not cached locally — Testcontainers will pull it fresh as part of starting the
// container, so whatever comes up is already the latest build. Nothing to warn about.
logTestcontainers(`server image "${image}" isn't cached locally yet — will pull the latest build.`);
return;
}
const ageHours = (Date.now() - created.getTime()) / (60 * 60 * 1000);
const age = ageHours >= 48 ? `${(ageHours / 24).toFixed()}d` : `${ageHours.toFixed()}h`;
const looksStale = MUTABLE_IMAGE_TAG_PATTERN.test(image) && ageHours > 24;
logTestcontainers(
`server image "${image}" (built ${created.toISOString()}, ${age} ago).` +
(looksStale
? ` This is a moving tag and the cached copy may be outdated — run "docker pull --platform linux/amd64 ${image}" for the latest build.`
: ''),
);
}
function logStackStarted(stack: StartedStack): void {
const lines = containerEntries(stack).map(([name, container, metadata]) =>
formatContainerLine(name, container, metadata),
);
// eslint-disable-next-line no-console
console.log(`Testcontainers (network ${stack.network.getId()}):\n${lines.join('\n')}\n${formatServerEnvSummary()}`);
}
const SENSITIVE_SERVER_ENV_KEYS = new Set([
'MM_LICENSE',
'MM_FILESETTINGS_AMAZONS3SECRETACCESSKEY',
'MM_FILESETTINGS_AZUREACCESSKEY',
]);
function redactServerEnvValue(key: string, value: string): string {
if (SENSITIVE_SERVER_ENV_KEYS.has(key)) {
return '***';
}
if (key === 'MM_SQLSETTINGS_DATASOURCE') {
return value.replace(/:([^:@/]+)@/, ':***@');
}
return value;
}
function redactServerEnv(env: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, redactServerEnvValue(key, value)]));
}
function redactEnvFileForArchive(contents: string): string {
return contents.replace(/^PW_TESTCONTAINERS_BOOT_ENV='(.*)'$/gm, (_match, json: string) => {
try {
const parsed = JSON.parse(json) as Record<string, string>;
return `PW_TESTCONTAINERS_BOOT_ENV='${JSON.stringify(redactServerEnv(parsed))}'`;
} catch {
return "PW_TESTCONTAINERS_BOOT_ENV='{}'";
}
});
}
function formatServerEnvSummary(): string {
const env = resolveMattermostBootEnv(testConfig.bootEnvOverrides);
const lines = Object.entries(env)
.filter(([key]) => key.startsWith('MM_'))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => ` - ${key} = ${redactServerEnvValue(key, value)}`);
return `server env (MM_*):\n${lines.join('\n')}`;
}
function logStackStopped(stack: StartedStack): void {
const names = containerEntries(stack).map(([name]) => name);
logTestcontainers(`stopped ${names.join(', ')}.`);
}
function logStackLeftRunning(stack: StartedStack): void {
const serverUrl = resolveUrl(stack.mattermost, MATTERMOST_PORT, MATTERMOST_ALIAS);
logTestcontainers(
`left running (PW_TESTCONTAINERS_REUSE=true) — server reachable at ${serverUrl}; may tear down with: "npm run testcontainers:down"`,
);
}
// Mutates the testConfig singleton in place so the rest of globalSetup (same process) sees the
// real resolved values immediately — the generated env file (resetEnvFile) is what hands these
// same values to worker processes.
function applyResolvedConfig(stack: StartedStack): void {
testConfig.baseURL = resolveUrl(stack.mattermost, MATTERMOST_PORT, MATTERMOST_ALIAS);
testConfig.smtpURL = resolveUrl(stack.inbucket, INBUCKET_WEB_PORT, INBUCKET_ALIAS);
testConfig.postgresUrl = resolvePostgresUrl(stack.postgres);
testConfig.webhookBaseUrl = resolveUrl(stack.webhook, WEBHOOK_PORT, WEBHOOK_ALIAS);
testConfig.testcontainersNetworkName = stack.network.getName();
testConfig.mattermostContainerId = stack.mattermost.getId();
if (stack.additional.openldap) {
const [host, port] = resolveHostAndPort(stack.additional.openldap, OPENLDAP_PORT, OPENLDAP_ALIAS);
testConfig.ldapHost = host;
testConfig.ldapPort = port;
}
if (stack.additional.keycloak) {
testConfig.keycloakUrl = resolveUrl(stack.additional.keycloak, KEYCLOAK_PORT, KEYCLOAK_ALIAS);
}
if (stack.additional.elasticsearch) {
testConfig.elasticsearchUrl = resolveUrl(
stack.additional.elasticsearch,
ELASTICSEARCH_PORT,
ELASTICSEARCH_ALIAS,
);
}
if (stack.additional.opensearch) {
testConfig.opensearchUrl = resolveUrl(stack.additional.opensearch, OPENSEARCH_PORT, OPENSEARCH_ALIAS);
}
if (stack.additional.minio) {
testConfig.minioUrl = resolveUrl(stack.additional.minio, MINIO_PORT, MINIO_ALIAS);
}
if (stack.additional.azurite) {
testConfig.azuriteUrl = resolveUrl(stack.additional.azurite, AZURITE_BLOB_PORT, AZURITE_ALIAS);
}
}
// One block per write: a human-readable `# [timestamp] label` comment line (dotenv ignores
// `#`-led lines) followed by the current resolved KEY=VALUE state, including bootEnvOverrides
// JSON-encoded and single-quoted so its embedded double quotes/braces survive dotenv's parser.
function envFileLines(label: string): string[] {
return [
`# [${new Date().toISOString()}] ${label}`,
`PW_BASE_URL=${testConfig.baseURL}`,
`PW_SMTP_URL=${testConfig.smtpURL}`,
`PW_POSTGRES_URL=${testConfig.postgresUrl}`,
`PW_WEBHOOK_BASE_URL=${testConfig.webhookBaseUrl}`,
`PW_TESTCONTAINERS_NETWORK_GATEWAY_IP=${testConfig.testcontainersNetworkGatewayIp}`,
`PW_LDAP_HOST=${testConfig.ldapHost}`,
`PW_LDAP_PORT=${testConfig.ldapPort}`,
`PW_KEYCLOAK_URL=${testConfig.keycloakUrl}`,
`PW_ELASTICSEARCH_URL=${testConfig.elasticsearchUrl}`,
`PW_OPENSEARCH_URL=${testConfig.opensearchUrl}`,
`PW_MINIO_URL=${testConfig.minioUrl}`,
`PW_AZURITE_URL=${testConfig.azuriteUrl}`,
`PW_TESTCONTAINERS_NETWORK_NAME=${testConfig.testcontainersNetworkName}`,
`PW_TESTCONTAINERS_MATTERMOST_CONTAINER_ID=${testConfig.mattermostContainerId}`,
// Persist the image currently running (separate from process SERVER_IMAGE / to-image).
`PW_TESTCONTAINERS_SERVER_IMAGE=${testConfig.serverImage}`,
// Unredacted: the next process reads this back into bootEnvOverrides so upgrade-to and
// pw.ensure*() can adopt the running server. CI artifacts get a redacted copy via archiveEnvFile.
`PW_TESTCONTAINERS_BOOT_ENV='${JSON.stringify(testConfig.bootEnvOverrides)}'`,
'',
];
}
// (Re)creates .env.testcontainers from scratch — only called once, when a brand new stack boots,
// so a leftover file from an earlier (now-dead) stack never bleeds into this one.
function resetEnvFile(label: string): void {
fs.writeFileSync(ENV_FILE_PATH, envFileLines(label).join('\n') + '\n', 'utf-8');
}
/**
* Appends a new snapshot block instead of overwriting — dotenv resolves the correct current value
* per key when a fresh process parses the file (later occurrences win), while the file as a whole
* becomes a chronological log of every restart: which spec/test triggered it, what env diff was
* requested, and the full resolved state right after. That's what's needed to investigate
* server-state drift after the fact, since in the CI dispatch model no single process ever sees
* the whole picture on its own.
*/
function appendEnvFile(label: string): void {
fs.appendFileSync(ENV_FILE_PATH, envFileLines(label).join('\n') + '\n', 'utf-8');
}
// Preserves restart history as a debug artifact before it's deleted — logs/ is what CI's
// upload-debug-artifacts step picks up. Boot-env secrets stay in the live .env.testcontainers
// (needed to adopt the stack) and are redacted in this copy.
function archiveEnvFile(): void {
if (!fs.existsSync(ENV_FILE_PATH)) {
return;
}
fs.mkdirSync(LOG_DIR, {recursive: true});
const redacted = redactEnvFileForArchive(fs.readFileSync(ENV_FILE_PATH, 'utf-8'));
fs.writeFileSync(path.join(LOG_DIR, 'testcontainers_env_history.log'), redacted, 'utf-8');
}
function removeEnvFile(): void {
if (fs.existsSync(ENV_FILE_PATH)) {
fs.rmSync(ENV_FILE_PATH);
}
}
async function collectLogs(stack: StartedStack): Promise<void> {
fs.mkdirSync(LOG_DIR, {recursive: true});
const targets: Array<[string, StartedTestContainer]> = [
['mattermost', stack.mattermost],
['postgres', stack.postgres],
['inbucket', stack.inbucket],
['webhook', stack.webhook],
...Object.entries(stack.additional).filter(
(entry): entry is [string, StartedTestContainer] => entry[1] !== undefined,
),
];
await Promise.allSettled(
targets.map(async ([name, container]) => {
const logStream = await container.logs();
const outFile = fs.createWriteStream(path.join(LOG_DIR, `${name}.log`));
await new Promise<void>((resolve) => {
let settled = false;
// container.logs() follows a running container, so a stack left running
// (PW_TESTCONTAINERS_REUSE=true) never emits 'end' on its own — unpipe and
// destroy the source before ending outFile so a chunk already in flight can't
// land on the closed destination and crash the process with ERR_STREAM_WRITE_AFTER_END.
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
logStream.unpipe(outFile);
logStream.destroy();
outFile.end(resolve);
};
logStream.pipe(outFile, {end: false});
const timer = setTimeout(finish, 10_000);
logStream.on('end', finish);
logStream.on('error', finish);
outFile.on('error', finish);
});
}),
);
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GenericContainer, Wait} from 'testcontainers';
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
import {TESTCONTAINERS_LABELS, WEBHOOK_ALIAS, WEBHOOK_PORT} from './constants';
import {containerAssetPath} from './paths';
import {startWithRetry} from './retry';
import {testConfig} from '@/test_config';
// Vendored from e2e-tests/cypress/webhook_serve.js — the interactive-message/dialog callback
// sidecar tests point PW_WEBHOOK_BASE_URL at. Always started, unlike the other optional services,
// since testConfig.webhookBaseUrl defaults to localhost:3000 outside Testcontainers mode too.
export async function startWebhookContainer(network: StartedNetwork): Promise<StartedTestContainer> {
return startWithRetry('webhook', async () => {
// deleteOnExit: false — otherwise the built image bakes in this session's Ryuk id, so Ryuk
// reaps the "reused" container the moment this session ends, defeating withReuse() below.
const image = await GenericContainer.fromDockerfile(containerAssetPath('webhook'), 'Dockerfile.webhook').build(
undefined,
{deleteOnExit: false},
);
let builder = image
.withExposedPorts(WEBHOOK_PORT)
.withNetwork(network)
.withNetworkAliases(WEBHOOK_ALIAS)
.withLabels(TESTCONTAINERS_LABELS)
.withWaitStrategy(Wait.forHttp('/', WEBHOOK_PORT).forStatusCode(200));
if (testConfig.testcontainersReuse) {
builder = builder.withReuse();
}
return builder.start();
});
}

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';
import {resolvePlaywrightPath} from './util';
const commonAssetPath = path.resolve(__dirname, 'asset');
export const assetPath = resolvePlaywrightPath('asset');
type AvailableFilename = 'mattermost-icon_128x128.png';
/**
* 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

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fork, type ChildProcess} from 'node:child_process';
import path from 'node:path';
import {test} from './test_fixture';
import {testConfig} from '@/test_config';
/** Hostname containers use to reach the Docker host. */
export const DOCKER_HOST_INTERNAL = 'host.docker.internal';
/**
* Starts a server that serves files from ./asset. When run from the monorepo, this will serve files from
* e2e-tests/playwright/asset.
*
* The server will be started automatically by beforeAll and then cleaned up by afterAll.
*
* @returns A promise that resolves to the URL of the file server
*/
export function setupFileServer(): Promise<string> {
return new Promise((resolve, reject) => {
let fileServer: ChildProcess;
test.beforeAll(async () => {
try {
resolve(await startFileServer());
} catch (err) {
reject(err);
}
});
test.afterAll(() => {
fileServer?.kill();
});
function startFileServer(): Promise<string> {
const playwrightRoot = process.cwd();
const serverPath = path.join(playwrightRoot, 'mock_file_server.js');
return new Promise<string>((resolve, reject) => {
fileServer = fork(serverPath, [], {
env: {...process.env, PORT: '0', ASSET_DIR: path.join(playwrightRoot, 'asset')},
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
});
const timeout = setTimeout(
() => reject(new Error('Timed out waiting for the mock file server to start')),
10000,
);
fileServer.once('message', (message: {type?: string; port?: number}) => {
if (message?.type === 'listening' && message.port) {
clearTimeout(timeout);
resolve(`http://${fileServerHost()}:${message.port}`);
}
});
fileServer.once('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
});
}
/**
* Host used in file-server URLs embedded in posts. Must be reachable from both the host-side
* browser (Markdown images load directly) and the Mattermost container (link previews / metadata).
* On macOS/Windows the bridge gateway does not reach host listeners, so use host.docker.internal;
* on Linux use the bridge gateway IP (works both sides; host.docker.internal is often absent from host DNS).
*/
export function fileServerHost(): string {
if (!testConfig.useTestContainers) {
return 'localhost';
}
if (process.platform === 'darwin' || process.platform === 'win32') {
return DOCKER_HOST_INTERNAL;
}
return testConfig.testcontainersNetworkGatewayIp || DOCKER_HOST_INTERNAL;
}

View File

@@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import os from 'node:os';
import {expect, test} from '@playwright/test';
import type {PluginManifest} from '@mattermost/types/plugins';
import {callsPluginId} from './constant';
import {getAdminClient} from './server/init';
import {testConfig} from './test_config';
export async function shouldHaveCallsEnabled(enabled = true) {
const {adminClient} = await getAdminClient();
const config = await adminClient.getConfig();
const callsEnabled = config.PluginSettings.PluginStates[callsPluginId].Enable;
const matched = callsEnabled === enabled;
expect(matched, matched ? '' : `Calls expect "${enabled}" but actual "${callsEnabled}"`).toBeTruthy();
}
export async function shouldHaveFeatureFlag(name: string, value: string | boolean) {
const {adminClient} = await getAdminClient();
const config = await adminClient.getConfig();
const matched = config.FeatureFlags[name] === value;
expect(
matched,
matched ? '' : `FeatureFlags["${name}'] expect "${value}" but actual "${config.FeatureFlags[name]}"`,
).toBeTruthy();
}
export async function shouldRunInLinux() {
const platform = os.platform();
expect(platform, 'Run in Linux or Playwright docker image only').toBe('linux');
}
export async function ensureLicense() {
const {adminClient} = await getAdminClient();
let license = await adminClient.getClientLicenseOld();
if (license?.IsLicensed !== 'true') {
const config = await adminClient.getClientConfig();
expect(
config.ServiceEnvironment === 'dev',
'The trial license request fails in the local development environment. Please manually upload the test license.',
).toBeFalsy();
await requestTrialLicense();
license = await adminClient.getClientLicenseOld();
}
expect(license?.IsLicensed === 'true', 'Ensure server has license').toBeTruthy();
}
export async function requestTrialLicense() {
const {adminClient} = await getAdminClient();
const admin = await adminClient.getMe();
try {
await adminClient.requestTrialLicense({
receive_emails_accepted: true,
terms_accepted: true,
users: 100,
contact_name: admin.first_name + ' ' + admin.last_name,
contact_email: admin.email,
company_name: 'Mattermost Playwright E2E Tests',
company_size: '101-250',
company_country: 'United States',
});
} catch (error) {
expect(error, 'Failed to request trial license').toBeFalsy();
throw error;
}
}
export async function skipIfNoLicense() {
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(license.IsLicensed === 'false', 'Skipping test - server not licensed');
}
export async function skipIfFeatureFlagNotSet(name: string, value: string | boolean) {
const {adminClient} = await getAdminClient();
const cfg = await adminClient.getConfig();
test.skip(cfg.FeatureFlags[name] !== value, `Skipping test - Feature Flag ${name} needs to be set to ${value}`);
}
// ensureServerDeployment is used to ensure server deployment type is as expected.
// If server is not deployed as expected, test will fail.
export async function ensureServerDeployment() {
const {adminClient} = await getAdminClient();
// Based on test config, ensure server is on an HA cluster.
if (testConfig.haClusterEnabled) {
const {haClusterNodeCount, haClusterName} = testConfig;
const {Enable, ClusterName} = (await adminClient.getConfig()).ClusterSettings;
expect(Enable, Enable ? '' : 'Should have cluster enabled').toBe(true);
const sameClusterName = ClusterName === haClusterName;
expect(
sameClusterName,
sameClusterName
? ''
: `Should have cluster name set and as expected. Got "${ClusterName}" but expected "${haClusterName}"`,
).toBe(true);
const clusterInfo = await adminClient.getClusterStatus();
const sameCount = clusterInfo?.length === haClusterNodeCount;
expect(
sameCount,
sameCount
? ''
: `Should match number of nodes in a cluster as expected. Got "${clusterInfo?.length}" but expected "${haClusterNodeCount}"`,
).toBe(true);
clusterInfo.forEach((info) =>
// eslint-disable-next-line no-console
console.log(`hostname: ${info.hostname}, version: ${info.version}, config_hash: ${info.config_hash}`),
);
}
}
// ensurePluginsLoaded is used to ensure all pluginIds including from testConfig.ensurePluginsInstalled are installed and active.
// If any pluginId is not installed, test will fail.
// testConfig.ensurePluginsInstalled is derived from `PW_ENSURE_PLUGINS_INSTALLED` environment variable.
export async function ensurePluginsLoaded(pluginIds: string[] = []) {
const {adminClient} = await getAdminClient();
const pluginStatus = await adminClient.getPluginStatuses();
const plugins = await adminClient.getPlugins();
// Ensure all plugins are installed and active.
testConfig.ensurePluginsInstalled
.concat(pluginIds)
.filter((pluginId) => Boolean(pluginId))
.forEach(async (pluginId) => {
const isInstalled = pluginStatus.some((plugin) => plugin.plugin_id === pluginId);
// If not installed, test will fail.
expect(isInstalled, `${pluginId} is not installed. Related test will fail.`).toBe(true);
const isActive = plugins.active.some((plugin: PluginManifest) => plugin.id === pluginId);
if (isActive) {
// eslint-disable-next-line no-console
console.log(`${pluginId} is installed and active.`);
} else {
await adminClient.enablePlugin(pluginId);
// eslint-disable-next-line no-console
console.log(`${pluginId} is installed and has been activated.`);
}
});
}

View File

@@ -0,0 +1,233 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import type {PluginManifest} from '@mattermost/types/plugins';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {UserProfile} from '@mattermost/types/users';
import {
createNewTeam,
disableUnexpectedPlugins,
getAdminClient,
getDefaultAdminUser,
makeClient,
runMmctlLocal,
} from './server';
import {testConfig} from './test_config';
import {isUpgradePathProjectSelected} from './upgrade_env';
import {defaultTeam} from './util';
export async function baseGlobalSetup() {
let adminClient: Client4;
let adminUser: UserProfile | null;
({adminClient, adminUser} = await getAdminClient({skipLog: true}));
if (!adminUser) {
await enableEmailNotifications();
const firstClient = new Client4();
firstClient.setUrl(testConfig.baseURL);
const defaultAdmin = getDefaultAdminUser();
await firstClient.createUser(defaultAdmin, '', '');
({client: adminClient, user: adminUser} = await makeClient(defaultAdmin));
}
// Print playwright configs
printPlaywrightTestConfig();
await sysadminSetup(adminClient, adminUser);
}
// SendEmailNotifications is off in the server's own defaults, so creating the very first sysadmin
// logs "Failed to send welcome email on create user from signup". No admin exists yet to
// authenticate patchConfig with, so this goes through mmctl's --local socket rather than boot env —
// the server's env surface stays the same as any other run.
async function enableEmailNotifications(): Promise<void> {
if (!testConfig.useTestContainers) {
return;
}
try {
const {exitCode, output} = await runMmctlLocal([
'config',
'set',
'EmailSettings.SendEmailNotifications',
'true',
]);
if (exitCode !== 0) {
throw new Error(`mmctl exited with code ${exitCode}: ${output}`);
}
} catch (error) {
// Costs only a warn-level log line during first-user creation — never worth failing setup over.
// eslint-disable-next-line no-console
console.log('Could not enable email notifications before creating the first user', error);
}
}
async function sysadminSetup(client: Client4, user: UserProfile | null) {
// Ensure admin's email is verified.
if (!user) {
await client.verifyUserEmail(client.token);
}
// Log license and config info
await printLicenseInfo(client);
await printClientInfo(client);
// Create default team if not present.
// Otherwise, create other teams and channels other than the default team cna channels (town-square and off-topic).
const myTeams = await client.getMyTeams();
const myDefaultTeam = myTeams && myTeams.length > 0 && myTeams.find((team) => team.name === defaultTeam.name);
if (!myDefaultTeam) {
await createNewTeam(client, {name: defaultTeam.name, displayName: defaultTeam.displayName});
} else if (myDefaultTeam && testConfig.resetBeforeTest) {
await Promise.all(
myTeams.filter((team) => team.name !== defaultTeam.name).map((team) => client.deleteTeam(team.id)),
);
const myChannels = await client.getMyChannels(myDefaultTeam.id);
await Promise.all(
myChannels
.filter((channel) => {
return (
channel.team_id === myDefaultTeam.id &&
channel.name !== 'town-square' &&
channel.name !== 'off-topic'
);
})
.map((channel) => client.deleteChannel(channel.id)),
);
}
// Set default preferences
await savePreferences(client, user?.id ?? '');
await resetPluginState(client);
await printPluginDetails(client);
}
/**
* Deactivates plugins a previous run left enabled, so they cannot alter the webapp for the specs
* that follow. Skipped for the upgrade-path projects, where surviving plugin state is under test.
*/
async function resetPluginState(client: Client4) {
if (isUpgradePathProjectSelected()) {
return;
}
try {
const disabled = await disableUnexpectedPlugins(client, testConfig.ensurePluginsInstalled);
if (disabled.length) {
// eslint-disable-next-line no-console
console.log(`Disabled plugins left active by an earlier run: ${disabled.join(', ')}`);
}
} catch (error) {
// eslint-disable-next-line no-console
console.log('Could not reset plugin state', error);
throw error;
}
}
function printPlaywrightTestConfig() {
// eslint-disable-next-line no-console
console.log(`Playwright Test Config:
- Headless = ${testConfig.headless}
- SlowMo = ${testConfig.slowMo}
- Workers = ${testConfig.workers}`);
}
async function printLicenseInfo(client: Client4) {
const license = await client.getClientLicenseOld();
// eslint-disable-next-line no-console
console.log(`Server License:
- IsLicensed = ${license.IsLicensed}
- IsTrial = ${license.IsTrial}
- SkuName = ${license.SkuName}
- SkuShortName = ${license.SkuShortName}
- Cloud = ${license.Cloud}
- Users = ${license.Users}`);
}
async function printClientInfo(client: Client4) {
const config = await client.getClientConfig();
// eslint-disable-next-line no-console
console.log(`Build Info:
- BuildNumber = ${config.BuildNumber}
- BuildDate = ${config.BuildDate}
- Version = ${config.Version}
- BuildHash = ${config.BuildHash}
- BuildHashEnterprise = ${config.BuildHashEnterprise}
- BuildEnterpriseReady = ${config.BuildEnterpriseReady}
- TelemetryId = ${config.TelemetryId}
- ServiceEnvironment = ${config.ServiceEnvironment}`);
const {LogSettings, ServiceSettings, PluginSettings, FeatureFlags} = await client.getConfig();
// eslint-disable-next-line no-console
console.log(`Notable Server Config:
- ServiceSettings.EnableSecurityFixAlert = ${ServiceSettings?.EnableSecurityFixAlert}
- LogSettings.EnableDiagnostics = ${LogSettings?.EnableDiagnostics}`);
// eslint-disable-next-line no-console
console.log('Feature Flags:');
// eslint-disable-next-line no-console
console.log(
Object.entries(FeatureFlags)
.map(([key, value]) => ` - ${key} = ${value}`)
.join('\n'),
);
// eslint-disable-next-line no-console
console.log(`Plugin Settings:
- Enable = ${PluginSettings?.Enable}
- EnableUploads = ${PluginSettings?.EnableUploads}
- AutomaticPrepackagedPlugins = ${PluginSettings?.AutomaticPrepackagedPlugins}`);
}
async function printPluginDetails(client: Client4) {
const plugins = await client.getPlugins();
if (plugins.active.length) {
// eslint-disable-next-line no-console
console.log('Active plugins:');
}
plugins.active.forEach((plugin: PluginManifest) => {
// eslint-disable-next-line no-console
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
});
if (plugins.inactive.length) {
// eslint-disable-next-line no-console
console.log('Inactive plugins:');
}
plugins.inactive.forEach((plugin: PluginManifest) => {
// eslint-disable-next-line no-console
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
});
// eslint-disable-next-line no-console
console.log('');
}
async function savePreferences(client: Client4, userId: UserProfile['id']) {
try {
if (!userId) {
throw new Error('userId is not defined');
}
const preferences: PreferenceType[] = [
{user_id: userId, category: 'tutorial_step', name: userId, value: '999'},
{user_id: userId, category: 'crt_thread_pane_step', name: userId, value: '999'},
{user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
{user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
];
await client.savePreferences(userId, preferences);
} catch (error) {
// eslint-disable-next-line no-console
console.log('Error saving preferences', error);
}
}

View File

@@ -0,0 +1,211 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {CDPSession, Page} from '@playwright/test';
/**
* This is Korean for "Test if Hangul is typed well".
*
* When testing manually, if you want to type this on a US English Qwerty keyboard with your OS language set to Korean,
* this can be typed as "gksrmfdl wkf dlqfurehlsmswl xptmxm"
*/
export const koreanTestPhrase = '한글이 잘 입력되는지 테스트';
/**
* Simulates typing a phrase containing Korean Hangul characters using an Input Method Editor that composes characters
* from multiple keypresses as the user types. This isn't completely realistic because it's missing keyboard events, but
* it's sufficient to reproduce composition bugs like MM-66937.
*
* This finishes by ending composition on the final character typed, so this can't be used to test anything involving
* the character that's actively being composed (such as autocompleting partial characters).
*
* Note: This only works on Chrome-based browsers because it relies on the Chrome Devtools Protocol (CDP).
*/
export async function typeHangulWithIme(page: Page, text: string) {
const client = await page.context().newCDPSession(page);
const decomposed = decomposeKorean(text);
for (let i = 0; i < decomposed.length; i++) {
await typeHangulCharacterWithIme(client, decomposed[i], decomposed[i - 1]);
}
const lastCharacter = decomposed.at(-1);
if (lastCharacter && 'jamo' in lastCharacter) {
// # End composition by inserting the final character into the textbox
await client.send('Input.insertText', {
text: lastCharacter.character,
});
}
await client.detach();
}
export async function typeHangulCharacterWithIme(
client: CDPSession,
current: HangleCharacter,
previous: HangleCharacter | undefined,
) {
if (previous && previous.jamo) {
// # End composition of the previous character by inserting it into the textbox
await client.send('Input.insertText', {
text: previous.character,
});
}
if (current.jamo) {
// # Type the individual jamo
// The first one is typed as-is
await client.send('Input.imeSetComposition', {
selectionStart: 0,
selectionEnd: 0,
text: current.jamo[0],
});
// When you type the second one, the IME combines the two into the resulting character. Instead of reversing
// the math, we can do that by concatenating them and then normalizing the Unicode.
await client.send('Input.imeSetComposition', {
selectionStart: 0,
selectionEnd: 0,
text: (current.jamo[0] + current.jamo[1]).normalize('NFKD'),
});
// For the third one, we can't normalize the Unicode because there are some initial and final jamo which
// look identical and normalize to the same value, so just use the original character
await client.send('Input.imeSetComposition', {
selectionStart: 0,
selectionEnd: 0,
text: current.character,
});
} else {
// # Insert the character
await client.send('Input.insertText', {
text: current.character,
});
}
}
/**
* HangleCharacter is an object containing either:
* - An non-Korean character
* - A Korean character and the array of jamo (syllables) that make up that character
*/
export interface HangleCharacter {
character: string;
jamo?: string[];
}
export function decomposeKorean(text: string): HangleCharacter[] {
// Adapted from https://useless-factor.blogspot.com/2007/08/unicode-implementers-guide-part-3.html and
// https://web.archive.org/web/20190512031142/http://www.programminginkorean.com/programming/hangul-in-unicode/composing-syllables-in-unicode/
// All Korean Hangul characters/syllables are in this range of Unicode
const hangulStart = 0xac00;
const hangulEnd = 0xd7a3;
// Hangul characters are made up of an initial consonant, a medial vowel, and an optional final vowel
const initial = [
'ㄱ',
'ㄲ',
'ㄴ',
'ㄷ',
'ㄸ',
'ㄹ',
'ㅁ',
'ㅂ',
'ㅃ',
'ㅅ',
'ㅆ',
'ㅇ',
'ㅈ',
'ㅉ',
'ㅊ',
'ㅋ',
'ㅌ',
'ㅍ',
'ㅎ',
];
const medial = [
'ㅏ',
'ㅐ',
'ㅑ',
'ㅒ',
'ㅓ',
'ㅔ',
'ㅕ',
'ㅖ',
'ㅗ',
'ㅘ',
'ㅙ',
'ㅚ',
'ㅛ',
'ㅜ',
'ㅝ',
'ㅞ',
'ㅟ',
'ㅠ',
'ㅡ',
'ㅢ',
'ㅣ',
];
const final = [
'',
'ㄱ',
'ㄲ',
'ㄳ',
'ㄴ',
'ㄵ',
'ㄶ',
'ㄷ',
'ㄹ',
'ㄺ',
'ㄻ',
'ㄼ',
'ㄽ',
'ㄾ',
'ㄿ',
'ㅀ',
'ㅁ',
'ㅂ',
'ㅄ',
'ㅅ',
'ㅆ',
'ㅇ',
'ㅈ',
'ㅊ',
'ㅋ',
'ㅌ',
'ㅍ',
'ㅎ',
];
const result = [];
for (let i = 0; i < text.length; i++) {
const character = text[i];
const code = character.charCodeAt(0);
if (code >= hangulStart && code <= hangulEnd) {
// This is a Hangul character, so we can break it down into the individual constants and vowel
const syllableIndex = code - hangulStart;
// See the linked blog posts for more information on this math
const initialIndex = Math.floor(syllableIndex / (21 * 28));
const medialIndex = Math.floor((syllableIndex % (21 * 28)) / 28);
const finalIndex = syllableIndex % 28;
const jamo = [];
jamo.push(initial[initialIndex]);
jamo.push(medial[medialIndex]);
if (final[finalIndex]) {
jamo.push(final[finalIndex]);
}
result.push({character, jamo});
} else {
// This is some other character, so just add it separately
result.push({character});
}
}
return result;
}

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {test, expect, PlaywrightExtended} from './test_fixture';
export type {ExtendedFixtures} from './test_fixture';
export {testConfig, TESTCONTAINERS_SERVICE_NAMES} from './test_config';
export type {TestContainersServiceName} from './test_config';
export {
assertUpgradeFromFreshStart,
assertUpgradeToRequiresPriorFromRun,
getUpgradeFromServerImage,
getUpgradeToServerImage,
isUpgradeFromProjectSelected,
isUpgradePathProjectSelected,
isUpgradeToPhaseProjectSelected,
logUpgradeFromServerImage,
logUpgradeToServerImage,
UPGRADE_FROM_SERVER_IMAGE_ENV,
} from './upgrade_env';
export {baseGlobalSetup} from './global_setup';
export {TestBrowser} from './browser_context';
export {assetPath, getBlobFromAsset, getFileFromAsset, getFileData, getBlobData} from './file';
export {setupFileServer} from './file_server';
export {decomposeKorean, koreanTestPhrase, typeHangulCharacterWithIme, typeHangulWithIme} from './ime';
export {type SizeObservation, type SizeWatcher, watchElementSize} from './layout_shift';
export {duration, getRandomId, wait, newTestPassword} from './util';
export {LicenseSkus, appsPluginId, callsPluginId, playbooksPluginId} from './constant';
export {
getAdminClient,
getOnPremServerConfig,
getRecentEmail,
extractEmailLink,
isWebhookTestServerReachable,
setupWebhookTestServer,
PlaywrightClient4,
generateLdapUser,
createLdapUser,
updateLdapUser,
deleteLdapUser,
ldapServerConfig,
ensureOpenldap,
createKeycloakUser,
deleteKeycloakUser,
listMinioObjectKeys,
ensureMinio,
samlServerConfig,
ensureKeycloak,
elasticsearchServerConfig,
opensearchServerConfig,
ensureElasticsearch,
ensureOpensearch,
ensureAzurite,
listAzuriteBlobNames,
ensureLocalFile,
listMattermostDataFiles,
ensurePostgresSearch,
ensureFeatureFlag,
runMmctl,
ensureMmctl,
upgradeServerImage,
saveUpgradePhaseLogs,
installAndEnablePlugin,
getPluginStatus,
isPluginActive,
} from './server';
export type {InbucketEmail, LdapUser, KeycloakUser, MmctlResult, UpgradeLogPhase} from './server';
export {startStack, stopStack} from './containers';
export {
ChannelsPage,
LandingLoginPage,
LoginPage,
RecapsPage,
ResetPasswordPage,
SignupPage,
ScheduledPostsPage,
SystemConsolePage,
DraftsPage,
} from './ui/pages';
export {
components,
AboutBuildModal,
GlobalHeader,
SearchBox,
ChannelsCenterView,
ChannelsSidebarLeft,
ChannelsSidebarRight,
ChannelsAppBar,
ChannelsHeader,
ChannelsPostCreate,
ChannelsPostEdit,
ChannelsPost,
ChannelSettingsModal,
DraftPost,
FindChannelsModal,
DeletePostModal,
DeleteScheduledPostModal,
SettingsModal,
PostDotMenu,
PostMenu,
ThreadFooter,
Footer,
MainHeader,
PostReminderMenu,
EmojiGifPicker,
GenericConfirmModal,
ScheduleMessageMenu,
ScheduleMessageModal,
ScheduledPostIndicator,
ScheduledDraftModal,
ScheduledPost,
SendMessageNowModal,
SystemConsoleFeatureDiscovery,
MessagePriority,
UserProfilePopover,
UserAccountMenu,
DeletePostConfirmationDialog,
RestorePostConfirmationDialog,
ProfileModal,
WysiwygEditor,
} from './ui/components';
export {setWysiwygUserPreference, WYSIWYG_PREF_CATEGORY, WYSIWYG_PREF_NAME} from './wysiwyg_helpers';
export {TextInputSetting} from './ui/components/system_console/base_components';
export {expectFilesVisible, expectFilesRedacted} from './ui/components/channels/post';
export {TestArgs, ScreenshotOptions} from './types';
export {
enableAutotranslationConfig,
disableAutotranslationConfig,
enableChannelAutotranslation,
disableChannelAutotranslation,
setUserChannelAutotranslation,
setMockSourceLanguage,
ensureAutotranslationPermissions,
} from './autotranslation_helpers';
export type {EnableAutotranslationOptions} from './autotranslation_helpers';
export {
hasAutotranslationLicense,
hasSharedChannelsLicense,
hasCustomPermissionsSchemesLicense,
licenseTier,
} from './license_helpers';
// ABAC (Attribute-Based Access Control) helpers
export {
createUserWithAttributes,
enableABAC,
disableABAC,
navigateToABACPage,
navigateToPermissionPoliciesPage,
navigateToAttributeBasedAccessPage,
createBasicPolicy,
createAdvancedPolicy,
editPolicy,
deletePolicy,
runSyncJob,
verifyUserInChannel,
verifyUserNotInChannel,
updateUserAttributes,
} from './server';

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
export type SizeObservation = {width: number; height: number; at: number};
export type SizeWatcher = {
/** Callback to clean up and remove the SizeWatcher */
cleanup: () => Promise<void>;
/** Returns an array of all height observations made since the last time the page loaded. */
getObservations: () => Promise<SizeObservation[]>;
/** Callback that returns true if the watched element has been found. */
isWatchingElement: () => Promise<boolean>;
};
/**
* Registers and returns an object that can be used to observe how the height of an element changes over its lifetime
* The watcher persists through navigation and can be registered before the page loads, but the list of observed
* heights will be reset when that happens.
*/
export async function watchElementSize(page: Page, elementId: string): Promise<SizeWatcher> {
const selector = `#${elementId}`;
const watcherKey = `watchElementSize-${elementId}`;
type SizeWatcherInternal = {
element?: Element;
observations: SizeObservation[];
mutationObserver?: MutationObserver;
resizeObserver?: ResizeObserver;
};
const disposeWatcher = await page.addInitScript(
([selector, watcherKey]) => {
const sizeWatcher: SizeWatcherInternal = {
observations: [],
};
(window as any)[watcherKey] = sizeWatcher;
const attachResizeObserver = (el: Element) => {
sizeWatcher.element = el;
const resizeObserver = new ResizeObserver((entries) => {
for (const e of entries) {
const width = e.borderBoxSize[0].inlineSize;
const height = e.borderBoxSize[0].blockSize;
sizeWatcher.observations.push({width, height, at: performance.now()});
}
});
resizeObserver.observe(el);
sizeWatcher.resizeObserver = resizeObserver;
};
const existingEl = document.querySelector(selector);
if (existingEl) {
attachResizeObserver(existingEl);
} else {
const mutationObserver = new MutationObserver(() => {
const el = document.querySelector(selector);
if (!el || sizeWatcher.element) {
return;
}
attachResizeObserver(el);
mutationObserver.disconnect();
sizeWatcher.mutationObserver = undefined;
});
mutationObserver.observe(document, {childList: true, subtree: true});
sizeWatcher.mutationObserver = mutationObserver;
}
},
[selector, watcherKey],
);
const cleanupWatcher = async () => {
await page.evaluate((watcherKey) => {
const sizeWatcher: SizeWatcherInternal = (window as any)[watcherKey];
sizeWatcher.element = undefined;
sizeWatcher.mutationObserver?.disconnect();
sizeWatcher.mutationObserver = undefined;
sizeWatcher.resizeObserver?.disconnect();
sizeWatcher.resizeObserver = undefined;
}, watcherKey);
await disposeWatcher.dispose();
};
const getObservations = async () => {
return page.evaluate((watcherKey) => {
const sizeWatcher: SizeWatcherInternal = (window as any)[watcherKey];
return sizeWatcher.observations;
}, watcherKey);
};
const isWatchingElement = async () => {
return page.evaluate((watcherKey) => {
const sizeWatcher: SizeWatcherInternal = (window as any)[watcherKey];
return Boolean(sizeWatcher.element);
}, watcherKey);
};
return {
cleanup: cleanupWatcher,
getObservations,
isWatchingElement,
};
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* License helpers for E2E tests.
* Logic mirrors server (model/license.go) and webapp (licensedForFeature, license_utils).
*/
export type ClientLicense = Record<string, string>;
/**
* Returns true if the server has a license that includes autotranslation
* Use with test.skip(!hasAutotranslationLicense(license.SkuShortName), '...') in autotranslation specs.
*/
export function hasAutotranslationLicense(skuShortName: string): boolean {
return skuShortName === 'enterprise' || skuShortName === 'entry' || skuShortName === 'advanced';
}
/**
* Returns true if the server has a license that includes Shared Channels.
* The client receives SharedChannels === 'true' when the server's HasSharedChannels() is true
* (see server/public/model/license.go and server/channels/utils/license.go).
* Use with test.skip(!hasSharedChannelsLicense(license), '...') in shared channel specs.
*/
export function hasSharedChannelsLicense(license: ClientLicense | null | undefined): boolean {
return license?.SharedChannels === 'true';
}
/**
* Returns true if the server has a license that includes Custom Permission Schemes
* (required to create/patch/delete permission schemes and assign custom roles via API,
* e.g. shared_channel_manager or roles in a custom team scheme).
* The client receives CustomPermissionsSchemes === 'true' when the feature is enabled
* (see server/public/model/license.go and server/channels/utils/license.go).
*/
export function hasCustomPermissionsSchemesLicense(license: ClientLicense | null | undefined): boolean {
return license?.CustomPermissionsSchemes === 'true';
}
/**
* Mirrors webapp `getLicenseTier` (utils/constants) for client `SkuShortName` values.
*/
export function licenseTier(skuShortName: string): number {
switch (skuShortName) {
case 'professional':
return 10;
case 'enterprise':
return 20;
case 'entry':
case 'advanced':
return 30;
default:
return 0;
}
}

View File

@@ -0,0 +1,230 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
type NotificationData = {title: string} & NotificationOptions;
type MockWebSocket = {
wrappedSocket: WebSocket | null;
onopen: ((ev: Event) => void) | null;
onmessage: ((ev: MessageEvent) => void) | null;
onerror: ((ev: Event) => void) | null;
onclose: ((ev: CloseEvent) => void) | null;
readyState: number;
send(data: string | ArrayBuffer): void;
close(): void;
connect(): void;
};
// Extend the Window interface to add custom properties
declare global {
interface Window {
originalNotification: typeof Notification;
capturedNotifications: NotificationData[];
capturedNotificationInstances: Notification[];
getNotifications: () => NotificationData[];
mockWebsockets: MockWebSocket[];
}
}
/**
* Installed in the browser via addInitScript and page.evaluate. Must be
* self-contained (no closure over module scope) so Playwright can serialize it.
*/
function installNotificationStub(notificationPermission: NotificationPermission) {
window.Notification.requestPermission = () => Promise.resolve(notificationPermission);
if (!window.originalNotification) {
window.originalNotification = window.Notification;
}
window.capturedNotifications = window.capturedNotifications ?? [];
// Keep the live notification instances too, so a test can invoke their
// onclick handler to simulate a user clicking a desktop notification.
window.capturedNotificationInstances = window.capturedNotificationInstances ?? [];
class CustomNotification extends window.originalNotification {
constructor(title: string, options?: NotificationOptions) {
super(title, options);
const notification = {title, ...options};
window.capturedNotifications.push(notification);
window.capturedNotificationInstances.push(this);
}
}
Object.defineProperties(CustomNotification, {
permission: {
get: () => notificationPermission,
},
requestPermission: {
value: () => Promise.resolve(notificationPermission),
},
});
window.Notification = CustomNotification as unknown as typeof Notification;
window.getNotifications = () => window.capturedNotifications;
}
/**
* `stubNotification` intercepts the Notification API to capture notifications.
*
* Note:
* - Works across browsers and devices, except in headless mode, where stubbing the Notification API is supported only in Firefox and WebKit.
* - Uses addInitScript so the stub survives channel navigations after login without clearing captured notifications.
*
* @param page Page object
* @param permission Permission setting for notifications, with possible values: "default" | "granted" | "denied". Note: A notification sound may still occur even when set to "denied", as the browser might attempt to trigger system notifications.
*/
export async function stubNotification(page: Page, permission: NotificationPermission) {
await page.addInitScript(installNotificationStub, permission);
await page.evaluate(installNotificationStub, permission);
}
/** Clears notifications captured by {@link stubNotification}. */
export async function clearCapturedNotifications(page: Page) {
await page.evaluate(() => {
window.capturedNotifications = [];
window.capturedNotificationInstances = [];
});
}
/**
* `waitForNotification` waits for a specified number of notifications to be received on the page within a given timeout.
* @param page Page object
* @param expectedCount Number of notifications to wait for before returning. (default: 1)
* @param timeout Wait time in milliseconds. (default: 5000ms)
* @returns An array of notifications received
*/
export async function waitForNotification(
page: Page,
expectedCount = 1,
timeout: number = 5000,
): Promise<NotificationData[]> {
const start = Date.now();
while (Date.now() - start < timeout) {
const notifications = await page.evaluate(() => window.getNotifications());
if (notifications.length >= expectedCount) {
return notifications;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
// eslint-disable-next-line no-console
console.error(`Notification not received within the timeout period of ${timeout}ms`);
return [];
}
/**
* `clickNotification` simulates a user clicking a captured desktop notification by invoking the
* `onclick` handler the app assigned to it (which focuses the window and navigates to the post).
* Requires `stubNotification` to have been called on the same page first.
* @param page Page object
* @param index Index of the captured notification to click (default: the first one)
*/
export async function clickNotification(page: Page, index = 0) {
await page.evaluate((notificationIndex) => {
const instance = window.capturedNotificationInstances?.[notificationIndex];
if (!instance || typeof instance.onclick !== 'function') {
throw new Error(`No clickable notification captured at index ${notificationIndex}`);
}
instance.onclick(new Event('click'));
}, index);
}
/**
* `mockWebsockets` wraps `window.WebSocket` so the test can close and reopen the underlying
* socket connection(s) on demand, to simulate a socket disconnect/reconnect without navigating
* away from the page. Mocked sockets do not auto-connect on construction — call
* `connectWebsockets` to open them.
*
* Must be called on a `page` that has not yet navigated (e.g. right after `pw.testBrowser.login()`,
* before `channelsPage.goto(...)`), since it installs the override via `addInitScript` so it's in
* place before the app's own bundle constructs its WebSocket client.
*
* @param page Page object, not yet navigated
*/
export async function mockWebsockets(page: Page) {
await page.addInitScript(() => {
const RealWebSocket = window.WebSocket;
window.mockWebsockets = [];
class MockWebSocketImpl {
// Match the standard WebSocket.readyState values so client code comparing
// against `WebSocket.OPEN` (now this class, since it replaces window.WebSocket)
// sees real state transitions instead of coincidental `undefined === undefined`.
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
wrappedSocket: WebSocket | null = null;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
readyState: number = MockWebSocketImpl.CONNECTING;
private readonly args: [string | URL, (string | string[])?];
constructor(...args: [string | URL, (string | string[])?]) {
this.args = args;
window.mockWebsockets.push(this);
}
send(data: string | ArrayBuffer) {
if (this.wrappedSocket) {
this.wrappedSocket.send(data);
} else if (this.onerror) {
this.onerror(new Event('error'));
}
}
close() {
if (this.wrappedSocket) {
this.readyState = MockWebSocketImpl.CLOSING;
this.wrappedSocket.close(1000);
} else {
this.readyState = MockWebSocketImpl.CLOSED;
}
}
connect() {
const socket = new RealWebSocket(...this.args);
this.readyState = MockWebSocketImpl.CONNECTING;
socket.onopen = (ev) => {
this.readyState = MockWebSocketImpl.OPEN;
this.onopen?.(ev);
};
socket.onmessage = (ev) => this.onmessage?.(ev);
socket.onerror = (ev) => this.onerror?.(ev);
socket.onclose = (ev) => {
this.readyState = MockWebSocketImpl.CLOSED;
this.onclose?.(ev);
};
this.wrappedSocket = socket;
}
}
window.WebSocket = MockWebSocketImpl as unknown as typeof WebSocket;
});
}
/**
* Opens (or reopens) every mocked WebSocket tracked by `mockWebsockets`, simulating a reconnect.
* @param page Page object
*/
export async function connectWebsockets(page: Page) {
await page.evaluate(() => {
window.mockWebsockets.forEach((ws) => ws.connect());
});
}
/**
* Closes every mocked WebSocket tracked by `mockWebsockets`, simulating a disconnect.
* @param page Page object
*/
export async function closeWebsockets(page: Page) {
await page.evaluate(() => {
window.mockWebsockets.forEach((ws) => ws.close());
});
}

View File

@@ -0,0 +1,376 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import {getRandomId, newTestPassword} from '../util';
/**
* Create a user with custom profile attributes
* IMPORTANT: This creates the user first, then sets attributes by field ID
*/
export async function createUserWithAttributes(
client: Client4,
attributes: Record<string, string>,
): Promise<UserProfile> {
const randomId = getRandomId();
// Ensure username starts with a letter (Mattermost requirement)
const username = `user${randomId}`.toLowerCase();
// Create user without attributes first
const user = await client.createUser(
{
email: `${username}@example.com`,
username,
password: newTestPassword(),
} as any,
'',
'',
);
// Set attributes using field IDs (if any provided)
if (Object.keys(attributes).length > 0) {
const fields = await client.getCustomProfileAttributeFields();
// Convert attribute names to field IDs
const valuesByFieldId: Record<string, string> = {};
for (const [attrName, attrValue] of Object.entries(attributes)) {
const field = fields.find((f: any) => f.name === attrName);
if (field) {
valuesByFieldId[field.id] = attrValue;
} else {
throw new Error(
`Attribute field "${attrName}" not found. Available fields: ${fields.map((f: any) => f.name).join(', ')}`,
);
}
}
// Set the attribute values
if (Object.keys(valuesByFieldId).length > 0) {
await client.updateUserCustomProfileAttributesValues(user.id, valuesByFieldId);
}
}
return user;
}
/**
* Enable ABAC in System Console.
* Always navigates to the Attribute-Based Access settings page (where the toggle lives)
* before interacting, then navigates to Membership Policies afterward so callers land
* on the correct page for policy management.
*/
export async function enableABAC(page: Page): Promise<void> {
await page.goto('/admin_console/system_attributes/attribute_based_access_control');
await page.waitForLoadState('networkidle');
const enableRadio = page.locator('#AccessControlSettings\\.EnableAttributeBasedAccessControltrue');
const saveButton = page.getByRole('button', {name: 'Save'});
await enableRadio.click();
await saveButton.waitFor({state: 'visible', timeout: 5000});
if (!(await saveButton.isDisabled())) {
await saveButton.click();
await page.waitForLoadState('networkidle');
}
// Always land on Membership Policies so callers can immediately create/manage policies
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
}
/**
* Disable ABAC in System Console.
* Always navigates to the Attribute-Based Access settings page first.
*/
export async function disableABAC(page: Page): Promise<void> {
await page.goto('/admin_console/system_attributes/attribute_based_access_control');
await page.waitForLoadState('networkidle');
const disableRadio = page.locator('#AccessControlSettings\\.EnableAttributeBasedAccessControlfalse');
const saveButton = page.getByRole('button', {name: 'Save'});
await disableRadio.click();
await saveButton.waitFor({state: 'visible', timeout: 5000});
if (!(await saveButton.isDisabled())) {
await saveButton.click();
await page.waitForLoadState('networkidle');
}
}
/**
* Navigate to Membership Policies page in System Console
*/
export async function navigateToABACPage(page: Page): Promise<void> {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
}
/**
* Navigate to Permission Policies page in System Console
*/
export async function navigateToPermissionPoliciesPage(page: Page): Promise<void> {
await page.goto('/admin_console/system_attributes/permission_policies');
await page.waitForLoadState('networkidle');
}
/**
* Navigate to the ABAC enable/disable settings page
*/
export async function navigateToAttributeBasedAccessPage(page: Page): Promise<void> {
await page.goto('/admin_console/system_attributes/attribute_based_access_control');
await page.waitForLoadState('networkidle');
}
/**
* Create a basic policy using Table Editor (Basic mode)
*/
export async function createBasicPolicy(
page: Page,
options: {
name: string;
attribute: string;
operator: string;
value: string;
autoSync?: boolean;
channels?: string[];
},
): Promise<void> {
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.click();
await page.waitForLoadState('networkidle');
// Fill policy name
const nameInput = page.locator('input[name="name"]').first();
await nameInput.fill(options.name);
// Set auto-sync if specified
if (options.autoSync) {
const autoSyncToggle = page
.locator('input[type="checkbox"]')
.filter({hasText: /auto|sync/i})
.first();
if (await autoSyncToggle.isVisible({timeout: 1000})) {
await autoSyncToggle.check();
}
}
// Set policy expression using table editor
const attributeDropdown = page.locator('select').first();
const operatorDropdown = page.locator('select').nth(1);
const valueInput = page.locator('input[type="text"]').last();
await attributeDropdown.selectOption(options.attribute);
await operatorDropdown.selectOption(options.operator);
await valueInput.fill(options.value);
// Add channels if specified
if (options.channels && options.channels.length > 0) {
const addChannelsButton = page.getByRole('button', {name: /add.*channel/i});
if (await addChannelsButton.isVisible({timeout: 1000})) {
await addChannelsButton.click();
const channelModal = page.locator('.modal, [role="dialog"]').first();
for (const channelName of options.channels) {
await channelModal.getByText(channelName, {exact: false}).click();
}
const modalSaveButton = channelModal.getByRole('button', {name: 'Save'});
await modalSaveButton.click();
}
}
// Save policy
const saveButton = page.getByRole('button', {name: 'Save'}).last();
await saveButton.click();
await page.waitForLoadState('networkidle');
}
/**
* Create an advanced policy using CEL Editor (Advanced mode)
*/
export async function createAdvancedPolicy(
page: Page,
options: {
name: string;
celExpression: string;
autoSync?: boolean;
channels?: string[];
},
): Promise<void> {
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.click();
await page.waitForLoadState('networkidle');
// Fill policy name
const nameInput = page.locator('input[name="name"]').first();
await nameInput.fill(options.name);
// Set auto-sync if specified
if (options.autoSync) {
const autoSyncToggle = page
.locator('input[type="checkbox"]')
.filter({hasText: /auto|sync/i})
.first();
if (await autoSyncToggle.isVisible({timeout: 1000})) {
await autoSyncToggle.check();
}
}
// Switch to Advanced mode
const modeToggle = page.getByRole('button', {name: /advanced|basic/i});
if (await modeToggle.isVisible({timeout: 1000})) {
await modeToggle.click();
await page.waitForTimeout(500);
}
// Fill CEL expression
const celEditor = page.locator('textarea').first();
await celEditor.fill(options.celExpression);
// Add channels if specified
if (options.channels && options.channels.length > 0) {
const addChannelsButton = page.getByRole('button', {name: /add.*channel/i});
if (await addChannelsButton.isVisible({timeout: 1000})) {
await addChannelsButton.click();
const channelModal = page.locator('.modal, [role="dialog"]').first();
for (const channelName of options.channels) {
await channelModal.getByText(channelName, {exact: false}).click();
}
const modalSaveButton = channelModal.getByRole('button', {name: 'Save'});
await modalSaveButton.click();
}
}
// Save policy
const saveButton = page.getByRole('button', {name: 'Save'}).last();
await saveButton.click();
await page.waitForLoadState('networkidle');
}
/**
* Edit an existing policy
*/
export async function editPolicy(page: Page, policyName: string): Promise<void> {
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).locator('..').locator('..');
const menuButton = policyRow.locator('button[id*="policy-menu"]');
await menuButton.click();
const editButton = page.getByRole('menuitem', {name: 'Edit'});
await editButton.click();
await page.waitForLoadState('networkidle');
}
/**
* Delete a policy
*/
export async function deletePolicy(page: Page, policyName: string): Promise<void> {
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).locator('..').locator('..');
const menuButton = policyRow.locator('button[id*="policy-menu"]');
await menuButton.click();
const deleteButton = page.getByRole('menuitem', {name: 'Delete'});
await deleteButton.click();
// Confirm deletion if modal appears
const confirmButton = page.getByRole('button', {name: /delete|confirm/i});
if (await confirmButton.isVisible({timeout: 1000})) {
await confirmButton.click();
}
await page.waitForLoadState('networkidle');
}
/**
* Click the "Run Sync Job" button and return the new job ID immediately.
*
* Intercepts the POST /api/v4/jobs response so the caller gets the exact job ID
* without a polling round-trip. Pass the returned ID to waitForLatestSyncJob as
* the `jobId` argument to skip Phase 1 and poll the specific job directly.
*
* Throws if the server returns a non-2xx status or if the response body has no
* id field. Returns null only if the interception times out (network hiccup),
* allowing callers to fall back to list-based Phase 1 polling.
*/
export async function runSyncJob(page: Page): Promise<string | null> {
// Do NOT filter by resp.ok() here — capture the response regardless of status
// so we can surface API errors explicitly instead of silently swallowing them.
const jobResponsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/v4/jobs') && resp.request().method() === 'POST',
{timeout: 10000},
);
// "Run Channel Sync" when team ABAC is on, else "Run Sync Job".
await page.getByRole('button', {name: /Run (Sync Job|Channel Sync)/}).click();
try {
const response = await jobResponsePromise;
if (!response.ok()) {
throw new Error(`POST /api/v4/jobs failed with status ${response.status()}`);
}
const job = await response.json();
if (!job.id) {
throw new Error('POST /api/v4/jobs response missing id field');
}
return job.id as string;
} catch (err) {
if (err instanceof Error && err.message.startsWith('POST /api/v4/jobs')) {
throw err;
}
// Interception timed out — callers fall back to list-based polling in Phase 1.
return null;
}
}
/**
* Verify user is member of channel
*/
export async function verifyUserInChannel(client: Client4, userId: string, channelId: string): Promise<boolean> {
try {
const members = await client.getChannelMembers(channelId);
return members.some((m: any) => m.user_id === userId);
} catch {
return false;
}
}
/**
* Verify user is NOT member of channel
*/
export async function verifyUserNotInChannel(client: Client4, userId: string, channelId: string): Promise<boolean> {
return !(await verifyUserInChannel(client, userId, channelId));
}
/**
* Update user's custom profile attributes
* Converts attribute names to field IDs and uses the correct API
*/
export async function updateUserAttributes(
client: Client4,
userId: string,
attributes: Record<string, string>,
): Promise<void> {
const fields = await client.getCustomProfileAttributeFields();
// Convert attribute names to field IDs
const valuesByFieldId: Record<string, string> = {};
for (const [attrName, attrValue] of Object.entries(attributes)) {
const field = fields.find((f: any) => f.name === attrName);
if (field) {
valuesByFieldId[field.id] = attrValue;
} else {
throw new Error(
`Attribute field "${attrName}" not found. Available fields: ${fields.map((f: any) => f.name).join(', ')}`,
);
}
}
// Update attributes using field IDs
if (Object.keys(valuesByFieldId).length > 0) {
await client.updateUserCustomProfileAttributesValues(userId, valuesByFieldId);
}
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {AdminConfig} from '@mattermost/types/config';
import {getRandomId} from '@/util';
type AIBridgeOperation = 'recap_summary' | 'rewrite';
export type AIBridgeMockStatus = {
available: boolean;
reason?: string;
};
export type AIBridgeMockAgent = {
id: string;
displayName: string;
username: string;
service_id: string;
service_type: string;
is_default?: boolean;
};
export type AIBridgeMockService = {
id: string;
name: string;
type: string;
};
export type AIBridgeMockCompletion = {
completion?: string;
error?: string;
status_code?: number;
};
export type AIBridgeMockMessage = {
role: string;
message: string;
file_ids?: string[];
};
export type AIBridgeMockRecordedRequest = {
operation: AIBridgeOperation | string;
client_operation?: string;
operation_sub_type?: string;
session_user_id?: string;
user_id?: string;
channel_id?: string;
agent_id?: string;
service_id?: string;
messages: AIBridgeMockMessage[];
json_output_format?: Record<string, unknown>;
};
export type AIBridgeMockConfig = {
status?: AIBridgeMockStatus;
agents?: AIBridgeMockAgent[];
services?: AIBridgeMockService[];
agent_completions?: Partial<Record<AIBridgeOperation | string, AIBridgeMockCompletion[]>>;
feature_flags?: {
enable_ai_plugin_bridge?: boolean;
enable_ai_recaps?: boolean;
};
record_requests?: boolean;
};
export type AIBridgeMockState = AIBridgeMockConfig & {
record_requests: boolean;
recorded_requests: AIBridgeMockRecordedRequest[];
};
type EnableAIBridgeTestModeOptions = {
enableRecaps?: boolean;
};
type CreateMockAIAgentOverrides = {
agent?: Partial<AIBridgeMockAgent>;
service?: Partial<AIBridgeMockService>;
status?: AIBridgeMockStatus;
record_requests?: boolean;
};
const AI_BRIDGE_TEST_HELPER_ROUTE = '/system/e2e/ai_bridge';
async function doAdminFetch<T>(adminClient: Client4, method: 'GET' | 'PUT' | 'DELETE', body?: unknown): Promise<T> {
const route = `${adminClient.getBaseRoute()}${AI_BRIDGE_TEST_HELPER_ROUTE}`;
return (adminClient as any).doFetch(route, {
method,
...(body === undefined ? {} : {body: JSON.stringify(body)}),
});
}
function upsertById<T extends {id: string}>(items: T[], item: T): T[] {
const existingIndex = items.findIndex((existingItem) => existingItem.id === item.id);
if (existingIndex === -1) {
return [...items, item];
}
const nextItems = [...items];
nextItems[existingIndex] = item;
return nextItems;
}
export async function enableAIBridgeTestMode(
adminClient: Client4,
{enableRecaps = false}: EnableAIBridgeTestModeOptions = {},
): Promise<AdminConfig> {
await adminClient.patchConfig({
ServiceSettings: {
EnableTesting: true,
},
});
await configureAIBridgeMock(adminClient, {
feature_flags: {
enable_ai_plugin_bridge: true,
...(enableRecaps ? {enable_ai_recaps: true} : {}),
},
});
return adminClient.getConfig();
}
export async function configureAIBridgeMock(
adminClient: Client4,
config: AIBridgeMockConfig,
): Promise<AIBridgeMockState> {
return doAdminFetch<AIBridgeMockState>(adminClient, 'PUT', config);
}
export async function getAIBridgeMock(adminClient: Client4): Promise<AIBridgeMockState> {
return doAdminFetch<AIBridgeMockState>(adminClient, 'GET');
}
export async function resetAIBridgeMock(adminClient: Client4): Promise<void> {
await doAdminFetch(adminClient, 'DELETE');
}
export async function createMockAIAgent(
adminClient: Client4,
overrides: CreateMockAIAgentOverrides = {},
): Promise<{agent: AIBridgeMockAgent; service: AIBridgeMockService; state: AIBridgeMockState}> {
const state = await getAIBridgeMock(adminClient);
const randomId = getRandomId();
const service: AIBridgeMockService = {
id: overrides.service?.id ?? overrides.agent?.service_id ?? `mock-service-${randomId}`,
name: overrides.service?.name ?? 'Mock AI Service',
type: overrides.service?.type ?? overrides.agent?.service_type ?? 'openaicompatible',
};
const agent: AIBridgeMockAgent = {
id: overrides.agent?.id ?? `mock-agent-${randomId}`,
displayName: overrides.agent?.displayName ?? 'Mock AI Agent',
username: overrides.agent?.username ?? `mock.ai.${randomId}`,
service_id: overrides.agent?.service_id ?? service.id,
service_type: overrides.agent?.service_type ?? service.type,
is_default: overrides.agent?.is_default ?? (state.agents ?? []).length === 0,
};
const nextState = await configureAIBridgeMock(adminClient, {
status: overrides.status ?? state.status,
agents: upsertById(state.agents ?? [], agent),
services: upsertById(state.services ?? [], service),
agent_completions: state.agent_completions,
record_requests: overrides.record_requests ?? state.record_requests,
});
return {agent, service, state: nextState};
}
export function rewriteCompletion(text: string): AIBridgeMockCompletion {
return {
completion: JSON.stringify({rewritten_text: text}),
};
}
export function recapCompletion({
highlights,
actionItems,
}: {
highlights: string[];
actionItems: string[];
}): AIBridgeMockCompletion {
return {
completion: JSON.stringify({
highlights,
action_items: actionItems,
}),
};
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BlobServiceClient, StorageSharedKeyCredential} from '@azure/storage-blob';
import {test} from '@playwright/test';
import {
AZURITE_ACCOUNT_KEY,
AZURITE_ACCOUNT_NAME,
AZURITE_ALIAS,
AZURITE_BLOB_PORT,
AZURITE_CONTAINER,
} from '../containers/constants';
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
import {uploadProbeImage} from './filestore';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
function getBlobServiceClient(): BlobServiceClient {
const credential = new StorageSharedKeyCredential(AZURITE_ACCOUNT_NAME, AZURITE_ACCOUNT_KEY);
return new BlobServiceClient(`${testConfig.azuriteUrl}/${AZURITE_ACCOUNT_NAME}`, credential);
}
// Only used internally by ensureAzurite() — not a spec-facing entry point.
async function ensureAzuriteContainer(container: string = AZURITE_CONTAINER): Promise<void> {
const client = getBlobServiceClient().getContainerClient(container);
const exists = await client.exists();
if (!exists) {
await client.create();
}
}
/** Lists every blob name in the container, to confirm the server actually wrote to Azurite. */
export async function listAzuriteBlobNames(container: string = AZURITE_CONTAINER): Promise<string[]> {
const client = getBlobServiceClient().getContainerClient(container);
const names: string[] = [];
for await (const blob of client.listBlobsFlat()) {
names.push(blob.name);
}
return names;
}
// FileSettings' backend is chosen once when the Mattermost process boots and never re-read from a
// running server's config, so pointing the server at Azurite can only happen via the env vars the
// container starts with — always via the network alias, since the server itself always runs
// inside the Testcontainers network. AzureEndpoint under the "custom" cloud is the full service
// URL (path-style, account name included), unlike the vhost-style URLs real Azure uses.
function azuriteServerEnv(): Record<string, string> {
return {
MM_FILESETTINGS_DRIVERNAME: 'azureblob',
MM_FILESETTINGS_AZURESTORAGEACCOUNT: AZURITE_ACCOUNT_NAME,
MM_FILESETTINGS_AZUREAUTHMODE: 'shared_key',
MM_FILESETTINGS_AZUREACCESSKEY: AZURITE_ACCOUNT_KEY,
MM_FILESETTINGS_AZURECONTAINER: AZURITE_CONTAINER,
MM_FILESETTINGS_AZURECLOUD: 'custom',
MM_FILESETTINGS_AZUREENDPOINT: `http://${AZURITE_ALIAS}:${AZURITE_BLOB_PORT}/${AZURITE_ACCOUNT_NAME}`,
MM_FILESETTINGS_AZURESSL: 'false',
};
}
/**
* Checks Azurite was started this run, restarts the server onto it if it isn't already the active
* file storage backend, and confirms a real upload actually lands in Azurite — skipping the test
* otherwise, instead of failing on an unmet precondition.
*/
export async function ensureAzurite(): Promise<void> {
if (!testConfig.testcontainersServices.includes('azurite')) {
test.skip(true, 'Skipping test - azurite not started (set PW_TESTCONTAINERS_SERVICES=azurite)');
return;
}
try {
await ensureAzuriteContainer();
const env = azuriteServerEnv();
if (!bootEnvMatches(env)) {
await restartMattermostContainer(env);
}
const {adminClient, adminUser} = await getAdminClient();
await uploadProbeImage(adminClient, adminUser);
const blobNames = await listAzuriteBlobNames();
if (blobNames.length === 0) {
throw new Error(
'Azurite container is still empty after a real upload — the server is not actually using ' +
'Azurite as its file backend.',
);
}
} catch (error) {
test.skip(true, `Skipping test - Azurite connection test failed: ${String(error)}`);
}
}

View File

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

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {UserProfile} from '@mattermost/types/users';
import {PlaywrightClient4} from './playwright_client';
import {testConfig} from '@/test_config';
// Variable to hold cache
const clients: Record<string, ClientCache> = {};
/**
* Drops every cached client, so the next makeClient()/getAdminClient() call logs in again instead
* of reusing a session pointed at a Mattermost container that no longer exists — needed after
* restartMattermostContainer() swaps in a fresh container (new base URL, new session).
*/
export function clearClientCache(): void {
for (const key of Object.keys(clients)) {
delete clients[key];
}
}
export async function makeClient(
userRequest?: UserRequest,
opts: {useCache?: boolean; skipLog?: boolean} = {useCache: true, skipLog: false},
): Promise<ClientCache> {
const client = new PlaywrightClient4();
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: PlaywrightClient4;
user: UserProfile | null;
};

View File

@@ -0,0 +1,918 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import merge from 'deepmerge';
import type {
AccessControlSettings,
AdminConfig,
AnnouncementSettings,
ClusterSettings,
EmailSettings,
ExperimentalSettings,
LogSettings,
PasswordSettings,
ServiceSettings,
TeamSettings,
} from '@mattermost/types/config';
import {CollapsedThreads} from '@mattermost/types/config';
import {testConfig} from '@/test_config';
export function getOnPremServerConfig(): AdminConfig {
return merge<AdminConfig>(defaultServerConfig, onPremServerConfig() as AdminConfig);
}
/** Live-reloadable on-prem overrides only — safe for patchConfig (no restart-required keys). */
export function getOnPremServerConfigPatch(): Partial<AdminConfig> {
return onPremServerConfig() as Partial<AdminConfig>;
}
type TestAdminConfig = {
AccessControlSettings: Partial<AccessControlSettings>;
AnnouncementSettings: Partial<AnnouncementSettings>;
ClusterSettings: Partial<ClusterSettings>;
EmailSettings: Partial<EmailSettings>;
ExperimentalSettings: Partial<ExperimentalSettings>;
LogSettings: Partial<LogSettings>;
PasswordSettings: Partial<PasswordSettings>;
ServiceSettings: Partial<ServiceSettings>;
TeamSettings: Partial<TeamSettings>;
};
// On-prem setting that is different from the default.
//
// Carries no PluginSettings: patchConfig replaces the PluginStates map wholesale, which would
// clobber the plugins a running spec enabled. Specs enable and disable plugins per id instead.
const onPremServerConfig = (): Partial<TestAdminConfig> => {
return {
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
AnnouncementSettings: {
// An in-product notice opens a modal over the channel view and swallows clicks near it.
AdminNoticesEnabled: false,
UserNoticesEnabled: false,
},
ClusterSettings: {
Enable: testConfig.haClusterEnabled,
ClusterName: testConfig.haClusterName,
},
EmailSettings: {
FeedbackName: 'Mattermost',
PushNotificationServer: testConfig.pushNotificationServer,
},
LogSettings: {
EnableDiagnostics: false,
},
PasswordSettings: {
MinimumLength: 14,
Lowercase: false,
Number: false,
Uppercase: false,
Symbol: false,
EnableForgotLink: true,
},
ServiceSettings: {
// SiteURL is the server's own view of itself (e.g. for building plugin callback
// URLs), so it must use an address the server can reach itself with. In `testcontainers` mode
// testConfig.baseURL is a host-mapped port the server's own container can't reach;
// internalBaseURL is the Docker network alias there, and the same as baseURL in
// `external` mode — correct in both cases.
SiteURL: testConfig.internalBaseURL,
EnableOnboardingFlow: false,
EnableSecurityFixAlert: false,
GiphySdkKey: 's0glxvzVg9azvPipKxcPLpXV0q1x1fVP',
},
TeamSettings: {
EnableOpenServer: true,
MaxUsersPerTeam: 2000,
},
};
};
// Should be based only from the generated default config from ./server via "make config-reset"
// Based on v11.11 server
const defaultServerConfig: AdminConfig = {
ServiceSettings: {
SiteURL: '',
WebsocketURL: '',
LicenseFileLocation: '',
ListenAddress: ':8065',
ConnectionSecurity: '',
TLSCertFile: '',
TLSKeyFile: '',
TLSMinVer: '1.2',
TLSStrictTransport: false,
TLSStrictTransportMaxAge: 63072000,
TLSOverwriteCiphers: [],
UseLetsEncrypt: false,
LetsEncryptCertificateCacheFile: './config/letsencrypt.cache',
Forward80To443: false,
TrustedProxyIPHeader: [],
ReadTimeout: 300,
WriteTimeout: 300,
IdleTimeout: 60,
MaximumLoginAttempts: 10,
GoroutineHealthThreshold: -1,
EnableOAuthServiceProvider: true,
EnableDynamicClientRegistration: false,
DCRRedirectURIAllowlist: [],
EnableIncomingWebhooks: true,
EnableOutgoingWebhooks: true,
EnableOutgoingOAuthConnections: false,
EnableCommands: true,
OutgoingIntegrationRequestsTimeout: 30,
EnablePostUsernameOverride: false,
EnablePostIconOverride: false,
GoogleDeveloperKey: '',
EnableLinkPreviews: true,
EnablePermalinkPreviews: true,
RestrictLinkPreviews: '',
EnableTesting: false,
EnableDeveloper: false,
DeveloperFlags: '',
EnableClientPerformanceDebugging: false,
EnableSecurityFixAlert: true,
EnableInsecureOutgoingConnections: false,
AllowedUntrustedInternalConnections: '',
EnableMultifactorAuthentication: false,
EnforceMultifactorAuthentication: false,
EnableUserAccessTokens: false,
MaximumPersonalAccessTokenLifetimeDays: 0,
AllowCorsFrom: '',
CorsExposedHeaders: '',
CorsAllowCredentials: false,
CorsDebug: false,
AllowCookiesForSubdomains: false,
ExtendSessionLengthWithActivity: true,
TerminateSessionsOnPasswordChange: true,
SessionLengthWebInDays: 30,
SessionLengthWebInHours: 720,
SessionLengthMobileInDays: 30,
SessionLengthMobileInHours: 720,
SessionLengthSSOInDays: 30,
SessionLengthSSOInHours: 720,
SessionCacheInMinutes: 10,
SessionIdleTimeoutInMinutes: 43200,
WebsocketSecurePort: 443,
WebsocketPort: 80,
WebserverMode: 'gzip',
EnableGifPicker: true,
GiphySdkKey: '',
EnableCustomEmoji: true,
EnableEmojiPicker: true,
PostEditTimeLimit: -1,
TimeBetweenUserTypingUpdatesMilliseconds: 5000,
EnableCrossTeamSearch: true,
EnablePostSearch: true,
EnableFileSearch: true,
MinimumHashtagLength: 3,
EnableUserTypingMessages: true,
EnableChannelViewedMessages: true,
EnableUserStatuses: true,
ExperimentalEnableAuthenticationTransfer: true,
ClusterLogTimeoutMilliseconds: 2000,
EnableTutorial: true,
EnableOnboardingFlow: true,
ExperimentalEnableDefaultChannelLeaveJoinMessages: true,
ExperimentalGroupUnreadChannels: 'disabled',
EnableAPITeamDeletion: false,
EnableAPITriggerAdminNotifications: false,
EnableAPIUserDeletion: false,
EnableAPIPostDeletion: false,
EnableDesktopLandingPage: true,
MinimumDesktopAppVersion: '',
EnableHardenedMode: false,
ExperimentalStrictCSRFEnforcement: false,
EnableEmailInvitations: false,
DisableBotsWhenOwnerIsDeactivated: true,
EnableBotAccountCreation: false,
EnableSVGs: false,
EnableLatex: false,
EnableInlineLatex: true,
PostPriority: true,
AllowPersistentNotifications: true,
AllowPersistentNotificationsForGuests: false,
PersistentNotificationIntervalMinutes: 5,
PersistentNotificationMaxCount: 6,
PersistentNotificationMaxRecipients: 5,
EnableBurnOnRead: true,
BurnOnReadDurationSeconds: 600,
BurnOnReadMaximumTimeToLiveSeconds: 604800,
BurnOnReadSchedulerFrequencySeconds: 600,
EnableAPIChannelDeletion: false,
EnableLocalMode: false,
LocalModeSocketLocation: '/var/tmp/mattermost_local.socket',
EnableAWSMetering: false,
AWSMeteringTimeoutSeconds: 30,
SplitKey: '',
FeatureFlagSyncIntervalSeconds: 30,
DebugSplit: false,
ThreadAutoFollow: true,
CollapsedThreads: CollapsedThreads.ALWAYS_ON,
ManagedResourcePaths: '',
EnableCustomGroups: true,
AllowSyncedDrafts: true,
UniqueEmojiReactionLimitPerPost: 50,
RefreshPostStatsRunTime: '00:00',
MaximumPayloadSizeBytes: 300000,
MaximumURLLength: 2048,
ScheduledPosts: true,
EnableWebHubChannelIteration: false,
FrameAncestors: '',
DeleteAccountLink: '',
},
TeamSettings: {
SiteName: 'Mattermost',
MaxUsersPerTeam: 50,
EnableJoinLeaveMessageByDefault: true,
EnableUserCreation: true,
EnableOpenServer: false,
EnableUserDeactivation: false,
RestrictCreationToDomains: '',
EnableCustomUserStatuses: true,
EnableCustomBrand: false,
CustomBrandText: '',
CustomDescriptionText: '',
RestrictDirectMessage: 'any',
EnableLastActiveTime: true,
UserStatusAwayTimeout: 300,
MaxChannelsPerTeam: 2000,
EnableChannelCategorySorting: true,
MaxNotificationsPerChannel: 1000,
EnableConfirmNotificationsToChannel: true,
TeammateNameDisplay: 'username',
ExperimentalEnableAutomaticReplies: false,
LockTeammateNameDisplay: false,
LockProfileFieldsForEmailUsers: 'none',
ExperimentalPrimaryTeam: '',
ExperimentalDefaultChannels: [],
},
ClientRequirements: {
AndroidLatestVersion: '',
AndroidMinVersion: '',
IosLatestVersion: '',
IosMinVersion: '',
},
SqlSettings: {
DriverName: 'postgres',
DataSource:
'postgres://mmuser:mostest_password@localhost/mattermost_test?sslmode=disable\u0026connect_timeout=10\u0026binary_parameters=yes',
DataSourceReplicas: [],
DataSourceSearchReplicas: [],
MaxIdleConns: 50,
ConnMaxLifetimeMilliseconds: 3600000,
ConnMaxIdleTimeMilliseconds: 300000,
MaxOpenConns: 100,
Trace: false,
QueryTimeout: 30,
AnalyticsQueryTimeout: 300,
DisableDatabaseSearch: false,
MigrationsStatementTimeoutSeconds: 100000,
ReplicaLagSettings: [],
ReplicaMonitorIntervalSeconds: 5,
},
LogSettings: {
EnableConsole: true,
ConsoleLevel: 'DEBUG',
ConsoleJson: true,
EnableColor: false,
EnableFile: true,
FileLevel: 'INFO',
FileJson: true,
FileLocation: '',
EnableWebhookDebugging: true,
EnableDiagnostics: true,
EnableSentry: true,
AdvancedLoggingJSON: {},
MaxFieldSize: 2048,
},
ExperimentalAuditSettings: {
FileEnabled: false,
FileName: '',
AdvancedLoggingJSON: {},
Certificate: '',
},
PasswordSettings: {
MinimumLength: 8,
Lowercase: false,
Number: false,
Uppercase: false,
Symbol: false,
EnableForgotLink: true,
},
FileSettings: {
EnableFileAttachments: true,
EnableMobileUpload: true,
EnableMobileDownload: true,
MaxFileSize: 104857600,
MaxImageResolution: 33177600,
MaxImageDecoderConcurrency: -1,
DriverName: 'local',
Directory: './data/',
EnablePublicLink: false,
ExtractContent: true,
ExtractContentTimeout: 10,
ArchiveRecursion: false,
PublicLinkSalt: '',
InitialFont: 'nunito-bold.ttf',
AmazonS3AccessKeyId: '',
AmazonS3SecretAccessKey: '',
AmazonS3Bucket: '',
AmazonS3PathPrefix: '',
AmazonS3Region: '',
AmazonS3Endpoint: 's3.amazonaws.com',
AmazonS3SSL: true,
AmazonS3SignV2: false,
AmazonS3SSE: false,
AmazonS3Trace: false,
AmazonS3RequestTimeoutMilliseconds: 30000,
AmazonS3UploadPartSizeBytes: 5242880,
AmazonS3StorageClass: '',
AzureStorageAccount: '',
AzureAuthMode: 'shared_key',
AzureAccessKey: '',
AzureContainer: '',
AzurePathPrefix: '',
AzureCloud: 'commercial',
AzureEndpoint: '',
AzureSSL: true,
AzureRequestTimeoutMilliseconds: 30000,
DedicatedExportStore: false,
ExportDriverName: 'local',
ExportDirectory: './data/',
ExportAmazonS3AccessKeyId: '',
ExportAmazonS3SecretAccessKey: '',
ExportAmazonS3Bucket: '',
ExportAmazonS3PathPrefix: '',
ExportAmazonS3Region: '',
ExportAmazonS3Endpoint: 's3.amazonaws.com',
ExportAmazonS3SSL: true,
ExportAmazonS3SignV2: false,
ExportAmazonS3SSE: false,
ExportAmazonS3Trace: false,
ExportAmazonS3RequestTimeoutMilliseconds: 30000,
ExportAmazonS3PresignExpiresSeconds: 21600,
ExportAmazonS3UploadPartSizeBytes: 104857600,
ExportAmazonS3StorageClass: '',
ExportAzureStorageAccount: '',
ExportAzureAuthMode: 'shared_key',
ExportAzureAccessKey: '',
ExportAzureContainer: '',
ExportAzurePathPrefix: '',
ExportAzureCloud: 'commercial',
ExportAzureEndpoint: '',
ExportAzureSSL: true,
ExportAzureRequestTimeoutMilliseconds: 30000,
ExportAzurePresignExpiresSeconds: 21600,
},
EmailSettings: {
EnableSignUpWithEmail: true,
EnableSignInWithEmail: true,
EnableSignInWithUsername: true,
SendEmailNotifications: true,
UseChannelInEmailNotifications: false,
RequireEmailVerification: false,
FeedbackName: '',
FeedbackEmail: 'test@example.com',
ReplyToAddress: 'test@example.com',
FeedbackOrganization: '',
EnableSMTPAuth: false,
SMTPUsername: '',
SMTPPassword: '',
SMTPServer: 'localhost',
SMTPPort: '10025',
SMTPServerTimeout: 10,
ConnectionSecurity: '',
SendPushNotifications: true,
PushNotificationServer: 'https://push-test.mattermost.com',
PushNotificationServerType: 'custom',
PushNotificationServerLocation: 'global',
PushNotificationContents: 'full',
PushNotificationBuffer: 1000,
EnableEmailBatching: false,
EmailBatchingBufferSize: 256,
EmailBatchingInterval: 30,
EnablePreviewModeBanner: true,
SkipServerCertificateVerification: false,
EmailNotificationContentsType: 'full',
},
RateLimitSettings: {
Enable: false,
PerSec: 10,
MaxBurst: 100,
MemoryStoreSize: 10000,
VaryByRemoteAddr: true,
VaryByUser: false,
VaryByHeader: '',
},
PrivacySettings: {
ShowEmailAddress: true,
ShowFullName: true,
UseAnonymousURLs: false,
},
SupportSettings: {
TermsOfServiceLink: 'https://mattermost.com/pl/terms-of-use/',
PrivacyPolicyLink: 'https://mattermost.com/pl/privacy-policy/',
AboutLink: 'https://mattermost.com/pl/about-mattermost',
HelpLink: 'https://mattermost.com/pl/help/',
ReportAProblemLink: 'https://mattermost.com/pl/report-a-bug',
ReportAProblemType: 'default',
ReportAProblemMail: '',
AllowDownloadLogs: true,
ForgotPasswordLink: '',
SupportEmail: '',
CustomTermsOfServiceEnabled: false,
CustomTermsOfServiceReAcceptancePeriod: 365,
EnableAskCommunityLink: true,
},
AnnouncementSettings: {
EnableBanner: false,
BannerText: '',
BannerColor: '#f2a93b',
BannerTextColor: '#333333',
AllowBannerDismissal: true,
AdminNoticesEnabled: true,
UserNoticesEnabled: true,
NoticesURL: 'https://notices.mattermost.com/',
NoticesFetchFrequency: 3600,
NoticesSkipCache: false,
},
ThemeSettings: {
EnableThemeSelection: true,
DefaultTheme: 'default',
AllowCustomThemes: true,
AllowedThemes: [],
},
GitLabSettings: {
Enable: false,
Secret: '',
Id: '',
Scope: '',
AuthEndpoint: '',
TokenEndpoint: '',
UserAPIEndpoint: '',
DiscoveryEndpoint: '',
ButtonText: '',
ButtonColor: '',
UsePreferredUsername: false,
},
GoogleSettings: {
Enable: false,
Secret: '',
Id: '',
Scope: 'profile email',
AuthEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
TokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token',
UserAPIEndpoint:
'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses,nicknames,metadata',
DiscoveryEndpoint: '',
ButtonText: '',
ButtonColor: '',
UsePreferredUsername: false,
},
Office365Settings: {
Enable: false,
Secret: '',
Id: '',
Scope: 'User.Read',
AuthEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
TokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
UserAPIEndpoint: 'https://graph.microsoft.com/v1.0/me',
DiscoveryEndpoint: '',
DirectoryId: '',
UsePreferredUsername: false,
},
OpenIdSettings: {
Enable: false,
Secret: '',
Id: '',
Scope: 'profile openid email',
AuthEndpoint: '',
TokenEndpoint: '',
UserAPIEndpoint: '',
DiscoveryEndpoint: '',
ButtonText: '',
ButtonColor: '#145DBF',
UsePreferredUsername: false,
},
LdapSettings: {
Enable: false,
EnableSync: false,
LdapServer: '',
LdapPort: 389,
ConnectionSecurity: '',
BaseDN: '',
BindUsername: '',
BindPassword: '',
MaximumLoginAttempts: 10,
UserFilter: '',
GroupFilter: '',
GuestFilter: '',
EnableAdminFilter: false,
AdminFilter: '',
GroupDisplayNameAttribute: '',
GroupIdAttribute: '',
FirstNameAttribute: '',
LastNameAttribute: '',
EmailAttribute: '',
UsernameAttribute: '',
NicknameAttribute: '',
IdAttribute: '',
PositionAttribute: '',
LoginIdAttribute: '',
PictureAttribute: '',
SyncIntervalMinutes: 60,
ReAddRemovedMembers: false,
SkipCertificateVerification: false,
PublicCertificateFile: '',
PrivateKeyFile: '',
QueryTimeout: 60,
MaxPageSize: 0,
LoginFieldName: '',
},
ComplianceSettings: {
Enable: false,
Directory: './data/',
EnableDaily: false,
BatchSize: 30000,
},
LocalizationSettings: {
DefaultServerLocale: 'en',
DefaultClientLocale: 'en',
AvailableLocales: '',
},
SamlSettings: {
Enable: false,
EnableSyncWithLdap: false,
EnableSyncWithLdapIncludeAuth: false,
IgnoreGuestsLdapSync: false,
Verify: true,
Encrypt: true,
SignRequest: false,
IdpURL: '',
IdpDescriptorURL: '',
IdpMetadataURL: '',
ServiceProviderIdentifier: '',
AssertionConsumerServiceURL: '',
SignatureAlgorithm: 'RSAwithSHA256',
CanonicalAlgorithm: 'Canonical1.0',
ScopingIDPProviderId: '',
ScopingIDPName: '',
IdpCertificateFile: '',
PublicCertificateFile: '',
PrivateKeyFile: '',
IdAttribute: '',
GuestAttribute: '',
EnableAdminAttribute: false,
AdminAttribute: '',
FirstNameAttribute: '',
LastNameAttribute: '',
EmailAttribute: '',
UsernameAttribute: '',
NicknameAttribute: '',
LocaleAttribute: '',
PositionAttribute: '',
LoginButtonText: 'SAML',
},
NativeAppSettings: {
AppCustomURLSchemes: ['mmauth://', 'mmauthbeta://'],
AppDownloadLink: 'https://mattermost.com/pl/download-apps',
AndroidAppDownloadLink: 'https://mattermost.com/pl/android-app/',
IosAppDownloadLink: 'https://mattermost.com/pl/ios-app/',
MobileExternalBrowser: false,
MobileEnableBiometrics: false,
MobilePreventScreenCapture: false,
MobileJailbreakProtection: false,
MobileEnableSecureFilePreview: false,
MobileAllowPdfLinkNavigation: false,
},
IntuneSettings: {
Enable: false,
TenantId: '',
ClientId: '',
AuthService: '',
},
CacheSettings: {
CacheType: 'lru',
RedisAddress: '',
RedisPassword: '',
RedisDB: -1,
RedisCachePrefix: '',
DisableClientCache: false,
},
ClusterSettings: {
Enable: false,
ClusterName: '',
OverrideHostname: '',
NetworkInterface: '',
BindAddress: '',
AdvertiseAddress: '',
UseIPAddress: true,
EnableGossipCompression: true,
EnableGossipEncryption: true,
ReadOnlyConfig: true,
GossipPort: 8074,
},
MetricsSettings: {
Enable: false,
BlockProfileRate: 0,
ListenAddress: ':8067',
EnableClientMetrics: true,
EnableNotificationMetrics: true,
ClientSideUserIds: [],
},
ExperimentalSettings: {
ClientSideCertEnable: false,
LinkMetadataTimeoutMilliseconds: 5000,
RestrictSystemAdmin: false,
EnableSharedChannels: false,
EnableRemoteClusterService: false,
DisableAppBar: false,
DisableRefetchingOnBrowserFocus: false,
DelayChannelAutocomplete: false,
DisableWakeUpReconnectHandler: false,
UsersStatusAndProfileFetchingPollIntervalMilliseconds: 3000,
YoutubeReferrerPolicy: false,
EnableWatermark: false,
},
AnalyticsSettings: {
MaxUsersForStatistics: 2500,
},
ElasticsearchSettings: {
ConnectionURL: 'http://localhost:9200',
Backend: 'elasticsearch',
Username: 'elastic',
Password: 'changeme',
EnableIndexing: false,
EnableSearching: false,
EnableCJKAnalyzers: false,
EnableAutocomplete: false,
Sniff: true,
PostIndexReplicas: 1,
PostIndexShards: 1,
ChannelIndexReplicas: 1,
ChannelIndexShards: 1,
UserIndexReplicas: 1,
UserIndexShards: 1,
AggregatePostsAfterDays: 365,
PostsAggregatorJobStartTime: '03:00',
IndexPrefix: '',
GlobalSearchPrefix: '',
LiveIndexingBatchSize: 10,
BatchSize: 10000,
RequestTimeoutSeconds: 30,
SkipTLSVerification: false,
CA: '',
ClientCert: '',
ClientKey: '',
Trace: '',
IgnoredPurgeIndexes: '',
EnableSearchPublicChannelsWithoutMembership: true,
},
DataRetentionSettings: {
EnableMessageDeletion: false,
EnableFileDeletion: false,
EnableBoardsDeletion: false,
MessageRetentionDays: 365,
MessageRetentionHours: 0,
FileRetentionDays: 365,
FileRetentionHours: 0,
BoardsRetentionDays: 365,
DeletionJobStartTime: '02:00',
BatchSize: 3000,
TimeBetweenBatchesMilliseconds: 100,
RetentionIdsBatchSize: 100,
PreservePinnedPosts: false,
},
MobileEphemeralModeSettings: {
Enable: false,
DisconnectionTimeoutSeconds: 60,
OfflinePersistenceTimerHours: 24,
AutoCacheCleanupDays: 7,
},
MessageExportSettings: {
EnableExport: false,
ExportFormat: 'actiance',
DailyRunTime: '01:00',
ExportFromTimestamp: 0,
BatchSize: 10000,
DownloadExportResults: false,
ChannelBatchSize: 100,
ChannelHistoryBatchSize: 10,
GlobalRelaySettings: {
CustomerType: 'A9',
SMTPUsername: '',
SMTPPassword: '',
EmailAddress: '',
SMTPServerTimeout: 1800,
CustomSMTPServerName: '',
CustomSMTPPort: '25',
CustomHeaderName: '',
CustomHeaderValue: '',
SenderAddress: '',
},
},
JobSettings: {
RunJobs: true,
RunScheduler: true,
CleanupJobsThresholdDays: -1,
CleanupConfigThresholdDays: -1,
},
PluginSettings: {
Enable: true,
EnableUploads: false,
AllowInsecureDownloadURL: false,
EnableHealthCheck: true,
Directory: './plugins',
ClientDirectory: './client/plugins',
Plugins: {},
PluginStates: {
'com.mattermost.calls': {
Enable: true,
},
'com.mattermost.nps': {
Enable: true,
},
'mattermost-ai': {
Enable: true,
},
playbooks: {
Enable: true,
},
},
EnableMarketplace: true,
EnableRemoteMarketplace: true,
AutomaticPrepackagedPlugins: true,
RequirePluginSignature: false,
MarketplaceURL: 'https://api.integrations.mattermost.com',
SignaturePublicKeyFiles: [],
ChimeraOAuthProxyURL: '',
},
DisplaySettings: {
CustomURLSchemes: [],
MaxMarkdownNodes: 0,
},
GuestAccountsSettings: {
Enable: false,
HideTags: false,
EnforceMultifactorAuthentication: false,
RestrictCreationToDomains: '',
EnableGuestMagicLink: false,
},
ImageProxySettings: {
Enable: false,
ImageProxyType: 'local',
},
CloudSettings: {
CWSURL: 'https://customers.mattermost.com',
CWSAPIURL: 'https://portal.internal.prod.cloud.mattermost.com',
CWSMock: false,
Disable: false,
PreviewModalBucketURL: '',
},
FeatureFlags: {
TestFeature: 'off',
TestBoolFeature: false,
EnableSharedChannelsDMs: false,
EnableSyncAllUsersForRemoteCluster: false,
AppsEnabled: false,
NormalizeLdapDNs: false,
WysiwygEditor: false,
MoveThreadsEnabled: false,
NotificationMonitoring: true,
AttributeValueMasking: true,
PermissionPolicies: true,
ChannelPermissionPolicies: true,
PolicySimulation: true,
ContentFlagging: true,
EnableMattermostEntry: true,
MobileSSOCodeExchange: false,
EnableShiftEscapeToMarkAllRead: false,
AutoTranslation: true,
ClassificationMarkings: true,
GlobalAttributes: false,
BurnOnRead: true,
EnableAIPluginBridge: false,
EnableAIRecaps: false,
IntegratedBoards: false,
EnableDocs: false,
CJKSearch: true,
AggregatePluginMetrics: false,
ManagedChannelCategories: false,
SessionAttributes: false,
PostAttributes: false,
DiscoverableChannels: false,
MobileEphemeralMode: false,
PropertyFieldRank: true,
TeamMembershipAccessControl: true,
MmBlocksEnabled: true,
ClusterGracefulDrain: true,
ChannelBookmarks: true,
EnableMFIPluginSignaturePublicKey: true,
RecurringScheduledPosts: false,
},
ImportSettings: {
Directory: './import',
RetentionDays: 30,
},
ExportSettings: {
Directory: './export',
RetentionDays: 30,
},
WranglerSettings: {
PermittedWranglerRoles: [],
AllowedEmailDomain: [],
MoveThreadMaxCount: 100,
MoveThreadToAnotherTeamEnable: false,
MoveThreadFromPrivateChannelEnable: false,
MoveThreadFromDirectMessageChannelEnable: false,
MoveThreadFromGroupMessageChannelEnable: false,
},
ConnectedWorkspacesSettings: {
EnableSharedChannels: false,
EnableRemoteClusterService: false,
DisableSharedChannelsStatusSync: false,
SyncUsersOnConnectionOpen: false,
GlobalUserSyncBatchSize: 25,
MaxPostsPerSync: 50,
MemberSyncBatchSize: 20,
},
AccessControlSettings: {
EnableAttributeBasedAccessControl: false,
EnableUserManagedAttributes: false,
EnableChannelPolicyIndicators: true,
TrustProxyDeviceIdentityHeader: false,
EnforceDeviceIDConsistency: false,
EnableAccessControlAuditLogging: false,
SyncJobIntervalSeconds: 3600,
AttributeRefreshIntervalSeconds: 30,
},
ContentFlaggingSettings: {
EnableContentFlagging: false,
NotificationSettings: {
EventTargetMapping: {
assigned: ['reviewers'],
dismissed: ['reviewers', 'reporter'],
flagged: ['reviewers'],
removed: ['reviewers', 'author', 'reporter'],
},
},
AdditionalSettings: {
Reasons: [
'Classification mismatch',
'Need-to-know violation',
'Personally identifiable information (PII) exposure',
'Operational security (OPSEC) concern',
'Controlled Unclassified Information (CUI) violation',
'Unauthorized disclosure',
'Other',
],
ReporterCommentRequired: true,
ReviewerCommentRequired: true,
HideFlaggedContent: true,
},
ReviewerSettings: {
CommonReviewers: true,
CommonReviewerIds: [],
TeamReviewersSetting: {},
SystemAdminsAsReviewers: false,
TeamAdminsAsReviewers: true,
},
},
AutoTranslationSettings: {
Enable: false,
RestrictDMAndGM: false,
Provider: '',
TargetLanguages: ['en'],
Workers: 6,
TimeoutMs: 5000,
LibreTranslate: {
URL: '',
APIKey: '',
},
Agents: {
LLMServiceID: '',
},
},
AIRecapSettings: {
Enable: true,
DefaultLimits: {
MaxRecapsPerDay: 10,
MaxScheduledRecaps: 5,
MaxChannelsPerRecap: -1,
MaxPostsPerRecap: 500,
MaxTokensPerRecap: 100000,
MaxPostsPerDay: 5000,
CooldownMinutes: 60,
},
EnforceRecapsPerDay: true,
EnforceScheduledRecaps: true,
EnforceChannelsPerRecap: true,
EnforcePostsPerRecap: true,
EnforceTokensPerRecap: true,
EnforcePostsPerDay: true,
EnforceCooldown: true,
},
};

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@playwright/test';
import type {AdminConfig} from '@mattermost/types/config';
import {ELASTICSEARCH_ALIAS, ELASTICSEARCH_PORT} from '../containers/constants';
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
// The Mattermost server always connects to Elasticsearch itself (indexing/search requests), so it
// needs the Testcontainers network alias, not a host-mapped address.
export function elasticsearchServerConfig(): Partial<AdminConfig['ElasticsearchSettings']> {
return {
ConnectionURL: `http://${ELASTICSEARCH_ALIAS}:${ELASTICSEARCH_PORT}`,
EnableIndexing: true,
EnableSearching: true,
EnableAutocomplete: true,
Sniff: false,
};
}
/**
* Checks Elasticsearch was started this run, restarts the server onto it if the Elasticsearch Go
* client isn't already the registered search engine, and confirms the server can actually reach
* it — skipping the test otherwise, instead of failing on an unmet precondition. Backend picks
* which of two Go implementations (Elasticsearch vs OpenSearch client) the server registers — a
* factory invoked once at startup, never re-invoked by the config-change watcher — so switching it
* needs a restart, unlike the rest of ElasticsearchSettings, which the watcher does pick up live.
*/
export async function ensureElasticsearch(): Promise<void> {
if (!testConfig.testcontainersServices.includes('elasticsearch')) {
test.skip(true, 'Skipping test - elasticsearch not started (set PW_TESTCONTAINERS_SERVICES=elasticsearch)');
return;
}
try {
const env = {MM_ELASTICSEARCHSETTINGS_BACKEND: 'elasticsearch'};
if (!bootEnvMatches(env)) {
await restartMattermostContainer(env);
}
const {adminClient} = await getAdminClient();
await adminClient.patchConfig({ElasticsearchSettings: elasticsearchServerConfig()});
await adminClient.testElasticsearch();
} catch (error) {
test.skip(true, `Skipping test - Elasticsearch connection test failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {testConfig} from '@/test_config';
export type InbucketEmail = {
id: string;
to: string[];
date: string;
subject: string;
body: {
text: string;
html: string;
};
};
type InbucketEmailSummary = Pick<InbucketEmail, 'id' | 'date' | 'subject' | 'to'>;
type GetRecentEmailOptions = {
receivedAfter?: Date;
timeout?: number;
};
const DEFAULT_EMAIL_TIMEOUT = 30_000;
const EMAIL_POLL_INTERVAL = 500;
/**
* Returns the newest email addressed to the exact recipient, waiting for Inbucket
* when mail delivery is still in progress.
*/
export async function getRecentEmail(
recipient: string,
{receivedAfter, timeout = DEFAULT_EMAIL_TIMEOUT}: GetRecentEmailOptions = {},
): Promise<InbucketEmail> {
const mailbox = recipient.split('@')[0];
const mailboxURL = `${testConfig.smtpURL}/api/v1/mailbox/${encodeURIComponent(mailbox)}`;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const email = await getNewestMatchingEmail(mailboxURL, recipient, receivedAfter);
if (email) {
return email;
}
await new Promise((resolve) => setTimeout(resolve, EMAIL_POLL_INTERVAL));
}
throw new Error(`Timed out waiting for email to ${recipient}`);
}
/**
* Extracts a link matching `pathname` from the email body and rewrites its origin to
* testConfig.baseURL. Links come back carrying ServiceSettings.SiteURL's origin, which in
* `testcontainers` mode is the Docker network alias the server uses to reach itself — not reachable from this
* test process or its browser.
*/
export function extractEmailLink(email: InbucketEmail, pathname: string): string {
const escapedPathname = pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const link = email.body.text.match(new RegExp(`https?://[^\\s<>"')]+${escapedPathname}[^\\s<>"')]+`))?.[0];
if (!link) {
throw new Error(`Email to ${email.to.join(', ')} does not contain a link for ${pathname}`);
}
const unescaped = link.replaceAll('&amp;', '&');
const rewritten = new URL(unescaped);
const reachable = new URL(testConfig.baseURL);
rewritten.protocol = reachable.protocol;
rewritten.host = reachable.host;
return rewritten.toString();
}
/**
* Extracts the bare email address from a mailbox `to` entry, which Inbucket
* reports as `addr@host`, `<addr@host>`, or `Display Name <addr@host>`.
*/
function parseEmailAddress(entry: string): string {
const angleBracketMatch = entry.match(/<([^<>]+)>\s*$/);
return (angleBracketMatch ? angleBracketMatch[1] : entry).trim().toLowerCase();
}
async function getNewestMatchingEmail(
mailboxURL: string,
recipient: string,
receivedAfter?: Date,
): Promise<InbucketEmail | undefined> {
const response = await fetch(mailboxURL);
if (!response.ok) {
return undefined;
}
const summaries = (await response.json()) as InbucketEmailSummary[];
const normalizedRecipient = recipient.toLowerCase();
const receivedAfterTime = receivedAfter?.getTime();
const matchingSummaries = summaries.filter((summary) => {
const addressedToRecipient = summary.to.some((address) => parseEmailAddress(address) === normalizedRecipient);
const arrivedInTime = receivedAfterTime === undefined || new Date(summary.date).getTime() >= receivedAfterTime;
return addressedToRecipient && arrivedInTime;
});
for (const summary of matchingSummaries.reverse()) {
const messageResponse = await fetch(`${mailboxURL}/${encodeURIComponent(summary.id)}`);
if (!messageResponse.ok) {
continue;
}
return (await messageResponse.json()) as InbucketEmail;
}
return undefined;
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@playwright/test';
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
/**
* Restarts the server with the given feature flag set to `value` if it isn't already, and
* confirms the running server actually reports that value — skipping the test otherwise, instead
* of failing on an unmet precondition.
*
* FeatureFlags can't be changed via patchConfig on a running server at all: with no Split key
* configured (this test setup never sets one), the config store's readOnlyFF handling reverts any
* FeatureFlags patch back to its prior value before it's even persisted
* (server/config/store.go's Set()/Load()), so only a boot-time MM_FEATUREFLAGS_* env var actually
* takes effect.
*/
export async function ensureFeatureFlag(flagName: string, value: boolean): Promise<void> {
if (!testConfig.useTestContainers) {
test.skip(true, 'Skipping test - feature flag restart requires PW_USE_TESTCONTAINERS=true');
return;
}
const envKey = `MM_FEATUREFLAGS_${flagName.toUpperCase()}`;
const envValue = String(value);
try {
const env = {[envKey]: envValue};
if (!bootEnvMatches(env)) {
await restartMattermostContainer(env);
}
const {adminClient} = await getAdminClient();
const config = await adminClient.getConfig();
const actual = config.FeatureFlags?.[flagName];
if (String(actual) !== envValue) {
throw new Error(`Feature flag "${flagName}" is "${String(actual)}" after restart, expected "${envValue}".`);
}
} catch (error) {
test.skip(true, `Skipping test - feature flag "${flagName}" check failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {execFileSync} from 'node:child_process';
import fs from 'node:fs';
import {test} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import {MATTERMOST_DATA_DIR} from '../containers/constants';
import {POSTGRES_IMAGE} from '../containers/default_images';
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
// A 1x1 transparent PNG, just to exercise a real write through the server's file backend.
const PROBE_IMAGE_BASE64_PNG =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
/** Uploads a throwaway profile image, exercising the server's real, live file backend. */
export async function uploadProbeImage(adminClient: Client4, adminUser: UserProfile | null): Promise<void> {
if (!adminUser) {
throw new Error('No admin user available to probe a real upload with.');
}
const probeImage = new File([Buffer.from(PROBE_IMAGE_BASE64_PNG, 'base64')], 'probe.png', {type: 'image/png'});
await adminClient.uploadProfileImage(adminUser.id, probeImage);
}
/**
* Restarts the server onto local disk storage if it isn't already there, and confirms a real
* upload actually works — skipping the test otherwise, instead of failing on an unmet
* precondition. The counterpart to ensureMinio()/ensureAzurite() for specs that specifically need
* local storage active (e.g. after another spec in the same run switched it away).
*/
export async function ensureLocalFile(): Promise<void> {
if (!testConfig.useTestContainers) {
test.skip(true, 'Skipping test - local file storage restart requires PW_USE_TESTCONTAINERS=true');
return;
}
try {
const env = {MM_FILESETTINGS_DRIVERNAME: 'local'};
if (!bootEnvMatches(env)) {
await restartMattermostContainer(env);
}
const {adminClient, adminUser} = await getAdminClient();
await uploadProbeImage(adminClient, adminUser);
} catch (error) {
test.skip(true, `Skipping test - local file storage check failed: ${String(error)}`);
}
}
/**
* Mattermost runs as UID 2000 and creates dated storage dirs mode 0750 (localstore.go). The host
* runner cannot scandir those on Linux CI. Distroless server images have no chmod, so open the
* bind mount via a one-shot helper container (postgres is always pulled with the stack).
*/
function makeMattermostDataHostReadable(): void {
execFileSync(
'docker',
[
'run',
'--rm',
'--user',
'0',
'-v',
`${MATTERMOST_DATA_DIR}:/data`,
POSTGRES_IMAGE,
'chmod',
'-R',
'a+rX',
'/data',
],
{stdio: 'pipe'},
);
}
/**
* Lists every file the server wrote to its bind-mounted data directory (see MATTERMOST_DATA_DIR),
* confirming the server actually wrote to disk — the same "check the backend itself, not just
* that the API says so" signal listMinioObjectKeys()/listAzuriteBlobNames() already provide for
* their own backends.
*/
export function listMattermostDataFiles(): string[] {
if (!fs.existsSync(MATTERMOST_DATA_DIR)) {
return [];
}
try {
return fs.readdirSync(MATTERMOST_DATA_DIR, {recursive: true}) as string[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EACCES') {
throw error;
}
makeMattermostDataHostReadable();
return fs.readdirSync(MATTERMOST_DATA_DIR, {recursive: true}) as string[];
}
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {makeClient} from './client';
export {PlaywrightClient4} from './playwright_client';
export {createRandomChannel} from './channel';
export {getOnPremServerConfig} from './default_config';
export {initSetup, getAdminClient} from './init';
export {createRandomPost} from './post';
export {createNewTeam, createRandomTeam} from './team';
export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user';
export {extractEmailLink, getRecentEmail} from './email';
export type {InbucketEmail} from './email';
export {
enableAIBridgeTestMode,
configureAIBridgeMock,
getAIBridgeMock,
resetAIBridgeMock,
createMockAIAgent,
rewriteCompletion,
recapCompletion,
} from './ai_bridge';
export {
createUserWithAttributes,
enableABAC,
disableABAC,
navigateToABACPage,
navigateToPermissionPoliciesPage,
navigateToAttributeBasedAccessPage,
createBasicPolicy,
createAdvancedPolicy,
editPolicy,
deletePolicy,
runSyncJob,
verifyUserInChannel,
verifyUserNotInChannel,
updateUserAttributes,
} from './abac_helpers';
export {
defaultEnabledPluginIds,
disableUnexpectedPlugins,
installAndEnablePlugin,
isPluginActive,
getPluginStatus,
} from './plugin';
export {isWebhookTestServerReachable, setupWebhookTestServer} from './webhook_server';
export {
generateLdapUser,
createLdapUser,
updateLdapUser,
deleteLdapUser,
ldapServerConfig,
ensureOpenldap,
} from './openldap';
export type {LdapUser} from './openldap';
export {createKeycloakUser, deleteKeycloakUser, samlServerConfig, ensureKeycloak} from './keycloak';
export type {KeycloakUser} from './keycloak';
export {listMinioObjectKeys, ensureMinio} from './minio';
export {elasticsearchServerConfig, ensureElasticsearch} from './elasticsearch';
export {opensearchServerConfig, ensureOpensearch} from './opensearch';
export {ensureAzurite, listAzuriteBlobNames} from './azurite';
export {ensureLocalFile, listMattermostDataFiles} from './filestore';
export {ensurePostgresSearch} from './postgres_search';
export {ensureFeatureFlag} from './feature_flags';
export {runMmctl, runMmctlLocal, ensureMmctl} from './mmctl';
export type {MmctlResult} from './mmctl';
export {upgradeServerImage} from './version';
export {saveUpgradePhaseLogs} from './upgrade_logs';
export type {UpgradeLogPhase} from './upgrade_logs';

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect} from '@playwright/test';
import type {TeamType} from '@mattermost/types/teams';
import {makeClient} from './client';
import {getOnPremServerConfigPatch} from './default_config';
import {createNewTeam} from './team';
import {createNewUserProfile} from './user';
import {getFileFromCommonAsset} from '@/file';
import {testConfig} from '@/test_config';
type InitSetupOptions = {
userOptions?: Partial<Parameters<typeof createNewUserProfile>[1]>;
teamsOptions?: Partial<Parameters<typeof createNewTeam>[1]>;
withDefaultProfileImage?: boolean;
};
export async function initSetup({
userOptions = {prefix: 'user', disableTutorial: true, disableOnboarding: true},
teamsOptions = {name: 'team', displayName: 'Team', type: 'O' as TeamType, unique: true},
withDefaultProfileImage = true,
}: Partial<InitSetupOptions> = {}) {
try {
// Login the admin user via API
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Failed to setup admin: Admin user not found.');
}
if (!adminClient) {
throw new Error(
"Failed to setup admin: Check that you're able to access the server using the same admin credential.",
);
}
// The on-prem overrides only. Sending the full defaultServerConfig snapshot instead would
// 403 on PluginSettings.EnableUploads and replace the PluginStates map of concurrent specs.
// Settings outside this patch keep their current value, so specs patch what they depend on.
const adminConfig = await adminClient.patchConfig(getOnPremServerConfigPatch() as any);
// Create new team
const team = await createNewTeam(adminClient, teamsOptions);
// Create new user and add to newly created team
const user = await createNewUserProfile(adminClient, userOptions);
await adminClient.addToTeam(team.id, user.id);
// Log in new user via API
const {client: userClient} = await makeClient(user);
if (withDefaultProfileImage) {
const file = getFileFromCommonAsset('mattermost-icon_128x128.png');
await userClient.uploadProfileImage(user.id, file);
}
return {
adminClient,
adminUser,
adminConfig,
user,
userClient,
team,
offTopicUrl: getUrl(team.name, 'off-topic'),
townSquareUrl: getUrl(team.name, 'town-square'),
};
} catch (error) {
expect(error, 'Should not throw an error').toBeFalsy();
throw error;
}
}
export async function getAdminClient(opts: {skipLog: boolean} = {skipLog: false}) {
const {client: adminClient, user: adminUser} = await makeClient(
{
username: testConfig.adminUsername,
password: testConfig.adminPassword,
},
opts,
);
return {adminClient, adminUser};
}
function getUrl(teamName: string, channelName: string) {
return `/${teamName}/channels/${channelName}`;
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import type {AdminConfig} from '@mattermost/types/config';
import {
KEYCLOAK_ADMIN_PASSWORD,
KEYCLOAK_ADMIN_USER,
KEYCLOAK_ALIAS,
KEYCLOAK_PORT,
KEYCLOAK_REALM,
} from '../containers/constants';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
export type KeycloakUser = {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
};
async function getAdminToken(): Promise<string> {
const response = await fetch(`${testConfig.keycloakUrl}/realms/master/protocol/openid-connect/token`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'password',
client_id: 'admin-cli',
username: KEYCLOAK_ADMIN_USER,
password: KEYCLOAK_ADMIN_PASSWORD,
}),
});
if (!response.ok) {
throw new Error(`Failed to get Keycloak admin token: ${response.status} ${await response.text()}`);
}
const body = (await response.json()) as {access_token: string};
return body.access_token;
}
/** Creates the user in Keycloak and returns its Keycloak user id. */
export async function createKeycloakUser(user: KeycloakUser): Promise<string> {
const token = await getAdminToken();
const response = await fetch(`${testConfig.keycloakUrl}/admin/realms/${KEYCLOAK_REALM}/users`, {
method: 'POST',
headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json'},
body: JSON.stringify({
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
enabled: true,
credentials: [{type: 'password', value: user.password, temporary: false}],
}),
});
if (!response.ok) {
throw new Error(`Failed to create Keycloak user: ${response.status} ${await response.text()}`);
}
const location = response.headers.get('Location');
const userId = location?.split('/').pop();
if (!userId) {
throw new Error('Keycloak user creation response had no Location header to read the new user id from.');
}
return userId;
}
export async function deleteKeycloakUser(userId: string): Promise<void> {
const token = await getAdminToken();
const response = await fetch(`${testConfig.keycloakUrl}/admin/realms/${KEYCLOAK_REALM}/users/${userId}`, {
method: 'DELETE',
headers: {Authorization: `Bearer ${token}`},
});
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to delete Keycloak user: ${response.status} ${await response.text()}`);
}
}
// Matches the SAML client's clientId in keycloak-realm-export.json.
const SAML_SERVICE_PROVIDER_ID = 'mattermost';
// The Mattermost server fetches this URL itself (via POST /saml/metadatafromidp), so it must be
// reachable from inside the Testcontainers network — unlike the IdpURL/IdpDescriptorURL below,
// which the browser follows directly and so must be reachable from the host instead. Only the
// certificate from this response is used; its embedded URLs reflect the alias host, not the one
// the browser needs.
function keycloakSamlDescriptorUrl(): string {
return `http://${KEYCLOAK_ALIAS}:${KEYCLOAK_PORT}/realms/${KEYCLOAK_REALM}/protocol/saml/descriptor`;
}
// The metadata response's certificate is the raw base64 DER content straight out of the SAML
// metadata XML's <X509Certificate> element — no PEM armor — but IdpCertificateFile parsing
// requires a proper PEM block.
function toPemCertificate(base64Der: string): string {
const lines = base64Der.replace(/\s+/g, '').match(/.{1,64}/g) ?? [];
return `-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----\n`;
}
/**
* Fetches Keycloak's SAML IdP certificate (via the server's own metadata-from-IdP call) and
* uploads it, then returns a `SamlSettings` patch pointing the server at Keycloak's SAML IdP and
* the users `createKeycloakUser` creates.
*/
export async function samlServerConfig(adminClient: Client4): Promise<Partial<AdminConfig['SamlSettings']>> {
const metadata = await adminClient.getSamlMetadataFromIdp(keycloakSamlDescriptorUrl());
const certificate = toPemCertificate(metadata.idp_public_certificate);
await adminClient.uploadIdpSamlCertificate(new File([certificate], 'idp-certificate.crt'));
return {
Enable: true,
Verify: true,
Encrypt: false,
SignRequest: false,
IdpURL: `${testConfig.keycloakUrl}/realms/${KEYCLOAK_REALM}/protocol/saml`,
IdpDescriptorURL: `${testConfig.keycloakUrl}/realms/${KEYCLOAK_REALM}`,
ServiceProviderIdentifier: SAML_SERVICE_PROVIDER_ID,
AssertionConsumerServiceURL: `${testConfig.baseURL}/login/sso/saml`,
IdAttribute: 'id',
EmailAttribute: 'email',
UsernameAttribute: 'username',
FirstNameAttribute: 'givenName',
LastNameAttribute: 'surname',
LoginButtonText: 'Keycloak SAML',
};
}
/**
* Checks Keycloak was started this run, points the server's SAML settings at it (fetching its IdP
* metadata/certificate along the way), and skips the test if either step fails, instead of
* failing on an unmet precondition.
*/
export async function ensureKeycloak(): Promise<void> {
if (!testConfig.testcontainersServices.includes('keycloak')) {
test.skip(true, 'Skipping test - keycloak not started (set PW_TESTCONTAINERS_SERVICES=keycloak)');
return;
}
try {
const {adminClient} = await getAdminClient();
const config = await samlServerConfig(adminClient);
await adminClient.patchConfig({SamlSettings: config});
} catch (error) {
test.skip(true, `Skipping test - Keycloak SAML setup failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client as MinioClient} from 'minio';
import {test} from '@playwright/test';
import {MINIO_ACCESS_KEY, MINIO_ALIAS, MINIO_BUCKET, MINIO_PORT, MINIO_SECRET_KEY} from '../containers/constants';
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
import {uploadProbeImage} from './filestore';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
function getMinioClient(): MinioClient {
const url = new URL(testConfig.minioUrl);
return new MinioClient({
endPoint: url.hostname,
port: Number(url.port),
useSSL: url.protocol === 'https:',
accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY,
});
}
// Only used internally by ensureMinio() — not a spec-facing entry point.
async function ensureMinioBucket(bucket: string = MINIO_BUCKET): Promise<void> {
const client = getMinioClient();
const exists = await client.bucketExists(bucket);
if (!exists) {
await client.makeBucket(bucket);
}
}
/** Lists every object key in the bucket, to confirm the server actually wrote to Minio. */
export async function listMinioObjectKeys(bucket: string = MINIO_BUCKET): Promise<string[]> {
const client = getMinioClient();
const keys: string[] = [];
for await (const item of client.listObjectsV2(bucket, '', true)) {
if (item.name) {
keys.push(item.name);
}
}
return keys;
}
// FileSettings' backend is chosen once when the Mattermost process boots and never re-read from a
// running server's config, so pointing the server at Minio can only happen via the env vars the
// container starts with — always via the network alias, since the server itself always runs
// inside the Testcontainers network.
function minioServerEnv(): Record<string, string> {
return {
MM_FILESETTINGS_DRIVERNAME: 'amazons3',
MM_FILESETTINGS_AMAZONS3ENDPOINT: `${MINIO_ALIAS}:${MINIO_PORT}`,
MM_FILESETTINGS_AMAZONS3ACCESSKEYID: MINIO_ACCESS_KEY,
MM_FILESETTINGS_AMAZONS3SECRETACCESSKEY: MINIO_SECRET_KEY,
MM_FILESETTINGS_AMAZONS3BUCKET: MINIO_BUCKET,
MM_FILESETTINGS_AMAZONS3SSL: 'false',
};
}
/**
* Checks Minio was started this run, restarts the server onto it if it isn't already the active
* file storage backend, and confirms a real upload actually lands in Minio — skipping the test
* otherwise, instead of failing on an unmet precondition.
*/
export async function ensureMinio(): Promise<void> {
if (!testConfig.testcontainersServices.includes('minio')) {
test.skip(true, 'Skipping test - minio not started (set PW_TESTCONTAINERS_SERVICES=minio)');
return;
}
try {
await ensureMinioBucket();
const env = minioServerEnv();
if (!bootEnvMatches(env)) {
await restartMattermostContainer(env);
}
const {adminClient, adminUser} = await getAdminClient();
await uploadProbeImage(adminClient, adminUser);
const objectKeys = await listMinioObjectKeys();
if (objectKeys.length === 0) {
throw new Error(
'Minio bucket is still empty after a real upload — the server is not actually using Minio ' +
'as its file backend.',
);
}
} catch (error) {
test.skip(true, `Skipping test - Minio connection test failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@playwright/test';
import {runMmctl as runMmctlContainer, runMmctlLocal} from '../containers/mmctl_container';
import type {MmctlResult} from '../containers/mmctl_container';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
export type {MmctlResult};
export {runMmctlLocal};
/**
* Runs an mmctl command as a real remote client: a separate container built from the same server
* image, authenticated with the current admin session, reaching the server over the
* Testcontainers network rather than the `--local` unix socket the server's own container uses.
*/
export async function runMmctl(args: string[]): Promise<MmctlResult> {
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('No admin user available to authenticate mmctl with.');
}
return runMmctlContainer(args, adminUser.username, adminClient.getToken());
}
/**
* Checks full (Testcontainers) mode is active and a real remote mmctl invocation actually reaches
* the server — skipping the test otherwise, instead of failing on an unmet precondition.
*/
export async function ensureMmctl(): Promise<void> {
if (!testConfig.useTestContainers) {
test.skip(true, 'Skipping test - remote mmctl container requires PW_USE_TESTCONTAINERS=true');
return;
}
try {
const result = await runMmctl(['version']);
if (result.exitCode !== 0) {
throw new Error(`mmctl exited with code ${result.exitCode}: ${result.output}`);
}
} catch (error) {
test.skip(true, `Skipping test - mmctl connectivity check failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AlreadyExistsError, Attribute, Change, Client} from 'ldapts';
import {test} from '@playwright/test';
import type {AdminConfig} from '@mattermost/types/config';
import {
OPENLDAP_ADMIN_DN,
OPENLDAP_ADMIN_PASSWORD,
OPENLDAP_ALIAS,
OPENLDAP_BASE_DN,
OPENLDAP_PORT,
} from '../containers/constants';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
import {getRandomId} from '@/util';
// Typed in-process LDAP client — avoids depending on external ldapadd/ldapmodify binaries.
export type LdapUser = {
username: string;
password: string;
email: string;
firstname: string;
lastname: string;
};
const ORG_UNIT = 'e2etest';
const ORG_UNIT_DN = `ou=${ORG_UNIT},${OPENLDAP_BASE_DN}`;
export function generateLdapUser(prefix = 'ldap'): LdapUser {
const randomId = getRandomId();
const username = `${prefix}user${randomId}`;
return {
username,
password: 'Password1',
email: `${username}@mmtest.com`,
firstname: `Firstname-${randomId}`,
lastname: `Lastname-${randomId}`,
};
}
async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> {
const client = new Client({url: `ldap://${testConfig.ldapHost}:${testConfig.ldapPort}`});
try {
await client.bind(OPENLDAP_ADMIN_DN, OPENLDAP_ADMIN_PASSWORD);
return await fn(client);
} finally {
await client.unbind();
}
}
async function ensureOrgUnit(client: Client): Promise<void> {
try {
await client.add(ORG_UNIT_DN, {objectClass: 'organizationalUnit', ou: ORG_UNIT});
} catch (error) {
if (!(error instanceof AlreadyExistsError)) {
throw error;
}
}
}
/** Creates the user (and the shared org unit, if it doesn't exist yet) and returns it. */
export async function createLdapUser(user: LdapUser = generateLdapUser()): Promise<LdapUser> {
await withAdminClient(async (client) => {
await ensureOrgUnit(client);
await client.add(`uid=${user.username},${ORG_UNIT_DN}`, {
objectClass: 'inetOrgPerson',
cn: user.firstname,
sn: user.lastname,
uid: user.username,
mail: user.email,
userPassword: user.password,
});
});
return user;
}
export async function updateLdapUser(username: string, changes: Partial<Omit<LdapUser, 'username'>>): Promise<void> {
await withAdminClient(async (client) => {
const dn = `uid=${username},${ORG_UNIT_DN}`;
const attributeByType: Array<[string, string | undefined]> = [
['cn', changes.firstname],
['sn', changes.lastname],
['mail', changes.email],
['userPassword', changes.password],
];
const modifications = attributeByType
.filter((entry): entry is [string, string] => entry[1] !== undefined)
.map(
([type, value]) =>
new Change({operation: 'replace', modification: new Attribute({type, values: [value]})}),
);
if (modifications.length > 0) {
await client.modify(dn, modifications);
}
});
}
export async function deleteLdapUser(username: string): Promise<void> {
await withAdminClient(async (client) => {
await client.del(`uid=${username},${ORG_UNIT_DN}`);
});
}
// In `testcontainers` mode the Mattermost server is itself a container on the Testcontainers network, so it
// must reach OpenLDAP via its network alias — unlike testConfig.ldapHost/ldapPort, which resolve to
// a host-mapped address for this module's own ldapts client (a separate, non-containerized process).
function ldapServerAddress(): [string, number] {
return testConfig.useTestContainers ? [OPENLDAP_ALIAS, OPENLDAP_PORT] : [testConfig.ldapHost, testConfig.ldapPort];
}
/** `LdapSettings` patch pointing the server at the OpenLDAP container and the users this module creates. */
export function ldapServerConfig(): Partial<AdminConfig['LdapSettings']> {
const [ldapServer, ldapPort] = ldapServerAddress();
return {
Enable: true,
LdapServer: ldapServer,
LdapPort: ldapPort,
BaseDN: ORG_UNIT_DN,
BindUsername: OPENLDAP_ADMIN_DN,
BindPassword: OPENLDAP_ADMIN_PASSWORD,
UserFilter: '(objectClass=inetOrgPerson)',
IdAttribute: 'uid',
LoginIdAttribute: 'uid',
UsernameAttribute: 'uid',
EmailAttribute: 'mail',
FirstNameAttribute: 'cn',
LastNameAttribute: 'sn',
};
}
/**
* Checks OpenLDAP was started this run, points the server at it, and confirms the server can
* actually reach it — skipping the test otherwise, instead of failing on an unmet precondition.
*/
export async function ensureOpenldap(): Promise<void> {
if (!testConfig.testcontainersServices.includes('openldap')) {
test.skip(true, 'Skipping test - openldap not started (set PW_TESTCONTAINERS_SERVICES=openldap)');
return;
}
const {adminClient} = await getAdminClient();
await adminClient.patchConfig({LdapSettings: ldapServerConfig()});
// testLdap() searches under BaseDN and requires at least one matching user to succeed, so
// probe with a throwaway user rather than depending on whatever the spec creates afterward.
const probeUser = generateLdapUser('ensureprobe');
try {
await createLdapUser(probeUser);
await adminClient.testLdap();
} catch (error) {
test.skip(true, `Skipping test - LDAP connection test failed: ${String(error)}`);
} finally {
await deleteLdapUser(probeUser.username).catch(() => undefined);
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@playwright/test';
import type {AdminConfig} from '@mattermost/types/config';
import {OPENSEARCH_ADMIN_PASSWORD, OPENSEARCH_ALIAS, OPENSEARCH_PORT} from '../containers/constants';
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
// The Mattermost server always connects to OpenSearch itself (indexing/search requests), so it
// needs the Testcontainers network alias, not a host-mapped address.
export function opensearchServerConfig(): Partial<AdminConfig['ElasticsearchSettings']> {
return {
ConnectionURL: `http://${OPENSEARCH_ALIAS}:${OPENSEARCH_PORT}`,
Username: 'admin',
Password: OPENSEARCH_ADMIN_PASSWORD,
EnableIndexing: true,
EnableSearching: true,
EnableAutocomplete: true,
Sniff: false,
};
}
/**
* Checks OpenSearch was started this run, restarts the server onto it if the OpenSearch Go client
* isn't already the registered search engine, and confirms the server can actually reach it —
* skipping the test otherwise, instead of failing on an unmet precondition. Backend picks which of
* two Go implementations (Elasticsearch vs OpenSearch client) the server registers — a factory
* invoked once at startup, never re-invoked by the config-change watcher — so switching it needs a
* restart, unlike the rest of ElasticsearchSettings, which the watcher does pick up live.
*/
export async function ensureOpensearch(): Promise<void> {
if (!testConfig.testcontainersServices.includes('opensearch')) {
test.skip(true, 'Skipping test - opensearch not started (set PW_TESTCONTAINERS_SERVICES=opensearch)');
return;
}
try {
const env = {MM_ELASTICSEARCHSETTINGS_BACKEND: 'opensearch'};
if (!bootEnvMatches(env)) {
await restartMattermostContainer(env);
}
const {adminClient} = await getAdminClient();
await adminClient.patchConfig({ElasticsearchSettings: opensearchServerConfig()});
await adminClient.testElasticsearch();
} catch (error) {
test.skip(true, `Skipping test - OpenSearch connection test failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import type {Channel, ChannelType} from '@mattermost/types/channels';
import type {ClientLicense} from '@mattermost/types/config';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import type {PartialExcept} from '@mattermost/types/utilities';
import {createRandomChannel} from './channel';
import {createNewUserProfile} from './user';
import {getFileFromAsset} from '@/file';
/**
* Client4 extended with Playwright test-setup helpers.
* Prefer not adding general Mattermost API wrappers here — keep those on Client4 —
* except for test-only compatibility shims needed against older server images.
*/
export class PlaywrightClient4 extends Client4 {
/**
* Same as Client4.getClientLicenseOld, but keeps `format=old` for older from-images
* that still require it. Current servers ignore the parameter.
*/
getClientLicenseOld = () => {
return this.doFetch<ClientLicense>(`${this.getBaseRoute()}/license/client?format=old`, {method: 'get'});
};
private createChannelOfType(
teamId: string,
displayName: string,
type: ChannelType,
name?: string,
): Promise<Channel> {
return this.createChannel(
createRandomChannel({
teamId,
name: name ?? displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
displayName,
type,
unique: true,
}),
);
}
async createPublicChannel(teamId: string, displayName = 'Public', name?: string): Promise<Channel> {
return this.createChannelOfType(teamId, displayName, 'O', name);
}
async createPrivateChannel(teamId: string, displayName = 'Private', name?: string): Promise<Channel> {
return this.createChannelOfType(teamId, displayName, 'P', name);
}
async createUsers(teamId: string, count: number, prefix = 'user'): Promise<UserProfile[]> {
const users: UserProfile[] = [];
for (let i = 0; i < count; i++) {
const user = await createNewUserProfile(this, {prefix});
await this.addToTeam(teamId, user.id);
users.push(user);
}
return users;
}
/**
* Creates a post with a default message and any number of files from the assets folder.
*/
async createTestPost(override: PartialExcept<Post, 'channel_id'>, files: string[] = []) {
const post = {
message: 'test post',
...override,
};
if (!post.file_ids) {
post.file_ids = await Promise.all(
files.map((filename) => {
return new Promise<string>((resolve) => {
const formData = new FormData();
formData.set('channel_id', post.channel_id);
formData.set('files', getFileFromAsset(filename), filename);
this.uploadFile(formData).then((data) => {
resolve(data.file_infos[0].id);
});
});
}),
);
}
return this.createPost(post);
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {PluginManifest} from '@mattermost/types/plugins';
import {aiPluginId, callsPluginId, npsPluginId, playbooksPluginId} from '@/constant';
/** The prepackaged plugins model.Config.SetDefaults() enables, so active on a fresh install. */
export const defaultEnabledPluginIds = [aiPluginId, callsPluginId, npsPluginId, playbooksPluginId];
/** Deactivates every active plugin outside the default set plus `keep`, returning what it disabled. */
export async function disableUnexpectedPlugins(client: Client4, keep: string[] = []): Promise<string[]> {
const expected = new Set([...defaultEnabledPluginIds, ...keep]);
const {active} = await client.getPlugins();
const unexpected = active.filter((plugin: PluginManifest) => !expected.has(plugin.id)).map((plugin) => plugin.id);
for (const pluginId of unexpected) {
await client.disablePlugin(pluginId);
}
return unexpected;
}
export async function isPluginActive(client: Client4, pluginId: string): Promise<boolean> {
const plugins = await client.getPlugins();
return plugins.active.some((plugin: PluginManifest) => plugin.id === pluginId);
}
export async function getPluginStatus(
client: Client4,
pluginId: string,
): Promise<{isInstalled: boolean; isActive: boolean}> {
const plugins = await client.getPlugins();
const isActive = plugins.active.some((plugin: PluginManifest) => plugin.id === pluginId);
const isInactive = plugins.inactive.some((plugin: PluginManifest) => plugin.id === pluginId);
return {
isInstalled: isActive || isInactive,
isActive,
};
}
/**
* Installs and enables a plugin with smart status checking
* - If already active: does nothing
* - If already installed: just enables it
* - Otherwise: installs from URL, then enables
*/
export async function installAndEnablePlugin(
client: Client4,
pluginUrl: string,
pluginId: string,
force = true,
): Promise<void> {
// Check current status
const status = await getPluginStatus(client, pluginId);
// If already active, nothing to do
if (status.isActive) {
return;
}
// If already installed but not active, just enable it
if (status.isInstalled) {
await client.enablePlugin(pluginId);
return;
}
// Not installed - install from URL then enable
await client.installPluginFromUrl(pluginUrl, force);
await client.enablePlugin(pluginId);
}

View File

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

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@playwright/test';
import {getAdminClient} from './init';
/**
* Disables Elasticsearch/OpenSearch indexing/searching so search falls back to the database —
* skipping the test otherwise, instead of failing on an unmet precondition. The counterpart to
* ensureElasticsearch()/ensureOpensearch() for specs that specifically need database-backed search
* active (e.g. after another spec in the same run switched it away).
*
* No restart involved, unlike most other ensure*() functions: which search engine (if any) is
* active is decided at runtime from these enable flags, not from the boot-time Backend setting, so
* disabling them here is enough to make the server fall through to Postgres-backed search.
*/
export async function ensurePostgresSearch(): Promise<void> {
try {
const {adminClient} = await getAdminClient();
await adminClient.patchConfig({
ElasticsearchSettings: {EnableIndexing: false, EnableSearching: false, EnableAutocomplete: false},
});
} catch (error) {
test.skip(true, `Skipping test - database search check failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team, TeamType} from '@mattermost/types/teams';
import {getRandomId} from '@/util';
export async function createNewTeam(
client: Client4,
options: {name?: string; displayName?: string; type?: TeamType; unique?: boolean} = {
name: 'team',
displayName: 'Team',
type: 'O' as TeamType,
unique: true,
},
) {
const randomTeam = await createRandomTeam(options.name, options.displayName, options.type, options.unique);
const newTeam = await client.createTeam(randomTeam);
return newTeam;
}
export async function createRandomTeam(
name = 'team',
displayName = 'Team',
type: TeamType = 'O',
unique = true,
): Promise<Team> {
const randomSuffix = getRandomId();
const team = {
name: unique ? `${name}-${randomSuffix}` : name,
display_name: unique ? `${displayName} ${randomSuffix}` : displayName,
type,
};
return team as Team;
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {execFile} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {promisify} from 'node:util';
import {TESTCONTAINERS_LABEL_KEY, TESTCONTAINERS_LABEL_VALUE} from '../containers/constants';
import {logTestcontainers} from '../containers/log';
import {testConfig} from '@/test_config';
const execFileAsync = promisify(execFile);
export type UpgradeLogPhase = 'from' | 'to';
const UPGRADE_LOG_DIR = path.resolve(process.cwd(), 'logs', 'upgrade');
const BASELINE_PATH = path.resolve(process.cwd(), '.upgrade_baseline.json');
/** Lines worth skimming in CI when diagnosing a failed rolling upgrade. */
const HIGHLIGHT_PATTERNS: RegExp[] = [
/All migrations are complete/i,
/\bmigration\b/i,
/\bschema\b/i,
/\blicense\b/i,
/\bpanic\b/i,
/\bfatal\b/i,
/failed to/i,
/error.*upgrade/i,
/current version is/i,
/database.*version/i,
];
/**
* Snapshots Mattermost (and Postgres) container logs for an upgrade phase into
* `logs/upgrade/`, which CI already uploads via `ci/upload-debug-artifacts`.
*
* Must run for `from` before upgrade-swap-to replaces the Mattermost container —
* teardown-only collection would only ever see the to-image.
*/
export async function saveUpgradePhaseLogs(phase: UpgradeLogPhase): Promise<void> {
if (!testConfig.useTestContainers) {
return;
}
fs.mkdirSync(UPGRADE_LOG_DIR, {recursive: true});
if (testConfig.mattermostContainerId) {
await writeDockerLogs(
testConfig.mattermostContainerId,
path.join(UPGRADE_LOG_DIR, `${phase}-mattermost.log`),
path.join(UPGRADE_LOG_DIR, `${phase}-mattermost.highlights.txt`),
);
} else {
logTestcontainers(`saveUpgradePhaseLogs(${phase}): no Mattermost container id — skipped`);
}
const postgresId = await findLabeledContainerId(/postgres/i);
if (postgresId) {
await writeDockerLogs(postgresId, path.join(UPGRADE_LOG_DIR, `${phase}-postgres.log`));
}
if (phase === 'from' && fs.existsSync(BASELINE_PATH)) {
fs.copyFileSync(BASELINE_PATH, path.join(UPGRADE_LOG_DIR, 'from-baseline.json'));
}
logTestcontainers(`saveUpgradePhaseLogs(${phase}): wrote artifacts under logs/upgrade/`);
}
async function writeDockerLogs(containerId: string, outPath: string, highlightsPath?: string): Promise<void> {
try {
const {stdout, stderr} = await execFileAsync('docker', ['logs', containerId], {
maxBuffer: 64 * 1024 * 1024,
encoding: 'utf-8',
});
const body = [stdout, stderr].filter(Boolean).join('\n');
fs.writeFileSync(outPath, body, 'utf-8');
if (highlightsPath) {
const highlights = body
.split('\n')
.filter((line) => HIGHLIGHT_PATTERNS.some((pattern) => pattern.test(line)));
fs.writeFileSync(
highlightsPath,
highlights.length
? highlights.join('\n') + '\n'
: '(no migration/license/panic/fatal highlight lines matched)\n',
'utf-8',
);
}
} catch (error) {
logTestcontainers(`saveUpgradePhaseLogs: docker logs failed for ${containerId.slice(0, 12)}: ${String(error)}`);
}
}
/** Finds a Testcontainers-labeled container, including stopped ones, whose image matches `imagePattern`. */
async function findLabeledContainerId(imagePattern: RegExp): Promise<string | undefined> {
try {
const {stdout} = await execFileAsync('docker', [
'ps',
'-aq',
'--filter',
`label=${TESTCONTAINERS_LABEL_KEY}=${TESTCONTAINERS_LABEL_VALUE}`,
]);
const ids = stdout
.split('\n')
.map((id) => id.trim())
.filter(Boolean);
for (const id of ids) {
const {stdout: image} = await execFileAsync('docker', ['inspect', '-f', '{{.Config.Image}}', id]);
if (imagePattern.test(image.trim())) {
return id;
}
}
} catch {
// Best-effort — missing postgres must not fail the upgrade suite.
}
return undefined;
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {UserProfile, UserTimezone} from '@mattermost/types/users';
import {DateTime} from 'luxon';
import {getRandomId, newTestPassword} from '@/util';
import {testConfig} from '@/test_config';
import {REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY, REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY} from '@/constant';
export async function createNewUserProfile(
client: Client4,
options: {prefix?: string; disableTutorial?: boolean; disableOnboarding?: boolean} = {
prefix: 'user',
disableTutorial: true,
disableOnboarding: true,
},
) {
const randomUser = await createRandomUser(options.prefix);
const newUser = await client.createUser(randomUser, '', '');
// Set password to the created user profile so it can be used to login later
newUser.password = randomUser.password;
if (options.disableTutorial) {
await client.savePreferences(newUser.id, [
{user_id: newUser.id, category: 'tutorial_step', name: newUser.id, value: '999'},
{user_id: newUser.id, category: 'crt_thread_pane_step', name: newUser.id, value: '999'},
]);
}
if (options.disableOnboarding) {
await client.savePreferences(newUser.id, [
{user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
{user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
]);
}
return newUser;
}
export async function createRandomUser(prefix = 'user') {
const randomId = getRandomId();
const user = {
email: `${prefix}${randomId}@sample.mattermost.com`,
username: `${prefix}${randomId}`,
password: newTestPassword(),
first_name: `First${randomId}`,
last_name: `Last${randomId}`,
nickname: `Nickname${randomId}`,
};
return user as UserProfile;
}
export function getDefaultAdminUser() {
const admin = {
username: testConfig.adminUsername,
password: testConfig.adminPassword,
first_name: 'Kenneth',
last_name: 'Moreno',
email: testConfig.adminEmail,
};
return admin as UserProfile;
}
export function isOutsideRemoteUserHour(userTz: UserTimezone | undefined) {
const timezone = (userTz?.useAutomaticTimezone ? userTz?.automaticTimezone : userTz?.manualTimezone) || 'UTC';
const teammateUserDate = DateTime.local().setZone(timezone);
const currentHour = teammateUserDate.hour;
return (
currentHour >= REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY ||
currentHour < REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY
);
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {restartMattermostContainer} from '../containers/stack';
import {logTestcontainers} from '../containers/log';
import {getAdminClient} from './init';
import {testConfig} from '@/test_config';
import {duration} from '@/util';
const execFileAsync = promisify(execFile);
/**
* Swaps the running Testcontainers-managed Mattermost server to `image`, keeping the same
* network and Postgres database — i.e. an in-place upgrade (or downgrade) test scenario.
*
* Unlike ensureFeatureFlag()/ensureMinio()/etc., which skip the test on a failed precondition
* (the version/flag/service is incidental to whatever else that test is checking), this throws:
* for an upgrade spec, the upgrade itself is the thing under test, so failure must fail the test,
* not silently skip it.
*/
export async function upgradeServerImage(image: string, extraEnv: Record<string, string> = {}): Promise<void> {
if (!testConfig.useTestContainers) {
throw new Error('upgradeServerImage requires PW_USE_TESTCONTAINERS=true.');
}
const fromImage = testConfig.serverImage;
logTestcontainers(`upgrade swap: ${fromImage} → ${image}`);
testConfig.serverImage = image;
await restartMattermostContainer(extraEnv);
await assertRunningContainerImage(image);
await waitForServerApiReady();
logTestcontainers(`upgrade swap complete — server ready at ${testConfig.baseURL} (${image}).`);
}
async function assertRunningContainerImage(expectedImage: string): Promise<void> {
if (!testConfig.mattermostContainerId) {
throw new Error('Missing Mattermost container id after image swap.');
}
const {stdout} = await execFileAsync('docker', [
'inspect',
testConfig.mattermostContainerId,
'--format',
'{{.Config.Image}}',
]);
// Compare the full reference: matching on the tag alone would accept a same-tag image from
// a different repository, so a mis-set image could pass the swap assertion.
const normalizeRef = (image: string) => image.replace(/^docker\.io\/(library\/)?/, '');
const runningImage = stdout.trim();
if (normalizeRef(runningImage) !== normalizeRef(expectedImage)) {
throw new Error(
`Upgrade swap expected container image "${expectedImage}" but docker reports "${runningImage}".`,
);
}
}
async function waitForServerApiReady(): Promise<void> {
const deadline = Date.now() + duration.four_min;
while (Date.now() < deadline) {
const {adminUser} = await getAdminClient({skipLog: true});
if (adminUser) {
return;
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error('Server API did not become ready after image swap.');
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {APIRequestContext} from '@playwright/test';
import {testConfig} from '@/test_config';
/**
* Health check for the webhook sidecar used by Cypress and Playwright integration tests
* (`e2e-tests/cypress`: `npm run start:webhook`, default http://localhost:3000).
*/
export async function isWebhookTestServerReachable(
request: APIRequestContext,
webhookBaseUrl: string = testConfig.webhookBaseUrl,
): Promise<boolean> {
try {
const res = await request.get(webhookBaseUrl, {timeout: 5000});
return res.ok();
} catch {
return false;
}
}
/**
* POST /setup on the webhook sidecar so it can call back into Mattermost (dialogs, OAuth, etc.).
* Required before routes that use `baseUrl` / admin credentials.
*/
export async function setupWebhookTestServer(
request: APIRequestContext,
opts: {
mattermostBaseUrl: string;
adminUsername: string;
adminPassword: string;
webhookBaseUrl?: string;
},
): Promise<void> {
const webhookBaseUrl = opts.webhookBaseUrl ?? testConfig.webhookBaseUrl;
const res = await request.post(`${webhookBaseUrl}/setup`, {
headers: {'Content-Type': 'application/json'},
data: {
baseUrl: opts.mattermostBaseUrl,
webhookBaseUrl,
adminUsername: opts.adminUsername,
adminPassword: opts.adminPassword,
},
timeout: 15000,
});
if (!res.ok()) {
const body = await res.text();
throw new Error(`Webhook test server /setup failed: HTTP ${res.status()} ${body}`);
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator, Page} from '@playwright/test';
import {expect} from '@playwright/test';
export {waitUntil} from 'async-wait-until';
const visibilityHidden = 'visibility: hidden !important;';
const hideTeamHeader = `#sidebarTeamMenuButton {${visibilityHidden}} `;
const hidePostHeaderTime = `.post__time {${visibilityHidden}} `;
const hidePostProfileIcon = `.profile-icon {${visibilityHidden}} `;
const hideKeywordsAndMentionsDesc = `#keywordsAndMentionsDesc {${visibilityHidden}} `;
export async function hideDynamicChannelsContent(page: Page) {
await page.addStyleTag({
content: hideTeamHeader + hidePostHeaderTime + hidePostProfileIcon + hideKeywordsAndMentionsDesc,
});
}
export async function waitForAnimationEnd(locator: Locator) {
return locator.evaluate((element) =>
Promise.all(element.getAnimations({subtree: true}).map((animation) => animation.finished)),
);
}
export async function toBeFocusedWithFocusVisible(locator: Locator) {
await expect(locator).toBeVisible();
await expect(locator).toBeFocused();
return locator.evaluate((element) => element.matches(':focus-visible'));
}
export async function logFocusedElement(page: Page) {
const focusedElementInfo = await page.evaluate(() => {
const activeElement = document.activeElement;
if (!activeElement) {
return 'No element has focus';
}
const tagName = activeElement.tagName.toLowerCase();
const id = activeElement.id ? `#${activeElement.id}` : '(none)';
const className = activeElement.className ? `.${Array.from(activeElement.classList).join('.')}` : '(none)';
const role = activeElement.getAttribute('role') || '';
const ariaLabel = activeElement.getAttribute('aria-label') || '';
const text = activeElement.textContent?.slice(0, 50) || '';
const htmlElement = activeElement as HTMLElement;
return {
selector: `tag: ${tagName} | id: ${id} | class: ${className}`,
role,
ariaLabel,
text: text.length > 50 ? `${text}...` : text,
nodeName: activeElement.nodeName,
isVisible: htmlElement.offsetWidth > 0 && htmlElement.offsetHeight > 0,
};
});
// eslint-disable-next-line no-console
console.log('Currently focused element:', focusedElementInfo);
return focusedElementInfo;
}

View File

@@ -0,0 +1,297 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as dotenv from 'dotenv';
import {MATTERMOST_ALIAS, MATTERMOST_PORT, WEBHOOK_ALIAS, WEBHOOK_PORT} from './containers/constants';
import {MATTERMOST_SERVER_IMAGE} from './containers/default_images';
import {SERVER_ENV_BASELINE} from './containers/env_baseline';
import {
getUpgradeFromServerImage,
getUpgradeToServerImage,
isUpgradeFromProjectSelected,
isUpgradeToPhaseProjectSelected,
} from './upgrade_env';
dotenv.config({quiet: true});
// Capture before .env.testcontainers override — that file persists the *running* image under
// PW_TESTCONTAINERS_SERVER_IMAGE and must not clobber the process/CI SERVER_IMAGE (to-image).
const configuredServerImage = process.env.SERVER_IMAGE;
dotenv.config({path: '.env.testcontainers', quiet: true, override: true});
if (configuredServerImage === undefined) {
delete process.env.SERVER_IMAGE;
} else {
process.env.SERVER_IMAGE = configuredServerImage;
}
// The set of additional services `testcontainers` mode knows how to start.
// Single source of truth for `requirements.ts` and for validating PW_TESTCONTAINERS_SERVICES.
export const TESTCONTAINERS_SERVICE_NAMES = [
'openldap',
'keycloak',
'elasticsearch',
'opensearch',
'minio',
'azurite',
] as const;
export type TestContainersServiceName = (typeof TESTCONTAINERS_SERVICE_NAMES)[number];
// Started whenever PW_TESTCONTAINERS_SERVICES is unset
// opensearch and azurite are opt-in only
const DEFAULT_TESTCONTAINERS_SERVICES: TestContainersServiceName[] = ['minio', 'openldap', 'keycloak', 'elasticsearch'];
// 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;
smtpURL: string;
/**
* Postgres connection string for specs needing direct DB access to bypass API-level
* validation. In `testcontainers` mode the default port is a placeholder — startStack() overwrites
* it with the actual Testcontainers-assigned host-mapped port once the container is up.
*/
postgresUrl: string;
/** Base URL of the Cypress/Playwright webhook sidecar (`e2e-tests/cypress`: `npm run start:webhook`). */
webhookBaseUrl: string;
/**
* How OTHER containers (not the test process) reach Mattermost — used when a URL is embedded
* in a request body the server itself later dereferences (e.g. a webhook callback URL). In
* `external` mode this equals baseURL. In `testcontainers` mode it's the fixed Docker network alias,
* since a container can't reach another container's host-mapped port via `localhost`.
*/
internalBaseURL: string;
/** Same distinction as internalBaseURL, for the webhook sidecar's own address. */
webhookInternalUrl: string;
/**
* Gateway IP (e.g. 172.18.0.1) of the Testcontainers bridge network in `testcontainers` mode — an
* address on the Docker host itself, reachable both from the browser and from inside the
* Mattermost container, so a single mock file server URL works for both without a network
* alias or mapped port. Empty in `external` mode, where plain localhost already works for
* both since neither runs in a container.
*/
testcontainersNetworkGatewayIp: string;
// Testcontainers (`testcontainers` mode)
/** Selects `testcontainers` mode (Testcontainers manages the server + dependencies) over `external` mode (default). */
useTestContainers: boolean;
/** Prebuilt Mattermost server image `testcontainers` mode starts. Same env var Compose-based CI already uses. */
serverImage: string;
/** Arbitrary MM_* config overrides merged over the test-oriented baseline, comma-separated KEY=VALUE pairs. */
serverEnv: Record<string, string>;
/**
* Which additional services `testcontainers` mode starts, validated against
* `TESTCONTAINERS_SERVICE_NAMES`. Defaults to `DEFAULT_TESTCONTAINERS_SERVICES` when
* PW_TESTCONTAINERS_SERVICES is unset; set it to an empty string to start none.
*/
testcontainersServices: TestContainersServiceName[];
/** Reuse containers across repeated local `testcontainers`-mode runs instead of recreating them every time. */
testcontainersReuse: boolean;
/** Playwright itself runs inside a container (e.g. CI); join it to the Testcontainers network instead of using mapped ports. */
containerRunner: boolean;
/**
* Name of the Docker network the stack runs on. getNetwork()'s in-process cache only helps
* within the process that called startStack() (global setup) — worker processes need this to
* join a container (e.g. the mmctl runner) to that same network instead of creating their own.
*/
testcontainersNetworkName: string;
/**
* ID of the currently-running Mattermost container. Global setup and worker processes are
* separate OS processes — this is how a worker's restartMattermostContainer() (e.g. to switch
* file storage backends) finds the container to replace without an in-process object handle.
*/
mattermostContainerId: string;
/**
* The full set of env vars the current Mattermost container actually booted with — baseline
* plus every boot-time-only setting a pw.ensure*() has switched via
* restartMattermostContainer() so far. Each ensure*() checks this before restarting, so it
* only restarts when something actually needs to change, and restartMattermostContainer()
* merges into this record rather than replacing it, so a restart for one setting doesn't
* undo another restart's change.
*
* Persisted to .env.testcontainers and read back rather than recomputed, because a container
* can outlive the process that booted it (e.g. reused across specs in CI) — a later process
* must see the server's actual current state, not assume defaults.
*/
bootEnvOverrides: Record<string, string>;
/** Used in every mode; defaults match the fixed ports the Testcontainers stack publishes. */
ldapHost: string;
ldapPort: number;
keycloakUrl: string;
elasticsearchUrl: string;
opensearchUrl: string;
minioUrl: string;
azuriteUrl: string;
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 = Boolean(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);
// Email
this.smtpURL = process.env.PW_SMTP_URL || 'http://localhost:9001';
this.postgresUrl =
process.env.PW_POSTGRES_URL ||
'postgres://mmuser:mostest_password@localhost:5432/mattermost_test?sslmode=disable&connect_timeout=10&binary_parameters=yes';
this.webhookBaseUrl = process.env.PW_WEBHOOK_BASE_URL || 'http://localhost:3000';
// Testcontainers
this.useTestContainers = parseBool(process.env.PW_USE_TESTCONTAINERS, false);
// Fixed Docker network aliases, not derived from any started container object — stable
// across a restartMattermostContainer() call, unlike baseURL's host-mapped port.
this.internalBaseURL = this.useTestContainers ? `http://${MATTERMOST_ALIAS}:${MATTERMOST_PORT}` : this.baseURL;
this.webhookInternalUrl = this.useTestContainers
? `http://${WEBHOOK_ALIAS}:${WEBHOOK_PORT}`
: this.webhookBaseUrl;
this.testcontainersNetworkGatewayIp = process.env.PW_TESTCONTAINERS_NETWORK_GATEWAY_IP || '';
// upgrade-from → PW_UPGRADE_FROM_SERVER_IMAGE; upgrade-to/swap-to → SERVER_IMAGE (never the
// from-image persisted as PW_TESTCONTAINERS_SERVER_IMAGE); otherwise prefer persisted image.
if (isUpgradeFromProjectSelected()) {
this.serverImage = getUpgradeFromServerImage();
} else if (isUpgradeToPhaseProjectSelected()) {
this.serverImage = getUpgradeToServerImage();
} else {
this.serverImage =
process.env.PW_TESTCONTAINERS_SERVER_IMAGE || process.env.SERVER_IMAGE || MATTERMOST_SERVER_IMAGE;
}
this.serverEnv = parseKeyValueList(process.env.MM_ENV);
this.testcontainersServices = parseTestContainersServices(process.env.PW_TESTCONTAINERS_SERVICES);
// Defaults to true so a local Ctrl+C (or just finishing a run) doesn't throw away the
// stack you were likely about to inspect — tear down explicitly with `npm run testcontainers:down`.
this.testcontainersReuse = parseBool(process.env.PW_TESTCONTAINERS_REUSE, true);
this.containerRunner = parseBool(process.env.PW_TESTCONTAINERS_CONTAINER_RUNNER, false);
this.testcontainersNetworkName = process.env.PW_TESTCONTAINERS_NETWORK_NAME || '';
this.mattermostContainerId = process.env.PW_TESTCONTAINERS_MATTERMOST_CONTAINER_ID || '';
// Prefer a previously-persisted boot env, since it reflects the container's actual
// current state; only compute the default when it's genuinely absent (first boot). A
// stale file pointing at a container that's no longer running is detected and reset
// elsewhere, before a fresh container starts.
this.bootEnvOverrides =
parsePersistedBootEnv(process.env.PW_TESTCONTAINERS_BOOT_ENV) ?? computeDefaultBootEnv(this.serverEnv);
this.ldapHost = process.env.PW_LDAP_HOST || 'localhost';
this.ldapPort = parseNumber(process.env.PW_LDAP_PORT, 389);
this.keycloakUrl = process.env.PW_KEYCLOAK_URL || 'http://localhost:8484';
this.elasticsearchUrl = process.env.PW_ELASTICSEARCH_URL || 'http://localhost:9200';
this.opensearchUrl = process.env.PW_OPENSEARCH_URL || 'http://localhost:9201';
this.minioUrl = process.env.PW_MINIO_URL || 'http://localhost:9000';
this.azuriteUrl = process.env.PW_AZURITE_URL || 'http://localhost:10000';
}
}
// 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;
}
function parseKeyValueList(actualValue: string | undefined): Record<string, string> {
if (!actualValue) {
return {};
}
const result: Record<string, string> = {};
for (const entry of actualValue
.split(',')
.map((part) => part.trim())
.filter(Boolean)) {
const separatorIndex = entry.indexOf('=');
if (separatorIndex === -1) {
throw new Error(`Invalid MM_ENV entry "${entry}" — expected KEY=VALUE.`);
}
result[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
}
return result;
}
// Mirrors startMattermostContainer()'s actual default boot env: baseline plus MM_ENV overrides
// (same precedence), plus the two settings it never sets itself, so the server falls back to
// its own defaults for those.
function computeDefaultBootEnv(serverEnv: Record<string, string>): Record<string, string> {
return {
...SERVER_ENV_BASELINE,
MM_FILESETTINGS_DRIVERNAME: 'local',
MM_ELASTICSEARCHSETTINGS_BACKEND: 'elasticsearch',
...serverEnv,
};
}
/**
* Boot env a genuinely fresh container starts with. Used to reset bootEnvOverrides when a
* persisted boot env turns out to reference a container that's no longer running.
*/
export function defaultBootEnv(): Record<string, string> {
return computeDefaultBootEnv(testConfig.serverEnv);
}
function parsePersistedBootEnv(actualValue: string | undefined): Record<string, string> | undefined {
if (!actualValue) {
return undefined;
}
try {
return JSON.parse(actualValue);
} catch {
return undefined;
}
}
function parseTestContainersServices(actualValue: string | undefined): TestContainersServiceName[] {
if (actualValue === undefined) {
return DEFAULT_TESTCONTAINERS_SERVICES;
}
// An explicit empty string is a deliberate opt-out (e.g. a spec that wants only the core
// stack), distinct from not setting the var at all.
if (!actualValue) {
return [];
}
const requested = actualValue.split(',').filter(Boolean);
const unknown = requested.filter((name) => !(TESTCONTAINERS_SERVICE_NAMES as readonly string[]).includes(name));
if (unknown.length > 0) {
throw new Error(
`Unknown PW_TESTCONTAINERS_SERVICES entr${unknown.length > 1 ? 'ies' : 'y'}: ${unknown.join(', ')}. ` +
`Valid services are: ${TESTCONTAINERS_SERVICE_NAMES.join(', ')}.`,
);
}
return requested as TestContainersServiceName[];
}

View File

@@ -0,0 +1,389 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Browser, Page} from '@playwright/test';
import {test as base} from '@playwright/test';
import type {AxeResults} from 'axe-core';
import {AxeBuilder} from '@axe-core/playwright';
import {TestBrowser} from './browser_context';
import {
ensureLicense,
ensurePluginsLoaded,
ensureServerDeployment,
shouldHaveCallsEnabled,
shouldHaveFeatureFlag,
shouldRunInLinux,
skipIfFeatureFlagNotSet,
skipIfNoLicense,
} from './flag';
import {getBlobFromAsset, getFileFromAsset} from './file';
import {
configureAIBridgeMock,
createKeycloakUser,
createLdapUser,
createMockAIAgent,
createNewUserProfile,
createNewTeam,
createRandomChannel,
createRandomPost,
createRandomTeam,
createRandomUser,
createUserWithAttributes,
deleteKeycloakUser,
deleteLdapUser,
enableAIBridgeTestMode,
listMinioObjectKeys,
ensureMinio,
ensureAzurite,
listAzuriteBlobNames,
ensureLocalFile,
listMattermostDataFiles,
ensurePostgresSearch,
ensureFeatureFlag,
generateLdapUser,
getAIBridgeMock,
getAdminClient,
initSetup,
isOutsideRemoteUserHour,
ldapServerConfig,
ensureOpenldap,
makeClient,
recapCompletion,
resetAIBridgeMock,
rewriteCompletion,
installAndEnablePlugin,
isPluginActive,
samlServerConfig,
ensureKeycloak,
elasticsearchServerConfig,
opensearchServerConfig,
ensureElasticsearch,
ensureOpensearch,
runMmctl,
ensureMmctl,
updateLdapUser,
upgradeServerImage,
saveUpgradePhaseLogs,
} from './server';
import {
toBeFocusedWithFocusVisible,
hideDynamicChannelsContent,
waitForAnimationEnd,
waitUntil,
logFocusedElement,
} from './test_action';
import {pages} from './ui/pages';
import {matchSnapshot} from './visual';
import {
clearCapturedNotifications,
clickNotification,
closeWebsockets,
connectWebsockets,
mockWebsockets,
stubNotification,
waitForNotification,
} from './mock_browser_api';
import {duration, getRandomId, newTestPassword, simpleEmailRe, wait} from './util';
export {expect} from '@playwright/test';
export type ExtendedFixtures = {
axe: AxeBuilderExtended;
pw: PlaywrightExtended;
};
type AxeBuilderOptions = {
disableColorContrast?: boolean;
disableLinkInTextBlock?: boolean;
};
export const test = base.extend<ExtendedFixtures>({
axe: async ({}, use) => {
const ab = new AxeBuilderExtended();
await use(ab);
},
pw: async ({browser, page, isMobile}, use) => {
const pw = new PlaywrightExtended(browser, page, isMobile);
await use(pw);
await pw.testBrowser.close();
},
});
export class PlaywrightExtended {
// ./browser_context
readonly testBrowser;
// ./flag
readonly shouldHaveCallsEnabled;
readonly shouldHaveFeatureFlag;
readonly shouldRunInLinux;
readonly ensureLicense;
readonly ensureServerDeployment;
readonly skipIfNoLicense;
readonly skipIfFeatureFlagNotSet;
// ./file
readonly getBlobFromAsset;
readonly getFileFromAsset;
// ./server
readonly ensurePluginsLoaded;
readonly getAdminClient;
readonly initSetup;
readonly enableAIBridgeTestMode;
readonly configureAIBridgeMock;
readonly getAIBridgeMock;
readonly resetAIBridgeMock;
readonly createMockAIAgent;
readonly rewriteCompletion;
readonly recapCompletion;
readonly installAndEnablePlugin;
readonly isPluginActive;
// ./server/openldap, ./server/keycloak, ./server/elasticsearch, ./server/opensearch, ./server/minio
readonly generateLdapUser;
readonly createLdapUser;
readonly updateLdapUser;
readonly deleteLdapUser;
readonly ldapServerConfig;
readonly ensureOpenldap;
readonly createKeycloakUser;
readonly deleteKeycloakUser;
readonly samlServerConfig;
readonly ensureKeycloak;
readonly elasticsearchServerConfig;
readonly opensearchServerConfig;
readonly ensureElasticsearch;
readonly ensureOpensearch;
readonly listMinioObjectKeys;
readonly ensureMinio;
readonly ensureAzurite;
readonly ensureLocalFile;
readonly listMattermostDataFiles;
readonly ensurePostgresSearch;
readonly ensureFeatureFlag;
readonly listAzuriteBlobNames;
readonly runMmctl;
readonly ensureMmctl;
// ./server/version
readonly upgradeServerImage;
// ./server/upgrade_logs
readonly saveUpgradePhaseLogs;
// ./test_action
readonly toBeFocusedWithFocusVisible;
readonly hideDynamicChannelsContent;
readonly waitForAnimationEnd;
readonly waitUntil;
readonly logFocusedElement;
// ./mock_browser_api
readonly stubNotification;
readonly clearCapturedNotifications;
readonly waitForNotification;
readonly clickNotification;
readonly mockWebsockets;
readonly connectWebsockets;
readonly closeWebsockets;
// ./server
readonly createNewUserProfile;
readonly createNewTeam;
readonly isOutsideRemoteUserHour;
readonly makeClient;
// ./visual
readonly matchSnapshot;
// ./util
readonly duration;
readonly newTestPassword;
readonly simpleEmailRe;
readonly wait;
// random
readonly random;
// unauthenticated page
readonly loginPage;
readonly landingLoginPage;
readonly signupPage;
readonly resetPasswordPage;
readonly hasSeenLandingPage;
constructor(browser: Browser, page: Page, isMobile: boolean) {
// ./browser_context
this.testBrowser = new TestBrowser(browser);
// ./flag
this.shouldHaveCallsEnabled = shouldHaveCallsEnabled;
this.shouldHaveFeatureFlag = shouldHaveFeatureFlag;
this.shouldRunInLinux = shouldRunInLinux;
this.ensureLicense = ensureLicense;
this.ensureServerDeployment = ensureServerDeployment;
this.skipIfNoLicense = skipIfNoLicense;
this.skipIfFeatureFlagNotSet = skipIfFeatureFlagNotSet;
// ./file
this.getBlobFromAsset = getBlobFromAsset;
this.getFileFromAsset = getFileFromAsset;
// ./server
this.ensurePluginsLoaded = ensurePluginsLoaded;
this.initSetup = initSetup;
this.getAdminClient = getAdminClient;
this.enableAIBridgeTestMode = enableAIBridgeTestMode;
this.configureAIBridgeMock = configureAIBridgeMock;
this.getAIBridgeMock = getAIBridgeMock;
this.resetAIBridgeMock = resetAIBridgeMock;
this.createMockAIAgent = createMockAIAgent;
this.rewriteCompletion = rewriteCompletion;
this.recapCompletion = recapCompletion;
this.isOutsideRemoteUserHour = isOutsideRemoteUserHour;
this.installAndEnablePlugin = installAndEnablePlugin;
this.isPluginActive = isPluginActive;
// ./server/openldap, ./server/keycloak, ./server/elasticsearch, ./server/opensearch, ./server/minio
this.generateLdapUser = generateLdapUser;
this.createLdapUser = createLdapUser;
this.updateLdapUser = updateLdapUser;
this.deleteLdapUser = deleteLdapUser;
this.ldapServerConfig = ldapServerConfig;
this.ensureOpenldap = ensureOpenldap;
this.createKeycloakUser = createKeycloakUser;
this.deleteKeycloakUser = deleteKeycloakUser;
this.samlServerConfig = samlServerConfig;
this.ensureKeycloak = ensureKeycloak;
this.elasticsearchServerConfig = elasticsearchServerConfig;
this.opensearchServerConfig = opensearchServerConfig;
this.ensureElasticsearch = ensureElasticsearch;
this.ensureOpensearch = ensureOpensearch;
this.listMinioObjectKeys = listMinioObjectKeys;
this.ensureMinio = ensureMinio;
this.ensureAzurite = ensureAzurite;
this.ensureLocalFile = ensureLocalFile;
this.listMattermostDataFiles = listMattermostDataFiles;
this.ensurePostgresSearch = ensurePostgresSearch;
this.ensureFeatureFlag = ensureFeatureFlag;
this.listAzuriteBlobNames = listAzuriteBlobNames;
this.runMmctl = runMmctl;
this.ensureMmctl = ensureMmctl;
// ./server/version
this.upgradeServerImage = upgradeServerImage;
// ./server/upgrade_logs
this.saveUpgradePhaseLogs = saveUpgradePhaseLogs;
// ./test_action
this.toBeFocusedWithFocusVisible = toBeFocusedWithFocusVisible;
this.hideDynamicChannelsContent = hideDynamicChannelsContent;
this.waitForAnimationEnd = waitForAnimationEnd;
this.waitUntil = waitUntil;
this.logFocusedElement = logFocusedElement;
// unauthenticated page
this.loginPage = new pages.LoginPage(page);
this.landingLoginPage = new pages.LandingLoginPage(page, isMobile);
this.signupPage = new pages.SignupPage(page);
this.resetPasswordPage = new pages.ResetPasswordPage(page);
// ./mock_browser_api
this.stubNotification = stubNotification;
this.clearCapturedNotifications = clearCapturedNotifications;
this.waitForNotification = waitForNotification;
this.clickNotification = clickNotification;
this.mockWebsockets = mockWebsockets;
this.connectWebsockets = connectWebsockets;
this.closeWebsockets = closeWebsockets;
// ./server
this.createNewUserProfile = createNewUserProfile;
this.createNewTeam = createNewTeam;
this.makeClient = makeClient;
// ./visual
this.matchSnapshot = matchSnapshot;
// ./util
this.duration = duration;
this.wait = wait;
this.newTestPassword = newTestPassword;
this.simpleEmailRe = simpleEmailRe;
this.random = {
id: getRandomId,
channel: createRandomChannel,
post: createRandomPost,
team: createRandomTeam,
user: createRandomUser,
userWithAttributes: createUserWithAttributes,
};
this.hasSeenLandingPage = async () => {
// Visit the base URL to be able to set the localStorage
await page.goto('/');
return waitUntilLocalStorageIsSet(page, '__landingPageSeen__', 'true');
};
}
}
export class AxeBuilderExtended {
readonly builder: (page: Page, options?: AxeBuilderOptions) => AxeBuilder;
// See https://github.com/dequelabs/axe-core/blob/master/doc/API.md#axe-core-tags
readonly tags: string[] = ['wcag2a', 'wcag2aa', 'wcag21aa'];
constructor() {
this.builder = (page: Page, options: AxeBuilderOptions = {}) => {
// See https://github.com/dequelabs/axe-core/blob/master/doc/rule-descriptions.md#wcag-20-level-a--aa-rules
const disabledRules: string[] = [];
if (options.disableColorContrast) {
// Disabled in pages due to impact to overall theme of Mattermost.
// Option: make use of custom theme to improve color contrast.
disabledRules.push('color-contrast');
}
if (options.disableLinkInTextBlock) {
// Disabled in pages due to impact to overall theme of Mattermost.
// Option: make use of custom theme to improve color contrast.
disabledRules.push('link-in-text-block');
}
return new AxeBuilder({page}).withTags(this.tags).disableRules(disabledRules);
};
}
violationFingerprints(accessibilityScanResults: AxeResults) {
const fingerprints = accessibilityScanResults.violations.map((violation) => ({
rule: violation.id,
description: violation.description,
helpUrl: violation.helpUrl,
targets: violation.nodes.map((node) => {
return {target: node.target, impact: node.impact, html: node.html};
}),
}));
return JSON.stringify(fingerprints, null, 2);
}
}
async function waitUntilLocalStorageIsSet(page: Page, key: string, value: string, timeout = duration.ten_sec) {
await waitUntil(
() =>
page.evaluate(
({key, value}) => {
if (localStorage.getItem(key) === value) {
return true;
}
localStorage.setItem(key, value);
return false;
},
{key, value},
),
{timeout},
);
}

View File

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

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
/**
* The "About Mattermost" modal — opened from the Channels product switch menu
* (GlobalHeader.openAbout()) or from System Console's sidebar header menu
* (SystemConsoleSidebarHeader.openAbout()). Same component either way.
*/
export default class AboutBuildModal {
readonly container: Locator;
readonly versionInfo: Locator;
readonly hashInfo: Locator;
readonly closeButton: Locator;
constructor(container: Locator) {
this.container = container;
this.versionInfo = container.getByTestId('aboutModalVersionInfo');
this.hashInfo = container.locator('.about-modal__hash');
this.closeButton = container.getByRole('button', {name: 'Close'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await expect(this.versionInfo).toBeVisible();
}
/** Reads the "Server Version:" line. */
async getServerVersion(): Promise<string> {
const text = await this.versionInfo.innerText();
const match = text.match(/Server Version:\s*(\S+)/);
if (!match) {
throw new Error(`Could not find "Server Version:" in About modal text: ${text}`);
}
return match[1];
}
/** Reads the "Build Number:" line. */
async getBuildNumber(): Promise<string> {
const text = await this.versionInfo.innerText();
const match = text.match(/Build Number:\s*(\S+)/);
if (!match) {
throw new Error(`Could not find "Build Number:" in About modal text: ${text}`);
}
return match[1];
}
async close() {
await this.closeButton.click();
await expect(this.container).not.toBeVisible();
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class AddPeopleToChannelModal {
readonly container: Locator;
readonly closeButton;
readonly alreadyInChannelLabel;
constructor(container: Locator) {
this.container = container;
this.closeButton = container.getByRole('button', {name: 'Close'});
this.alreadyInChannelLabel = container.getByText('Already in channel');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Types into the auto-focused react-select search input. The input is not a
* standard textbox, so type via the keyboard once the modal is visible.
*/
async search(text: string) {
await this.toBeVisible();
await this.container.page().keyboard.type(text);
}
}

View File

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

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class BrowseChannelsModal {
readonly container: Locator;
readonly createNewChannelButton: Locator;
readonly hideArchivedCheckbox: Locator;
readonly hideJoinedCheckbox: Locator;
readonly searchInput: Locator;
readonly results: Locator;
constructor(container: Locator) {
this.container = container;
this.createNewChannelButton = container.getByRole('button', {name: 'Create New Channel'});
this.hideArchivedCheckbox = container.getByRole('checkbox', {name: 'Hide Archived'});
this.hideJoinedCheckbox = container.getByRole('checkbox', {name: 'Hide Joined'});
this.searchInput = container.getByRole('textbox', {name: 'Search channels'});
// This role seems incorrect and will likely need to be changed later
this.results = this.container.getByRole('search');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeDoneLoading() {
await expect(this.container.getByTestId('loading-screen')).toHaveCount(0);
}
async toHaveNResults(count: number) {
await expect(this.results.locator('[data-testid^="ChannelRow-"]')).toHaveCount(count);
}
async fillSearchInput(text: string) {
await this.searchInput.fill(text);
}
async toHaveChannelAsNthResult(channelName: string, index: number) {
const row = this.results.locator('[data-testid^="ChannelRow-"]').nth(index);
expect(await row.getAttribute('data-testid')).toEqual(`ChannelRow-${channelName}`);
}
getChannelRow(channelDisplayName: string): Locator {
return this.results.locator('.more-modal__row').filter({hasText: channelDisplayName});
}
async clickRequestToJoin(channelDisplayName: string) {
await this.getChannelRow(channelDisplayName).getByText('Request to join').click();
}
async clickWithdraw(channelDisplayName: string) {
await this.getChannelRow(channelDisplayName).getByText('Withdraw', {exact: true}).click();
}
async toHaveWithdrawButton(channelDisplayName: string) {
await expect(this.getChannelRow(channelDisplayName).getByText('Withdraw', {exact: true})).toBeVisible();
}
async toHaveRequestToJoinButton(channelDisplayName: string) {
await expect(this.getChannelRow(channelDisplayName).getByText('Request to join')).toBeVisible();
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class BurnOnReadBadge {
readonly container: Locator;
readonly flameIcon: Locator;
constructor(container: Locator) {
this.container = container;
this.flameIcon = container.locator('svg');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
async click() {
await this.container.click();
}
async hover() {
await this.container.hover();
}
/**
* Get the tooltip/label text
* Uses aria-label which contains the same information as the visible tooltip
*/
async getTooltipText(): Promise<string> {
// The aria-label contains the full tooltip information
// e.g., "Click to delete message for everyone. Read by 1 of 2 recipients"
const ariaLabel = await this.container.getAttribute('aria-label');
if (ariaLabel) {
return ariaLabel;
}
// Fallback: try to get text content
return (await this.container.textContent()) || '';
}
/**
* Get aria-label for accessibility testing
*/
async getAriaLabel(): Promise<string> {
return (await this.container.getAttribute('aria-label')) || '';
}
/**
* Parse recipient count from tooltip
* Returns {revealed: number, total: number}
*/
async getRecipientCount(): Promise<{revealed: number; total: number}> {
const tooltipText = await this.getTooltipText();
const match = tooltipText.match(/Read by (\d+) of (\d+)/);
if (!match) {
throw new Error(`Could not parse recipient count from tooltip: ${tooltipText}`);
}
return {
revealed: parseInt(match[1], 10),
total: parseInt(match[2], 10),
};
}
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class BurnOnReadConcealedPlaceholder {
readonly container: Locator;
readonly icon: Locator;
readonly text: Locator;
constructor(container: Locator) {
this.container = container;
// The container itself is the button - no need for nested locator
this.icon = container.locator('svg');
this.text = container.getByText('View message');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
/**
* Click to reveal the concealed message
* The container itself is the clickable button
*/
async clickToReveal() {
await expect(this.container).toBeAttached();
await expect(this.container).toBeEnabled();
await this.container.click();
}
/**
* Wait for the reveal process to complete
* The placeholder should disappear after successful reveal
*/
async waitForReveal(timeout = 5000) {
await expect(this.container).not.toBeVisible({timeout});
}
/**
* Get the placeholder text (e.g., "View message")
*/
async getText(): Promise<string> {
return (await this.text.textContent()) || '';
}
/**
* Get the aria-label of the button
*/
async getAriaLabel(): Promise<string> {
return (await this.container.getAttribute('aria-label')) || '';
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class BurnOnReadConfirmationModal {
readonly container: Locator;
readonly title: Locator;
readonly message: Locator;
readonly deleteButton: Locator;
readonly cancelButton: Locator;
readonly dontShowAgainCheckbox: Locator;
constructor(container: Locator) {
this.container = container;
// Modal elements
this.title = container.getByRole('heading').first();
this.message = container.locator('#confirmModalBody');
// Action buttons - use flexible selectors
this.deleteButton = container.getByRole('button', {name: /delete|burn|confirm/i});
this.cancelButton = container.getByRole('button', {name: /cancel/i});
// Checkbox for "don't show again" preference
this.dontShowAgainCheckbox = container.getByRole('checkbox');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
/**
* Confirm deletion without checking "don't show again"
*/
async confirm() {
await this.deleteButton.click();
await this.toBeHidden();
}
/**
* Confirm deletion and check "don't show again"
*/
async confirmWithDontShowAgain() {
await this.dontShowAgainCheckbox.check();
await this.deleteButton.click();
await this.toBeHidden();
}
/**
* Cancel the deletion
*/
async cancel() {
await this.cancelButton.click();
await this.toBeHidden();
}
/**
* Get the modal title text
*/
async getTitleText(): Promise<string> {
return (await this.title.textContent()) || '';
}
/**
* Get the modal message text
*/
async getMessageText(): Promise<string> {
return (await this.message.textContent()) || '';
}
/**
* Check if "don't show again" checkbox is present
*/
async hasDontShowAgainOption(): Promise<boolean> {
return this.dontShowAgainCheckbox.isVisible();
}
/**
* Check if "don't show again" is already checked
*/
async isDontShowAgainChecked(): Promise<boolean> {
return this.dontShowAgainCheckbox.isChecked();
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class BurnOnReadTimerChip {
readonly container: Locator;
readonly flameIcon: Locator;
readonly timerText: Locator;
constructor(container: Locator) {
this.container = container;
this.flameIcon = container.locator('svg');
this.timerText = container.locator('span');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).not.toBeVisible();
}
async click() {
await this.container.click();
}
async hover() {
await this.container.hover();
}
/**
* Get the displayed time remaining (e.g., "0:45", "1:30")
*/
async getTimeRemaining(): Promise<string> {
return (await this.timerText.textContent()) || '';
}
/**
* Parse time remaining into seconds
*/
async getTimeRemainingInSeconds(): Promise<number> {
const timeText = await this.getTimeRemaining();
const parts = timeText.split(':');
if (parts.length !== 2) {
throw new Error(`Invalid timer format: ${timeText}`);
}
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
return minutes * 60 + seconds;
}
/**
* Check if timer is in warning state (last 30 seconds)
*/
async isWarning(): Promise<boolean> {
const className = await this.container.getAttribute('class');
return className?.includes('BurnOnReadTimerChip--warning') || false;
}
/**
* Check if timer has expired
*/
async isExpired(): Promise<boolean> {
const className = await this.container.getAttribute('class');
return className?.includes('BurnOnReadTimerChip--expired') || false;
}
/**
* Get tooltip text
*/
async getTooltipText(): Promise<string> {
await this.hover();
// Wait for tooltip to appear
const tooltip = this.container.page().locator('[role="tooltip"]').first();
await tooltip.waitFor({state: 'visible', timeout: 2000});
return (await tooltip.textContent()) || '';
}
}

View File

@@ -0,0 +1,294 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator, Page} from '@playwright/test';
import {expect} from '@playwright/test';
import ChannelsHeader from './header';
import ChannelsPostCreate from './post_create';
import ChannelsPostEdit from './post_edit';
import ChannelsPost from './post';
import ScheduledPostIndicator from './scheduled_post_indicator';
import FlagPostConfirmationDialog from './flag_post_confirmation_dialog';
import {duration, hexToRgb} from '@/util';
import {waitUntil} from '@/test_action';
export default class ChannelsCenterView {
readonly container: Locator;
readonly page: Page;
readonly header;
readonly postCreate;
readonly scheduledDraftOptions;
readonly scheduledPostIndicator;
readonly postEdit;
readonly editedPostIcon;
readonly channelBanner;
readonly autotranslationBadge;
readonly flagPostConfirmationDialog;
readonly notificationSeparator;
readonly postViews;
readonly channelIntro;
constructor(container: Locator, page: Page) {
this.container = container;
this.page = page;
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.getByTestId('post-edit-container'));
this.scheduledPostIndicator = new ScheduledPostIndicator(container.getByTestId('scheduledPostIndicator'));
this.editedPostIcon = (postID: string) => container.locator(`#postEdited_${postID}`);
this.channelBanner = container.getByTestId('channel_banner_container');
this.autotranslationBadge = container.getByTestId('autotranslation-badge');
this.flagPostConfirmationDialog = new FlagPostConfirmationDialog(
page.getByRole('dialog', {name: 'Quarantine for Review'}),
page,
);
this.notificationSeparator = container.locator('.NotificationSeparator');
this.postViews = container.getByTestId('postView');
this.channelIntro = container.locator('#channelIntro');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await this.postCreate.toBeVisible();
}
async postMessage(message: string, files?: string[]) {
await this.postCreate.postMessage(message, files);
}
/**
* Return the first post in the Center
*/
async getFirstPost() {
const firstPost = this.container.getByTestId('postView').first();
await firstPost.waitFor();
return new ChannelsPost(firstPost);
}
/**
* Return the last post in the Center
*/
async getLastPost() {
const lastPost = this.container.getByTestId('postView').last();
await lastPost.waitFor();
return new ChannelsPost(lastPost);
}
/**
* Return the Center post whose body contains the given text.
*/
async getPostByText(text: string) {
const post = this.container.getByTestId('postView').filter({hasText: text}).last();
await expect(post).toBeVisible();
return new ChannelsPost(post);
}
/**
* Return the concealed BoR post, pinned by post id so the locator survives reveal.
*/
async getConcealedBorPost() {
const placeholder = this.container.getByTestId(/^burn-on-read-concealed-/).last();
await expect(placeholder).toBeVisible();
const testId = await placeholder.getAttribute('data-testid');
return this.getPostById((testId || '').replace('burn-on-read-concealed-', ''));
}
/**
* Return the ID of the last post in the Center
*/
async getLastPostID() {
const lastPost = await this.getLastPost();
return lastPost.getId();
}
/**
* Return the Nth post in the Center from the top
* @param index
* @returns
*/
async getNthPost(index: number) {
const nthPost = this.container.getByTestId('postView').nth(index);
await nthPost.waitFor();
return new ChannelsPost(nthPost);
}
/**
* Returns the Center post by post's id
* @param postId Just the ID without the prefix
* Note: Handles both simple posts (post_id) and combined posts (post_id:timestamp)
*/
postById(id: string) {
// Match either exact ID or ID with timestamp suffix (for combined posts)
// Use CSS selector that matches: post_id OR post_id:*
return this.container.locator(`[id="post_${id}"], [id^="post_${id}:"]`).first();
}
async getPostById(id: string) {
const postById = this.postById(id);
await postById.waitFor();
return new ChannelsPost(postById);
}
async waitUntilLastPostContains(text: string, timeout = duration.ten_sec) {
await waitUntil(
async () => {
const post = await this.getLastPost();
const content = await post.container.textContent();
return content?.includes(text);
},
{timeout},
);
}
async waitUntilPostWithIdContains(id: string, text: string, timeout = duration.ten_sec) {
// A single retrying assertion, rather than polling around getPostById(): that helper waits
// on the locator itself, so a post that has not rendered yet consumes the whole budget in
// one iteration and the outer timeout reports nothing about what the post actually held.
await expect(this.postById(id)).toContainText(text, {timeout});
}
async toHaveNewMessagesSeparator() {
await expect(this.notificationSeparator).toBeVisible();
await expect(this.notificationSeparator).toContainText('New Messages');
}
async clickOnLastEditedPost(postID: string | null) {
if (postID) {
await this.editedPostIcon(postID).click();
}
}
async assertChannelBanner(text: string, backgroundColor: string) {
await expect(this.channelBanner).toBeVisible();
const actualText = await this.channelBanner.textContent();
expect(actualText).toBe(text);
const actualBackgroundColor = await this.channelBanner.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('background-color');
});
expect(actualBackgroundColor).toBe(hexToRgb(backgroundColor));
}
async assertChannelBannerNotVisible() {
await expect(this.channelBanner).not.toBeVisible();
}
async assertChannelBannerTextNotClipped() {
const bannerText = this.channelBanner.getByTestId('channel_banner_text');
await expect(bannerText).toBeVisible();
await this.assertElementContainedInBanner(bannerText);
}
async assertChannelBannerHasBoldText(text: string) {
const boldText = await this.channelBanner.locator('strong');
expect(boldText).toBeVisible();
const actualText = await boldText.textContent();
expect(actualText).toBe(text);
}
async assertChannelBannerHasItalicText(text: string) {
const italicText = await this.channelBanner.locator('em');
expect(italicText).toBeVisible();
const actualText = await italicText.textContent();
expect(actualText).toBe(text);
}
async assertChannelBannerHasStrikethroughText(text: string) {
const strikethroughText = await this.channelBanner.locator('del');
expect(strikethroughText).toBeVisible();
const actualText = await strikethroughText.textContent();
expect(actualText).toBe(text);
}
async assertChannelBannerHasEmoticon() {
const emoji = this.channelBanner.getByTestId(/^postEmoji\./).first();
await expect(emoji).toBeVisible();
const backgroundImage = await emoji.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('background-image');
});
expect(backgroundImage).not.toBe('none');
}
async assertChannelBannerImageEmojiSize(expectedSizePx: number) {
const emoji = this.channelBanner.getByTestId(/^postEmoji\./).first();
await expect(emoji).toBeVisible();
const {width, height} = await emoji.evaluate((el) => {
const styles = window.getComputedStyle(el);
return {
width: styles.getPropertyValue('width'),
height: styles.getPropertyValue('height'),
};
});
expect(width).toBe(`${expectedSizePx}px`);
expect(height).toBe(`${expectedSizePx}px`);
await this.assertElementContainedInBanner(emoji);
}
async assertChannelBannerUnicodeEmojiSize(expectedSizePx: number) {
const emoji = this.channelBanner.getByTestId('channel-banner-unicode-emoji').first();
await expect(emoji).toBeVisible();
const fontSize = await emoji.evaluate((el) => {
return window.getComputedStyle(el).getPropertyValue('font-size');
});
expect(fontSize).toBe(`${expectedSizePx}px`);
await this.assertElementContainedInBanner(emoji);
}
/**
* Asserts that the given element's bounding box lies fully within the channel
* banner's content area (banner bounds minus computed padding).
*
* Uses getBoundingClientRect() coordinates, which are NOT clipped by parent
* overflow — so if an element protrudes into or beyond the padding zone it will
* be visually clipped by `overflow: hidden` on the text container, and this
* assertion will catch that.
*
* A small epsilon is applied to each boundary to avoid flaky failures caused
* by sub-pixel rounding differences in layout engines.
*/
private async assertElementContainedInBanner(element: Locator) {
const EPSILON = 0.5;
const bannerBox = await this.channelBanner.boundingBox();
const elementBox = await element.boundingBox();
expect(bannerBox).not.toBeNull();
expect(elementBox).not.toBeNull();
const banner = bannerBox!;
const el = elementBox!;
const {paddingTop, paddingBottom, paddingLeft, paddingRight} = await this.channelBanner.evaluate((node) => {
const styles = window.getComputedStyle(node);
return {
paddingTop: parseFloat(styles.paddingTop),
paddingBottom: parseFloat(styles.paddingBottom),
paddingLeft: parseFloat(styles.paddingLeft),
paddingRight: parseFloat(styles.paddingRight),
};
});
expect(el.y).toBeGreaterThanOrEqual(banner.y + paddingTop - EPSILON);
expect(el.y + el.height).toBeLessThanOrEqual(banner.y + banner.height - paddingBottom + EPSILON);
expect(el.x).toBeGreaterThanOrEqual(banner.x + paddingLeft - EPSILON);
expect(el.x + el.width).toBeLessThanOrEqual(banner.x + banner.width - paddingRight + EPSILON);
}
}

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
/**
* Which of the channel header's two chip slots to address: 'header' is the row
* under the channel name, 'info' the inline strip beside the member count. Each
* slot carries its own test ids, since both can be on screen at once.
*/
export type ChannelAttributeSurface = 'header' | 'info';
/**
* The attribute chips in one channel header slot.
*/
export class ChannelAttributeLabels {
readonly container: Locator;
readonly surface: ChannelAttributeSurface;
constructor(container: Locator, surface: ChannelAttributeSurface) {
this.container = container;
this.surface = surface;
}
get chips() {
return this.container.getByTestId('attributeChip');
}
chip(value: string) {
return this.chips.filter({hasText: value});
}
get overflowButton() {
return this.container.getByTestId(`channelAttributeLabelsOverflow-${this.surface}`);
}
get popover() {
return this.container.page().getByTestId(`channelAttributeLabelsPopover-${this.surface}`);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async toBeHidden() {
await expect(this.container).toHaveCount(0);
}
get visibleRow(): Locator {
return this.container.locator('.ChannelAttributeLabels__visible');
}
async openOverflow() {
await this.overflowButton.click();
await expect(this.popover).toBeVisible();
return this.popover;
}
}
/**
* The Channel Attributes block in the Channel Info panel, including its inline editors.
* Locators key off the machine name, which is what the product puts in every test id.
*/
export class ChannelInfoAttributes {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
row(name: string) {
return this.container.getByTestId(`channelInfoAttributeRow-${name}`);
}
chip(name: string) {
return this.row(name).getByTestId('attributeChip');
}
editButton(name: string) {
return this.row(name).getByTestId(`channelInfoAttributeEdit-${name}`);
}
editor(name: string) {
return this.row(name).getByTestId(`channelAttributeEdit-${name}`);
}
error(name: string) {
return this.container.getByTestId(`channelInfoAttributeError-${name}`);
}
lock(name: string) {
return this.container.getByTestId(`channelInfoAttributeLock-${name}`);
}
unset(name: string) {
return this.container.getByTestId(`channelInfoAttributeUnset-${name}`);
}
get addButton() {
return this.container.getByTestId('channelInfoAddAttributeButton');
}
addMenuItem(name: string) {
return this.container.page().getByTestId(`channelInfoAddAttribute-${name}`);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async startEditing(name: string) {
await this.editButton(name).click();
await expect(this.editor(name)).toBeVisible();
}
/**
* Only Escape is guaranteed to close the editor: a commit closes it on a
* successful write and deliberately keeps it open on a failed one, so callers
* assert the outcome they expect.
*/
async setText(name: string, text: string, commit: 'enter' | 'blur' | 'escape' = 'enter') {
await this.startEditing(name);
const input = this.editor(name);
await input.fill(text);
if (commit === 'enter') {
await input.press('Enter');
} else if (commit === 'escape') {
await input.press('Escape');
await expect(input).not.toBeVisible();
} else {
await this.container
.page()
.locator('body')
.click({position: {x: 0, y: 0}});
}
}
async select(name: string, option: string) {
await this.startEditing(name);
await this.editor(name).click();
await this.container.page().getByText(option, {exact: true}).click();
}
/**
* Reopens the editor first: each pick commits and closes it.
*/
async deselect(name: string, option: string) {
await this.startEditing(name);
const chip = this.editor(name).locator('.DropDown__multi-value', {hasText: option});
await chip.locator('.DropDown__multi-value__remove').click();
}
async add(name: string, option?: string) {
await this.addButton.click();
await this.addMenuItem(name).click();
await expect(this.editor(name)).toBeVisible();
if (option !== undefined) {
await this.editor(name).click();
await this.container.page().getByText(option, {exact: true}).click();
}
}
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class ChannelBookmarksBar {
readonly container: Locator;
readonly addBookmarkButton;
readonly addLinkMenuItem;
readonly attachFileMenuItem;
readonly editMenuItem;
readonly openMenuItem;
readonly copyLinkMenuItem;
readonly deleteMenuItem;
readonly deleteDialog;
readonly confirmDeleteButton;
constructor(container: Locator) {
this.container = container;
const page = container.page();
this.addBookmarkButton = container.getByRole('button', {name: 'Add a bookmark'});
this.addLinkMenuItem = page.getByRole('menuitem', {name: 'Add a link'});
this.attachFileMenuItem = page.getByRole('menuitem', {name: 'Attach a file'});
this.editMenuItem = page.getByRole('menuitem', {name: 'Edit'});
this.openMenuItem = page.getByRole('menuitem', {name: 'Open'});
this.copyLinkMenuItem = page.getByRole('menuitem', {name: 'Copy link'});
this.deleteMenuItem = page.getByRole('menuitem', {name: 'Delete'});
this.deleteDialog = page.getByRole('dialog', {name: 'Delete bookmark'});
this.confirmDeleteButton = this.deleteDialog.getByRole('button', {name: 'Yes, delete'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
getBookmark(name: string | RegExp): Locator {
return this.container.getByRole('link', {name, exact: typeof name === 'string'});
}
getBookmarkLinks(): Locator {
return this.container.getByRole('link');
}
getBookmarkItem(name: string | RegExp): Locator {
return this.container.getByTestId(/^bookmark-item-/).filter({hasText: name});
}
getBookmarkIcon(name: string | RegExp): Locator {
return this.getBookmarkItem(name).getByTestId('bookmark-icon');
}
async openAddMenu() {
await this.addBookmarkButton.click();
await expect(this.addLinkMenuItem).toBeVisible();
}
async openBookmarkMenu(name: string | RegExp) {
const item = this.getBookmarkItem(name);
await item.hover();
await item.getByRole('button', {name: 'Bookmark menu'}).click();
await expect(this.openMenuItem).toBeVisible();
}
getOverflowButton(): Locator {
return this.container.getByRole('button', {name: /more bookmarks?/});
}
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class ChannelBookmarksCreateModal {
readonly container: Locator;
readonly linkInput;
readonly titleInput;
readonly addButton;
readonly saveButton;
readonly cancelButton;
readonly emojiButton;
readonly removeEmojiButton;
readonly invalidLinkMessage;
readonly emojiSearchInput;
constructor(container: Locator) {
this.container = container;
this.linkInput = container.getByTestId('linkInput');
this.titleInput = container.getByTestId('titleInput');
this.addButton = container.getByRole('button', {name: 'Add bookmark'});
this.saveButton = container.getByRole('button', {name: 'Save bookmark'});
this.cancelButton = container.getByRole('button', {name: 'Cancel'});
this.emojiButton = container.getByRole('button', {name: 'select an emoji'});
this.removeEmojiButton = container.getByText('Remove emoji');
this.invalidLinkMessage = container.getByText(
/Could not find|may not be a valid link|Please enter a valid link|Could not parse/i,
);
this.emojiSearchInput = container.page().getByPlaceholder('Search emojis');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async notToBeVisible() {
await expect(this.container).not.toBeVisible();
}
/**
* Fills the link field and confirms, creating a link bookmark.
*/
async addLink(url: string) {
await this.linkInput.fill(url);
await this.addButton.click();
}
async selectEmoji(searchTerm: string) {
await this.emojiButton.click();
await expect(this.emojiSearchInput).toBeVisible();
await this.emojiSearchInput.fill(searchTerm);
await this.emojiSearchInput.press('Enter');
}
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
/**
* The channel header dropdown menu, opened from the channel name at the top of the center channel.
* Its accessible name is "<channel> Channel Menu", so it is located by role with a name matcher.
*/
export default class ChannelMenu {
readonly container: Locator;
readonly openInNewWindow: Locator;
readonly viewInfo: Locator;
readonly muteToggle: Locator;
readonly notificationPreferences: Locator;
readonly channelSettings: Locator;
readonly bookmarksBar: Locator;
readonly addBookmarkLink: Locator;
readonly addBookmarkFile: Locator;
readonly members: Locator;
readonly moveTo: Locator;
readonly leaveChannel: Locator;
readonly archiveToggle: Locator;
readonly closeConversation: Locator;
constructor(container: Locator) {
this.container = container;
this.openInNewWindow = this.item('Open in new window');
this.viewInfo = this.item('View Info');
this.muteToggle = this.item(/^(Mute|Unmute)( Channel)?$/);
this.notificationPreferences = this.item('Notification Preferences');
this.channelSettings = this.item('Channel Settings');
this.bookmarksBar = this.item('Bookmarks Bar');
// The Bookmarks Bar submenu items render in a portal outside this menu's container.
this.addBookmarkLink = container.page().getByRole('menuitem', {name: 'Add a link'});
this.addBookmarkFile = container.page().getByRole('menuitem', {name: 'Attach a file'});
this.members = this.item('Members');
this.moveTo = this.item('Move to...');
this.leaveChannel = this.item('Leave Channel');
this.archiveToggle = this.item(/^(Archive|Unarchive) Channel$/);
this.closeConversation = this.item(/^Close (Direct Message|Group Message|Conversation)$/);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
/**
* Locates a menu item by its accessible name.
*/
item(name: string | RegExp): Locator {
return this.container.getByRole('menuitem', {name});
}
/**
* Opens the "Bookmarks Bar" submenu by hovering its parent item.
*/
async openBookmarksSubmenu() {
await this.bookmarksBar.hover();
await expect(this.addBookmarkLink).toBeVisible();
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class ChannelNotificationPreferencesModal {
readonly container: Locator;
readonly muteChannelCheckbox;
readonly ignoreMentionsCheckbox;
readonly saveButton;
constructor(container: Locator) {
this.container = container;
this.muteChannelCheckbox = container.getByRole('checkbox', {name: /Mute channel/i});
this.ignoreMentionsCheckbox = container.getByRole('checkbox', {
name: 'Ignore mentions for @channel, @here and @all',
});
this.saveButton = container.getByRole('button', {name: 'Save'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
await this.container.getByText('Mute or ignore').waitFor();
}
async save() {
await this.saveButton.click();
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
import InfoSettings from './info_settings';
import ConfigurationSettings from './configuration_settings';
export default class ChannelSettingsModal {
readonly container: Locator;
readonly closeButton;
readonly saveButton;
readonly infoTab;
readonly configurationTab;
readonly infoSettings;
readonly configurationSettings;
constructor(container: Locator) {
this.container = container;
this.closeButton = container.getByRole('button', {name: 'Close'});
this.saveButton = container.getByTestId('SaveChangesPanel__save-btn');
this.infoTab = container.getByRole('tab', {name: 'info'});
this.configurationTab = container.getByRole('tab', {name: 'configuration'});
this.infoSettings = new InfoSettings(container.getByTestId('channel-settings-info-tab'));
this.configurationSettings = new ConfigurationSettings(
container.getByTestId('channel-settings-configuration-tab'),
);
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
getContainerId() {
return this.container.getAttribute('id');
}
async close() {
await this.closeButton.click();
// The modal uses a two-step close when there are unsaved changes:
// the first click warns the user (sets hasBeenWarned=true) but keeps the modal open;
// only the second click actually closes it. Click again if needed.
try {
await expect(this.container).not.toBeVisible({timeout: 1000});
} catch {
await this.closeButton.click();
await expect(this.container).not.toBeVisible({timeout: 10000});
}
}
async save() {
await expect(this.saveButton).toBeVisible();
await this.saveButton.click();
}
async openInfoTab(): Promise<InfoSettings> {
await expect(this.infoTab).toBeVisible();
await this.infoTab.click();
await this.infoSettings.toBeVisible();
return this.infoSettings;
}
async openConfigurationTab(): Promise<ConfigurationSettings> {
await expect(this.configurationTab).toBeVisible();
await this.configurationTab.click();
await this.configurationSettings.toBeVisible();
return this.configurationSettings;
}
}

View File

@@ -0,0 +1,231 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class ConfigurationSettings {
readonly container: Locator;
constructor(container: Locator) {
this.container = container;
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async save() {
const saveButton = this.container.getByTestId('SaveChangesPanel__save-btn');
// Wait up to 5s for the save panel to appear. Some toggle-only changes
// (e.g. disable sharing after a reload) may not trigger the save panel
// if the component auto-synchronises or the panel has a render delay.
const isVisible = await saveButton.isVisible().catch(() => false);
if (!isVisible) {
await saveButton.waitFor({state: 'visible', timeout: 5000}).catch(() => {
// Panel didn't appear — change may already be applied or nothing to save.
});
// Double-check — if still not visible, bail out silently.
const stillNotVisible = !(await saveButton.isVisible().catch(() => false));
if (stillNotVisible) {
return;
}
}
await saveButton.click();
const unshareConfirm = this.container.page().getByRole('button', {name: 'Yes, unshare'});
try {
await unshareConfirm.waitFor({state: 'visible', timeout: 2000});
await unshareConfirm.click();
} catch {
// No unshare confirmation modal; save proceeded directly
}
// Wait for save to either succeed (panel hides) or enter an error state.
// Do not throw on error — callers that need to handle service-unavailable cases
// (e.g. Remote Cluster Service not running) can inspect the result via API after save.
await expect
.poll(
async () => {
if (!(await saveButton.isVisible())) {
return 'hidden';
}
return (await saveButton.getAttribute('class')) ?? '';
},
{timeout: 10000},
)
.toMatch(/hidden|error/);
}
async enableChannelBanner() {
const toggleButton = this.container.getByTestId('channelBannerToggle-button');
const classes = await toggleButton.getAttribute('class');
if (!classes?.includes('active')) {
await toggleButton.click();
}
}
async disableChannelBanner() {
const toggleButton = this.container.getByTestId('channelBannerToggle-button');
const classes = await toggleButton.getAttribute('class');
if (classes?.includes('active')) {
await toggleButton.click();
}
}
async enableChannelAutotranslation() {
const toggleButton = this.container.getByTestId('channelTranslationToggle-button');
const classes = await toggleButton.getAttribute('class');
if (!classes?.includes('active')) {
await toggleButton.click();
}
}
async disableChannelAutotranslation() {
const toggleButton = this.container.getByTestId('channelTranslationToggle-button');
const classes = await toggleButton.getAttribute('class');
if (classes?.includes('active')) {
await toggleButton.click();
}
}
async setChannelBannerText(text: string) {
const textBox = this.container.getByTestId('channel_banner_banner_text_textbox');
await expect(textBox).toBeVisible();
await textBox.fill(text);
}
async setChannelBannerBackgroundColor(color: string) {
const colorInput = this.container.locator('#channel_banner_banner_background_color_picker-inputColorValue');
await expect(colorInput).toBeVisible();
await colorInput.fill(color);
expect((await colorInput.inputValue()).replace('#', '')).toBe(color);
}
get joinLeaveMessagesToggle() {
return this.container.getByTestId('channelJoinLeaveMessagesToggle-button');
}
async enableJoinLeaveMessages() {
const toggleButton = this.container.getByTestId('channelJoinLeaveMessagesToggle-button');
const classes = await toggleButton.getAttribute('class');
if (!classes?.includes('active')) {
await toggleButton.click();
}
}
async disableJoinLeaveMessages() {
const toggleButton = this.container.getByTestId('channelJoinLeaveMessagesToggle-button');
const classes = await toggleButton.getAttribute('class');
if (classes?.includes('active')) {
await toggleButton.click();
}
}
get bannerTextbox() {
return this.container.getByTestId('channel_banner_banner_text_textbox');
}
get bannerTextEditor() {
return this.container.getByTestId('bannerTextEditor');
}
get bannerTokenButton() {
return this.container.getByTestId('bannerAttributeTokenButton');
}
get bannerTokenPreview() {
return this.container.getByTestId('bannerAttributePreview');
}
bannerTokenChip(name: string) {
return this.container.getByTestId(`bannerTextEditorChip-${name}`);
}
bannerTokenChipRemove(name: string) {
return this.container.getByTestId(`bannerTextEditorChipRemove-${name}`);
}
async typeBannerText(text: string) {
await this.bannerTextEditor.click();
await this.container.page().keyboard.press('End');
await this.container.page().keyboard.type(text);
}
async clearBannerText() {
const editor = this.bannerTextEditor;
await editor.click();
// Keyboard events and execCommand('delete') both honour contenteditable=false
// on chip spans and refuse to remove them. Direct DOM removal bypasses that
// restriction; the synthetic input event syncs React's emittedRef so the
// component does not re-seed the chips from the stale template string.
await editor.evaluate((el: HTMLElement) => {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
el.dispatchEvent(new InputEvent('input', {bubbles: true, cancelable: false}));
});
await expect(editor).toHaveText('');
}
async insertBannerToken(name: string) {
await expect(this.bannerTokenButton).toBeVisible();
await this.bannerTokenButton.click();
const item = this.container.page().getByTestId(`bannerAttributeToken-${name}`);
await expect(item).toBeVisible();
await item.click();
await expect(this.bannerTokenChip(name)).toHaveCount(1);
}
async removeBannerToken(name: string) {
await this.container.getByTestId(`bannerTextEditorChipRemove-${name}`).click();
await expect(this.bannerTokenChip(name)).toHaveCount(0);
}
get shareWithConnectedWorkspacesSection() {
return this.container.getByText('Share with connected workspaces');
}
get shareWithWorkspacesToggle() {
return this.container.getByTestId('shareChannelWithWorkspacesToggle-button');
}
async isShareWithWorkspacesSectionVisible(): Promise<boolean> {
return this.shareWithConnectedWorkspacesSection.isVisible();
}
async enableShareWithWorkspaces() {
const toggle = this.shareWithWorkspacesToggle;
const ariaPressed = await toggle.getAttribute('aria-pressed');
const classes = await toggle.getAttribute('class');
if (ariaPressed !== 'true' && !classes?.includes('active')) {
await toggle.click();
}
}
async disableShareWithWorkspaces() {
const toggle = this.shareWithWorkspacesToggle;
const ariaPressed = await toggle.getAttribute('aria-pressed');
const classes = await toggle.getAttribute('class');
if (ariaPressed === 'true' || classes?.includes('active')) {
await toggle.click();
}
}
/**
* Opens the "Add workspace" dropdown and selects the first available workspace.
* Call after enableShareWithWorkspaces() so the dropdown is visible. Required for save to apply.
*/
async addFirstAvailableWorkspace() {
const addButton = this.container.getByRole('button', {name: 'Add workspace'});
await expect(addButton).toBeVisible();
await addButton.click();
const firstWorkspaceItem = this.container.page().locator('[id^="add_workspace_to_channel_menu-item-"]').first();
await expect(firstWorkspaceItem).toBeVisible({timeout: 5000});
await firstWorkspaceItem.click();
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class InfoSettings {
readonly container: Locator;
readonly nameInput: Locator;
readonly purposeInput: Locator;
readonly headerInput: Locator;
readonly urlLabel: Locator;
readonly urlEditButton: Locator;
readonly urlInput: Locator;
readonly saveChangesPanel: Locator;
constructor(container: Locator) {
this.container = container;
this.nameInput = container.locator('#input_channel-settings-name');
this.purposeInput = container.getByPlaceholder('Enter a purpose for this channel (optional)');
this.headerInput = container.getByPlaceholder('Enter a header for this channel');
this.urlLabel = container.getByTestId('urlInputLabel');
this.urlEditButton = container.getByRole('button', {name: 'Edit'});
this.urlInput = container.getByTestId('channelURLInput');
this.saveChangesPanel = container.locator('.SaveChangesPanel');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async updateName(name: string) {
await expect(this.nameInput).toBeVisible();
await this.nameInput.clear();
await this.nameInput.fill(name);
}
async updateHeader(header: string) {
await expect(this.headerInput).toBeVisible();
await this.headerInput.fill(header);
}
async updateUrl(url: string) {
await expect(this.urlEditButton).toBeVisible();
await this.urlEditButton.click();
await expect(this.urlInput).toBeVisible();
await this.urlInput.clear();
await this.urlInput.fill(url);
}
async updatePurpose(purpose: string) {
await expect(this.purposeInput).toBeVisible();
await this.purposeInput.fill(purpose);
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class CreateTeamForm {
readonly container: Locator;
// Display name step
readonly teamNameInput: Locator;
readonly teamNameSubmitButton: Locator;
readonly teamNameNextButton: Locator;
readonly teamNameError: Locator;
// Team URL step
readonly teamURLInput: Locator;
readonly teamURLSubmitButton: Locator;
readonly teamURLFinishButton: Locator;
readonly teamURLError: Locator;
readonly backLink: Locator;
constructor(container: Locator) {
this.container = container;
this.teamNameInput = container.locator('#teamNameInput');
this.teamNameSubmitButton = container.locator('#teamNameNextButton');
this.teamNameNextButton = container.locator('#teamNameNextButton');
this.teamNameError = container.locator('#teamNameInputError');
this.teamURLInput = container.locator('#teamURLInput');
this.teamURLSubmitButton = container.locator('#teamURLFinishButton');
this.teamURLFinishButton = container.locator('#teamURLFinishButton');
this.teamURLError = container.locator('#teamURLInputError');
this.backLink = container.getByText('Back to previous step');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async fillTeamName(name: string) {
await this.teamNameInput.fill(name);
}
async submitDisplayName() {
await this.teamNameSubmitButton.click();
}
async fillTeamURL(url: string) {
await this.teamURLInput.fill(url);
}
async submitTeamURL() {
await this.teamURLSubmitButton.click();
}
async goBack() {
await this.backLink.click();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class DeleteScheduledPostModal {
readonly container: Locator;
readonly body: Locator;
readonly deleteButton: Locator;
readonly cancelButton: Locator;
readonly closeButton: Locator;
constructor(container: Locator) {
this.container = container;
this.body = container.locator('#confirmModalBody');
this.deleteButton = container.getByRole('button', {name: 'Yes, delete'});
this.cancelButton = container.getByRole('button', {name: 'Cancel'});
this.closeButton = container.getByRole('button', {name: 'Close'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {UserProfile} from '@mattermost/types/users';
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class DirectChannelsModal {
readonly container;
readonly goButton;
readonly results;
readonly searchInput;
readonly memberLimitHelpText;
constructor(container: Locator) {
this.container = container;
this.goButton = container.getByRole('button', {name: 'Go'});
this.results = container.getByTestId('more-modal-list');
this.searchInput = container.getByRole('combobox', {name: 'Search for people'});
this.memberLimitHelpText = container.locator('#multiSelectHelpMemberInfo');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async selectUser(user: UserProfile) {
await this.fillSearchInput(user.username);
// This may fail if there's too many group channels containing the provided user
const row = this.results.getByTestId('direct-message-row').getByText(`@${user.username}`, {exact: false});
await row.click();
await expect(this.getRemoveButton(user.username)).toBeVisible();
}
/**
* Locates the "Remove {username}" button for a selected user.
* @param username
*/
getRemoveButton(username: string): Locator {
return this.container.getByRole('button', {name: `Remove ${username}`});
}
async removeUser(username: string) {
await this.getRemoveButton(username).click();
}
async toHaveNUsersSelected(count: number) {
await expect(this.container.getByRole('button', {name: /^Remove /})).toHaveCount(count);
}
async goToChannel() {
await this.goButton.click();
await expect(this.container).not.toBeAttached();
}
async toHaveNResults(count: number) {
await expect(
this.results.locator('[data-testid="direct-message-row"], [data-testid="group-message-row"]'),
).toHaveCount(count);
}
async fillSearchInput(text: string) {
await this.searchInput.fill(text);
}
async toHaveUserAsNthResult(user: UserProfile, index: number) {
const row = this.results
.locator('[data-testid="direct-message-row"], [data-testid="group-message-row"]')
.nth(index);
await expect(row).toContainText(`@${user.username}`);
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class DraftPost {
readonly container: Locator;
readonly panelHeader;
readonly panelBody;
readonly postBody;
readonly postHeader;
readonly postImage;
readonly deleteButton;
readonly editButton;
readonly scheduleButton;
readonly sendButton;
constructor(container: Locator) {
this.container = container;
this.panelHeader = container.getByTestId('draft-panel-header');
this.panelBody = container.getByTestId('draft-panel-body');
this.postBody = container.getByTestId('draft-post-body');
this.postHeader = container.getByTestId('draft-post-header');
this.postImage = container.getByTestId('draft-post-img');
this.deleteButton = container.locator('#draft_icon-trash-can-outline_delete');
this.editButton = container.locator('#draft_icon-pencil-outline_edit');
this.scheduleButton = container.locator('#draft_icon-clock-send-outline_reschedule');
this.sendButton = container.locator('#draft_icon-send-outline_send');
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async hover() {
await this.container.hover();
}
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
export default class EditChannelHeaderModal {
readonly container: Locator;
readonly input: Locator;
readonly saveButton: Locator;
readonly cancelButton: Locator;
constructor(container: Locator) {
this.container = container;
this.input = container.getByPlaceholder('Enter the Channel Header');
this.saveButton = container.getByRole('button', {name: 'Save'});
this.cancelButton = container.getByRole('button', {name: 'Cancel'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async setHeader(header: string) {
await expect(this.input).toBeVisible();
await this.input.fill(header);
await this.saveButton.click();
await expect(this.container).not.toBeVisible();
}
async cancel() {
await this.cancelButton.click();
await expect(this.container).not.toBeVisible();
}
}

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