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
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:
BIN
e2e-tests/playwright/lib/src/asset/mattermost-icon_128x128.png
Normal file
BIN
e2e-tests/playwright/lib/src/asset/mattermost-icon_128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
144
e2e-tests/playwright/lib/src/autotranslation_helpers.ts
Normal file
144
e2e-tests/playwright/lib/src/autotranslation_helpers.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
136
e2e-tests/playwright/lib/src/browser_context.ts
Normal file
136
e2e-tests/playwright/lib/src/browser_context.ts
Normal 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;
|
||||
}
|
||||
23
e2e-tests/playwright/lib/src/constant.ts
Normal file
23
e2e-tests/playwright/lib/src/constant.ts
Normal 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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
13
e2e-tests/playwright/lib/src/containers/assets/README.md
Normal file
13
e2e-tests/playwright/lib/src/containers/assets/README.md
Normal 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
@@ -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'
|
||||
@@ -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"]
|
||||
@@ -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};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
37
e2e-tests/playwright/lib/src/containers/azurite_container.ts
Normal file
37
e2e-tests/playwright/lib/src/containers/azurite_container.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
77
e2e-tests/playwright/lib/src/containers/constants.ts
Normal file
77
e2e-tests/playwright/lib/src/containers/constants.ts
Normal 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');
|
||||
18
e2e-tests/playwright/lib/src/containers/default_images.ts
Normal file
18
e2e-tests/playwright/lib/src/containers/default_images.ts
Normal 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';
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
27
e2e-tests/playwright/lib/src/containers/env_baseline.ts
Normal file
27
e2e-tests/playwright/lib/src/containers/env_baseline.ts
Normal 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',
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
4
e2e-tests/playwright/lib/src/containers/index.ts
Normal file
4
e2e-tests/playwright/lib/src/containers/index.ts
Normal 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';
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
21
e2e-tests/playwright/lib/src/containers/log.ts
Normal file
21
e2e-tests/playwright/lib/src/containers/log.ts
Normal 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}`);
|
||||
}
|
||||
139
e2e-tests/playwright/lib/src/containers/mattermost_container.ts
Normal file
139
e2e-tests/playwright/lib/src/containers/mattermost_container.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
33
e2e-tests/playwright/lib/src/containers/minio_container.ts
Normal file
33
e2e-tests/playwright/lib/src/containers/minio_container.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
143
e2e-tests/playwright/lib/src/containers/mmctl_container.ts
Normal file
143
e2e-tests/playwright/lib/src/containers/mmctl_container.ts
Normal 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)};
|
||||
}
|
||||
}
|
||||
73
e2e-tests/playwright/lib/src/containers/network.ts
Normal file
73
e2e-tests/playwright/lib/src/containers/network.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
19
e2e-tests/playwright/lib/src/containers/paths.ts
Normal file
19
e2e-tests/playwright/lib/src/containers/paths.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
27
e2e-tests/playwright/lib/src/containers/requirements.ts
Normal file
27
e2e-tests/playwright/lib/src/containers/requirements.ts
Normal 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,
|
||||
};
|
||||
38
e2e-tests/playwright/lib/src/containers/retry.ts
Normal file
38
e2e-tests/playwright/lib/src/containers/retry.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
771
e2e-tests/playwright/lib/src/containers/stack.ts
Normal file
771
e2e-tests/playwright/lib/src/containers/stack.ts
Normal 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);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
38
e2e-tests/playwright/lib/src/containers/webhook_container.ts
Normal file
38
e2e-tests/playwright/lib/src/containers/webhook_container.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
93
e2e-tests/playwright/lib/src/file.ts
Normal file
93
e2e-tests/playwright/lib/src/file.ts
Normal 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);
|
||||
}
|
||||
83
e2e-tests/playwright/lib/src/file_server.ts
Normal file
83
e2e-tests/playwright/lib/src/file_server.ts
Normal 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;
|
||||
}
|
||||
158
e2e-tests/playwright/lib/src/flag.ts
Normal file
158
e2e-tests/playwright/lib/src/flag.ts
Normal 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.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
233
e2e-tests/playwright/lib/src/global_setup.ts
Normal file
233
e2e-tests/playwright/lib/src/global_setup.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
211
e2e-tests/playwright/lib/src/ime.ts
Normal file
211
e2e-tests/playwright/lib/src/ime.ts
Normal 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;
|
||||
}
|
||||
167
e2e-tests/playwright/lib/src/index.ts
Normal file
167
e2e-tests/playwright/lib/src/index.ts
Normal 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';
|
||||
113
e2e-tests/playwright/lib/src/layout_shift.ts
Normal file
113
e2e-tests/playwright/lib/src/layout_shift.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
55
e2e-tests/playwright/lib/src/license_helpers.ts
Normal file
55
e2e-tests/playwright/lib/src/license_helpers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
230
e2e-tests/playwright/lib/src/mock_browser_api.ts
Normal file
230
e2e-tests/playwright/lib/src/mock_browser_api.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
376
e2e-tests/playwright/lib/src/server/abac_helpers.ts
Normal file
376
e2e-tests/playwright/lib/src/server/abac_helpers.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
194
e2e-tests/playwright/lib/src/server/ai_bridge.ts
Normal file
194
e2e-tests/playwright/lib/src/server/ai_bridge.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
94
e2e-tests/playwright/lib/src/server/azurite.ts
Normal file
94
e2e-tests/playwright/lib/src/server/azurite.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
38
e2e-tests/playwright/lib/src/server/channel.ts
Normal file
38
e2e-tests/playwright/lib/src/server/channel.ts
Normal 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;
|
||||
}
|
||||
70
e2e-tests/playwright/lib/src/server/client.ts
Normal file
70
e2e-tests/playwright/lib/src/server/client.ts
Normal 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;
|
||||
};
|
||||
918
e2e-tests/playwright/lib/src/server/default_config.ts
Normal file
918
e2e-tests/playwright/lib/src/server/default_config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
52
e2e-tests/playwright/lib/src/server/elasticsearch.ts
Normal file
52
e2e-tests/playwright/lib/src/server/elasticsearch.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
112
e2e-tests/playwright/lib/src/server/email.ts
Normal file
112
e2e-tests/playwright/lib/src/server/email.ts
Normal 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('&', '&');
|
||||
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;
|
||||
}
|
||||
47
e2e-tests/playwright/lib/src/server/feature_flags.ts
Normal file
47
e2e-tests/playwright/lib/src/server/feature_flags.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
101
e2e-tests/playwright/lib/src/server/filestore.ts
Normal file
101
e2e-tests/playwright/lib/src/server/filestore.ts
Normal 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[];
|
||||
}
|
||||
}
|
||||
69
e2e-tests/playwright/lib/src/server/index.ts
Normal file
69
e2e-tests/playwright/lib/src/server/index.ts
Normal 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';
|
||||
88
e2e-tests/playwright/lib/src/server/init.ts
Normal file
88
e2e-tests/playwright/lib/src/server/init.ts
Normal 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}`;
|
||||
}
|
||||
151
e2e-tests/playwright/lib/src/server/keycloak.ts
Normal file
151
e2e-tests/playwright/lib/src/server/keycloak.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
93
e2e-tests/playwright/lib/src/server/minio.ts
Normal file
93
e2e-tests/playwright/lib/src/server/minio.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
47
e2e-tests/playwright/lib/src/server/mmctl.ts
Normal file
47
e2e-tests/playwright/lib/src/server/mmctl.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
162
e2e-tests/playwright/lib/src/server/openldap.ts
Normal file
162
e2e-tests/playwright/lib/src/server/openldap.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
54
e2e-tests/playwright/lib/src/server/opensearch.ts
Normal file
54
e2e-tests/playwright/lib/src/server/opensearch.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
92
e2e-tests/playwright/lib/src/server/playwright_client.ts
Normal file
92
e2e-tests/playwright/lib/src/server/playwright_client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
74
e2e-tests/playwright/lib/src/server/plugin.ts
Normal file
74
e2e-tests/playwright/lib/src/server/plugin.ts
Normal 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);
|
||||
}
|
||||
33
e2e-tests/playwright/lib/src/server/post.ts
Normal file
33
e2e-tests/playwright/lib/src/server/post.ts
Normal 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');
|
||||
}
|
||||
27
e2e-tests/playwright/lib/src/server/postgres_search.ts
Normal file
27
e2e-tests/playwright/lib/src/server/postgres_search.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
39
e2e-tests/playwright/lib/src/server/team.ts
Normal file
39
e2e-tests/playwright/lib/src/server/team.ts
Normal 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;
|
||||
}
|
||||
121
e2e-tests/playwright/lib/src/server/upgrade_logs.ts
Normal file
121
e2e-tests/playwright/lib/src/server/upgrade_logs.ts
Normal 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;
|
||||
}
|
||||
80
e2e-tests/playwright/lib/src/server/user.ts
Normal file
80
e2e-tests/playwright/lib/src/server/user.ts
Normal 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
|
||||
);
|
||||
}
|
||||
74
e2e-tests/playwright/lib/src/server/version.ts
Normal file
74
e2e-tests/playwright/lib/src/server/version.ts
Normal 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.');
|
||||
}
|
||||
52
e2e-tests/playwright/lib/src/server/webhook_server.ts
Normal file
52
e2e-tests/playwright/lib/src/server/webhook_server.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
60
e2e-tests/playwright/lib/src/test_action.ts
Normal file
60
e2e-tests/playwright/lib/src/test_action.ts
Normal 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;
|
||||
}
|
||||
297
e2e-tests/playwright/lib/src/test_config.ts
Normal file
297
e2e-tests/playwright/lib/src/test_config.ts
Normal 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[];
|
||||
}
|
||||
389
e2e-tests/playwright/lib/src/test_fixture.ts
Normal file
389
e2e-tests/playwright/lib/src/test_fixture.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
112
e2e-tests/playwright/lib/src/types.ts
Normal file
112
e2e-tests/playwright/lib/src/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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')) || '';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()) || '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?/});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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 EmojiGifPicker {
|
||||
readonly container: Locator;
|
||||
|
||||
readonly gifTab: Locator;
|
||||
readonly gifSearchInput: Locator;
|
||||
readonly gifPickerItems: Locator;
|
||||
readonly emojiSearchInput: Locator;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
|
||||
this.gifTab = container.getByText('GIFs');
|
||||
this.gifSearchInput = container.getByPlaceholder('Search GIPHY');
|
||||
this.gifPickerItems = container.getByTestId('gif-picker-items');
|
||||
this.emojiSearchInput = container.getByPlaceholder('Search emojis');
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
await expect(this.container).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Types into the emoji picker search field to filter emojis.
|
||||
*/
|
||||
async searchEmoji(text: string) {
|
||||
await expect(this.emojiSearchInput).toBeVisible();
|
||||
await this.emojiSearchInput.fill(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the picker button for an emoji by its name, e.g. "taxi".
|
||||
*/
|
||||
getEmoji(emojiName: string) {
|
||||
return this.container.getByRole('button', {name: `${emojiName} emoji`, exact: true});
|
||||
}
|
||||
|
||||
async notToBeVisible() {
|
||||
await expect(this.container).not.toBeVisible();
|
||||
}
|
||||
|
||||
async clickEmoji(emojiName: string) {
|
||||
await this.getEmoji(emojiName).click();
|
||||
}
|
||||
|
||||
async openGifTab() {
|
||||
await expect(this.gifTab).toBeVisible();
|
||||
|
||||
await this.gifTab.click({force: true});
|
||||
|
||||
await expect(this.gifSearchInput).toBeVisible();
|
||||
await expect(this.gifPickerItems).toBeVisible();
|
||||
}
|
||||
|
||||
async searchGif(name: string) {
|
||||
await this.gifSearchInput.fill(name);
|
||||
await expect(this.gifSearchInput).toHaveValue(name);
|
||||
}
|
||||
|
||||
async getNthGif(n: number) {
|
||||
await expect(this.gifPickerItems).toBeVisible();
|
||||
|
||||
await this.gifPickerItems.getByRole('img').nth(n).waitFor();
|
||||
const nthGif = this.gifPickerItems.getByRole('img').nth(n);
|
||||
await expect(nthGif).toBeVisible();
|
||||
|
||||
const nthGifSrc = await nthGif.getAttribute('src');
|
||||
const nthGifAlt = await nthGif.getAttribute('alt');
|
||||
|
||||
if (!nthGifSrc || !nthGifAlt) {
|
||||
throw new Error('Gif src or alt is empty');
|
||||
}
|
||||
|
||||
return {
|
||||
src: nthGifSrc,
|
||||
alt: nthGifAlt,
|
||||
img: nthGif,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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 FindChannelsModal {
|
||||
readonly container: Locator;
|
||||
readonly input;
|
||||
readonly searchList;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container;
|
||||
|
||||
this.input = container.getByRole('combobox', {name: 'quick switch input'});
|
||||
this.searchList = container.getByRole('option');
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
await expect(this.container).toBeVisible();
|
||||
}
|
||||
|
||||
getResult(channelName: string) {
|
||||
return this.container.getByTestId(channelName);
|
||||
}
|
||||
|
||||
async selectChannel(channelName: string) {
|
||||
await this.getResult(channelName).click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// 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 default class FlagPostConfirmationDialog {
|
||||
readonly page: Page;
|
||||
readonly container: Locator;
|
||||
|
||||
readonly cancelButton;
|
||||
readonly flagPostReasonInput;
|
||||
readonly flagPostCommentInput;
|
||||
readonly submitButton;
|
||||
readonly postContainer;
|
||||
readonly postText;
|
||||
readonly flagReasonOption;
|
||||
readonly flagReasonMenuItems;
|
||||
readonly cannotFlagPostErrorMessage;
|
||||
readonly requireCommentsErrorMessage;
|
||||
|
||||
constructor(container: Locator, page: Page) {
|
||||
this.container = container;
|
||||
this.page = page;
|
||||
|
||||
this.flagPostReasonInput = container.locator('#FlagPostModal__reason');
|
||||
this.flagPostCommentInput = container.locator('#FlagPostModal__comment');
|
||||
this.cancelButton = container.getByRole('button', {name: 'Cancel'});
|
||||
this.submitButton = container.getByRole('button', {name: 'Submit'});
|
||||
this.postContainer = container.getByTestId('FlagPostModal__post-preview_container');
|
||||
this.postText = container.getByTestId('post-message-text');
|
||||
this.flagReasonOption = page.getByRole('listbox');
|
||||
this.flagReasonMenuItems = (reason: string) => this.flagReasonOption.getByRole('option', {name: reason});
|
||||
this.cannotFlagPostErrorMessage = container.getByTestId('flag-post-request-error-text');
|
||||
this.requireCommentsErrorMessage = container.getByTestId('advanced-textbox-error-text');
|
||||
}
|
||||
|
||||
async fillFlagComment(comment: string) {
|
||||
await this.flagPostCommentInput.fill(comment);
|
||||
}
|
||||
|
||||
async selectFlagReason(reason: string) {
|
||||
// Open the dropdown
|
||||
await this.flagPostReasonInput.click();
|
||||
// Wait for dropdown menu list to appear, then wait for the specific option
|
||||
// to be visible before clicking. The second waitFor guards against a race
|
||||
// where the list renders but the individual options are not yet in the DOM.
|
||||
await this.flagReasonOption.waitFor({state: 'visible'});
|
||||
const menuItem = this.flagReasonMenuItems(reason);
|
||||
await menuItem.waitFor({state: 'visible', timeout: 10000});
|
||||
await menuItem.click();
|
||||
}
|
||||
|
||||
async toBeVisible() {
|
||||
await expect(this.container).toBeVisible();
|
||||
await expect(this.cancelButton).toBeVisible();
|
||||
await expect(this.submitButton).toBeVisible();
|
||||
await expect(this.postContainer).toBeVisible();
|
||||
}
|
||||
|
||||
async toContainPostText(message: string) {
|
||||
await expect(this.postText).toBeVisible();
|
||||
await expect(this.postText).toHaveText(message);
|
||||
}
|
||||
|
||||
async notToBeVisible() {
|
||||
await expect(this.container).not.toBeVisible({timeout: 10000});
|
||||
await expect(this.cancelButton).not.toBeVisible({timeout: 10000});
|
||||
await expect(this.submitButton).not.toBeVisible({timeout: 10000});
|
||||
}
|
||||
|
||||
async cannotFlagAlreadyFlaggedPostToBeVisible() {
|
||||
await expect(this.cannotFlagPostErrorMessage).toBeVisible();
|
||||
await expect(this.cannotFlagPostErrorMessage).toHaveText(
|
||||
'Cannot quarantine this post as it is already quarantined for review.',
|
||||
);
|
||||
}
|
||||
|
||||
async requireCommentsForFlaggingPost() {
|
||||
await expect(this.requireCommentsErrorMessage).toBeVisible();
|
||||
await expect(this.requireCommentsErrorMessage).toHaveText(
|
||||
'Please add a comment explaining why you’re quarantining this message.',
|
||||
);
|
||||
}
|
||||
|
||||
async cannotFlagPreviouslyRetainedPostToBeVisible() {
|
||||
await expect(this.cannotFlagPostErrorMessage).toBeVisible();
|
||||
await expect(this.cannotFlagPostErrorMessage).toHaveText(
|
||||
'Cannot quarantine this post as it was retained in a previous review.',
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user