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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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