snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Copied from server/build/Dockerfile.elasticsearch on 2026-07-19.
|
||||
ARG ELASTICSEARCH_VERSION=9.0.0
|
||||
FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION}
|
||||
|
||||
RUN /usr/share/elasticsearch/bin/elasticsearch-plugin install --batch analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copied from server/build/Dockerfile.opensearch on 2026-07-19.
|
||||
ARG OPENSEARCH_VERSION=3.0.0
|
||||
FROM opensearchproject/opensearch:$OPENSEARCH_VERSION
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
13
e2e-tests/playwright/lib/src/containers/assets/README.md
Normal file
13
e2e-tests/playwright/lib/src/containers/assets/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Vendored assets
|
||||
|
||||
These files are copied from `server/build/` in the Mattermost monorepo so that
|
||||
`@mattermost/playwright-lib`'s Testcontainers support is self-contained once published to npm
|
||||
(it can't reach outside its own package at runtime). If the source files change, these copies
|
||||
need to be refreshed manually — they are not symlinked or build-generated.
|
||||
|
||||
| File | Copied from | Date |
|
||||
| ---------------------------- | ------------------------------------------------ | ---------- |
|
||||
| `postgres.conf` | `server/build/docker/postgres.conf` | 2026-07-19 |
|
||||
| `keycloak-realm-export.json` | `server/build/docker/keycloak/realm-export.json` | 2026-07-19 |
|
||||
| `Dockerfile.elasticsearch` | `server/build/Dockerfile.elasticsearch` | 2026-07-19 |
|
||||
| `Dockerfile.opensearch` | `server/build/Dockerfile.opensearch` | 2026-07-19 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
# Copied from server/build/docker/postgres.conf on 2026-07-19.
|
||||
max_connections = 500
|
||||
listen_addresses = '*'
|
||||
fsync = off
|
||||
full_page_writes = off
|
||||
default_text_search_config = 'pg_catalog.english'
|
||||
commit_delay=1000
|
||||
logging_collector=off
|
||||
password_encryption = 'scram-sha-256'
|
||||
@@ -0,0 +1,20 @@
|
||||
# webhook_serve.js, utils/webhook_utils.js, and tests/plugins/post_message_as.js are vendored
|
||||
# copies of e2e-tests/cypress's files of the same name (shared sidecar, also used by Cypress) —
|
||||
# copied on 2026-07-20. Dependencies match e2e-tests/.ci/server.generate.sh's "playwright" Compose
|
||||
# service, just installed at build time instead of on every container start.
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /webhook
|
||||
|
||||
RUN npm init -y >/dev/null && \
|
||||
npm install express@5.1.0 axios@1.11.0 client-oauth2@github:larkox/js-client-oauth2#e24e2eb5dfcbbbb3a59d095e831dbe0012b0ac49
|
||||
|
||||
COPY webhook_serve.js ./webhook_serve.js
|
||||
COPY utils/ ./utils/
|
||||
COPY tests/plugins/post_message_as.js ./tests/plugins/post_message_as.js
|
||||
|
||||
RUN chown -R node:node /webhook
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "webhook_serve.js"]
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
module.exports = async ({sender, message, channelId, rootId, createAt = 0, baseUrl}) => {
|
||||
const loginResponse = await axios({
|
||||
url: `${baseUrl}/api/v4/users/login`,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
method: 'post',
|
||||
data: {login_id: sender.username, password: sender.password},
|
||||
});
|
||||
|
||||
const setCookie = loginResponse.headers['set-cookie'];
|
||||
let cookieString = '';
|
||||
setCookie.forEach((cookie) => {
|
||||
const nameAndValue = cookie.split(';')[0];
|
||||
cookieString += nameAndValue + ';';
|
||||
});
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await axios({
|
||||
url: `${baseUrl}/api/v4/posts`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Cookie: cookieString,
|
||||
},
|
||||
method: 'post',
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
message,
|
||||
type: '',
|
||||
create_at: createAt,
|
||||
root_id: rootId,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
expect(Boolean(err)).to.equal(false);
|
||||
}
|
||||
|
||||
return {status: response.status, data: response.data};
|
||||
};
|
||||
@@ -0,0 +1,843 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Helper function to create dialog base structure
|
||||
function createDialog(triggerId, webhookBaseUrl, dialogConfig) {
|
||||
const baseDialog = {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: dialogConfig.callback_id,
|
||||
title: dialogConfig.title,
|
||||
submit_label: dialogConfig.submit_label || 'Submit',
|
||||
notify_on_cancel: true,
|
||||
...dialogConfig.dialog_props,
|
||||
elements: dialogConfig.elements || [],
|
||||
},
|
||||
};
|
||||
|
||||
if (dialogConfig.icon_url) {
|
||||
baseDialog.dialog.icon_url = dialogConfig.icon_url;
|
||||
}
|
||||
|
||||
if (dialogConfig.introduction_text) {
|
||||
baseDialog.dialog.introduction_text = dialogConfig.introduction_text;
|
||||
}
|
||||
|
||||
if (dialogConfig.state) {
|
||||
baseDialog.dialog.state = dialogConfig.state;
|
||||
}
|
||||
|
||||
if (dialogConfig.source_url) {
|
||||
baseDialog.dialog.source_url = dialogConfig.source_url;
|
||||
}
|
||||
|
||||
return baseDialog;
|
||||
}
|
||||
|
||||
// Helper function to create form response structure
|
||||
function createFormResponse(formConfig) {
|
||||
return {
|
||||
callback_id: formConfig.callback_id,
|
||||
title: formConfig.title,
|
||||
submit_label: formConfig.submit_label || 'Submit',
|
||||
notify_on_cancel: true,
|
||||
elements: formConfig.elements || [],
|
||||
...formConfig.form_props,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create common form elements
|
||||
function createElement(type, config) {
|
||||
const baseElement = {
|
||||
display_name: config.display_name,
|
||||
name: config.name,
|
||||
type,
|
||||
optional: config.optional || false,
|
||||
};
|
||||
|
||||
if (config.placeholder) {
|
||||
baseElement.placeholder = config.placeholder;
|
||||
}
|
||||
if (config.help_text) {
|
||||
baseElement.help_text = config.help_text;
|
||||
}
|
||||
if (config.default) {
|
||||
baseElement.default = config.default;
|
||||
}
|
||||
if (config.subtype) {
|
||||
baseElement.subtype = config.subtype;
|
||||
}
|
||||
if (config.min_length) {
|
||||
baseElement.min_length = config.min_length;
|
||||
}
|
||||
if (config.max_length) {
|
||||
baseElement.max_length = config.max_length;
|
||||
}
|
||||
if (config.data_source) {
|
||||
baseElement.data_source = config.data_source;
|
||||
}
|
||||
if (config.options) {
|
||||
baseElement.options = config.options;
|
||||
}
|
||||
if (config.refresh) {
|
||||
baseElement.refresh = config.refresh;
|
||||
}
|
||||
if (config.action_button) {
|
||||
baseElement.action_button = config.action_button;
|
||||
}
|
||||
|
||||
return baseElement;
|
||||
}
|
||||
|
||||
// Standard icon URL
|
||||
const STANDARD_ICON = 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png';
|
||||
|
||||
// Dialog configurations
|
||||
const DIALOG_CONFIGS = {
|
||||
full: {
|
||||
callback_id: 'somecallbackid',
|
||||
title: 'Title for Full Dialog Test',
|
||||
icon_url: STANDARD_ICON,
|
||||
elements: [
|
||||
createElement('text', {
|
||||
display_name: 'Display Name',
|
||||
name: 'realname',
|
||||
default: 'default text',
|
||||
placeholder: 'placeholder',
|
||||
help_text: 'This a regular input in an interactive dialog triggered by a test integration.',
|
||||
}),
|
||||
createElement('text', {
|
||||
display_name: 'Email',
|
||||
name: 'someemail',
|
||||
subtype: 'email',
|
||||
placeholder: 'placeholder@bladekick.com',
|
||||
help_text: 'This a regular email input in an interactive dialog triggered by a test integration.',
|
||||
}),
|
||||
createElement('text', {display_name: 'Number', name: 'somenumber', subtype: 'number'}),
|
||||
createElement('text', {
|
||||
display_name: 'Password',
|
||||
name: 'somepassword',
|
||||
subtype: 'password',
|
||||
default: 'p@ssW0rd',
|
||||
placeholder: 'placeholder',
|
||||
help_text: 'This a password input in an interactive dialog triggered by a test integration.',
|
||||
optional: true,
|
||||
}),
|
||||
createElement('textarea', {
|
||||
display_name: 'Display Name Long Text Area',
|
||||
name: 'realnametextarea',
|
||||
placeholder: 'placeholder',
|
||||
optional: true,
|
||||
min_length: 5,
|
||||
max_length: 100,
|
||||
}),
|
||||
createElement('select', {
|
||||
display_name: 'User Selector',
|
||||
name: 'someuserselector',
|
||||
placeholder: 'Select a user...',
|
||||
data_source: 'users',
|
||||
}),
|
||||
createElement('select', {
|
||||
display_name: 'Channel Selector',
|
||||
name: 'somechannelselector',
|
||||
placeholder: 'Select a channel...',
|
||||
help_text: 'Choose a channel from the list.',
|
||||
data_source: 'channels',
|
||||
optional: true,
|
||||
}),
|
||||
createElement('select', {
|
||||
display_name: 'Option Selector',
|
||||
name: 'someoptionselector',
|
||||
placeholder: 'Select an option...',
|
||||
options: [
|
||||
{text: 'Option1', value: 'opt1'},
|
||||
{text: 'Option2', value: 'opt2'},
|
||||
{text: 'Option3', value: 'opt3'},
|
||||
],
|
||||
}),
|
||||
createElement('radio', {
|
||||
display_name: 'Radio Option Selector',
|
||||
name: 'someradiooptions',
|
||||
help_text: '',
|
||||
options: [
|
||||
{text: 'Engineering', value: 'engineering'},
|
||||
{text: 'Sales', value: 'sales'},
|
||||
],
|
||||
}),
|
||||
createElement('bool', {
|
||||
display_name: 'Boolean Selector',
|
||||
name: 'boolean_input',
|
||||
placeholder: 'Was this modal helpful?',
|
||||
default: 'True',
|
||||
optional: true,
|
||||
help_text: 'This is the help text',
|
||||
}),
|
||||
],
|
||||
dialog_props: {state: 'somestate'},
|
||||
},
|
||||
|
||||
simple: {
|
||||
callback_id: 'somecallbackid',
|
||||
title: 'Title for Dialog Test without elements',
|
||||
icon_url: STANDARD_ICON,
|
||||
submit_label: 'Submit Test',
|
||||
dialog_props: {state: 'somestate'},
|
||||
},
|
||||
|
||||
userAndChannel: {
|
||||
callback_id: 'somecallbackid',
|
||||
title: 'Title for Dialog Test with user and channel element',
|
||||
icon_url: STANDARD_ICON,
|
||||
submit_label: 'Submit Test',
|
||||
elements: [
|
||||
createElement('select', {
|
||||
display_name: 'User Selector',
|
||||
name: 'someuserselector',
|
||||
placeholder: 'Select a user...',
|
||||
data_source: 'users',
|
||||
}),
|
||||
createElement('select', {
|
||||
display_name: 'Channel Selector',
|
||||
name: 'somechannelselector',
|
||||
placeholder: 'Select a channel...',
|
||||
help_text: 'Choose a channel from the list.',
|
||||
data_source: 'channels',
|
||||
optional: true,
|
||||
}),
|
||||
],
|
||||
dialog_props: {state: 'somestate'},
|
||||
},
|
||||
|
||||
boolean: {
|
||||
callback_id: 'somecallbackid',
|
||||
title: 'Title for Dialog Test with boolean element',
|
||||
icon_url: STANDARD_ICON,
|
||||
submit_label: 'Submit Test',
|
||||
elements: [
|
||||
createElement('bool', {
|
||||
display_name: 'Boolean Selector',
|
||||
name: 'boolean_input',
|
||||
placeholder: 'Was this modal helpful?',
|
||||
default: 'True',
|
||||
optional: true,
|
||||
help_text: 'This is the help text',
|
||||
}),
|
||||
],
|
||||
dialog_props: {state: 'somestate'},
|
||||
},
|
||||
|
||||
fieldRefresh: {
|
||||
callback_id: 'field_refresh_callback',
|
||||
title: 'Field Refresh Demo',
|
||||
introduction_text: 'Enter project name then select type to see different fields',
|
||||
elements: [
|
||||
createElement('text', {
|
||||
display_name: 'Project Name',
|
||||
name: 'project_name',
|
||||
placeholder: 'Enter project name',
|
||||
}),
|
||||
createElement('select', {
|
||||
display_name: 'Project Type',
|
||||
name: 'project_type',
|
||||
refresh: true,
|
||||
placeholder: 'Select project type...',
|
||||
options: [
|
||||
{text: 'Web Application', value: 'web'},
|
||||
{text: 'Mobile App', value: 'mobile'},
|
||||
{text: 'API Service', value: 'api'},
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
multistepStep1: {
|
||||
callback_id: 'multistep_callback',
|
||||
title: 'Step 1 - Personal Info',
|
||||
introduction_text: 'Multi-step registration - Step 1 of 3',
|
||||
submit_label: 'Next Step',
|
||||
elements: [
|
||||
createElement('text', {
|
||||
display_name: 'First Name',
|
||||
name: 'first_name',
|
||||
placeholder: 'Enter your first name',
|
||||
}),
|
||||
createElement('text', {
|
||||
display_name: 'Email',
|
||||
name: 'email',
|
||||
subtype: 'email',
|
||||
placeholder: 'Enter your email address',
|
||||
}),
|
||||
],
|
||||
dialog_props: {state: 'step1'},
|
||||
},
|
||||
|
||||
multistepStep2: {
|
||||
callback_id: 'multistep_callback',
|
||||
title: 'Step 2 - Work Info',
|
||||
introduction_text: 'Multi-step registration - Step 2 of 3',
|
||||
submit_label: 'Next Step',
|
||||
elements: [
|
||||
createElement('select', {
|
||||
display_name: 'Department',
|
||||
name: 'department',
|
||||
placeholder: 'Select department...',
|
||||
options: [
|
||||
{text: 'Engineering', value: 'engineering'},
|
||||
{text: 'Marketing', value: 'marketing'},
|
||||
{text: 'Sales', value: 'sales'},
|
||||
],
|
||||
}),
|
||||
createElement('radio', {
|
||||
display_name: 'Experience Level',
|
||||
name: 'experience_level',
|
||||
options: [
|
||||
{text: 'Junior', value: 'junior'},
|
||||
{text: 'Mid-level', value: 'mid'},
|
||||
{text: 'Senior', value: 'senior'},
|
||||
],
|
||||
}),
|
||||
],
|
||||
form_props: {state: 'step2'},
|
||||
},
|
||||
|
||||
multistepStep3: {
|
||||
callback_id: 'multistep_callback',
|
||||
title: 'Step 3 - Final Details',
|
||||
introduction_text: 'Multi-step registration - Step 3 of 3',
|
||||
submit_label: 'Complete Registration',
|
||||
elements: [
|
||||
createElement('textarea', {
|
||||
display_name: 'Comments',
|
||||
name: 'comments',
|
||||
placeholder: 'Any additional comments...',
|
||||
optional: true,
|
||||
}),
|
||||
createElement('bool', {display_name: 'Terms & Conditions', name: 'terms_accepted'}),
|
||||
],
|
||||
form_props: {state: 'step3'},
|
||||
},
|
||||
|
||||
actionButtonParent: {
|
||||
callback_id: 'action_button_parent_callback',
|
||||
title: 'Parent Dialog with Action Button',
|
||||
elements: [],
|
||||
},
|
||||
|
||||
actionButtonChild: {
|
||||
callback_id: 'child_callback',
|
||||
title: 'Child Dialog',
|
||||
elements: [
|
||||
createElement('text', {
|
||||
display_name: 'Child Input',
|
||||
name: 'child_input',
|
||||
placeholder: 'Enter value',
|
||||
optional: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Public API functions
|
||||
function getFullDialog(triggerId, webhookBaseUrl) {
|
||||
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.full);
|
||||
}
|
||||
|
||||
function getSimpleDialog(triggerId, webhookBaseUrl) {
|
||||
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.simple);
|
||||
}
|
||||
|
||||
function getUserAndChannelDialog(triggerId, webhookBaseUrl) {
|
||||
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.userAndChannel);
|
||||
}
|
||||
|
||||
function getBooleanDialog(triggerId, webhookBaseUrl) {
|
||||
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.boolean);
|
||||
}
|
||||
|
||||
function getFieldRefreshDialog(triggerId, webhookBaseUrl) {
|
||||
const config = {...DIALOG_CONFIGS.fieldRefresh};
|
||||
config.source_url = `${webhookBaseUrl}/field_refresh_source`;
|
||||
return createDialog(triggerId, webhookBaseUrl, config);
|
||||
}
|
||||
|
||||
function getMultistepStep1Dialog(triggerId, webhookBaseUrl) {
|
||||
return createDialog(triggerId, webhookBaseUrl, DIALOG_CONFIGS.multistepStep1);
|
||||
}
|
||||
|
||||
function getMultistepStep2Dialog(triggerId, webhookBaseUrl) {
|
||||
const config = {...DIALOG_CONFIGS.multistepStep2};
|
||||
config.dialog_props = {url: `${webhookBaseUrl}/dialog_submit`, ...config.form_props};
|
||||
return createFormResponse(config);
|
||||
}
|
||||
|
||||
function getMultistepStep3Dialog(triggerId, webhookBaseUrl) {
|
||||
const config = {...DIALOG_CONFIGS.multistepStep3};
|
||||
config.dialog_props = {url: `${webhookBaseUrl}/dialog_submit`, ...config.form_props};
|
||||
return createFormResponse(config);
|
||||
}
|
||||
|
||||
function getMultiSelectDialog(triggerId, webhookBaseUrl, includeDefaults = false) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'somecallbackid',
|
||||
title: 'Title for Dialog Test with multiselect elements',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
submit_label: 'Submit Multiselect Test',
|
||||
notify_on_cancel: true,
|
||||
state: 'somestate',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Multi Option Selector',
|
||||
name: 'multiselect_options',
|
||||
type: 'select',
|
||||
multiselect: true,
|
||||
default: includeDefaults ? 'opt1,opt3' : '',
|
||||
placeholder: 'Select multiple options...',
|
||||
help_text: 'You can select multiple options from this list.',
|
||||
optional: false,
|
||||
min_length: 0,
|
||||
max_length: 0,
|
||||
data_source: '',
|
||||
options: [
|
||||
{
|
||||
text: 'Engineering',
|
||||
value: 'opt1',
|
||||
},
|
||||
{
|
||||
text: 'Sales',
|
||||
value: 'opt2',
|
||||
},
|
||||
{
|
||||
text: 'Marketing',
|
||||
value: 'opt3',
|
||||
},
|
||||
{
|
||||
text: 'Support',
|
||||
value: 'opt4',
|
||||
},
|
||||
{
|
||||
text: 'Product',
|
||||
value: 'opt5',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Multi User Selector',
|
||||
name: 'multiselect_users',
|
||||
type: 'select',
|
||||
multiselect: true,
|
||||
default: '',
|
||||
placeholder: 'Select multiple users...',
|
||||
help_text: 'Choose multiple users from the team.',
|
||||
optional: false,
|
||||
min_length: 0,
|
||||
max_length: 0,
|
||||
data_source: 'users',
|
||||
options: null,
|
||||
},
|
||||
{
|
||||
display_name: 'Single Option Selector',
|
||||
name: 'single_select_options',
|
||||
type: 'select',
|
||||
multiselect: false,
|
||||
default: includeDefaults ? 'single2' : '',
|
||||
placeholder: 'Select one option...',
|
||||
help_text: 'This is a regular single-select for comparison.',
|
||||
optional: false,
|
||||
min_length: 0,
|
||||
max_length: 0,
|
||||
data_source: '',
|
||||
options: [
|
||||
{
|
||||
text: 'Single Option 1',
|
||||
value: 'single1',
|
||||
},
|
||||
{
|
||||
text: 'Single Option 2',
|
||||
value: 'single2',
|
||||
},
|
||||
{
|
||||
text: 'Single Option 3',
|
||||
value: 'single3',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getDynamicSelectDialog(triggerId, webhookBaseUrl) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'somecallbackid',
|
||||
title: 'Title for Dialog Test with dynamic select element',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
submit_label: 'Submit Dynamic Select Test',
|
||||
notify_on_cancel: true,
|
||||
state: 'somestate',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Dynamic Role Selector',
|
||||
name: 'dynamic_role_selector',
|
||||
type: 'select',
|
||||
data_source: 'dynamic',
|
||||
data_source_url: `${webhookBaseUrl}/dynamic_select_source`,
|
||||
default: '',
|
||||
placeholder: 'Search for a role...',
|
||||
help_text: 'Start typing to search for available roles. Options are loaded dynamically.',
|
||||
optional: false,
|
||||
min_length: 0,
|
||||
max_length: 0,
|
||||
},
|
||||
{
|
||||
display_name: 'Optional Dynamic Selector',
|
||||
name: 'optional_dynamic_selector',
|
||||
type: 'select',
|
||||
data_source: 'dynamic',
|
||||
data_source_url: `${webhookBaseUrl}/dynamic_select_source`,
|
||||
default: 'backend_eng',
|
||||
placeholder: 'Search for another role...',
|
||||
help_text: 'This field is optional and has a default value.',
|
||||
optional: true,
|
||||
min_length: 0,
|
||||
max_length: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Basic date field test - MM-T2530A
|
||||
function getBasicDateDialog(triggerId, webhookBaseUrl) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/datetime_dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'basic_date_callback',
|
||||
title: 'DateTime Fields Test',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Event Date',
|
||||
name: 'event_date',
|
||||
type: 'date',
|
||||
default: '',
|
||||
placeholder: 'Select a date',
|
||||
help_text: 'Select the date for your event',
|
||||
optional: false,
|
||||
},
|
||||
],
|
||||
submit_label: 'Submit',
|
||||
notify_on_cancel: true,
|
||||
state: 'datetime_state',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Basic datetime field test - MM-T2530B
|
||||
function getBasicDateTimeDialog(triggerId, webhookBaseUrl) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/datetime_dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'basic_datetime_callback',
|
||||
title: 'DateTime Fields Test',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Event Date',
|
||||
name: 'event_date',
|
||||
type: 'date',
|
||||
default: '',
|
||||
placeholder: 'Select a date',
|
||||
help_text: 'Select the date for your event',
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
display_name: 'Meeting Time',
|
||||
name: 'meeting_time',
|
||||
type: 'datetime',
|
||||
default: '',
|
||||
placeholder: 'Select date and time',
|
||||
help_text: 'Select the date and time for your meeting',
|
||||
optional: false,
|
||||
datetime_config: {
|
||||
time_interval: 60,
|
||||
},
|
||||
},
|
||||
],
|
||||
submit_label: 'Submit',
|
||||
notify_on_cancel: true,
|
||||
state: 'datetime_state',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Date field with min_date constraint - MM-T2530C
|
||||
function getMinDateConstraintDialog(triggerId, webhookBaseUrl) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/datetime_dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'mindate_callback',
|
||||
title: 'DateTime Fields Test',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Future Date Only',
|
||||
name: 'future_date',
|
||||
type: 'date',
|
||||
default: '',
|
||||
placeholder: 'Select a future date',
|
||||
help_text: 'Must be today or later',
|
||||
optional: true,
|
||||
datetime_config: {
|
||||
min_date: 'today',
|
||||
},
|
||||
},
|
||||
],
|
||||
submit_label: 'Submit',
|
||||
notify_on_cancel: true,
|
||||
state: 'datetime_state',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// DateTime field with custom time interval - MM-T2530D
|
||||
function getCustomIntervalDialog(triggerId, webhookBaseUrl) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/datetime_dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'interval_callback',
|
||||
title: 'DateTime Fields Test',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Custom Interval Time',
|
||||
name: 'interval_time',
|
||||
type: 'datetime',
|
||||
default: '',
|
||||
placeholder: 'Select time (30min intervals)',
|
||||
help_text: 'Time picker with 30-minute intervals',
|
||||
optional: true,
|
||||
datetime_config: {
|
||||
time_interval: 30,
|
||||
},
|
||||
},
|
||||
],
|
||||
submit_label: 'Submit',
|
||||
notify_on_cancel: true,
|
||||
state: 'datetime_state',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Relative date values test - MM-T2530F
|
||||
function getRelativeDateDialog(triggerId, webhookBaseUrl) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/datetime_dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'relative_callback',
|
||||
title: 'DateTime Fields Test',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Relative Date Example',
|
||||
name: 'relative_date',
|
||||
type: 'date',
|
||||
default: 'today',
|
||||
placeholder: 'Today by default',
|
||||
help_text: 'Defaults to today using relative date',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
display_name: 'Relative DateTime Example',
|
||||
name: 'relative_datetime',
|
||||
type: 'datetime',
|
||||
default: '+1d',
|
||||
placeholder: 'Tomorrow by default',
|
||||
help_text: 'Defaults to tomorrow using relative date',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
submit_label: 'Submit',
|
||||
notify_on_cancel: true,
|
||||
state: 'datetime_state',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy function for backward compatibility - returns basic datetime dialog
|
||||
function getDateTimeDialog(triggerId, webhookBaseUrl) {
|
||||
return getBasicDateTimeDialog(triggerId, webhookBaseUrl);
|
||||
}
|
||||
|
||||
function getTimezoneManualDialog(triggerId, webhookBaseUrl) {
|
||||
return createDialog(triggerId, webhookBaseUrl, {
|
||||
callback_id: 'timezone_manual',
|
||||
title: 'Timezone & Manual Entry Demo',
|
||||
introduction_text:
|
||||
'**Timezone & Manual Entry Demo**\n\n' +
|
||||
'This dialog demonstrates timezone support and manual time entry features.',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Your Local Time (Manual Entry)',
|
||||
name: 'local_manual',
|
||||
type: 'datetime',
|
||||
help_text: 'Type any time: 9am, 14:30, 3:45pm - no rounding',
|
||||
datetime_config: {
|
||||
manual_time_entry: true,
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
display_name: 'London Office Hours (Dropdown)',
|
||||
name: 'london_dropdown',
|
||||
type: 'datetime',
|
||||
help_text: 'Times shown in GMT - select from 60 min intervals',
|
||||
datetime_config: {
|
||||
location_timezone: 'Europe/London',
|
||||
time_interval: 60,
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
display_name: 'London Office Hours (Manual Entry)',
|
||||
name: 'london_manual',
|
||||
type: 'datetime',
|
||||
help_text: 'Type time in GMT: 9am, 14:30, 3:45pm - no rounding',
|
||||
datetime_config: {
|
||||
location_timezone: 'Europe/London',
|
||||
manual_time_entry: true,
|
||||
},
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function getFileUploadDialog(triggerId, webhookBaseUrl) {
|
||||
return {
|
||||
trigger_id: triggerId,
|
||||
url: `${webhookBaseUrl}/dialog_submit`,
|
||||
dialog: {
|
||||
callback_id: 'somecallbackid',
|
||||
title: 'Title for Dialog Test with file upload element',
|
||||
icon_url: 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png',
|
||||
submit_label: 'Submit File Upload Test',
|
||||
notify_on_cancel: true,
|
||||
state: 'somestate',
|
||||
elements: [
|
||||
{
|
||||
display_name: 'Upload Single Document',
|
||||
name: 'single_document',
|
||||
type: 'file',
|
||||
placeholder: 'Select one document...',
|
||||
help_text: 'Upload a single document (replaces previous selection).',
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
display_name: 'Upload Multiple Files',
|
||||
name: 'multiple_files',
|
||||
type: 'file',
|
||||
allow_multiple: true,
|
||||
placeholder: 'Select multiple files...',
|
||||
help_text: 'Upload multiple files (can select and add more).',
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
display_name: 'Description',
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
subtype: '',
|
||||
default: '',
|
||||
placeholder: 'Describe the uploaded files...',
|
||||
help_text: 'Provide a description for the uploaded files.',
|
||||
optional: true,
|
||||
min_length: 0,
|
||||
max_length: 500,
|
||||
data_source: '',
|
||||
options: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getActionButtonParentDialog(triggerId, webhookBaseUrl) {
|
||||
const config = {
|
||||
...DIALOG_CONFIGS.actionButtonParent,
|
||||
elements: [
|
||||
createElement('text', {
|
||||
display_name: 'Your Name',
|
||||
name: 'your_name',
|
||||
placeholder: 'Enter your name',
|
||||
optional: true,
|
||||
}),
|
||||
|
||||
// Two action buttons on the same dialog. Each carries a distinct
|
||||
// context.source so the child dialog can reflect which one was pressed.
|
||||
createElement('action_button', {
|
||||
display_name: 'Open Details',
|
||||
name: 'open_details',
|
||||
action_button: {
|
||||
url: `${webhookBaseUrl}/dialog/open_child`,
|
||||
context: {source: 'Details'},
|
||||
},
|
||||
}),
|
||||
createElement('action_button', {
|
||||
display_name: 'Open Summary',
|
||||
name: 'open_summary',
|
||||
action_button: {
|
||||
url: `${webhookBaseUrl}/dialog/open_child`,
|
||||
context: {source: 'Summary'},
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
return createDialog(triggerId, webhookBaseUrl, config);
|
||||
}
|
||||
|
||||
// `source` comes from the pressed action button's context.source and is reflected
|
||||
// in the child dialog's title and introduction text, so a test can verify which
|
||||
// button opened it.
|
||||
function getActionButtonChildDialog(triggerId, webhookBaseUrl, source) {
|
||||
const label = source || 'Unknown';
|
||||
const config = {
|
||||
...DIALOG_CONFIGS.actionButtonChild,
|
||||
title: `${label} Dialog`,
|
||||
introduction_text: `This child dialog was opened from the "${label}" action button.`,
|
||||
};
|
||||
return createDialog(triggerId, webhookBaseUrl, config);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getFullDialog,
|
||||
getSimpleDialog,
|
||||
getUserAndChannelDialog,
|
||||
getBooleanDialog,
|
||||
getFieldRefreshDialog,
|
||||
getMultistepStep1Dialog,
|
||||
getMultistepStep2Dialog,
|
||||
getMultistepStep3Dialog,
|
||||
getMultiSelectDialog,
|
||||
getDynamicSelectDialog,
|
||||
getDateTimeDialog,
|
||||
getBasicDateDialog,
|
||||
getBasicDateTimeDialog,
|
||||
getMinDateConstraintDialog,
|
||||
getCustomIntervalDialog,
|
||||
getRelativeDateDialog,
|
||||
getTimezoneManualDialog,
|
||||
getFileUploadDialog,
|
||||
getActionButtonParentDialog,
|
||||
getActionButtonChildDialog,
|
||||
};
|
||||
@@ -0,0 +1,770 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const ClientOAuth2 = require('client-oauth2');
|
||||
|
||||
const webhookUtils = require('./utils/webhook_utils');
|
||||
const postMessageAs = require('./tests/plugins/post_message_as');
|
||||
|
||||
const port = 3000;
|
||||
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
server.use(express.urlencoded({extended: true}));
|
||||
|
||||
process.title = process.argv[2];
|
||||
|
||||
server.get('/', ping);
|
||||
server.post('/setup', doSetup);
|
||||
server.post('/message_menus', postMessageMenus);
|
||||
server.post('/dialog_request', onDialogRequest);
|
||||
server.post('/simple_dialog_request', onSimpleDialogRequest);
|
||||
server.post('/user_and_channel_dialog_request', onUserAndChannelDialogRequest);
|
||||
server.post('/dialog_submit', onDialogSubmit);
|
||||
server.post('/boolean_dialog_request', onBooleanDialogRequest);
|
||||
server.post('/multiselect_dialog_request', onMultiSelectDialogRequest);
|
||||
server.post('/dynamic_select_dialog_request', onDynamicSelectDialogRequest);
|
||||
server.post('/file_upload_dialog_request', onFileUploadDialogRequest);
|
||||
server.post('/dynamic_select_source', onDynamicSelectSource);
|
||||
server.post('/dialog/field-refresh', onFieldRefreshDialogRequest);
|
||||
server.post('/dialog/multistep', onMultistepDialogRequest);
|
||||
server.post('/dialog/action_button_request', onActionButtonDialogRequest);
|
||||
server.post('/dialog/open_child', onOpenChildDialog);
|
||||
server.post('/field_refresh_source', onFieldRefreshSource);
|
||||
server.post('/datetime_dialog_request', onDateTimeDialogRequest);
|
||||
server.post('/datetime_dialog_submit', onDateTimeDialogSubmit);
|
||||
server.post('/slack_compatible_message_response', postSlackCompatibleMessageResponse);
|
||||
server.post('/mm_blocks_integration', postMmBlocksIntegration);
|
||||
server.post('/mm_blocks_integration_update', postMmBlocksIntegrationUpdate);
|
||||
server.post('/mm_blocks_integration_static_select', postMmBlocksIntegrationStaticSelect);
|
||||
server.post('/mm_blocks_integration_echo_query', postMmBlocksIntegrationEchoQuery);
|
||||
server.post('/mm_blocks_integration_echo_context', postMmBlocksIntegrationEchoContext);
|
||||
server.post('/send_message_to_channel', postSendMessageToChannel);
|
||||
server.post('/post_outgoing_webhook', postOutgoingWebhook);
|
||||
server.post('/send_oauth_credentials', postSendOauthCredentials);
|
||||
server.get('/start_oauth', getStartOAuth);
|
||||
server.get('/complete_oauth', getCompleteOauth);
|
||||
server.post('/post_oauth_message', postOAuthMessage);
|
||||
|
||||
server.listen(port, (err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
console.log(`Webhook test server listening on port ${port}!`);
|
||||
});
|
||||
|
||||
function ping(req, res) {
|
||||
return res.json({
|
||||
message: "I'm alive!",
|
||||
endpoints: [
|
||||
'GET /',
|
||||
'POST /setup',
|
||||
'POST /message_menus',
|
||||
'POST /dialog_request',
|
||||
'POST /simple_dialog_request',
|
||||
'POST /user_and_channel_dialog_request',
|
||||
'POST /dialog_submit',
|
||||
'POST /boolean_dialog_request',
|
||||
'POST /multiselect_dialog_request',
|
||||
'POST /dynamic_select_dialog_request',
|
||||
'POST /file_upload_dialog_request',
|
||||
'POST /dynamic_select_source',
|
||||
'POST /dialog/field-refresh',
|
||||
'POST /dialog/multistep',
|
||||
'POST /dialog/action_button_request',
|
||||
'POST /dialog/open_child',
|
||||
'POST /field_refresh_source',
|
||||
'POST /datetime_dialog_request',
|
||||
'POST /datetime_dialog_submit',
|
||||
'POST /slack_compatible_message_response',
|
||||
'POST /mm_blocks_integration',
|
||||
'POST /mm_blocks_integration_update',
|
||||
'POST /mm_blocks_integration_static_select',
|
||||
'POST /mm_blocks_integration_echo_query',
|
||||
'POST /mm_blocks_integration_echo_context',
|
||||
'POST /send_message_to_channel',
|
||||
'POST /post_outgoing_webhook',
|
||||
'POST /send_oauth_credentials',
|
||||
'GET /start_oauth',
|
||||
'GET /complete_oauth',
|
||||
'POST /post_oauth_message',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Set base URLs and credential to be accessible by any endpoint
|
||||
let baseUrl;
|
||||
let webhookBaseUrl;
|
||||
let adminUsername;
|
||||
let adminPassword;
|
||||
function doSetup(req, res) {
|
||||
baseUrl = req.body.baseUrl;
|
||||
webhookBaseUrl = req.body.webhookBaseUrl;
|
||||
adminUsername = req.body.adminUsername;
|
||||
adminPassword = req.body.adminPassword;
|
||||
|
||||
return res.status(201).send('Successfully setup the new base URLs and credential.');
|
||||
}
|
||||
|
||||
let client;
|
||||
let authedUser;
|
||||
function postSendOauthCredentials(req, res) {
|
||||
const {appID, appSecret} = req.body;
|
||||
client = new ClientOAuth2({
|
||||
clientId: appID,
|
||||
clientSecret: appSecret,
|
||||
authorizationUri: `${baseUrl}/oauth/authorize`,
|
||||
accessTokenUri: `${baseUrl}/oauth/access_token`,
|
||||
redirectUri: `${webhookBaseUrl}/complete_oauth`,
|
||||
});
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
function getStartOAuth(req, res) {
|
||||
return res.redirect(client.code.getUri());
|
||||
}
|
||||
|
||||
function getCompleteOauth(req, res) {
|
||||
client.code
|
||||
.getToken(req.originalUrl)
|
||||
.then((user) => {
|
||||
authedUser = user;
|
||||
return res.status(200).send('OK');
|
||||
})
|
||||
.catch((reason) => {
|
||||
return res.status(reason.status).send(reason);
|
||||
});
|
||||
}
|
||||
|
||||
async function postOAuthMessage(req, res) {
|
||||
const {channelId, message, rootId, createAt} = req.body;
|
||||
const apiUrl = `${baseUrl}/api/v4/posts`;
|
||||
authedUser.sign({
|
||||
method: 'post',
|
||||
url: apiUrl,
|
||||
});
|
||||
try {
|
||||
await axios({
|
||||
url: apiUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Authorization: 'Bearer ' + authedUser.accessToken,
|
||||
},
|
||||
method: 'post',
|
||||
data: {
|
||||
channel_id: channelId,
|
||||
message,
|
||||
type: '',
|
||||
create_at: createAt,
|
||||
root_id: rootId,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
function postSlackCompatibleMessageResponse(req, res) {
|
||||
const {spoiler, skipSlackParsing} = req.body.context;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({
|
||||
ephemeral_text: spoiler,
|
||||
skip_slack_parsing: skipSlackParsing,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mattermost mm_blocks external actions POST the same integration envelope as legacy message buttons.
|
||||
* @see model.PostActionIntegrationResponse
|
||||
*/
|
||||
function postMmBlocksIntegration(req, res) {
|
||||
const userName = req.body && req.body.user_name ? req.body.user_name : 'unknown';
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.status(200).json({
|
||||
ephemeral_text: `Playwright mm_blocks integration OK (user: ${userName}).`,
|
||||
skip_slack_parsing: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a PostActionIntegrationResponse update so the interactive post is edited in-place
|
||||
* (persisted webhook post or ephemeral mm_blocks post).
|
||||
*/
|
||||
function postMmBlocksIntegrationUpdate(req, res) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.status(200).json({
|
||||
update: {
|
||||
message: 'E2E mm_blocks post updated (message field).',
|
||||
props: {
|
||||
mm_blocks: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'PLAYWRIGHT_MM_BLOCKS_UPDATED',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
skip_slack_parsing: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Echoes URL query parameters Mattermost merged onto the integration request (action query + block query). */
|
||||
function postMmBlocksIntegrationEchoQuery(req, res) {
|
||||
const entries = Object.keys(req.query || {})
|
||||
.sort()
|
||||
.map((k) => `${k}=${String(req.query[k])}`);
|
||||
const summary = entries.join('&');
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.status(200).json({
|
||||
ephemeral_text: `Playwright mm_blocks query OK (${summary})`,
|
||||
skip_slack_parsing: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Echoes `context.test_marker` from the Mattermost integration POST body for mm_blocks external actions. */
|
||||
function postMmBlocksIntegrationEchoContext(req, res) {
|
||||
const ctx = (req.body && req.body.context) || {};
|
||||
const marker = typeof ctx.test_marker === 'string' ? ctx.test_marker : JSON.stringify(ctx.test_marker ?? null);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.status(200).json({
|
||||
ephemeral_text: `Playwright mm_blocks context OK (test_marker: ${marker}).`,
|
||||
skip_slack_parsing: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Echoes `context.selected_option` from the Mattermost integration POST for mm_blocks static_select. */
|
||||
function postMmBlocksIntegrationStaticSelect(req, res) {
|
||||
const selected = req.body && req.body.context && req.body.context.selected_option;
|
||||
const label = typeof selected === 'string' ? selected : JSON.stringify(selected ?? null);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.status(200).json({
|
||||
ephemeral_text: `Playwright mm_blocks static_select OK (selected_option: ${label}).`,
|
||||
skip_slack_parsing: true,
|
||||
});
|
||||
}
|
||||
|
||||
function postMessageMenus(req, res) {
|
||||
let responseData = {};
|
||||
const {body} = req;
|
||||
if (body && body.context.action === 'do_something') {
|
||||
responseData = {
|
||||
ephemeral_text: `Ephemeral | ${body.type} ${body.data_source} option: ${body.context.selected_option}`,
|
||||
};
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json(responseData);
|
||||
}
|
||||
|
||||
async function openDialog(dialog) {
|
||||
// Callers invoke this fire-and-forget (no await/catch), so any rejection here
|
||||
// would become an unhandled rejection and crash the whole webhook process.
|
||||
// Guard against a missing baseUrl (set by /setup) and swallow request errors.
|
||||
if (!baseUrl) {
|
||||
console.error('openDialog called before /setup ran — baseUrl is not set; skipping dialog open');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios({
|
||||
method: 'post',
|
||||
url: `${baseUrl}/api/v4/actions/dialogs/open`,
|
||||
data: dialog,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.response && err.response.status;
|
||||
const body = err.response && err.response.data;
|
||||
console.error(
|
||||
'openDialog request failed:',
|
||||
status || err.code || err.message,
|
||||
body ? JSON.stringify(body) : '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getFullDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Full dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onSimpleDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getSimpleDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Simple dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onUserAndChannelDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getUserAndChannelDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Simple dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onBooleanDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getBooleanDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Simple dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onMultiSelectDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
// Check URL parameters or body for includeDefaults flag
|
||||
const includeDefaults = req.query.includeDefaults === 'true' || req.query.includeDefaults === true;
|
||||
const dialog = webhookUtils.getMultiSelectDialog(body.trigger_id, webhookBaseUrl, includeDefaults);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Multiselect dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onDynamicSelectDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getDynamicSelectDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Dynamic select dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onFileUploadDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getFileUploadDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'File upload dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onDynamicSelectSource(req, res) {
|
||||
const {body} = req;
|
||||
|
||||
// Simulate dynamic options based on search text
|
||||
const searchText = (body.submission.query || '').toLowerCase();
|
||||
|
||||
const allOptions = [
|
||||
{text: 'Backend Engineer', value: 'backend_eng'},
|
||||
{text: 'Frontend Engineer', value: 'frontend_eng'},
|
||||
{text: 'Full Stack Engineer', value: 'fullstack_eng'},
|
||||
{text: 'DevOps Engineer', value: 'devops_eng'},
|
||||
{text: 'QA Engineer', value: 'qa_eng'},
|
||||
{text: 'Product Manager', value: 'product_mgr'},
|
||||
{text: 'Engineering Manager', value: 'eng_mgr'},
|
||||
{text: 'Senior Backend Engineer', value: 'sr_backend_eng'},
|
||||
{text: 'Senior Frontend Engineer', value: 'sr_frontend_eng'},
|
||||
{text: 'Principal Engineer', value: 'principal_eng'},
|
||||
{text: 'Staff Engineer', value: 'staff_eng'},
|
||||
{text: 'Technical Lead', value: 'tech_lead'},
|
||||
];
|
||||
|
||||
// Filter options based on search text
|
||||
const filteredOptions = searchText
|
||||
? allOptions.filter(
|
||||
(option) =>
|
||||
option.text.toLowerCase().includes(searchText) || option.value.toLowerCase().includes(searchText),
|
||||
)
|
||||
: allOptions.slice(0, 6); // Limit to first 6 if no search
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({
|
||||
items: filteredOptions,
|
||||
});
|
||||
}
|
||||
|
||||
function onDateTimeDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
let dialog;
|
||||
const command = body.text ? body.text.trim() : '';
|
||||
|
||||
// Use focused dialog functions based on command parameter
|
||||
switch (command) {
|
||||
case 'basic':
|
||||
dialog = webhookUtils.getBasicDateDialog(body.trigger_id, webhookBaseUrl);
|
||||
break;
|
||||
case 'mindate':
|
||||
dialog = webhookUtils.getMinDateConstraintDialog(body.trigger_id, webhookBaseUrl);
|
||||
break;
|
||||
case 'interval':
|
||||
dialog = webhookUtils.getCustomIntervalDialog(body.trigger_id, webhookBaseUrl);
|
||||
break;
|
||||
case 'relative':
|
||||
dialog = webhookUtils.getRelativeDateDialog(body.trigger_id, webhookBaseUrl);
|
||||
break;
|
||||
case 'timezone-manual':
|
||||
dialog = webhookUtils.getTimezoneManualDialog(body.trigger_id, webhookBaseUrl);
|
||||
break;
|
||||
default:
|
||||
// Default to basic datetime dialog for backward compatibility
|
||||
dialog = webhookUtils.getBasicDateTimeDialog(body.trigger_id, webhookBaseUrl);
|
||||
break;
|
||||
}
|
||||
console.log('Opening DateTime dialog', dialog.dialog.title);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'DateTime dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onDateTimeDialogSubmit(req, res) {
|
||||
console.log('DateTime dialog submit handler called!');
|
||||
const {body} = req;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
// Log the submitted datetime values for debugging
|
||||
console.log('DateTime dialog submission:', JSON.stringify(body, null, 2));
|
||||
|
||||
// Extract datetime values from submission
|
||||
const submission = body.submission || {};
|
||||
const eventDate = submission.event_date;
|
||||
const meetingTime = submission.meeting_time;
|
||||
const relativeDate = submission.relative_date;
|
||||
const relativeDateTime = submission.relative_datetime;
|
||||
|
||||
// Create a success message with the submitted values
|
||||
let message = 'Form submitted successfully! ';
|
||||
if (eventDate || meetingTime || relativeDate || relativeDateTime) {
|
||||
message += 'Submitted values: ';
|
||||
if (eventDate) {
|
||||
message += `Event Date: ${eventDate}, `;
|
||||
}
|
||||
if (meetingTime) {
|
||||
message += `Meeting Time: ${meetingTime}, `;
|
||||
}
|
||||
if (relativeDate) {
|
||||
message += `Relative Date: ${relativeDate}, `;
|
||||
}
|
||||
if (relativeDateTime) {
|
||||
message += `Relative DateTime: ${relativeDateTime}, `;
|
||||
}
|
||||
message = message.slice(0, -2); // Remove trailing comma and space
|
||||
}
|
||||
|
||||
// Send success response that will appear as a post in the channel
|
||||
sendSysadminResponse(message, body.channel_id);
|
||||
return res.json({text: message});
|
||||
}
|
||||
|
||||
function onDialogSubmit(req, res) {
|
||||
const {body} = req;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
let message;
|
||||
if (body.cancelled) {
|
||||
message = 'Dialog cancelled';
|
||||
console.log('[WEBHOOK] Dialog cancelled');
|
||||
sendSysadminResponse(message, body.channel_id);
|
||||
return res.json({text: message});
|
||||
}
|
||||
|
||||
// Check if this is a multistep submission
|
||||
if (body.callback_id === 'multistep_callback') {
|
||||
const currentState = body.state || '';
|
||||
|
||||
// Determine next step based on current state
|
||||
if (currentState === 'step1') {
|
||||
// Move to step 2
|
||||
const nextForm = webhookUtils.getMultistepStep2Dialog(null, webhookBaseUrl);
|
||||
return res.json({
|
||||
type: 'form',
|
||||
form: nextForm,
|
||||
});
|
||||
} else if (currentState === 'step2') {
|
||||
// Move to step 3
|
||||
const nextForm = webhookUtils.getMultistepStep3Dialog(null, webhookBaseUrl);
|
||||
return res.json({
|
||||
type: 'form',
|
||||
form: nextForm,
|
||||
});
|
||||
}
|
||||
|
||||
// Final step - complete the multistep
|
||||
const submission = body.submission || {};
|
||||
message = `Multistep completed successfully! Final step values: ${JSON.stringify(submission, null, 2)}`;
|
||||
sendSysadminResponse(message, body.channel_id);
|
||||
return res.json({text: message});
|
||||
}
|
||||
|
||||
// Check if this is a field refresh dialog submission
|
||||
if (body.callback_id === 'field_refresh_callback') {
|
||||
const submission = body.submission || {};
|
||||
message = `Field refresh dialog submitted successfully! Values: ${JSON.stringify(submission, null, 2)}`;
|
||||
sendSysadminResponse(message, body.channel_id);
|
||||
return res.json({text: message});
|
||||
}
|
||||
|
||||
// Regular dialog submission
|
||||
// Format submission data for the channel message
|
||||
const sanitize = (str) => String(str).replace(/[<>&"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
||||
const submissionData = Object.entries(body.submission || {})
|
||||
.map(([key, value]) => `**${sanitize(key)}**: ${sanitize(value)}`)
|
||||
.join('\n');
|
||||
|
||||
message = `Dialog submitted successfully!\n\n**Submission Data:**\n${submissionData}`;
|
||||
|
||||
sendSysadminResponse(message, body.channel_id);
|
||||
return res.json({text: message});
|
||||
}
|
||||
|
||||
/**
|
||||
* @route "POST /send_message_to_channel?type={messageType}&channel_id={channelId}"
|
||||
* @query type - message type of empty string for regular message if not provided (default), "system_message", etc
|
||||
* @query channel_id - channel where to send the message
|
||||
*/
|
||||
function postSendMessageToChannel(req, res) {
|
||||
const channelId = req.query.channel_id;
|
||||
const response = {
|
||||
response_type: 'in_channel',
|
||||
text: 'Extra response 2',
|
||||
channel_id: channelId,
|
||||
extra_responses: [
|
||||
{
|
||||
response_type: 'in_channel',
|
||||
text: 'Hello World',
|
||||
channel_id: channelId,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (req.query.type) {
|
||||
response.type = req.query.type;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
}
|
||||
|
||||
// Convenient way to send response in a channel by using sysadmin account
|
||||
function sendSysadminResponse(message, channelId) {
|
||||
postMessageAs({
|
||||
sender: {
|
||||
username: adminUsername,
|
||||
password: adminPassword,
|
||||
},
|
||||
message,
|
||||
channelId,
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const responseTypes = ['in_channel', 'comment'];
|
||||
|
||||
function getWebhookResponse(body, {responseType, username, iconUrl}) {
|
||||
const payload = Object.entries(body)
|
||||
.map(([key, value]) => `- ${key}: "${value}"`)
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
\`\`\`
|
||||
#### Outgoing Webhook Payload
|
||||
${payload}
|
||||
#### Webhook override to Mattermost instance
|
||||
- response_type: "${responseType}"
|
||||
- type: ""
|
||||
- username: "${username}"
|
||||
- icon_url: "${iconUrl}"
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @route "POST /post_outgoing_webhook?override_username={username}&override_icon_url={iconUrl}&response_type={comment}"
|
||||
* @query override_username - the user name that overrides the user name defined by the outgoing webhook
|
||||
* @query override_icon_url - the user icon url that overrides the user icon url defined by the outgoing webhook
|
||||
* @query response_type - "in_channel" (default) or "comment"
|
||||
*/
|
||||
function postOutgoingWebhook(req, res) {
|
||||
const {body, query} = req;
|
||||
if (!body) {
|
||||
res.status(404).send({error: 'Invalid data'});
|
||||
}
|
||||
|
||||
const responseType = query.response_type || responseTypes[0];
|
||||
const username = query.override_username || '';
|
||||
const iconUrl = query.override_icon_url || '';
|
||||
|
||||
const response = {
|
||||
text: getWebhookResponse(body, {responseType, username, iconUrl}),
|
||||
username,
|
||||
icon_url: iconUrl,
|
||||
type: '',
|
||||
response_type: responseType,
|
||||
};
|
||||
res.status(200).send(response);
|
||||
}
|
||||
|
||||
function onFieldRefreshDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getFieldRefreshDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Field refresh dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onMultistepDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getMultistepStep1Dialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Multistep dialog triggered via slash command!'});
|
||||
}
|
||||
|
||||
function onActionButtonDialogRequest(req, res) {
|
||||
const {body} = req;
|
||||
if (body.trigger_id) {
|
||||
const dialog = webhookUtils.getActionButtonParentDialog(body.trigger_id, webhookBaseUrl);
|
||||
openDialog(dialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({text: 'Action button dialog triggered!'});
|
||||
}
|
||||
|
||||
async function onOpenChildDialog(req, res) {
|
||||
const {body} = req;
|
||||
|
||||
// context.source identifies which action button on the parent dialog was
|
||||
// pressed; it is forwarded by the server in the PostActionIntegrationRequest.
|
||||
const source = (body.context && body.context.source) || 'Unknown';
|
||||
console.log('onOpenChildDialog called with trigger_id:', body.trigger_id, 'source:', source);
|
||||
if (body.trigger_id) {
|
||||
const childDialog = webhookUtils.getActionButtonChildDialog(body.trigger_id, webhookBaseUrl, source);
|
||||
|
||||
// Await the dialog open before responding. The server's /execute call
|
||||
// (DoActionRequest) waits for this response, so awaiting here ensures the
|
||||
// child's WS open_dialog event is published before the browser's
|
||||
// executeDialogAction promise resolves — removing the render race in tests.
|
||||
await openDialog(childDialog);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
return res.json({});
|
||||
}
|
||||
|
||||
function onFieldRefreshSource(req, res) {
|
||||
const {body} = req;
|
||||
const submission = body.submission || {};
|
||||
const projectType = submission.project_type;
|
||||
const projectName = submission.project_name || '';
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
|
||||
// Return updated form based on project type selection
|
||||
const elements = [
|
||||
{
|
||||
display_name: 'Project Name',
|
||||
name: 'project_name',
|
||||
type: 'text',
|
||||
placeholder: 'Enter project name',
|
||||
default: projectName,
|
||||
optional: false,
|
||||
},
|
||||
{
|
||||
display_name: 'Project Type',
|
||||
name: 'project_type',
|
||||
type: 'select',
|
||||
refresh: true,
|
||||
placeholder: 'Select project type...',
|
||||
default: projectType,
|
||||
options: [
|
||||
{text: 'Web Application', value: 'web'},
|
||||
{text: 'Mobile App', value: 'mobile'},
|
||||
{text: 'API Service', value: 'api'},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Add different fields based on project type
|
||||
if (projectType === 'web') {
|
||||
elements.push({
|
||||
display_name: 'Framework',
|
||||
name: 'framework',
|
||||
type: 'select',
|
||||
placeholder: 'Select framework...',
|
||||
options: [
|
||||
{text: 'React', value: 'react'},
|
||||
{text: 'Vue', value: 'vue'},
|
||||
{text: 'Angular', value: 'angular'},
|
||||
],
|
||||
});
|
||||
} else if (projectType === 'mobile') {
|
||||
elements.push({
|
||||
display_name: 'Platform',
|
||||
name: 'platform',
|
||||
type: 'select',
|
||||
placeholder: 'Select platform...',
|
||||
options: [
|
||||
{text: 'iOS', value: 'ios'},
|
||||
{text: 'Android', value: 'android'},
|
||||
{text: 'React Native', value: 'react-native'},
|
||||
],
|
||||
});
|
||||
} else if (projectType === 'api') {
|
||||
elements.push({
|
||||
display_name: 'Language',
|
||||
name: 'language',
|
||||
type: 'select',
|
||||
placeholder: 'Select language...',
|
||||
options: [
|
||||
{text: 'Go', value: 'go'},
|
||||
{text: 'Node.js', value: 'nodejs'},
|
||||
{text: 'Python', value: 'python'},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
type: 'form',
|
||||
form: {
|
||||
title: 'Field Refresh Demo',
|
||||
introduction_text: 'Enter project name then select type to see different fields',
|
||||
submit_label: 'Submit',
|
||||
elements,
|
||||
},
|
||||
});
|
||||
}
|
||||
37
e2e-tests/playwright/lib/src/containers/azurite_container.ts
Normal file
37
e2e-tests/playwright/lib/src/containers/azurite_container.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {AZURITE_ALIAS, AZURITE_BLOB_PORT, TESTCONTAINERS_LABELS} from './constants';
|
||||
import {AZURITE_IMAGE} from './default_images';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// An alternative to Minio for blob storage.
|
||||
export async function startAzuriteContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('azurite', async () => {
|
||||
let builder = new GenericContainer(AZURITE_IMAGE)
|
||||
.withCommand([
|
||||
'azurite-blob',
|
||||
'--blobHost',
|
||||
'0.0.0.0',
|
||||
'--blobPort',
|
||||
String(AZURITE_BLOB_PORT),
|
||||
'--skipApiVersionCheck',
|
||||
])
|
||||
.withExposedPorts(AZURITE_BLOB_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(AZURITE_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withWaitStrategy(Wait.forListeningPorts());
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
77
e2e-tests/playwright/lib/src/containers/constants.ts
Normal file
77
e2e-tests/playwright/lib/src/containers/constants.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
// Single source of truth for the fixed values every container/helper needs to agree on.
|
||||
// Kept separate from test_config.ts because these are not overridable — they're either
|
||||
// Testcontainers network aliases (only meaningful inside the Testcontainers network) or
|
||||
// fixed test-only credentials for a throwaway local service.
|
||||
|
||||
export const POSTGRES_ALIAS = 'postgres';
|
||||
export const POSTGRES_PORT = 5432;
|
||||
export const POSTGRES_DB = 'mattermost_test';
|
||||
export const POSTGRES_USER = 'mmuser';
|
||||
export const POSTGRES_PASSWORD = 'mostest_password';
|
||||
|
||||
export const INBUCKET_ALIAS = 'inbucket';
|
||||
export const INBUCKET_WEB_PORT = 9001;
|
||||
export const INBUCKET_SMTP_PORT = 10025;
|
||||
export const INBUCKET_POP3_PORT = 10110;
|
||||
|
||||
export const MATTERMOST_ALIAS = 'server';
|
||||
export const MATTERMOST_PORT = 8065;
|
||||
|
||||
// Interactive-message/dialog callback sidecar shared with Cypress. Always started, like
|
||||
// postgres/inbucket — not gated behind testcontainersServices.
|
||||
export const WEBHOOK_ALIAS = 'webhook';
|
||||
export const WEBHOOK_PORT = 3000;
|
||||
|
||||
export const OPENLDAP_ALIAS = 'openldap';
|
||||
export const OPENLDAP_PORT = 389;
|
||||
export const OPENLDAP_ADMIN_DN = 'cn=admin,dc=mm,dc=test,dc=com';
|
||||
export const OPENLDAP_ADMIN_PASSWORD = 'mostest_password';
|
||||
export const OPENLDAP_BASE_DN = 'dc=mm,dc=test,dc=com';
|
||||
|
||||
export const KEYCLOAK_ALIAS = 'keycloak';
|
||||
export const KEYCLOAK_PORT = 8080;
|
||||
export const KEYCLOAK_REALM = 'mattermost';
|
||||
export const KEYCLOAK_ADMIN_USER = 'admin';
|
||||
export const KEYCLOAK_ADMIN_PASSWORD = 'admin';
|
||||
|
||||
export const ELASTICSEARCH_ALIAS = 'elasticsearch';
|
||||
export const ELASTICSEARCH_PORT = 9200;
|
||||
|
||||
export const MINIO_ALIAS = 'minio';
|
||||
export const MINIO_PORT = 9000;
|
||||
export const MINIO_ACCESS_KEY = 'minioaccesskey';
|
||||
export const MINIO_SECRET_KEY = 'miniosecretkey';
|
||||
export const MINIO_BUCKET = 'mattermost-test';
|
||||
|
||||
// Alternative to Minio for blob storage.
|
||||
export const AZURITE_ALIAS = 'azurite';
|
||||
export const AZURITE_BLOB_PORT = 10000;
|
||||
// Azurite's well-known default emulator account — published by Microsoft's own docs, not a secret.
|
||||
export const AZURITE_ACCOUNT_NAME = 'devstoreaccount1';
|
||||
export const AZURITE_ACCOUNT_KEY =
|
||||
'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==';
|
||||
export const AZURITE_CONTAINER = 'mattermost-test';
|
||||
|
||||
// Alternative to Elasticsearch for search.
|
||||
export const OPENSEARCH_ALIAS = 'opensearch';
|
||||
export const OPENSEARCH_PORT = 9201;
|
||||
export const OPENSEARCH_ADMIN_PASSWORD = 'Test@dmin_123';
|
||||
|
||||
// Applied to every container this module starts, so `npm run testcontainers:down` can find and
|
||||
// remove them from a fresh process — the in-memory `started` state in stack.ts only exists in
|
||||
// the process that created it.
|
||||
export const TESTCONTAINERS_LABEL_KEY = 'mm-playwright-testcontainers';
|
||||
export const TESTCONTAINERS_LABEL_VALUE = 'true';
|
||||
export const TESTCONTAINERS_LABELS = {[TESTCONTAINERS_LABEL_KEY]: TESTCONTAINERS_LABEL_VALUE};
|
||||
|
||||
// Bind-mounted into every Mattermost container at /mattermost/data (mattermost_container.ts) so
|
||||
// local-disk file storage survives restartMattermostContainer()'s docker rm -f, which would
|
||||
// otherwise discard the anonymous VOLUME Docker creates for it. Fixed and repo-relative (not an
|
||||
// os.tmpdir()) so every process — including a later npm-script invocation or CI loop iteration —
|
||||
// resolves the same path independently, with nothing to persist through .env.testcontainers.
|
||||
export const MATTERMOST_DATA_DIR = path.resolve(process.cwd(), '.mattermost_data');
|
||||
18
e2e-tests/playwright/lib/src/containers/default_images.ts
Normal file
18
e2e-tests/playwright/lib/src/containers/default_images.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Single place to check and bump every image.
|
||||
// The Mattermost server's default is overridable via the SERVER_IMAGE env var (testConfig.serverImage).
|
||||
export const MATTERMOST_SERVER_IMAGE = 'mattermostdevelopment/mattermost-enterprise-edition:master';
|
||||
export const POSTGRES_IMAGE = 'postgres:15';
|
||||
export const INBUCKET_IMAGE = 'inbucket/inbucket:3.1.1';
|
||||
export const OPENLDAP_IMAGE = 'osixia/openldap:1.4.0';
|
||||
export const KEYCLOAK_IMAGE = 'quay.io/keycloak/keycloak:23.0.7';
|
||||
export const MINIO_IMAGE = 'cgr.dev/chainguard/minio:latest';
|
||||
export const AZURITE_IMAGE = 'mcr.microsoft.com/azure-storage/azurite:3.34.0';
|
||||
// Built from a Dockerfile on top of docker.elastic.co/elasticsearch/elasticsearch, rather than
|
||||
// pulled as a fixed image — so only the version is fixed here.
|
||||
export const ELASTICSEARCH_VERSION = '9.0.0';
|
||||
// Built from a Dockerfile on top of opensearchproject/opensearch, rather than pulled as a fixed
|
||||
// image — so only the version is fixed here.
|
||||
export const OPENSEARCH_VERSION = '3.0.0';
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {ELASTICSEARCH_ALIAS, ELASTICSEARCH_PORT, TESTCONTAINERS_LABELS} from './constants';
|
||||
import {ELASTICSEARCH_VERSION as DEFAULT_ELASTICSEARCH_VERSION} from './default_images';
|
||||
import {containerAssetPath} from './paths';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// Built from a vendored Dockerfile, not the generic @testcontainers/elasticsearch wrapper — it
|
||||
// installs the CJK analysis plugins (analysis-icu/nori/kuromoji/smartcn) real search tests rely on.
|
||||
export async function startElasticsearchContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('elasticsearch', async () => {
|
||||
// deleteOnExit: false — otherwise the built image bakes in this session's Ryuk id, so Ryuk
|
||||
// reaps the "reused" container the moment this session ends, defeating withReuse() below.
|
||||
const image = await GenericContainer.fromDockerfile(containerAssetPath(), 'Dockerfile.elasticsearch')
|
||||
.withBuildArgs({
|
||||
ELASTICSEARCH_VERSION: process.env.ELASTICSEARCH_VERSION || DEFAULT_ELASTICSEARCH_VERSION,
|
||||
})
|
||||
.build(undefined, {deleteOnExit: false});
|
||||
|
||||
let builder = image
|
||||
.withExposedPorts(ELASTICSEARCH_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(ELASTICSEARCH_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withEnvironment({
|
||||
'http.host': '0.0.0.0',
|
||||
'http.port': String(ELASTICSEARCH_PORT),
|
||||
'xpack.security.enabled': 'false',
|
||||
'action.destructive_requires_name': 'false',
|
||||
'transport.host': '127.0.0.1',
|
||||
ES_JAVA_OPTS: '-Xms512m -Xmx512m',
|
||||
})
|
||||
.withStartupTimeout(3 * 60_000)
|
||||
.withWaitStrategy(Wait.forHttp('/_cluster/health', ELASTICSEARCH_PORT).forStatusCode(200));
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
27
e2e-tests/playwright/lib/src/containers/env_baseline.ts
Normal file
27
e2e-tests/playwright/lib/src/containers/env_baseline.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// Test-oriented MM_* config the Mattermost server container starts with by default,
|
||||
// merged under testConfig.serverEnv (MM_ENV) so callers can still override any of it.
|
||||
export const SERVER_ENV_BASELINE: Record<string, string> = {
|
||||
MM_SERVICEENVIRONMENT: 'test',
|
||||
MM_CLUSTERSETTINGS_READONLYCONFIG: 'false',
|
||||
MM_CONNECTEDWORKSPACESSETTINGS_ENABLEREMOTECLUSTERSERVICE: 'true',
|
||||
MM_CONNECTEDWORKSPACESSETTINGS_ENABLESHAREDWORKSPACES: 'true',
|
||||
MM_LOGSETTINGS_CONSOLELEVEL: 'DEBUG',
|
||||
MM_LOGSETTINGS_ENABLEDIAGNOSTICS: 'false',
|
||||
MM_PLUGINSETTINGS_ENABLEUPLOADS: 'true',
|
||||
MM_SERVICESETTINGS_ALLOWCORSFROM: '*',
|
||||
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: 'keycloak elasticsearch opensearch minio azurite webhook',
|
||||
MM_SERVICESETTINGS_ENABLELOCALMODE: 'true',
|
||||
MM_SERVICESETTINGS_ENABLESECURITYFIXALERT: 'false',
|
||||
MM_SERVICESETTINGS_ENABLETESTING: 'true',
|
||||
// Feature flags this test suite needs on, off by default in the server
|
||||
MM_FEATUREFLAGS_ATTRIBUTEVALUEMASKING: 'true',
|
||||
MM_FEATUREFLAGS_PERMISSIONPOLICIES: 'true',
|
||||
MM_FEATUREFLAGS_PROPERTYFIELDRANK: 'true',
|
||||
MM_FEATUREFLAGS_RECURRINGSCHEDULEDPOSTS: 'true',
|
||||
MM_FEATUREFLAGS_RESOURCEATTRIBUTESINPOLICIES: 'true',
|
||||
MM_FEATUREFLAGS_TEAMMEMBERSHIPACCESSCONTROL: 'true',
|
||||
MM_FEATUREFLAGS_WYSIWYGEDITOR: 'true',
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {
|
||||
INBUCKET_ALIAS,
|
||||
INBUCKET_POP3_PORT,
|
||||
INBUCKET_SMTP_PORT,
|
||||
INBUCKET_WEB_PORT,
|
||||
TESTCONTAINERS_LABELS,
|
||||
} from './constants';
|
||||
import {INBUCKET_IMAGE} from './default_images';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export async function startInbucketContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('inbucket', async () => {
|
||||
let builder = new GenericContainer(INBUCKET_IMAGE)
|
||||
.withExposedPorts(INBUCKET_WEB_PORT, INBUCKET_SMTP_PORT, INBUCKET_POP3_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(INBUCKET_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withEnvironment({
|
||||
INBUCKET_WEB_ADDR: `0.0.0.0:${INBUCKET_WEB_PORT}`,
|
||||
INBUCKET_POP3_ADDR: `0.0.0.0:${INBUCKET_POP3_PORT}`,
|
||||
INBUCKET_SMTP_ADDR: `0.0.0.0:${INBUCKET_SMTP_PORT}`,
|
||||
})
|
||||
.withWaitStrategy(Wait.forListeningPorts());
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
4
e2e-tests/playwright/lib/src/containers/index.ts
Normal file
4
e2e-tests/playwright/lib/src/containers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {startStack, stopStack} from './stack';
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {
|
||||
KEYCLOAK_ADMIN_PASSWORD,
|
||||
KEYCLOAK_ADMIN_USER,
|
||||
KEYCLOAK_ALIAS,
|
||||
KEYCLOAK_PORT,
|
||||
TESTCONTAINERS_LABELS,
|
||||
} from './constants';
|
||||
import {KEYCLOAK_IMAGE} from './default_images';
|
||||
import {containerAssetPath} from './paths';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export async function startKeycloakContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('keycloak', async () => {
|
||||
let builder = new GenericContainer(KEYCLOAK_IMAGE)
|
||||
.withEntrypoint(['/opt/keycloak/bin/kc.sh'])
|
||||
.withCommand(['start', '--import-realm'])
|
||||
.withExposedPorts(KEYCLOAK_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(KEYCLOAK_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withEnvironment({
|
||||
KEYCLOAK_ADMIN: KEYCLOAK_ADMIN_USER,
|
||||
KEYCLOAK_ADMIN_PASSWORD,
|
||||
KC_HOSTNAME_STRICT: 'false',
|
||||
KC_HOSTNAME_STRICT_HTTPS: 'false',
|
||||
KC_HTTP_ENABLED: 'true',
|
||||
})
|
||||
.withCopyFilesToContainer([
|
||||
{
|
||||
source: containerAssetPath('keycloak-realm-export.json'),
|
||||
target: '/opt/keycloak/data/import/realm-export.json',
|
||||
},
|
||||
])
|
||||
.withStartupTimeout(3 * 60_000)
|
||||
.withWaitStrategy(Wait.forHttp('/realms/master', KEYCLOAK_PORT).forStatusCode(200));
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
21
e2e-tests/playwright/lib/src/containers/log.ts
Normal file
21
e2e-tests/playwright/lib/src/containers/log.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
const PREFIX = chalk.cyan('[testcontainers]');
|
||||
|
||||
export function logTestcontainers(message: string): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${PREFIX} ${message}`);
|
||||
}
|
||||
|
||||
export function warnTestcontainers(message: string): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`${PREFIX} ${message}`);
|
||||
}
|
||||
|
||||
export function errorTestcontainers(message: string): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`${PREFIX} ${message}`);
|
||||
}
|
||||
139
e2e-tests/playwright/lib/src/containers/mattermost_container.ts
Normal file
139
e2e-tests/playwright/lib/src/containers/mattermost_container.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {isUpgradeFromProjectSelected, isUpgradeToPhaseProjectSelected} from '../upgrade_env';
|
||||
|
||||
import {
|
||||
INBUCKET_ALIAS,
|
||||
INBUCKET_SMTP_PORT,
|
||||
MATTERMOST_DATA_DIR,
|
||||
MATTERMOST_ALIAS,
|
||||
MATTERMOST_PORT,
|
||||
POSTGRES_ALIAS,
|
||||
POSTGRES_DB,
|
||||
POSTGRES_PASSWORD,
|
||||
POSTGRES_PORT,
|
||||
POSTGRES_USER,
|
||||
TESTCONTAINERS_LABELS,
|
||||
} from './constants';
|
||||
import {SERVER_ENV_BASELINE} from './env_baseline';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// Env this container computes itself from the stack Testcontainers just built. Must win over any
|
||||
// stray testConfig.serverEnv (MM_ENV) entry and over testConfig.bootEnvOverrides (passed in as
|
||||
// `extraEnv` by restartMattermostContainer()), or a stray key collision there could break the
|
||||
// server's own connectivity. Deliberately does NOT know about any additional service (LDAP/
|
||||
// Keycloak/Elasticsearch/OpenSearch/Minio/Azurite) — those are each spec's own responsibility via
|
||||
// pw.ensure<Service>(), which points the already-running server at them through patchConfig.
|
||||
//
|
||||
// MM_LICENSE (if set) is passed straight through: the server reads it directly at startup
|
||||
// (platform.LoadLicense), so it boots already licensed instead of needing an authenticated upload
|
||||
// call after the fact.
|
||||
export function resolveMattermostBootEnv(extraEnv: Record<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
...SERVER_ENV_BASELINE,
|
||||
...testConfig.serverEnv,
|
||||
...extraEnv,
|
||||
...structuralEnv(),
|
||||
};
|
||||
}
|
||||
|
||||
const POSTGRES_DSN = `postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_ALIAS}:${POSTGRES_PORT}/${POSTGRES_DB}?sslmode=disable&connect_timeout=10&binary_parameters=yes`;
|
||||
|
||||
function structuralEnv(): Record<string, string> {
|
||||
return {
|
||||
// Config lives in Postgres (config.NewStoreFromDSN's DatabaseStore) rather than
|
||||
// /mattermost/config/config.json, which is how a production HA deployment runs and is what
|
||||
// makes the config outlive the container. The Dockerfile declares /mattermost/config as a
|
||||
// VOLUME, so each container Docker creates gets its own anonymous one seeded from the image
|
||||
// — a file-backed config would be silently factory-reset by every
|
||||
// restartMattermostContainer() and by the upgrade swap, leaving the to-image running its own
|
||||
// shipped defaults instead of the config it inherited. In the database it is preserved and
|
||||
// migrated across versions by the server itself, like the rest of the data.
|
||||
MM_CONFIG: POSTGRES_DSN,
|
||||
MM_SQLSETTINGS_DRIVERNAME: 'postgres',
|
||||
MM_SQLSETTINGS_DATASOURCE: POSTGRES_DSN,
|
||||
MM_EMAILSETTINGS_SMTPSERVER: INBUCKET_ALIAS,
|
||||
MM_EMAILSETTINGS_SMTPPORT: String(INBUCKET_SMTP_PORT),
|
||||
// Required at boot so prepackaged plugins (Agents, etc.) can activate without waiting for a later patchConfig.
|
||||
MM_SERVICESETTINGS_SITEURL: `http://${MATTERMOST_ALIAS}:${MATTERMOST_PORT}`,
|
||||
...(process.env.MM_LICENSE ? {MM_LICENSE: process.env.MM_LICENSE} : {}),
|
||||
// Replaces the baseline for this key: appends mock file-server hosts (host.docker.internal
|
||||
// and the bridge gateway IP, set by startStack() once the network is up).
|
||||
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: [
|
||||
SERVER_ENV_BASELINE.MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS,
|
||||
'host.docker.internal',
|
||||
testConfig.testcontainersNetworkGatewayIp,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// Readiness requires both the /api/v4/system/ping health check AND the permissions-migration job
|
||||
// scheduler's "All migrations are complete." log line (scheduler.go, jobs/migrations package).
|
||||
// Ping alone isn't enough: MigrationKeyAdvancedPermissionsPhase2 runs as an async job whose
|
||||
// scheduler deliberately delays its first tick 60s after startup — a real window a spec's very
|
||||
// first API call can otherwise land inside, tripping IsPhase2MigrationCompleted() gates with
|
||||
// "required migrations have not yet completed" (confirmed in practice: a permissions-page spec
|
||||
// hit exactly this, with the "Edit Scheme" link stuck disabled, when this wait was dropped).
|
||||
// Requires MM_LOGSETTINGS_CONSOLELEVEL=DEBUG (env_baseline.ts) since the scheduler logs that line
|
||||
// at Debug. Only paid on a genuinely fresh boot — a reused/adopted stack skips this entirely.
|
||||
//
|
||||
// upgrade-from and upgrade-swap-to skip the log wait for the same reason: API-only upgrade
|
||||
// harnesses, and restarts/reuse can miss the log line after the old container is removed.
|
||||
function mattermostWaitStrategy() {
|
||||
const ping = Wait.forHttp('/api/v4/system/ping', MATTERMOST_PORT).forStatusCode(200);
|
||||
if (isUpgradeFromProjectSelected() || isUpgradeToPhaseProjectSelected()) {
|
||||
return ping;
|
||||
}
|
||||
return Wait.forAll([ping, Wait.forLogMessage(/All migrations are complete\./, 1)]);
|
||||
}
|
||||
|
||||
// Joins the network by name (withNetworkMode) rather than a StartedNetwork object: also called
|
||||
// from restartMattermostContainer(), which runs in a worker process that never holds the actual
|
||||
// StartedNetwork handle — only the network's name (threaded through testConfig) is available
|
||||
// there.
|
||||
export async function startMattermostContainer(
|
||||
networkName: string,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): Promise<StartedTestContainer> {
|
||||
const env = resolveMattermostBootEnv(extraEnv);
|
||||
|
||||
return startWithRetry('server', async () => {
|
||||
let builder = new GenericContainer(testConfig.serverImage)
|
||||
.withPlatform('linux/amd64') // The published server images are amd64-only.
|
||||
.withNetworkMode(networkName)
|
||||
.withNetworkAliases(MATTERMOST_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
// Ensures host.docker.internal resolves to the Docker host (via host-gateway).
|
||||
.withExtraHosts([{host: 'host.docker.internal', ipAddress: 'host-gateway'}])
|
||||
.withExposedPorts(MATTERMOST_PORT)
|
||||
.withEnvironment(env)
|
||||
// Bind-mounted unconditionally so local-disk FileSettings data (server/build/Dockerfile's
|
||||
// VOLUME ["/mattermost/data", ...]) survives restartMattermostContainer()'s docker rm -f
|
||||
// instead of being discarded with the anonymous volume Docker would otherwise create.
|
||||
// Harmless — just an unused empty directory — when a different FileSettings backend
|
||||
// (Minio/Azurite) is active.
|
||||
.withBindMounts([{source: MATTERMOST_DATA_DIR, target: '/mattermost/data', mode: 'rw'}])
|
||||
.withStartupTimeout(5 * 60_000)
|
||||
.withWaitStrategy(mattermostWaitStrategy());
|
||||
|
||||
// Upgrade-swap-to skips reuse: the old container was rm -f'd and reuse can reattach to an
|
||||
// unrelated leftover on the same image tag.
|
||||
//
|
||||
// upgrade-from must still use withReuse() when PW_TESTCONTAINERS_REUSE=true — otherwise
|
||||
// Ryuk reaps the server when the from-phase process exits and upgrade-to cannot adopt the
|
||||
// stack. assertUpgradeFromFreshStart() already requires testcontainers:down before from.
|
||||
if (testConfig.testcontainersReuse && !isUpgradeToPhaseProjectSelected()) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
33
e2e-tests/playwright/lib/src/containers/minio_container.ts
Normal file
33
e2e-tests/playwright/lib/src/containers/minio_container.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {MINIO_ACCESS_KEY, MINIO_ALIAS, MINIO_PORT, MINIO_SECRET_KEY, TESTCONTAINERS_LABELS} from './constants';
|
||||
import {MINIO_IMAGE} from './default_images';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export async function startMinioContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('minio', async () => {
|
||||
let builder = new GenericContainer(MINIO_IMAGE)
|
||||
.withCommand(['server', '/data', '--console-address', ':9002'])
|
||||
.withExposedPorts(MINIO_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(MINIO_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withEnvironment({
|
||||
MINIO_ROOT_USER: MINIO_ACCESS_KEY,
|
||||
MINIO_ROOT_PASSWORD: MINIO_SECRET_KEY,
|
||||
})
|
||||
.withWaitStrategy(Wait.forHttp('/minio/health/live', MINIO_PORT).forStatusCode(200));
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
143
e2e-tests/playwright/lib/src/containers/mmctl_container.ts
Normal file
143
e2e-tests/playwright/lib/src/containers/mmctl_container.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {execFile} from 'node:child_process';
|
||||
import {promisify} from 'node:util';
|
||||
|
||||
import {GenericContainer} from 'testcontainers';
|
||||
import type {WaitStrategy} from 'testcontainers';
|
||||
|
||||
import {MATTERMOST_ALIAS, MATTERMOST_PORT, TESTCONTAINERS_LABELS} from './constants';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const MMCTL_ENTRYPOINT = '/mattermost/bin/mmctl';
|
||||
const MMCTL_CONFIG_DIR = '/tmp/mmctl-xdg';
|
||||
const MMCTL_CREDENTIALS_NAME = 'e2e';
|
||||
|
||||
export type MmctlResult = {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
};
|
||||
|
||||
// Matches the Credentials/CredentialsList shape mmctl reads (server/cmd/mmctl/commands/auth_utils.go).
|
||||
// Written directly instead of via `mmctl auth login`, since that needs a password file and this
|
||||
// image has no shell to create one. AuthMethod "T" treats authToken as a plain bearer token, which
|
||||
// mmctl reads into Client4.AuthToken.
|
||||
function buildCredentialsFileContent(username: string, authToken: string): string {
|
||||
const credentialsList = {
|
||||
[MMCTL_CREDENTIALS_NAME]: {
|
||||
name: MMCTL_CREDENTIALS_NAME,
|
||||
username,
|
||||
authToken,
|
||||
authMethod: 'T',
|
||||
instanceUrl: `http://${MATTERMOST_ALIAS}:${MATTERMOST_PORT}`,
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(credentialsList);
|
||||
}
|
||||
|
||||
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
// Wait.forOneShotStartup() throws on any non-zero exit code, but a non-zero exit from mmctl is a
|
||||
// meaningful result to inspect, not a startup failure. This strategy ignores the exit code;
|
||||
// completion is awaited afterward via `docker wait`.
|
||||
class NoOpWaitStrategy implements WaitStrategy {
|
||||
private startupTimeoutMs = 0;
|
||||
|
||||
async waitUntilReady(): Promise<void> {
|
||||
// No-op — completion is awaited by the caller via `docker wait`.
|
||||
}
|
||||
|
||||
withStartupTimeout(startupTimeoutMs: number): this {
|
||||
this.startupTimeoutMs = startupTimeoutMs;
|
||||
return this;
|
||||
}
|
||||
|
||||
isStartupTimeoutSet(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
getStartupTimeout(): number {
|
||||
return this.startupTimeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForExitCode(containerId: string): Promise<number> {
|
||||
// Bounded so a hung mmctl command can't stall cleanup indefinitely.
|
||||
const {stdout} = await execFileAsync('docker', ['wait', containerId], {timeout: 60_000});
|
||||
return parseInt(stdout.trim(), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single mmctl command in its own throwaway container built from the server image, acting
|
||||
* as a real remote client rather than the `--local` unix-socket mode used for the server's healthcheck.
|
||||
*
|
||||
* Joins the network by name (withNetworkMode), not via getNetwork() — this runs in the Playwright
|
||||
* worker process, a different OS process from the one that created the network, so getNetwork()'s
|
||||
* in-process cache would create a second, unrelated network instead of finding the real one.
|
||||
*/
|
||||
export async function runMmctl(args: string[], username: string, authToken: string): Promise<MmctlResult> {
|
||||
if (!testConfig.testcontainersNetworkName) {
|
||||
throw new Error(
|
||||
'No Testcontainers network name available (PW_TESTCONTAINERS_NETWORK_NAME) — is PW_USE_TESTCONTAINERS=true?',
|
||||
);
|
||||
}
|
||||
|
||||
const container = await new GenericContainer(testConfig.serverImage)
|
||||
.withPlatform('linux/amd64') // The published server images are amd64-only.
|
||||
.withNetworkMode(testConfig.testcontainersNetworkName)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withEnvironment({XDG_CONFIG_HOME: MMCTL_CONFIG_DIR})
|
||||
.withCopyContentToContainer([
|
||||
{
|
||||
content: buildCredentialsFileContent(username, authToken),
|
||||
target: `${MMCTL_CONFIG_DIR}/mmctl/config`,
|
||||
},
|
||||
])
|
||||
.withEntrypoint([MMCTL_ENTRYPOINT])
|
||||
.withCommand(args)
|
||||
.withWaitStrategy(new NoOpWaitStrategy())
|
||||
.withStartupTimeout(60_000)
|
||||
.start();
|
||||
|
||||
try {
|
||||
const exitCode = await waitForExitCode(container.getId());
|
||||
const output = await streamToString(await container.logs());
|
||||
return {exitCode, output};
|
||||
} finally {
|
||||
await container.stop({remove: true});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs mmctl inside the already-running server container over its `--local` unix socket
|
||||
* (enabled by SERVER_ENV_BASELINE's MM_SERVICESETTINGS_ENABLELOCALMODE).
|
||||
*
|
||||
* Unlike runMmctl(), this needs no admin session, which makes it the only way to change server
|
||||
* config before global setup creates the first user.
|
||||
*/
|
||||
export async function runMmctlLocal(args: string[]): Promise<MmctlResult> {
|
||||
if (!testConfig.mattermostContainerId) {
|
||||
throw new Error('No running Mattermost container to exec mmctl --local in.');
|
||||
}
|
||||
|
||||
const execArgs = ['exec', testConfig.mattermostContainerId, MMCTL_ENTRYPOINT, '--local', ...args];
|
||||
try {
|
||||
const {stdout, stderr} = await execFileAsync('docker', execArgs, {timeout: 60_000});
|
||||
return {exitCode: 0, output: `${stdout}${stderr}`};
|
||||
} catch (error) {
|
||||
const failure = error as {code?: unknown; stdout?: string; stderr?: string};
|
||||
const output = `${failure.stdout ?? ''}${failure.stderr ?? ''}`;
|
||||
return {exitCode: typeof failure.code === 'number' ? failure.code : 1, output: output || String(error)};
|
||||
}
|
||||
}
|
||||
73
e2e-tests/playwright/lib/src/containers/network.ts
Normal file
73
e2e-tests/playwright/lib/src/containers/network.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {execFile} from 'node:child_process';
|
||||
import {promisify} from 'node:util';
|
||||
|
||||
import {Network} from 'testcontainers';
|
||||
import type {StartedNetwork} from 'testcontainers';
|
||||
|
||||
import {logTestcontainers, warnTestcontainers} from './log';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// One bridge network per Playwright invocation, shared by every container it starts. When
|
||||
// Playwright itself runs inside a container, that container also joins this network so it can
|
||||
// reach everything by alias instead of a mapped port.
|
||||
let startedNetwork: StartedNetwork | undefined;
|
||||
|
||||
export async function getNetwork(): Promise<StartedNetwork> {
|
||||
if (!startedNetwork) {
|
||||
logTestcontainers('creating network...');
|
||||
startedNetwork = await new Network().start();
|
||||
logTestcontainers('network created.');
|
||||
}
|
||||
return startedNetwork;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge network's gateway IP (e.g. 172.18.0.1) — a real address bound to an interface on the
|
||||
* Docker host itself, so a process listening on 0.0.0.0 on the host is reachable both from the
|
||||
* host directly and from any container on this network, without a network alias or mapped port.
|
||||
* Takes a network id/name rather than a StartedNetwork object so it also works from
|
||||
* reuseExistingStack(), which only has testConfig's network name.
|
||||
*/
|
||||
export async function getNetworkGatewayIp(network: string): Promise<string> {
|
||||
const {stdout} = await execFileAsync('docker', [
|
||||
'network',
|
||||
'inspect',
|
||||
network,
|
||||
'--format',
|
||||
'{{(index .IPAM.Config 0).Gateway}}',
|
||||
]);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export async function stopNetwork(): Promise<void> {
|
||||
if (!startedNetwork) {
|
||||
return;
|
||||
}
|
||||
|
||||
const network = startedNetwork;
|
||||
startedNetwork = undefined;
|
||||
|
||||
// A container a worker process swapped in via restartMattermostContainer() (a different OS
|
||||
// process, invisible to this one) can still be mid-detach from the network at this exact
|
||||
// moment, which Docker reports as "has active endpoints". Ryuk removes the network anyway
|
||||
// once that settles, so retry briefly rather than surfacing a scary but harmless error.
|
||||
const attempts = 5;
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
await network.stop();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === attempts) {
|
||||
warnTestcontainers(
|
||||
`could not remove network ${network.getId()} (Ryuk will remove it shortly): ${String(error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {OPENLDAP_ADMIN_PASSWORD, OPENLDAP_ALIAS, OPENLDAP_PORT, TESTCONTAINERS_LABELS} from './constants';
|
||||
import {OPENLDAP_IMAGE} from './default_images';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// osixia/openldap is known to occasionally fail its first boot under load — startWithRetry
|
||||
// retries a bounded number of times rather than letting a flaky first attempt fail the whole run.
|
||||
export async function startOpenldapContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('openldap', async () => {
|
||||
let builder = new GenericContainer(OPENLDAP_IMAGE)
|
||||
.withExposedPorts(OPENLDAP_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(OPENLDAP_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withEnvironment({
|
||||
LDAP_TLS_VERIFY_CLIENT: 'never',
|
||||
LDAP_ORGANISATION: 'Mattermost Test',
|
||||
LDAP_DOMAIN: 'mm.test.com',
|
||||
LDAP_ADMIN_PASSWORD: OPENLDAP_ADMIN_PASSWORD,
|
||||
})
|
||||
.withStartupTimeout(2 * 60_000)
|
||||
.withWaitStrategy(Wait.forListeningPorts());
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {OPENSEARCH_ADMIN_PASSWORD, OPENSEARCH_ALIAS, OPENSEARCH_PORT, TESTCONTAINERS_LABELS} from './constants';
|
||||
import {OPENSEARCH_VERSION as DEFAULT_OPENSEARCH_VERSION} from './default_images';
|
||||
import {containerAssetPath} from './paths';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// Built from a vendored Dockerfile (installs the same CJK analysis plugins as the
|
||||
// Elasticsearch container) — an alternative to Elasticsearch for search.
|
||||
export async function startOpensearchContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('opensearch', async () => {
|
||||
// deleteOnExit: false — otherwise the built image bakes in this session's Ryuk id, so Ryuk
|
||||
// reaps the "reused" container the moment this session ends, defeating withReuse() below.
|
||||
const image = await GenericContainer.fromDockerfile(containerAssetPath(), 'Dockerfile.opensearch')
|
||||
.withBuildArgs({OPENSEARCH_VERSION: process.env.OPENSEARCH_VERSION || DEFAULT_OPENSEARCH_VERSION})
|
||||
.build(undefined, {deleteOnExit: false});
|
||||
|
||||
let builder = image
|
||||
.withExposedPorts(OPENSEARCH_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(OPENSEARCH_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withEnvironment({
|
||||
'http.port': String(OPENSEARCH_PORT),
|
||||
'discovery.type': 'single-node',
|
||||
'plugins.security.disabled': 'true',
|
||||
DISABLE_INSTALL_DEMO_CONFIG: 'true',
|
||||
OPENSEARCH_INITIAL_ADMIN_PASSWORD: OPENSEARCH_ADMIN_PASSWORD,
|
||||
OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m',
|
||||
})
|
||||
.withStartupTimeout(3 * 60_000)
|
||||
.withWaitStrategy(Wait.forHttp('/_cluster/health', OPENSEARCH_PORT).forStatusCode(200));
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
19
e2e-tests/playwright/lib/src/containers/paths.ts
Normal file
19
e2e-tests/playwright/lib/src/containers/paths.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
// Resolved relative to this module's own location (not the caller's cwd or the monorepo), so it
|
||||
// keeps working once `@mattermost/playwright-lib` is installed as an npm package with no access
|
||||
// to the rest of the repo. `preserveModules` keeps `containers/assets` alongside this file's
|
||||
// compiled output in `dist`, same as `src`.
|
||||
//
|
||||
// `__dirname` rather than `import.meta.url`: despite `"type": "module"`, Playwright loads this
|
||||
// package via `require()`, not `import()` — and Node's require()-of-ESM interop disallows
|
||||
// `import.meta` (throws "Cannot use 'import.meta' outside a module"), while `__dirname` still
|
||||
// resolves since Node wraps the module as CommonJS to support that require() call.
|
||||
const assetsDir = path.join(__dirname, 'assets');
|
||||
|
||||
export function containerAssetPath(...segments: string[]): string {
|
||||
return path.join(assetsDir, ...segments);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {PostgreSqlContainer} from '@testcontainers/postgresql';
|
||||
import type {StartedPostgreSqlContainer} from '@testcontainers/postgresql';
|
||||
import {Wait} from 'testcontainers';
|
||||
import type {StartedNetwork} from 'testcontainers';
|
||||
|
||||
import {POSTGRES_ALIAS, POSTGRES_DB, POSTGRES_PASSWORD, POSTGRES_USER, TESTCONTAINERS_LABELS} from './constants';
|
||||
import {POSTGRES_IMAGE} from './default_images';
|
||||
import {containerAssetPath} from './paths';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export async function startPostgresContainer(network: StartedNetwork): Promise<StartedPostgreSqlContainer> {
|
||||
return startWithRetry('postgres', async () => {
|
||||
let builder = new PostgreSqlContainer(POSTGRES_IMAGE)
|
||||
.withDatabase(POSTGRES_DB)
|
||||
.withUsername(POSTGRES_USER)
|
||||
.withPassword(POSTGRES_PASSWORD)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(POSTGRES_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withCopyFilesToContainer([
|
||||
{
|
||||
source: containerAssetPath('postgres.conf'),
|
||||
target: '/etc/postgresql/postgresql.conf',
|
||||
},
|
||||
])
|
||||
.withCommand(['postgres', '-c', 'config_file=/etc/postgresql/postgresql.conf'])
|
||||
.withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 1));
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
27
e2e-tests/playwright/lib/src/containers/requirements.ts
Normal file
27
e2e-tests/playwright/lib/src/containers/requirements.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {startAzuriteContainer} from './azurite_container';
|
||||
import {startElasticsearchContainer} from './elasticsearch_container';
|
||||
import {startKeycloakContainer} from './keycloak_container';
|
||||
import {startMinioContainer} from './minio_container';
|
||||
import {startOpenldapContainer} from './openldap_container';
|
||||
import {startOpensearchContainer} from './opensearch_container';
|
||||
|
||||
import type {TestContainersServiceName} from '@/test_config';
|
||||
|
||||
// The single place to extend when a new additional service is needed. `stack.ts` starts these
|
||||
// only for the names in testConfig.testcontainersServices.
|
||||
export const ADDITIONAL_SERVICE_STARTERS: Record<
|
||||
TestContainersServiceName,
|
||||
(network: StartedNetwork) => Promise<StartedTestContainer>
|
||||
> = {
|
||||
openldap: startOpenldapContainer,
|
||||
keycloak: startKeycloakContainer,
|
||||
elasticsearch: startElasticsearchContainer,
|
||||
opensearch: startOpensearchContainer,
|
||||
minio: startMinioContainer,
|
||||
azurite: startAzuriteContainer,
|
||||
};
|
||||
38
e2e-tests/playwright/lib/src/containers/retry.ts
Normal file
38
e2e-tests/playwright/lib/src/containers/retry.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {errorTestcontainers} from './log';
|
||||
|
||||
import {duration, wait} from '@/util';
|
||||
|
||||
const DEFAULT_ATTEMPTS = 3;
|
||||
|
||||
// Wraps a container's build+start so transient failures (image pull/build over a flaky network,
|
||||
// occasional first-boot flakiness) get a bounded, backed-off retry instead of failing the whole
|
||||
// run — and so any failure, at any attempt, is unambiguous about which container/image caused it,
|
||||
// rather than surfacing as testcontainers' own generic "Failed to build image"/"Failed to start
|
||||
// container" with no name attached.
|
||||
export async function startWithRetry<T>(
|
||||
label: string,
|
||||
start: () => Promise<T>,
|
||||
attempts = DEFAULT_ATTEMPTS,
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
return await start();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
errorTestcontainers(`"${label}" failed on attempt ${attempt}/${attempts}: ${message}`);
|
||||
if (attempt < attempts) {
|
||||
await wait(duration.two_sec * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to start "${label}" container after ${attempts} attempts: ${String(lastError)}`, {
|
||||
cause: lastError,
|
||||
});
|
||||
}
|
||||
771
e2e-tests/playwright/lib/src/containers/stack.ts
Normal file
771
e2e-tests/playwright/lib/src/containers/stack.ts
Normal file
@@ -0,0 +1,771 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {execFile} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {promisify} from 'node:util';
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
import type {StartedPostgreSqlContainer} from '@testcontainers/postgresql';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {
|
||||
AZURITE_ALIAS,
|
||||
AZURITE_BLOB_PORT,
|
||||
ELASTICSEARCH_ALIAS,
|
||||
ELASTICSEARCH_PORT,
|
||||
INBUCKET_ALIAS,
|
||||
INBUCKET_WEB_PORT,
|
||||
KEYCLOAK_ALIAS,
|
||||
KEYCLOAK_PORT,
|
||||
MATTERMOST_DATA_DIR,
|
||||
MATTERMOST_ALIAS,
|
||||
MATTERMOST_PORT,
|
||||
MINIO_ALIAS,
|
||||
MINIO_PORT,
|
||||
OPENLDAP_ALIAS,
|
||||
OPENLDAP_PORT,
|
||||
OPENSEARCH_ALIAS,
|
||||
OPENSEARCH_PORT,
|
||||
POSTGRES_ALIAS,
|
||||
POSTGRES_DB,
|
||||
POSTGRES_PASSWORD,
|
||||
POSTGRES_PORT,
|
||||
POSTGRES_USER,
|
||||
WEBHOOK_ALIAS,
|
||||
WEBHOOK_PORT,
|
||||
} from './constants';
|
||||
import {
|
||||
AZURITE_IMAGE,
|
||||
ELASTICSEARCH_VERSION,
|
||||
INBUCKET_IMAGE,
|
||||
KEYCLOAK_IMAGE,
|
||||
MINIO_IMAGE,
|
||||
OPENLDAP_IMAGE,
|
||||
OPENSEARCH_VERSION,
|
||||
POSTGRES_IMAGE,
|
||||
} from './default_images';
|
||||
import {startInbucketContainer} from './inbucket_container';
|
||||
import {logTestcontainers} from './log';
|
||||
import {resolveMattermostBootEnv, startMattermostContainer} from './mattermost_container';
|
||||
import {getNetwork, getNetworkGatewayIp, stopNetwork} from './network';
|
||||
import {startPostgresContainer} from './postgres_container';
|
||||
import {ADDITIONAL_SERVICE_STARTERS} from './requirements';
|
||||
import {startWebhookContainer} from './webhook_container';
|
||||
|
||||
import {clearClientCache} from '@/server/client';
|
||||
import {defaultBootEnv, testConfig} from '@/test_config';
|
||||
import type {TestContainersServiceName} from '@/test_config';
|
||||
import {isUpgradeToPhaseProjectSelected, upgradeToStackNotRunningError} from '@/upgrade_env';
|
||||
import {duration} from '@/util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const ENV_FILE_PATH = path.resolve(process.cwd(), '.env.testcontainers');
|
||||
const LOG_DIR = path.resolve(process.cwd(), 'logs');
|
||||
|
||||
type StartedStack = {
|
||||
network: StartedNetwork;
|
||||
postgres: StartedPostgreSqlContainer;
|
||||
inbucket: StartedTestContainer;
|
||||
webhook: StartedTestContainer;
|
||||
mattermost: StartedTestContainer;
|
||||
additional: Partial<Record<TestContainersServiceName, StartedTestContainer>>;
|
||||
};
|
||||
|
||||
let started: StartedStack | undefined;
|
||||
// True if this process is running against a stack an EARLIER process created (via
|
||||
// reuseExistingStack()) rather than one it started itself — there are no real Testcontainers
|
||||
// handles to hold in this case, only values .env.testcontainers resolved into testConfig via
|
||||
// dotenv. A reusing process never owns the stack's lifecycle, so stopStack() must leave both
|
||||
// the containers and the env file untouched for whatever other process still needs them.
|
||||
let reused = false;
|
||||
|
||||
/**
|
||||
* Brings up a bridge network, Postgres, Inbucket, the Mattermost server, and whichever
|
||||
* additional services testConfig.testcontainersServices names. No-op if `testcontainers` mode isn't
|
||||
* selected (PW_USE_TESTCONTAINERS unset), already started (repeated calls within the same
|
||||
* process, e.g. a stray double-invocation, are harmless), or already reused. Otherwise first
|
||||
* checks whether an earlier process's stack is still alive and reuses it instead — see
|
||||
* reuseExistingStack().
|
||||
*/
|
||||
export async function startStack(): Promise<void> {
|
||||
if (!testConfig.useTestContainers || started || reused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await reuseExistingStack()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUpgradeToPhaseProjectSelected()) {
|
||||
throw upgradeToStackNotRunningError();
|
||||
}
|
||||
|
||||
// reuseExistingStack() found nothing live to reuse — any bootEnvOverrides read from a stale
|
||||
// .env.testcontainers (e.g. left behind by a manual `docker rm` or a crashed prior process)
|
||||
// no longer describes anything real. Reset to the genuine defaults the container about to be
|
||||
// created will actually boot with, or a later restart could wrongly believe some stale
|
||||
// setting is already active and skip a restart it actually needs.
|
||||
testConfig.bootEnvOverrides = defaultBootEnv();
|
||||
|
||||
// The bind-mounted data dir persists across container restarts; reset only on a fresh boot.
|
||||
// Mattermost runs as UID 2000 and creates 0750 dirs (see makeMattermostDataHostReadable). The
|
||||
// host workspace is a different user, so this empty mount must be world-writable or the
|
||||
// server cannot create those dirs. Host chown to 2000 is not reliable (non-root CI, rootless
|
||||
// Docker, Docker Desktop UID mapping).
|
||||
await resetLocalStorageDir();
|
||||
|
||||
const network = await getNetwork();
|
||||
|
||||
if (testConfig.containerRunner) {
|
||||
await joinSelfToNetwork(network.getId());
|
||||
}
|
||||
|
||||
// Stored so a host-side mock file server can be reached from both the browser and containers
|
||||
// on this network (see getNetworkGatewayIp).
|
||||
testConfig.testcontainersNetworkGatewayIp = await getNetworkGatewayIp(network.getId());
|
||||
|
||||
const additionalNames = testConfig.testcontainersServices;
|
||||
|
||||
logTestcontainers(
|
||||
`pulling/starting images: server, postgres, inbucket, webhook${additionalNames.length ? `, ${additionalNames.join(', ')}` : ''}`,
|
||||
);
|
||||
await logServerImageAge(testConfig.serverImage);
|
||||
|
||||
// Tracks every container that actually comes up, independent of whether the group as a whole
|
||||
// (or the mattermost start after it) ultimately succeeds — so a failure partway through still
|
||||
// knows exactly what to tear down instead of leaking whatever already started.
|
||||
const startedContainers: StartedTestContainer[] = [];
|
||||
const trackAndLog = <T extends StartedTestContainer>(name: string, promise: Promise<T>): Promise<T> => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
// The biggest blind spot is the server: its own wait strategy alone can take minutes
|
||||
// (see mattermost_container.ts), during which nothing else prints — so ping every 30s to
|
||||
// make clear the run hasn't stalled.
|
||||
const heartbeat = setInterval(() => {
|
||||
logTestcontainers(`still waiting on ${name} (${elapsedSeconds(startedAt)}s elapsed)...`);
|
||||
}, duration.half_min);
|
||||
|
||||
return promise.then(
|
||||
(container) => {
|
||||
clearInterval(heartbeat);
|
||||
startedContainers.push(container);
|
||||
logTestcontainers(`${name} ready in ${elapsedSeconds(startedAt)}s.`);
|
||||
return container;
|
||||
},
|
||||
(error) => {
|
||||
clearInterval(heartbeat);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const [postgres, inbucket, webhook, ...additionalContainers] = await Promise.all([
|
||||
trackAndLog('postgres', startPostgresContainer(network)),
|
||||
trackAndLog('inbucket', startInbucketContainer(network)),
|
||||
trackAndLog('webhook', startWebhookContainer(network)),
|
||||
...additionalNames.map((name) => trackAndLog(name, ADDITIONAL_SERVICE_STARTERS[name](network))),
|
||||
]);
|
||||
|
||||
const additional: Partial<Record<TestContainersServiceName, StartedTestContainer>> = {};
|
||||
additionalNames.forEach((name, index) => {
|
||||
additional[name] = additionalContainers[index];
|
||||
});
|
||||
|
||||
const mattermost = await trackAndLog('server', startMattermostContainer(network.getName()));
|
||||
|
||||
started = {network, postgres, inbucket, webhook, mattermost, additional};
|
||||
} catch (error) {
|
||||
await Promise.allSettled(startedContainers.map((container) => container.stop()));
|
||||
await stopNetwork();
|
||||
throw error;
|
||||
}
|
||||
|
||||
applyResolvedConfig(started);
|
||||
resetEnvFile('initial boot');
|
||||
|
||||
logStackStarted(started);
|
||||
}
|
||||
|
||||
/**
|
||||
* True if .env.testcontainers points at a Mattermost container that's still running — i.e. some
|
||||
* OTHER process already brought up a stack this process should reuse instead of duplicating.
|
||||
* Always a different process (the only channel between them is the env file, not runtime IPC);
|
||||
* covers PW_TESTCONTAINERS_REUSE=true across separate local invocations, and a CI dispatcher that
|
||||
* starts the server once per worker job and runs one spec per process against it.
|
||||
*
|
||||
* Deliberately does not gate on testConfig.testcontainersReuse: that flag governs whether the
|
||||
* OWNING process leaves the stack running on its own exit — a different decision from whether
|
||||
* THIS process should reuse a stack it finds already alive. Reuse always applies once liveness
|
||||
* is confirmed.
|
||||
*/
|
||||
async function reuseExistingStack(): Promise<boolean> {
|
||||
if (!testConfig.mattermostContainerId || !(await isContainerRunning(testConfig.mattermostContainerId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
reused = true;
|
||||
|
||||
if (testConfig.containerRunner) {
|
||||
await joinSelfToNetwork(testConfig.testcontainersNetworkName);
|
||||
}
|
||||
|
||||
logTestcontainers(
|
||||
'reusing already-running server (with PW_TESTCONTAINERS_REUSE=true), see .env.testcontainers for stack information',
|
||||
);
|
||||
logStackReused();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Same shape of summary as logStackStarted(), but built from testConfig's resolved fields
|
||||
// instead of live StartedTestContainer handles — reusing never gets those (see `reused`'s
|
||||
// declaration above), only whatever an EARLIER process's startStack() persisted to
|
||||
// .env.testcontainers and this process's dotenv.config() read back into testConfig.
|
||||
function logStackReused(): void {
|
||||
const lines: string[] = [
|
||||
` - ${'server'.padEnd(13)} = ${testConfig.baseURL}`,
|
||||
` - ${'postgres'.padEnd(13)} = ${testConfig.postgresUrl}`,
|
||||
` - ${'inbucket'.padEnd(13)} = ${testConfig.smtpURL}`,
|
||||
` - ${'webhook'.padEnd(13)} = ${testConfig.webhookBaseUrl}`,
|
||||
];
|
||||
|
||||
const additionalUrls: Record<TestContainersServiceName, string> = {
|
||||
openldap: `${testConfig.ldapHost}:${testConfig.ldapPort}`,
|
||||
keycloak: testConfig.keycloakUrl,
|
||||
elasticsearch: testConfig.elasticsearchUrl,
|
||||
opensearch: testConfig.opensearchUrl,
|
||||
minio: testConfig.minioUrl,
|
||||
azurite: testConfig.azuriteUrl,
|
||||
};
|
||||
testConfig.testcontainersServices.forEach((name) => {
|
||||
lines.push(` - ${name.padEnd(13)} = ${additionalUrls[name]}`);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Testcontainers (reused, network ${testConfig.testcontainersNetworkName}, tear down with: "npm run testcontainers:down"):\n${lines.join('\n')}\n${formatServerEnvSummary()}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function isContainerRunning(containerId: string): Promise<boolean> {
|
||||
try {
|
||||
const {stdout} = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}}', containerId]);
|
||||
return stdout.trim() === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the stack: always collects logs and removes the generated env file; only actually
|
||||
* stops containers when reuse isn't enabled (PW_TESTCONTAINERS_REUSE=true leaves them running
|
||||
* for the next invocation — local or a CI dispatcher's next spec — to reuse).
|
||||
*
|
||||
* A no-op beyond clearing the local flag when this process reused rather than created the stack:
|
||||
* it never owned the containers or the env file, so it must leave both exactly as it found them
|
||||
* for whichever process (or later dispatch) still depends on them.
|
||||
*/
|
||||
export async function stopStack(options: {force?: boolean} = {}): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reused) {
|
||||
reused = false;
|
||||
logTestcontainers('this process reused an existing server — leaving it untouched.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stack = started;
|
||||
await collectLogs(stack);
|
||||
|
||||
const shouldStop = options.force || !testConfig.testcontainersReuse;
|
||||
if (shouldStop) {
|
||||
await Promise.allSettled([
|
||||
stack.mattermost.stop(),
|
||||
stack.inbucket.stop(),
|
||||
stack.webhook.stop(),
|
||||
stack.postgres.stop(),
|
||||
...Object.values(stack.additional).map((container) => container?.stop()),
|
||||
]);
|
||||
await stopNetwork();
|
||||
logStackStopped(stack);
|
||||
archiveEnvFile();
|
||||
removeEnvFile();
|
||||
} else {
|
||||
logStackLeftRunning(stack);
|
||||
}
|
||||
|
||||
started = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if every key in `env` already has the given value in testConfig.bootEnvOverrides — i.e.
|
||||
* the currently-running Mattermost container was already booted this way, so a pw.ensure*() can
|
||||
* skip restartMattermostContainer() for these settings.
|
||||
*/
|
||||
export function bootEnvMatches(env: Record<string, string>): boolean {
|
||||
return Object.entries(env).every(([key, value]) => testConfig.bootEnvOverrides[key] === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the current Mattermost container and starts a fresh one with additional env merged in —
|
||||
* used for settings like FileSettings.DriverName, ElasticsearchSettings.Backend, or
|
||||
* MM_FEATUREFLAGS_* which are never re-read from a running server, so patchConfig alone can't
|
||||
* change them.
|
||||
*
|
||||
* Safe to call from a Playwright worker process, unlike startStack()/stopStack(): it works from
|
||||
* testConfig's container id and network name rather than the in-process StartedStack, since
|
||||
* global setup (which owns that in-process state) and worker processes are different OS
|
||||
* processes.
|
||||
*
|
||||
* `env` is merged into testConfig.bootEnvOverrides (not replaced) so an earlier pw.ensure*()
|
||||
* call's settings survive a later, unrelated one restarting the same container again. The merged
|
||||
* result, new container id, and new baseURL are appended to .env.testcontainers so any other
|
||||
* process picks up the real current state instead of a stale or default one.
|
||||
*
|
||||
* Always performs the restart without checking whether `env` is already active — callers (e.g.
|
||||
* ensureMinio()/ensureAzurite()) own that decision via bootEnvMatches().
|
||||
*/
|
||||
export async function restartMattermostContainer(env: Record<string, string>): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
throw new Error('restartMattermostContainer requires PW_USE_TESTCONTAINERS=true.');
|
||||
}
|
||||
if (!testConfig.mattermostContainerId || !testConfig.testcontainersNetworkName) {
|
||||
throw new Error(
|
||||
'No running Testcontainers stack to restart (missing Mattermost container id or network name).',
|
||||
);
|
||||
}
|
||||
|
||||
extendTimeoutForRestart();
|
||||
|
||||
testConfig.bootEnvOverrides = {...testConfig.bootEnvOverrides, ...env};
|
||||
|
||||
await execFileAsync('docker', ['rm', '-f', testConfig.mattermostContainerId]);
|
||||
|
||||
const mattermost = await startMattermostContainer(
|
||||
testConfig.testcontainersNetworkName,
|
||||
testConfig.bootEnvOverrides,
|
||||
);
|
||||
|
||||
testConfig.baseURL = resolveUrl(mattermost, MATTERMOST_PORT, MATTERMOST_ALIAS);
|
||||
testConfig.mattermostContainerId = mattermost.getId();
|
||||
clearClientCache();
|
||||
|
||||
const redactedEnv = redactServerEnv(env);
|
||||
appendEnvFile(`restart requested by ${describeCurrentTest()} — env ${JSON.stringify(redactedEnv)}`);
|
||||
|
||||
logTestcontainers(`restarted server (${testConfig.serverImage}) with ${JSON.stringify(redactedEnv)}.`);
|
||||
await logServerImageAge(testConfig.serverImage);
|
||||
}
|
||||
|
||||
// Empties the server's bind-mounted data directory on a fresh boot. The server writes as UID 2000
|
||||
// into 0750 dirs, so on Linux the host user cannot delete them — which failed global setup on
|
||||
// every second boot, and so on every retest. Falls back to clearing from a container, as root.
|
||||
async function resetLocalStorageDir(): Promise<void> {
|
||||
try {
|
||||
fs.rmSync(MATTERMOST_DATA_DIR, {recursive: true, force: true});
|
||||
} catch (error) {
|
||||
// Not matched on errno: EACCES on Linux, ENOTEMPTY on macOS, same cause.
|
||||
const code = (error as NodeJS.ErrnoException).code ?? 'unknown';
|
||||
logTestcontainers(`server data dir not removable as this user (${code}); clearing it in a container`);
|
||||
try {
|
||||
await clearLocalStorageInContainer();
|
||||
} catch (containerError) {
|
||||
// Starting dirty would carry a previous run's uploads into this run's assertions.
|
||||
throw new Error(
|
||||
`Could not clear ${MATTERMOST_DATA_DIR} (host: ${code}, container: ` +
|
||||
`${(containerError as Error).message}). Remove it manually and re-run.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(MATTERMOST_DATA_DIR, {recursive: true});
|
||||
fs.chmodSync(MATTERMOST_DATA_DIR, 0o777);
|
||||
}
|
||||
|
||||
// Clears the mount's contents (not the mount point) as root, which the host uid mapping does not
|
||||
// restrict. Same shape as makeMattermostDataHostReadable(): Postgres image, already pulled per run.
|
||||
// `find -delete` needs no shell — the server image ships none.
|
||||
async function clearLocalStorageInContainer(): Promise<void> {
|
||||
fs.mkdirSync(MATTERMOST_DATA_DIR, {recursive: true});
|
||||
await execFileAsync('docker', [
|
||||
'run',
|
||||
'--rm',
|
||||
'--user',
|
||||
'0',
|
||||
'-v',
|
||||
`${MATTERMOST_DATA_DIR}:/data`,
|
||||
POSTGRES_IMAGE,
|
||||
'find',
|
||||
'/data',
|
||||
'-mindepth',
|
||||
'1',
|
||||
'-delete',
|
||||
]);
|
||||
}
|
||||
|
||||
// Identifies whichever spec/test is currently driving a restart, so .env.testcontainers's history
|
||||
// shows why the server ended up in its current state. restartMattermostContainer() is always
|
||||
// called from inside a running test, so test.info() should resolve; the fallback only guards a
|
||||
// future caller that isn't.
|
||||
function describeCurrentTest(): string {
|
||||
try {
|
||||
const info = test.info();
|
||||
return `${path.relative(process.cwd(), info.file)} > ${info.title}`;
|
||||
} catch {
|
||||
return 'unknown caller (not running inside a test)';
|
||||
}
|
||||
}
|
||||
|
||||
// startMattermostContainer()'s wait strategy blocks on a scheduler log line whose first tick is
|
||||
// deliberately delayed 60s after startup (see that function's comment), so a restart alone can
|
||||
// exceed the suite's default 60s test timeout before the test has done any of its own work.
|
||||
// Playwright's timeout wouldn't cancel the still-in-flight restart, so a timed-out retry can race
|
||||
// it and hit a container mid-swap. Raising (not just extending) the timeout avoids ratcheting it
|
||||
// down if a later restart in the same test calls this again after some budget is already spent.
|
||||
function extendTimeoutForRestart(): void {
|
||||
try {
|
||||
const info = test.info();
|
||||
info.setTimeout(Math.max(info.timeout, duration.four_min));
|
||||
} catch {
|
||||
// Not running inside a test (e.g. called from a script) — nothing to extend.
|
||||
}
|
||||
}
|
||||
|
||||
// containerRunner mode: join the calling `playwright` container to the same network so its own
|
||||
// connections can use aliases too, instead of mapped ports. Relies on Docker setting the
|
||||
// container's hostname to its own container ID by default, and on the `docker` CLI being
|
||||
// present alongside the mounted socket.
|
||||
//
|
||||
// Takes a network name/ID string rather than a StartedNetwork object: reuseExistingStack() only
|
||||
// has testConfig.testcontainersNetworkName (read from .env.testcontainers) to work with, not a
|
||||
// live handle — and the docker CLI resolves either form the same way, so the freshly-created path
|
||||
// below just passes network.getId() instead.
|
||||
async function joinSelfToNetwork(networkId: string): Promise<void> {
|
||||
const selfContainerId = os.hostname();
|
||||
try {
|
||||
await execFileAsync('docker', ['network', 'connect', networkId, selfContainerId]);
|
||||
} catch (error) {
|
||||
// A CI dispatcher running one spec per process reuses the same stack (and this same
|
||||
// runner container) on every invocation, so this join is attempted again every time —
|
||||
// already-connected isn't a failure, it's the expected steady state after the first.
|
||||
if (String(error).includes('already exists in network')) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'containerRunner mode (PW_TESTCONTAINERS_CONTAINER_RUNNER=true) requires the calling container ' +
|
||||
'to join the Testcontainers network, but "docker network connect" failed for container ' +
|
||||
`"${selfContainerId}": ${String(error)}. Ensure /var/run/docker.sock is mounted and the docker ` +
|
||||
'CLI is installed in this image.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUrl(container: StartedTestContainer, port: number, alias: string): string {
|
||||
if (testConfig.containerRunner) {
|
||||
return `http://${alias}:${port}`;
|
||||
}
|
||||
return `http://${container.getHost()}:${container.getMappedPort(port)}`;
|
||||
}
|
||||
|
||||
function resolveHostAndPort(container: StartedTestContainer, port: number, alias: string): [string, number] {
|
||||
if (testConfig.containerRunner) {
|
||||
return [alias, port];
|
||||
}
|
||||
return [container.getHost(), container.getMappedPort(port)];
|
||||
}
|
||||
|
||||
// Same containerRunner-aware resolution as resolveUrl()/resolveHostAndPort() above: the alias in
|
||||
// containerRunner mode (the test process is on the Testcontainers network), the host-mapped port
|
||||
// otherwise — direct-DB specs are just another client connecting from wherever the test process
|
||||
// runs.
|
||||
function resolvePostgresUrl(postgres: StartedPostgreSqlContainer): string {
|
||||
const [host, port] = resolveHostAndPort(postgres, POSTGRES_PORT, POSTGRES_ALIAS);
|
||||
return `postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${host}:${port}/${POSTGRES_DB}?sslmode=disable&connect_timeout=10&binary_parameters=yes`;
|
||||
}
|
||||
|
||||
type ContainerMetadata = {alias: string; port: number; image: string};
|
||||
|
||||
const ADDITIONAL_CONTAINER_METADATA: Record<TestContainersServiceName, ContainerMetadata> = {
|
||||
openldap: {alias: OPENLDAP_ALIAS, port: OPENLDAP_PORT, image: OPENLDAP_IMAGE},
|
||||
keycloak: {alias: KEYCLOAK_ALIAS, port: KEYCLOAK_PORT, image: KEYCLOAK_IMAGE},
|
||||
elasticsearch: {
|
||||
alias: ELASTICSEARCH_ALIAS,
|
||||
port: ELASTICSEARCH_PORT,
|
||||
image: `built, Elasticsearch ${ELASTICSEARCH_VERSION}`,
|
||||
},
|
||||
opensearch: {alias: OPENSEARCH_ALIAS, port: OPENSEARCH_PORT, image: `built, OpenSearch ${OPENSEARCH_VERSION}`},
|
||||
minio: {alias: MINIO_ALIAS, port: MINIO_PORT, image: MINIO_IMAGE},
|
||||
azurite: {alias: AZURITE_ALIAS, port: AZURITE_BLOB_PORT, image: AZURITE_IMAGE},
|
||||
};
|
||||
|
||||
function containerEntries(stack: StartedStack): Array<[string, StartedTestContainer, ContainerMetadata]> {
|
||||
const base: Array<[string, StartedTestContainer, ContainerMetadata]> = [
|
||||
['server', stack.mattermost, {alias: MATTERMOST_ALIAS, port: MATTERMOST_PORT, image: testConfig.serverImage}],
|
||||
['postgres', stack.postgres, {alias: POSTGRES_ALIAS, port: POSTGRES_PORT, image: POSTGRES_IMAGE}],
|
||||
['inbucket', stack.inbucket, {alias: INBUCKET_ALIAS, port: INBUCKET_WEB_PORT, image: INBUCKET_IMAGE}],
|
||||
['webhook', stack.webhook, {alias: WEBHOOK_ALIAS, port: WEBHOOK_PORT, image: 'built, webhook sidecar'}],
|
||||
];
|
||||
|
||||
const additional = Object.entries(stack.additional)
|
||||
.filter((entry): entry is [TestContainersServiceName, StartedTestContainer] => entry[1] !== undefined)
|
||||
.map((entry): [string, StartedTestContainer, ContainerMetadata] => [
|
||||
entry[0],
|
||||
entry[1],
|
||||
ADDITIONAL_CONTAINER_METADATA[entry[0]],
|
||||
]);
|
||||
|
||||
return [...base, ...additional];
|
||||
}
|
||||
|
||||
function formatContainerLine(name: string, container: StartedTestContainer, metadata: ContainerMetadata): string {
|
||||
const host = `${container.getHost()}:${container.getMappedPort(metadata.port)}`;
|
||||
return ` - ${name.padEnd(13)} = ${metadata.image} (network: ${metadata.alias}:${metadata.port}, host: ${host})`;
|
||||
}
|
||||
|
||||
function elapsedSeconds(startedAt: number): string {
|
||||
return ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
}
|
||||
|
||||
// `master`/`release-*` tags get rebuilt continuously, so a cached copy can silently go stale;
|
||||
// pinned version tags (e.g. `:11.10.0`) never change, so they're excluded.
|
||||
const MUTABLE_IMAGE_TAG_PATTERN = /:(master|release-.+)$/;
|
||||
|
||||
async function logServerImageAge(image: string): Promise<void> {
|
||||
let created: Date;
|
||||
try {
|
||||
const {stdout} = await execFileAsync('docker', ['image', 'inspect', image, '--format', '{{.Created}}']);
|
||||
created = new Date(stdout.trim());
|
||||
} catch {
|
||||
// Not cached locally — Testcontainers will pull it fresh as part of starting the
|
||||
// container, so whatever comes up is already the latest build. Nothing to warn about.
|
||||
logTestcontainers(`server image "${image}" isn't cached locally yet — will pull the latest build.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ageHours = (Date.now() - created.getTime()) / (60 * 60 * 1000);
|
||||
const age = ageHours >= 48 ? `${(ageHours / 24).toFixed()}d` : `${ageHours.toFixed()}h`;
|
||||
const looksStale = MUTABLE_IMAGE_TAG_PATTERN.test(image) && ageHours > 24;
|
||||
|
||||
logTestcontainers(
|
||||
`server image "${image}" (built ${created.toISOString()}, ${age} ago).` +
|
||||
(looksStale
|
||||
? ` This is a moving tag and the cached copy may be outdated — run "docker pull --platform linux/amd64 ${image}" for the latest build.`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
|
||||
function logStackStarted(stack: StartedStack): void {
|
||||
const lines = containerEntries(stack).map(([name, container, metadata]) =>
|
||||
formatContainerLine(name, container, metadata),
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Testcontainers (network ${stack.network.getId()}):\n${lines.join('\n')}\n${formatServerEnvSummary()}`);
|
||||
}
|
||||
|
||||
const SENSITIVE_SERVER_ENV_KEYS = new Set([
|
||||
'MM_LICENSE',
|
||||
'MM_FILESETTINGS_AMAZONS3SECRETACCESSKEY',
|
||||
'MM_FILESETTINGS_AZUREACCESSKEY',
|
||||
]);
|
||||
|
||||
function redactServerEnvValue(key: string, value: string): string {
|
||||
if (SENSITIVE_SERVER_ENV_KEYS.has(key)) {
|
||||
return '***';
|
||||
}
|
||||
if (key === 'MM_SQLSETTINGS_DATASOURCE') {
|
||||
return value.replace(/:([^:@/]+)@/, ':***@');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function redactServerEnv(env: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, redactServerEnvValue(key, value)]));
|
||||
}
|
||||
|
||||
function redactEnvFileForArchive(contents: string): string {
|
||||
return contents.replace(/^PW_TESTCONTAINERS_BOOT_ENV='(.*)'$/gm, (_match, json: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, string>;
|
||||
return `PW_TESTCONTAINERS_BOOT_ENV='${JSON.stringify(redactServerEnv(parsed))}'`;
|
||||
} catch {
|
||||
return "PW_TESTCONTAINERS_BOOT_ENV='{}'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatServerEnvSummary(): string {
|
||||
const env = resolveMattermostBootEnv(testConfig.bootEnvOverrides);
|
||||
const lines = Object.entries(env)
|
||||
.filter(([key]) => key.startsWith('MM_'))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => ` - ${key} = ${redactServerEnvValue(key, value)}`);
|
||||
|
||||
return `server env (MM_*):\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
function logStackStopped(stack: StartedStack): void {
|
||||
const names = containerEntries(stack).map(([name]) => name);
|
||||
logTestcontainers(`stopped ${names.join(', ')}.`);
|
||||
}
|
||||
|
||||
function logStackLeftRunning(stack: StartedStack): void {
|
||||
const serverUrl = resolveUrl(stack.mattermost, MATTERMOST_PORT, MATTERMOST_ALIAS);
|
||||
logTestcontainers(
|
||||
`left running (PW_TESTCONTAINERS_REUSE=true) — server reachable at ${serverUrl}; may tear down with: "npm run testcontainers:down"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Mutates the testConfig singleton in place so the rest of globalSetup (same process) sees the
|
||||
// real resolved values immediately — the generated env file (resetEnvFile) is what hands these
|
||||
// same values to worker processes.
|
||||
function applyResolvedConfig(stack: StartedStack): void {
|
||||
testConfig.baseURL = resolveUrl(stack.mattermost, MATTERMOST_PORT, MATTERMOST_ALIAS);
|
||||
testConfig.smtpURL = resolveUrl(stack.inbucket, INBUCKET_WEB_PORT, INBUCKET_ALIAS);
|
||||
testConfig.postgresUrl = resolvePostgresUrl(stack.postgres);
|
||||
testConfig.webhookBaseUrl = resolveUrl(stack.webhook, WEBHOOK_PORT, WEBHOOK_ALIAS);
|
||||
testConfig.testcontainersNetworkName = stack.network.getName();
|
||||
testConfig.mattermostContainerId = stack.mattermost.getId();
|
||||
|
||||
if (stack.additional.openldap) {
|
||||
const [host, port] = resolveHostAndPort(stack.additional.openldap, OPENLDAP_PORT, OPENLDAP_ALIAS);
|
||||
testConfig.ldapHost = host;
|
||||
testConfig.ldapPort = port;
|
||||
}
|
||||
if (stack.additional.keycloak) {
|
||||
testConfig.keycloakUrl = resolveUrl(stack.additional.keycloak, KEYCLOAK_PORT, KEYCLOAK_ALIAS);
|
||||
}
|
||||
if (stack.additional.elasticsearch) {
|
||||
testConfig.elasticsearchUrl = resolveUrl(
|
||||
stack.additional.elasticsearch,
|
||||
ELASTICSEARCH_PORT,
|
||||
ELASTICSEARCH_ALIAS,
|
||||
);
|
||||
}
|
||||
if (stack.additional.opensearch) {
|
||||
testConfig.opensearchUrl = resolveUrl(stack.additional.opensearch, OPENSEARCH_PORT, OPENSEARCH_ALIAS);
|
||||
}
|
||||
if (stack.additional.minio) {
|
||||
testConfig.minioUrl = resolveUrl(stack.additional.minio, MINIO_PORT, MINIO_ALIAS);
|
||||
}
|
||||
if (stack.additional.azurite) {
|
||||
testConfig.azuriteUrl = resolveUrl(stack.additional.azurite, AZURITE_BLOB_PORT, AZURITE_ALIAS);
|
||||
}
|
||||
}
|
||||
|
||||
// One block per write: a human-readable `# [timestamp] label` comment line (dotenv ignores
|
||||
// `#`-led lines) followed by the current resolved KEY=VALUE state, including bootEnvOverrides
|
||||
// JSON-encoded and single-quoted so its embedded double quotes/braces survive dotenv's parser.
|
||||
function envFileLines(label: string): string[] {
|
||||
return [
|
||||
`# [${new Date().toISOString()}] ${label}`,
|
||||
`PW_BASE_URL=${testConfig.baseURL}`,
|
||||
`PW_SMTP_URL=${testConfig.smtpURL}`,
|
||||
`PW_POSTGRES_URL=${testConfig.postgresUrl}`,
|
||||
`PW_WEBHOOK_BASE_URL=${testConfig.webhookBaseUrl}`,
|
||||
`PW_TESTCONTAINERS_NETWORK_GATEWAY_IP=${testConfig.testcontainersNetworkGatewayIp}`,
|
||||
`PW_LDAP_HOST=${testConfig.ldapHost}`,
|
||||
`PW_LDAP_PORT=${testConfig.ldapPort}`,
|
||||
`PW_KEYCLOAK_URL=${testConfig.keycloakUrl}`,
|
||||
`PW_ELASTICSEARCH_URL=${testConfig.elasticsearchUrl}`,
|
||||
`PW_OPENSEARCH_URL=${testConfig.opensearchUrl}`,
|
||||
`PW_MINIO_URL=${testConfig.minioUrl}`,
|
||||
`PW_AZURITE_URL=${testConfig.azuriteUrl}`,
|
||||
`PW_TESTCONTAINERS_NETWORK_NAME=${testConfig.testcontainersNetworkName}`,
|
||||
`PW_TESTCONTAINERS_MATTERMOST_CONTAINER_ID=${testConfig.mattermostContainerId}`,
|
||||
// Persist the image currently running (separate from process SERVER_IMAGE / to-image).
|
||||
`PW_TESTCONTAINERS_SERVER_IMAGE=${testConfig.serverImage}`,
|
||||
// Unredacted: the next process reads this back into bootEnvOverrides so upgrade-to and
|
||||
// pw.ensure*() can adopt the running server. CI artifacts get a redacted copy via archiveEnvFile.
|
||||
`PW_TESTCONTAINERS_BOOT_ENV='${JSON.stringify(testConfig.bootEnvOverrides)}'`,
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
// (Re)creates .env.testcontainers from scratch — only called once, when a brand new stack boots,
|
||||
// so a leftover file from an earlier (now-dead) stack never bleeds into this one.
|
||||
function resetEnvFile(label: string): void {
|
||||
fs.writeFileSync(ENV_FILE_PATH, envFileLines(label).join('\n') + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new snapshot block instead of overwriting — dotenv resolves the correct current value
|
||||
* per key when a fresh process parses the file (later occurrences win), while the file as a whole
|
||||
* becomes a chronological log of every restart: which spec/test triggered it, what env diff was
|
||||
* requested, and the full resolved state right after. That's what's needed to investigate
|
||||
* server-state drift after the fact, since in the CI dispatch model no single process ever sees
|
||||
* the whole picture on its own.
|
||||
*/
|
||||
function appendEnvFile(label: string): void {
|
||||
fs.appendFileSync(ENV_FILE_PATH, envFileLines(label).join('\n') + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
// Preserves restart history as a debug artifact before it's deleted — logs/ is what CI's
|
||||
// upload-debug-artifacts step picks up. Boot-env secrets stay in the live .env.testcontainers
|
||||
// (needed to adopt the stack) and are redacted in this copy.
|
||||
function archiveEnvFile(): void {
|
||||
if (!fs.existsSync(ENV_FILE_PATH)) {
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(LOG_DIR, {recursive: true});
|
||||
const redacted = redactEnvFileForArchive(fs.readFileSync(ENV_FILE_PATH, 'utf-8'));
|
||||
fs.writeFileSync(path.join(LOG_DIR, 'testcontainers_env_history.log'), redacted, 'utf-8');
|
||||
}
|
||||
|
||||
function removeEnvFile(): void {
|
||||
if (fs.existsSync(ENV_FILE_PATH)) {
|
||||
fs.rmSync(ENV_FILE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectLogs(stack: StartedStack): Promise<void> {
|
||||
fs.mkdirSync(LOG_DIR, {recursive: true});
|
||||
|
||||
const targets: Array<[string, StartedTestContainer]> = [
|
||||
['mattermost', stack.mattermost],
|
||||
['postgres', stack.postgres],
|
||||
['inbucket', stack.inbucket],
|
||||
['webhook', stack.webhook],
|
||||
...Object.entries(stack.additional).filter(
|
||||
(entry): entry is [string, StartedTestContainer] => entry[1] !== undefined,
|
||||
),
|
||||
];
|
||||
|
||||
await Promise.allSettled(
|
||||
targets.map(async ([name, container]) => {
|
||||
const logStream = await container.logs();
|
||||
const outFile = fs.createWriteStream(path.join(LOG_DIR, `${name}.log`));
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
// container.logs() follows a running container, so a stack left running
|
||||
// (PW_TESTCONTAINERS_REUSE=true) never emits 'end' on its own — unpipe and
|
||||
// destroy the source before ending outFile so a chunk already in flight can't
|
||||
// land on the closed destination and crash the process with ERR_STREAM_WRITE_AFTER_END.
|
||||
const finish = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
logStream.unpipe(outFile);
|
||||
logStream.destroy();
|
||||
outFile.end(resolve);
|
||||
};
|
||||
logStream.pipe(outFile, {end: false});
|
||||
const timer = setTimeout(finish, 10_000);
|
||||
logStream.on('end', finish);
|
||||
logStream.on('error', finish);
|
||||
outFile.on('error', finish);
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
38
e2e-tests/playwright/lib/src/containers/webhook_container.ts
Normal file
38
e2e-tests/playwright/lib/src/containers/webhook_container.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {TESTCONTAINERS_LABELS, WEBHOOK_ALIAS, WEBHOOK_PORT} from './constants';
|
||||
import {containerAssetPath} from './paths';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// Vendored from e2e-tests/cypress/webhook_serve.js — the interactive-message/dialog callback
|
||||
// sidecar tests point PW_WEBHOOK_BASE_URL at. Always started, unlike the other optional services,
|
||||
// since testConfig.webhookBaseUrl defaults to localhost:3000 outside Testcontainers mode too.
|
||||
export async function startWebhookContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('webhook', async () => {
|
||||
// deleteOnExit: false — otherwise the built image bakes in this session's Ryuk id, so Ryuk
|
||||
// reaps the "reused" container the moment this session ends, defeating withReuse() below.
|
||||
const image = await GenericContainer.fromDockerfile(containerAssetPath('webhook'), 'Dockerfile.webhook').build(
|
||||
undefined,
|
||||
{deleteOnExit: false},
|
||||
);
|
||||
|
||||
let builder = image
|
||||
.withExposedPorts(WEBHOOK_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(WEBHOOK_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withWaitStrategy(Wait.forHttp('/', WEBHOOK_PORT).forStatusCode(200));
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user