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
24
e2e-tests/playwright/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# playwright tests
|
||||
test/logs
|
||||
test/node_modules
|
||||
test/playwright-report
|
||||
test/test-results
|
||||
test/results
|
||||
test/storage_state
|
||||
test/specs-results
|
||||
test/specs/**/*-darwin.png
|
||||
test/specs/**/*-window.png
|
||||
test/specs/accessibility/**/*-snapshots
|
||||
test/.eslintcache
|
||||
|
||||
# build
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
|
||||
# testcontainers ("full" mode) — generated per-run, see docs/testcontainers/testcontainers_plan.md
|
||||
.env.testcontainers
|
||||
|
||||
# upgrade-path tests
|
||||
.mattermost_data
|
||||
.upgrade_baseline.json
|
||||
.upgrade_plugin_bundle.tar.gz
|
||||
3
e2e-tests/playwright/.percy.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
version: 2
|
||||
percy:
|
||||
defer-uploads: true
|
||||
14
e2e-tests/playwright/.prettierignore
Normal file
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
playwright-report
|
||||
storage_state
|
||||
test-results
|
||||
results
|
||||
.mattermost_data
|
||||
logs
|
||||
.upgrade_baseline.json
|
||||
.upgrade_plugin_bundle.tar.gz
|
||||
.env.testcontainers
|
||||
specs/**/*.yml
|
||||
specs/**/*.yaml
|
||||
6
e2e-tests/playwright/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"bracketSpacing": false,
|
||||
"printWidth": 120,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4
|
||||
}
|
||||
262
e2e-tests/playwright/CLAUDE.OPTIONAL.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Repository Purpose
|
||||
|
||||
This is the Playwright E2E testing suite for Mattermost. It contains end-to-end tests for validating the Mattermost web application functionality using the Playwright testing framework.
|
||||
|
||||
## Key Commands
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install npm packages
|
||||
npm i
|
||||
|
||||
# Install browser binaries (if prompted)
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run a specific test across all browsers (Chrome, Firefox, iPad)
|
||||
npm run test -- <test-name>
|
||||
|
||||
# Run a specific test for a specific browser
|
||||
npm run test -- <test-name> --project=chrome
|
||||
npm run test -- <test-name> --project=firefox
|
||||
npm run test -- <test-name> --project=ipad
|
||||
|
||||
# Run all tests (including visual tests)
|
||||
npm run test
|
||||
|
||||
# Run CI tests (excludes visual tests, runs only in Chrome)
|
||||
npm run test:ci
|
||||
|
||||
# Run tests with UI mode
|
||||
npm run playwright-ui
|
||||
|
||||
# Run tests with slow-motion to debug
|
||||
npm run test:slomo
|
||||
|
||||
# Run visual tests
|
||||
npm run test -- visual
|
||||
|
||||
# Update visual test snapshots
|
||||
npm run test:update-snapshots
|
||||
|
||||
# Visual testing with Percy
|
||||
npm run percy:docker
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# Build the project
|
||||
npm run build
|
||||
|
||||
# Watch mode for development
|
||||
npm run build:watch
|
||||
|
||||
# Type checking
|
||||
npm run tsc
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
|
||||
# Format code
|
||||
npm run prettier:fix
|
||||
|
||||
# Verify test documentation format
|
||||
npm run lint:test-docs
|
||||
|
||||
# Run all checks (lint, prettier, typescript, test docs)
|
||||
npm run check
|
||||
|
||||
# Clean the project
|
||||
npm run clean
|
||||
|
||||
# Show test report
|
||||
npm run show-report
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`lib/` Directory**: Contains the shared library (`@mattermost/playwright-lib`) that provides:
|
||||
- Page objects for Mattermost UI pages
|
||||
- Component abstractions for UI elements
|
||||
- Test utilities and fixtures
|
||||
- Server setup and management functions
|
||||
- Testcontainers orchestration (`lib/src/containers/`) for `testcontainers` mode (see Server Setup below)
|
||||
- Visual testing support
|
||||
|
||||
2. **`specs/` Directory**: Contains the actual test files organized by type:
|
||||
- `functional/` - Functional tests for various features
|
||||
- `visual/` - Visual regression tests
|
||||
- `accessibility/` - Accessibility tests
|
||||
- `client/` - Client API tests
|
||||
|
||||
3. **Test Fixtures**: The main test fixture (`pw`) provides:
|
||||
- Browser context management
|
||||
- Page actions and utilities
|
||||
- Server API helpers
|
||||
- Random data generators
|
||||
- Visual testing helpers
|
||||
|
||||
4. **Page Object Model**: UI abstractions are organized in:
|
||||
- `lib/src/ui/pages/` - Page objects (Login, Channels, etc.)
|
||||
- `lib/src/ui/components/` - Component objects (Posts, Menus, etc.)
|
||||
|
||||
### Test Flow
|
||||
|
||||
1. Tests typically follow this pattern:
|
||||
- Initialize test setup with `pw.initSetup()`
|
||||
- Login to a test account with `pw.testBrowser.login()`
|
||||
- Navigate to the relevant page
|
||||
- Perform actions and assertions
|
||||
- Optionally take visual snapshots
|
||||
|
||||
2. Visual tests also:
|
||||
- Hide dynamic content with `pw.hideDynamicChannelsContent()`
|
||||
- Take snapshots with `pw.matchSnapshot()`
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Tests can be configured through environment variables:
|
||||
|
||||
- `PW_BASE_URL` - Mattermost server URL (default: http://localhost:8065)
|
||||
- `PW_ADMIN_USERNAME` - Admin username (default: sysadmin)
|
||||
- `PW_ADMIN_PASSWORD` - Admin password (default: Sys@dmin-sample1)
|
||||
- `PW_HEADLESS` - Run tests headless (default: true)
|
||||
- `PW_SNAPSHOT_ENABLE` - Enable snapshot testing (default: false)
|
||||
- `PW_SLOWMO` - Add delay between actions in ms (default: 0)
|
||||
- `PW_WORKERS` - Number of parallel workers (default: 1)
|
||||
- `PERCY_TOKEN` - Authentication token for Percy visual testing service (required for Percy tests)
|
||||
- `PW_USE_TESTCONTAINERS` - Selects `testcontainers` mode, see Server Setup below (default: false)
|
||||
- `PW_TESTCONTAINERS_SERVICES` - Comma-separated additional services to start, e.g. `minio,openldap` (default: `minio,openldap,keycloak,elasticsearch`; set to an empty string to start none)
|
||||
- `PW_TESTCONTAINERS_REUSE` - Reuse containers across repeated local runs instead of recreating them; tear down explicitly with `npm run testcontainers:down` (default: true)
|
||||
- `SERVER_IMAGE` - Mattermost server image `testcontainers` mode starts (default: `mattermostdevelopment/mattermost-enterprise-edition:master`)
|
||||
- `MM_ENV` - Comma-separated `KEY=VALUE` server config overrides for `testcontainers` mode (default: none)
|
||||
|
||||
## Server Setup
|
||||
|
||||
Before running tests, a Mattermost server must be available. Two options:
|
||||
|
||||
1. **Run from source**:
|
||||
|
||||
```bash
|
||||
cd server && make run
|
||||
```
|
||||
|
||||
2. **Testcontainers** (recommended for testing, and what CI uses) — no separate step needed, Playwright starts and tears down the server + dependencies itself:
|
||||
```bash
|
||||
PW_USE_TESTCONTAINERS=true npm run test
|
||||
# Starts minio,openldap,keycloak,elasticsearch by default; add PW_TESTCONTAINERS_SERVICES=... to change (e.g. add opensearch,azurite, or set to "" to start none)
|
||||
# Containers are reused across runs by default; tear down with `npm run testcontainers:down` (or set PW_TESTCONTAINERS_REUSE=false for a self-cleaning one-off run)
|
||||
# `npm run testcontainers:up` brings the stack up (or confirms an existing one) without running any tests
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Page Object Pattern**: Always use page/component objects from the library. No static UI selectors should be in test files.
|
||||
|
||||
2. **Locator Priority**: Follow the Playwright recommended locator strategy (see [Playwright Locators Quick Guide](https://playwright.dev/docs/locators#quick-guide)). Use locators in this priority order:
|
||||
1. `getByRole()` - Preferred. Locates by accessibility role and accessible name (e.g., `getByRole('button', {name: 'Submit'})`).
|
||||
2. `getByText()` - Locates by visible text content.
|
||||
3. `getByLabel()` - Locates form controls by their associated label text.
|
||||
4. `getByPlaceholder()` - Locates inputs by placeholder text.
|
||||
5. `getByAltText()` - Locates elements (usually images) by alt text.
|
||||
6. `getByTitle()` - Locates by the `title` attribute.
|
||||
7. `getByTestId()` - Last resort. Locates by `data-testid` attribute.
|
||||
- **Avoid** CSS selectors (`.class`, `#id`), XPath, and raw `locator()` calls unless none of the above locators can identify the element.
|
||||
- Use `{exact: true}` when the accessible name might partially match other elements (e.g., `getByRole('button', {name: 'Invite', exact: true})`).
|
||||
|
||||
3. **Visual Testing**: For visual tests:
|
||||
- Place all visual tests in the `specs/visual/` directory
|
||||
- Always include the `@visual` tag in the test tags array
|
||||
- Run via Docker container for consistency to maintain screenshot integrity
|
||||
- Use `pw.hideDynamicChannelsContent()` to hide dynamic elements that could cause flaky tests
|
||||
- Update snapshots with `npm run test:update-snapshots` only from within the Docker container
|
||||
- For Percy-based visual testing:
|
||||
- A valid `PERCY_TOKEN` environment variable must be set
|
||||
- Tests should only be run inside the Playwright Docker container
|
||||
- Follow the visual test documentation format like other tests, with proper JSDoc and comments
|
||||
|
||||
4. **Test Title Validation with Claude Code**: When using Claude:
|
||||
- Run `claude spec/path/to/file.spec.ts` to check your test file
|
||||
- Ask: "Check if test titles follow the format in CLAUDE.md"
|
||||
- Claude will analyze each test title and suggest improvements
|
||||
- Format should be action-oriented, feature-specific, context-aware, and outcome-focused
|
||||
- Example: `creates scheduled message from channel and posts at scheduled time`
|
||||
|
||||
5. **Test Structure**:
|
||||
- Use descriptive test titles that follow this format:
|
||||
- **Action-oriented**: Start with a verb that describes the main action
|
||||
- **Feature-specific**: Include the feature or component being tested
|
||||
- **Context-aware**: Include relevant context (where/how it's being performed)
|
||||
- **Outcome-focused**: Specify the expected outcome or behavior
|
||||
- Examples of well-formatted test titles:
|
||||
- `"creates scheduled message from channel and posts at scheduled time"`
|
||||
- `"edits scheduled message content while preserving send date"`
|
||||
- `"reschedules message to a future date from scheduled posts page"`
|
||||
- `"deletes scheduled message from scheduled posts page"`
|
||||
- `"converts draft message to scheduled message"`
|
||||
- Test keys (`MM-T\d+`) in test titles are optional for new tests
|
||||
- New tests without keys will automatically be registered in the test management system after merge
|
||||
- Test keys will be assigned later through a separate automated process
|
||||
- Follow the `# Action` and `* Verification` comment pattern
|
||||
- Group related tests in the same spec file
|
||||
- Keep tests independent and isolated
|
||||
- Use tags to categorize tests with `{tag: '@feature_name'}`
|
||||
|
||||
6. **Test Documentation Format**:
|
||||
- Include JSDoc-style documentation before each test:
|
||||
```typescript
|
||||
/**
|
||||
* @objective Clear description of what the test verifies
|
||||
*
|
||||
* @precondition
|
||||
* Special setup or conditions required for the test
|
||||
* Note: Only include preconditions that are not part of the default setup.
|
||||
* Standard conditions like "a test server is running" should be omitted.
|
||||
*/
|
||||
test('MM-T1234 descriptive test title', {tag: '@feature_tag'}, async ({pw}) => {
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
- If no special preconditions are needed, omit the `@precondition` tag entirely:
|
||||
```typescript
|
||||
/**
|
||||
* @objective Clear description of what the test verifies
|
||||
*/
|
||||
test('descriptive test title', {tag: '@feature_tag'}, async ({pw}) => {
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
- For new tests, the MM-T ID is optional and will be assigned later:
|
||||
```typescript
|
||||
/**
|
||||
* @objective Clear description of what the test verifies
|
||||
*/
|
||||
test('descriptive test title', {tag: '@feature_tag'}, async ({pw}) => {
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
- Use comment prefixes to clearly indicate actions and verifications:
|
||||
- `// # descriptive action` - Comments that describe steps being taken (e.g., `// # Initialize user and login`)
|
||||
- `// * descriptive verification` - Comments that describe assertions/checks (e.g., `// * Verify message appears in channel`)
|
||||
|
||||
7. **Browser Compatibility**:
|
||||
- Tests run on Chrome, Firefox, and iPad by default
|
||||
- Consider browser-specific behaviors for certain features
|
||||
- Use `test.skip()` for browser-specific limitations
|
||||
|
||||
8. **Test Documentation Linting**:
|
||||
- Run `npm run lint:test-docs` to verify all spec files follow the documentation format
|
||||
- The linter checks for proper JSDoc tags, test titles, feature tags, and action/verification comments
|
||||
- This is also included in the standard `npm run check` command
|
||||
- See the example in `specs/functional/channels/scheduled_messages/scheduled_messages.spec.ts`
|
||||
305
e2e-tests/playwright/README.md
Normal file
@@ -0,0 +1,305 @@
|
||||
## Local development
|
||||
|
||||
#### 1. Start local server in a separate terminal.
|
||||
|
||||
There are two ways to run the local server:
|
||||
|
||||
**Option 1: Run from source**
|
||||
|
||||
```bash
|
||||
# Typically run the local server with:
|
||||
cd server && make run
|
||||
|
||||
# Or run webapp and server on separate terminals for better performance
|
||||
# First terminal: Build and run the webapp
|
||||
cd webapp && make run
|
||||
# Second terminal: Run the server
|
||||
cd server && make run-server
|
||||
```
|
||||
|
||||
**Option 2: Testcontainers (recommended for testing, and what CI uses)**
|
||||
|
||||
No separate terminal or setup step needed — Playwright brings up Postgres, Inbucket, and the Mattermost server itself via [Testcontainers](https://node.testcontainers.org/), then tears them down after the run.
|
||||
|
||||
```bash
|
||||
# Run with defaults (Postgres, Inbucket, Mattermost server, minio, openldap, keycloak, elasticsearch)
|
||||
PW_USE_TESTCONTAINERS=true npm run test -- login
|
||||
|
||||
# Change which additional services start, comma-separated (or "" to start none)
|
||||
PW_USE_TESTCONTAINERS=true PW_TESTCONTAINERS_SERVICES=minio,openldap npm run test
|
||||
|
||||
# Pin a specific server image (defaults to mattermostdevelopment/mattermost-enterprise-edition:master)
|
||||
PW_USE_TESTCONTAINERS=true SERVER_IMAGE=mattermostdevelopment/mattermost-enterprise-edition:<tag> npm run test
|
||||
|
||||
# Pass arbitrary MM_* config overrides as comma-separated KEY=VALUE pairs
|
||||
PW_USE_TESTCONTAINERS=true MM_ENV=MM_LICENSE='<your-license-key>' npm run test
|
||||
```
|
||||
|
||||
Containers are reused across invocations by default (`PW_TESTCONTAINERS_REUSE=true`) instead of being recreated every run — tear the stack down explicitly when you're done with `npm run testcontainers:down`. Set `PW_TESTCONTAINERS_REUSE=false` for a one-off run that tears itself down when it finishes. Use `npm run testcontainers:up` to just bring the stack up (or confirm an existing one's still reachable) without running any tests.
|
||||
|
||||
See `lib/README.md` for every available environment variable.
|
||||
|
||||
#### 2. Install dependencies and run the test.
|
||||
|
||||
```bash
|
||||
# Install npm packages
|
||||
npm i
|
||||
|
||||
# Install browser binaries as prompted if Playwright is just installed or updated
|
||||
# See https://playwright.dev/docs/browsers
|
||||
npx playwright install
|
||||
|
||||
# Run a specific test of all projects -- Chrome, Firefox, iPhone and iPad.
|
||||
# See https://playwright.dev/docs/test-cli.
|
||||
npm run test -- login
|
||||
|
||||
# Run a specific test of a project
|
||||
npm run test -- login --project=chrome
|
||||
|
||||
# Run all tests (including visual tests)
|
||||
npm run test
|
||||
|
||||
# Run CI tests (excludes visual tests, runs only in Chrome)
|
||||
# Note: visual tests run in a separate workflow
|
||||
npm run test:ci
|
||||
```
|
||||
|
||||
#### 3. Inspect test results at `/results/output` folder when something fails unexpectedly.
|
||||
|
||||
## Run tests in UI mode
|
||||
|
||||
Check out https://playwright.dev/docs/test-ui-mode for detailed guide on UI Mode to learn more about its features.
|
||||
|
||||
```bash
|
||||
npm run playwright-ui
|
||||
```
|
||||
|
||||
> **Note:** If no tests appear in the UI, check your filter settings:
|
||||
>
|
||||
> - Test name filters
|
||||
> - Project filters (setup, ipad, chrome, firefox)
|
||||
> - Tag filters (@tag)
|
||||
> - Execution status filters
|
||||
>
|
||||
> The "setup" project runs the initial configuration tests in `specs/test_setup.ts` (ensuring plugins are loaded and server deployment is correct). These setup tests are typically run only once before other tests and may be unchecked for subsequent runs, though they can remain checked if needed.
|
||||
|
||||
## Upgrade-path testing
|
||||
|
||||
Boots an older server image, seeds it, then replaces only the Mattermost container with a newer image while the same Postgres keeps running, and re-verifies through the **Client4 API** that migrations completed and prior data survived. Testcontainers mode only, since that in-place container swap is the whole mechanism.
|
||||
|
||||
The upgrade projects make no UI assertions: older images ship older webapps, so master POMs and testids cannot be trusted against `release-X.Y`. UI coverage comes from the normal suite, which CI runs afterwards against the upgraded server.
|
||||
|
||||
### How it works
|
||||
|
||||
Three projects in `playwright.config.ts`, each selecting its specs by directory (`upgrade-specs/from`, `upgrade-specs/to`). The `@upgrade-from` / `@upgrade-to` tags are bookkeeping for reports and ad-hoc `--grep`; no project filters on them.
|
||||
|
||||
| Project | Does |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `upgrade-from` | Boots fresh on `PW_UPGRADE_FROM_SERVER_IMAGE`, runs `setup`, seeds content via Client4, writes `.upgrade_baseline.json` |
|
||||
| `upgrade-swap-to` | Restarts the container on `SERVER_IMAGE` with the same boot env as from, including host `MM_LICENSE` |
|
||||
| `upgrade-to` | Re-verifies every baseline slice against the upgraded server |
|
||||
|
||||
- **The swap.** `pw.upgradeServerImage(image)` (`lib/src/server/version.ts`) repoints `testConfig.serverImage` and calls the same `restartMattermostContainer()` used by `pw.ensureMinio()` — stop the Mattermost container, start a fresh one on the same network. Postgres is never touched.
|
||||
- **State that must survive lives outside the container.** Config is stored in Postgres (`MM_CONFIG` set to the SQL datasource in `lib/src/containers/mattermost_container.ts`, a supported production mode) and `/mattermost/data` is bind-mounted to `.mattermost_data/` (`lib/src/containers/constants.ts`). Both are Docker volumes by default, so the swap would otherwise factory-reset the config and discard uploads.
|
||||
- **API-first harness.** `upgrade-specs/{from,to}/*.spec.ts` use Client4 / PlaywrightClient4 — no POMs. Authenticated binary downloads use Playwright's `request` fixture; browser login only via `loginByAPI` when a real session is needed. Helpers in `upgrade-specs/upgrade_fixtures.ts`.
|
||||
- **Continuity across phases.** Fixed (non-random) team/user/channel names are looked up idempotently, plus `.upgrade_baseline.json` for version identity, license, plugin state, and post/channel IDs. The from-phase container uses Testcontainers `withReuse()` so Ryuk does not reap it when that process exits.
|
||||
- **Plugins are driven by API, not env.** upgrade-from enables playbooks and installs the demo plugin, upgrade-to asserts both survived, and global setup's plugin reset is skipped for those phases. The first normal spec afterwards deactivates the demo plugin, which otherwise overrides the webapp's attachment button and breaks every UI file-upload spec.
|
||||
- **Phase logs.** `pw.saveUpgradePhaseLogs('from'|'to')` writes Mattermost/Postgres docker logs to `logs/upgrade/`, plus a migration/license/panic highlights file. The from-image has to be captured before swap-to replaces the container. CI uploads `logs/` via `ci/upload-debug-artifacts`.
|
||||
|
||||
### Run locally
|
||||
|
||||
The stack stays up between the two commands so you can inspect it. `test:upgrade:from` requires a clean slate — tear down any leftover stack first. `upgrade-to` adopts that stack, which needs `testcontainers.reuse.enable=true` in `~/.testcontainers.properties` (see `lib/README.md`).
|
||||
|
||||
```bash
|
||||
npm run testcontainers:down
|
||||
|
||||
# Phase 1 — boots fresh on the older from-image. Use a release-* tag; patch tags like 11.9.1 are
|
||||
# not published. `node script/resolve_upgrade_matrix.mjs` prints the tags CI uses.
|
||||
# MM_LICENSE is optional; when set, the baseline records license details.
|
||||
MM_LICENSE='<your-license-key>' \
|
||||
PW_UPGRADE_FROM_SERVER_IMAGE=mattermostdevelopment/mattermost-enterprise-edition:release-11.9 \
|
||||
npm run test:upgrade:from
|
||||
|
||||
# Phase 2 — swaps to SERVER_IMAGE (defaults to :master) with the same env as from.
|
||||
MM_LICENSE='<your-license-key>' \
|
||||
SERVER_IMAGE=mattermostdevelopment/mattermost-enterprise-edition:master \
|
||||
npm run test:upgrade:to
|
||||
|
||||
npm run testcontainers:down
|
||||
```
|
||||
|
||||
Both env vars can live in a local `.env` file instead of the command line.
|
||||
|
||||
### CI
|
||||
|
||||
CI tests a rolling matrix rather than one fixed version: the last 3 minor releases plus any release still in its Extended Support (ESR) window. `script/resolve_upgrade_matrix.mjs` resolves it at run time — last-3-minors from this checkout's `server/public/model/version.go`, support-end dates and ESR status fetched live from the published releases table on `master`, since those lapse with calendar time and a stale branch should not be trusted for them.
|
||||
|
||||
```bash
|
||||
node script/resolve_upgrade_matrix.mjs
|
||||
# [{"dockerTag":"release-10.11","minor":"10.11","patch":"10.11.22","isESR":true,"contextLabel":"release-10.11-esr"}, ...]
|
||||
```
|
||||
|
||||
Rolling upgrades run in a **separate pipeline** from the normal full suite, not inside `e2e-tests-playwright-template.yml`:
|
||||
|
||||
- `e2e-tests-playwright-rolling-upgrades.yml` resolves the matrix and calls the template once per from-version, in parallel.
|
||||
- `e2e-tests-playwright-rolling-upgrades-template.yml` tests **one** from-version, and owns that version's Test System IO run, dispatch workers, and commit status.
|
||||
|
||||
Each of a version's `workers` (default 20, same as the full suite) runs `--project=upgrade-from`, then `--project=upgrade-to`, then `dispatch-run` for its share of the normal suite. Nothing is re-prepared in between — no `setup`, no re-patching, no restart — so the suite exercises a server that reached the to-image by upgrading. An upgrade that needed the suite's setup re-run to be usable would not be a passing upgrade, which is why that step is absent. The upgrade specs themselves live in `upgrade-specs/`, outside `testDir`, so `dispatch-begin` never sees them.
|
||||
|
||||
Every worker upgrades its own server, since a server cannot be shared across runners: the harness runs `workers` times per from-version and the pipeline costs `matrix size × workers` runners, each on a 60m timeout. `dispatch-run` is skipped when the harness fails. No `testcontainers:down` first — the runner is fresh, so the clean-slate guard has nothing to trip over.
|
||||
|
||||
**When it runs**
|
||||
|
||||
| Pipeline | Rolling upgrades |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PR (automated) | On when the diff touches `upgrade-specs/`, `script/resolve_upgrade_matrix.mjs`, a rolling-upgrade workflow, or a server migration (`server/channels/db/migrations/`, `server/config/migrations/`, `server/channels/app/migrations.go`, `server/channels/app/permissions_migrations.go`, `server/public/model/version.go`) |
|
||||
| PR (manual `workflow_dispatch`) | Opt-in via **Run rolling upgrades** checkbox |
|
||||
| Merge to `master` / `release-*` | Off — too expensive per merge |
|
||||
| Release cut | On automatically |
|
||||
| Ad-hoc | **Run workflow** on _E2E Tests - Playwright Rolling Upgrades_; no PR needed — pick a ref, to-image tag, edition, and worker count |
|
||||
|
||||
**Commit statuses** — one per matrix entry and nothing else, so a 4-entry matrix produces exactly 4 contexts. When the resolver returns `[]`, no matrix jobs run and the workflow posts a single `upgrade-from-none` context instead. All sit under `e2e-test/playwright-full/{edition}/upgrade-from-*`, which is how the verified-label and override-status workflows discover them:
|
||||
|
||||
| Context | Covers |
|
||||
| --------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `upgrade-from-release-11.9` | that from-version's harness and post-upgrade suite (ESR entries append `-esr`) |
|
||||
| `upgrade-from-none` | resolver returned an empty matrix; nothing ran |
|
||||
|
||||
There is no "skipped" context: when rolling upgrades are not requested the job never starts and posts nothing. The pipeline is deliberately not gated on the generic `should_run` — its own triggers already decide, and the `.mjs` resolver falls outside `should_run`'s `^e2e-tests/.*\.(ts|tsx|js|jsx)$` pattern, so deferring to it would drop requested runs. The normal full suite keeps using `e2e-test/playwright-full/enterprise`, with no `/upgrade-from-…` suffix.
|
||||
|
||||
## Visual Testing
|
||||
|
||||
All visual tests must be placed in the `specs/visual/` directory and tagged with `@visual` in the test tags array. This organization ensures proper test discovery and execution patterns.
|
||||
|
||||
Visual tests are used to verify the UI appearance is consistent across browsers and remains stable across code changes. There are two types of visual tests supported:
|
||||
|
||||
1. **Built-in snapshot testing**: Uses Playwright's built-in snapshot comparison
|
||||
2. **Percy integration**: Uses the Percy service for more advanced visual testing and reporting
|
||||
|
||||
### CI Pipeline for Visual Tests
|
||||
|
||||
In CI environments, visual tests run in a separate dedicated pipeline:
|
||||
|
||||
- Regular tests run with `npm run test:ci` which excludes all tests with the `@visual` tag
|
||||
- Visual tests run in a separate workflow using the Playwright Docker container
|
||||
- This separation prevents visual tests from slowing down the main test pipeline
|
||||
- It also ensures visual tests always run in a consistent environment
|
||||
|
||||
### Writing Visual Tests
|
||||
|
||||
When creating visual tests:
|
||||
|
||||
1. **Follow the test documentation format** like other tests:
|
||||
- Include JSDoc with `@objective` tag
|
||||
- Use action-oriented test title
|
||||
- Add proper comment prefixes (`// #` for actions, `// *` for verifications)
|
||||
|
||||
2. **Place in the correct location**:
|
||||
- Put visual tests in the `specs/visual/` directory, organized by feature area
|
||||
- Example: `specs/visual/channels/intro_channel.spec.ts`
|
||||
|
||||
3. **Add required tags**:
|
||||
- Always include `@visual` tag
|
||||
- Add feature-specific tags as needed (e.g., `@login_page`, `@channel_page`)
|
||||
|
||||
4. **Manage dynamic content**:
|
||||
- Use `pw.hideDynamicChannelsContent()` to hide elements that could change between runs
|
||||
- Take snapshots only after UI is fully loaded and stable
|
||||
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @objective Capture visual snapshot of the landing/login page
|
||||
*/
|
||||
test(
|
||||
'displays landing page with login options',
|
||||
{tag: ['@visual', '@landing_page']},
|
||||
async ({pw, page, browserName, viewport}, testInfo) => {
|
||||
// # Go to landing login page
|
||||
await pw.landingLoginPage.goto();
|
||||
await pw.landingLoginPage.toBeVisible();
|
||||
|
||||
// * Verify landing page appears as expected
|
||||
await pw.matchSnapshot(testInfo, {page, browserName, viewport});
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
## Updating screenshots is done strictly via Playwright's docker container for consistency
|
||||
|
||||
#### 1. Run Playwright's docker container
|
||||
|
||||
Change to the `./` project directory, then run the docker container. (See https://playwright.dev/docs/docker for reference.)
|
||||
|
||||
```bash
|
||||
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.62.0-noble /bin/bash
|
||||
```
|
||||
|
||||
#### 2. Inside the docker container
|
||||
|
||||
```bash
|
||||
export PW_BASE_URL=http://host.docker.internal:8065
|
||||
export PW_HEADLESS=true
|
||||
cd mattermost/e2e-tests/playwright
|
||||
|
||||
# Install npm packages. Use "npm ci" to match the automated environment
|
||||
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci
|
||||
|
||||
# Run specific test. See https://playwright.dev/docs/test-cli.
|
||||
npm run test -- login --project=chrome
|
||||
|
||||
# Or run all tests
|
||||
npm run test
|
||||
|
||||
# Run visual tests (must be run inside Docker for consistency)
|
||||
npm run test -- specs/visual
|
||||
|
||||
# Update snapshots of visual tests (must be run inside Docker)
|
||||
npm run test -- specs/visual --update-snapshots
|
||||
|
||||
# Run Percy visual tests (requires PERCY_TOKEN environment variable)
|
||||
export PERCY_TOKEN=<your-percy-token>
|
||||
npm run percy:docker
|
||||
```
|
||||
|
||||
## Accessibility Testing
|
||||
|
||||
Accessibility tests ensure Mattermost meets WCAG 2.1 AA compliance standards. Tests are located in `specs/accessibility/` and cover keyboard navigation, screen reader support, focus management, and automated accessibility scanning.
|
||||
|
||||
For comprehensive guidelines on writing accessibility tests, aria snapshots, and folder structure, see [docs/accessibility/](docs/accessibility/).
|
||||
|
||||
### Accessibility Locators
|
||||
|
||||
**Playwright's accessibility locators should be the preferred approach for all tests, not just accessibility tests.** These locators query elements based on how users and assistive technologies perceive them, making tests more resilient to implementation changes and ensuring better accessibility by design.
|
||||
|
||||
#### Why Use Accessibility Locators?
|
||||
|
||||
- **Resilient to changes**: Tests won't break when CSS classes or data-testid attributes change
|
||||
- **Encourages accessibility**: Forces proper ARIA roles, labels, and semantic HTML
|
||||
- **Better readability**: `page.getByRole('button', {name: 'Save'})` is clearer than `page.locator('[data-testid="save-btn"]')`
|
||||
- **Aligns with user experience**: Tests what users actually perceive, not implementation details
|
||||
|
||||
#### Preferred Locators (in order of preference)
|
||||
|
||||
1. **Role-based**: `page.getByRole('button', {name: 'Save'})`, `page.getByRole('textbox', {name: 'Email'})`
|
||||
2. **Label-based**: `page.getByLabel('Email address')`
|
||||
3. **Text-based**: `page.getByText('Welcome')`, `page.getByPlaceholder('Enter email')`
|
||||
4. **Test IDs**: `page.locator('[data-testid="..."]')` - Use only when accessibility locators aren't possible
|
||||
5. **CSS selectors**: `page.locator('.class')` - Avoid unless absolutely necessary
|
||||
|
||||
#### When Test IDs Are Acceptable
|
||||
|
||||
Use `data-testid` only when:
|
||||
|
||||
- Element has no semantic role (e.g., decorative divs)
|
||||
- Multiple identical elements need distinction
|
||||
- Component is not interactive or visible to assistive tech
|
||||
|
||||
For all test examples, see [docs/accessibility/](docs/accessibility/) for comprehensive patterns and best practices.
|
||||
|
||||
## Page/Component Object Model
|
||||
|
||||
See https://playwright.dev/docs/test-pom.
|
||||
|
||||
Page and component abstractions are in shared library located at `./lib/src/ui`. They should be established before writing a spec file so that any future changes in the DOM structure will be made in one place only. No static UI text or fixed locator should be written in the spec file.
|
||||
BIN
e2e-tests/playwright/asset/archive.zip
Normal file
4
e2e-tests/playwright/asset/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="700" height="700" viewBox="0 0 700 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M496.909 147.716L499.54 200.779C542.559 248.303 559.539 315.609 538.125 378.865C506.159 473.292 400.753 522.93 302.694 489.735C204.635 456.54 151.057 353.081 183.023 258.653C204.508 195.186 259.171 151.953 322.48 140.505L356.685 100.091C249.969 97.2018 149.288 163.442 113.265 269.853C69.0048 400.598 139.114 542.468 269.859 586.729C400.604 630.99 542.474 560.88 586.735 430.135C622.7 323.895 583.148 210.308 496.909 147.716Z" fill="#222222"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M435.623 304.289L433.812 230.109L432.359 187.424L431.376 150.444C431.376 150.444 431.581 132.612 430.959 128.422C430.828 127.54 430.551 126.822 430.221 126.196C430.18 126.108 430.141 126.02 430.096 125.934C430.049 125.854 430.003 125.78 429.954 125.705C429.27 124.528 428.195 123.572 426.804 123.101C425.381 122.619 423.909 122.738 422.631 123.29C422.604 123.3 422.579 123.309 422.552 123.32C422.4 123.388 422.255 123.465 422.109 123.546C421.503 123.841 420.887 124.223 420.284 124.808C417.244 127.758 406.575 142.048 406.575 142.048L383.331 170.826L356.248 203.851L309.749 261.677C309.749 261.677 288.411 288.308 293.126 321.088C297.841 353.868 322.211 369.837 341.117 376.238C360.023 382.638 389.082 384.756 412.74 361.581C436.396 338.405 435.623 304.289 435.623 304.289Z" fill="#222222"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
e2e-tests/playwright/asset/image-400x40.jpg
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
e2e-tests/playwright/asset/image-40x400.jpg
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
14
e2e-tests/playwright/asset/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const availableAssets = fs.readdirSync(__dirname).filter((file) => !file.endsWith('.ts'));
|
||||
|
||||
export function getAsset(filename: string): string {
|
||||
if (!availableAssets.includes(filename)) {
|
||||
throw new Error(`Asset "${filename}" not found. Available: ${availableAssets.join(', ')}`);
|
||||
}
|
||||
return path.join(__dirname, filename);
|
||||
}
|
||||
BIN
e2e-tests/playwright/asset/mattermost-icon_128x128.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
e2e-tests/playwright/asset/mattermost.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
18
e2e-tests/playwright/asset/opengraph-huge.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenGraph Test Page</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Mattermost Test" />
|
||||
<meta property="og:title" content="OpenGraph Preview Title" />
|
||||
<meta property="og:description" content="This is a test page with a large image." />
|
||||
<meta property="og:url" content="/opengraph-huge.html" />
|
||||
<meta property="og:image" content="/huge-image.jpg" />
|
||||
<meta property="og:image:width" content="3664" />
|
||||
<meta property="og:image:height" content="2443" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>OpenGraph Test Page</h1>
|
||||
</body>
|
||||
</html>
|
||||
18
e2e-tests/playwright/asset/opengraph.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenGraph Test Page</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Mattermost Test" />
|
||||
<meta property="og:title" content="OpenGraph Preview Title" />
|
||||
<meta property="og:description" content="This is a test page to generate an OpenGraph link preview." />
|
||||
<meta property="og:url" content="/opengraph.html" />
|
||||
<meta property="og:image" content="/mattermost-icon_128x128.png" />
|
||||
<meta property="og:image:width" content="128" />
|
||||
<meta property="og:image:height" content="128" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>OpenGraph Test Page</h1>
|
||||
</body>
|
||||
</html>
|
||||
1
e2e-tests/playwright/asset/sample-file.demo
Normal file
@@ -0,0 +1 @@
|
||||
this is a sample .demo file
|
||||
19
e2e-tests/playwright/asset/sample_text_file.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
Lorem dolore adipisicing occaecat do qui. Laboris duis aliquip laborum ad sint. Est minim ipsum consequat. Ullamco quis magna ut anim cupidatat esse aliquip aliqua eiusmod voluptate. Aute officia aute eiusmod cillum aliquip non in elit cupidatat excepteur occaecat. Mollit elit incididunt eu dolor pariatur cillum proident id labore do Lorem officia laborum reprehenderit cupidatat. Eu nulla proident magna id dolore Lorem.
|
||||
|
||||
Non nisi minim mollit occaecat in culpa ullamco id dolore fugiat id eiusmod ut. Est enim commodo duis sunt fugiat. In non sunt eiusmod quis irure ullamco. Nulla eiusmod exercitation aute cillum anim.
|
||||
|
||||
Incididunt cillum do velit ipsum nisi enim ipsum officia non nostrud velit dolor do. Anim magna officia duis nulla occaecat labore pariatur enim. Ea sit Lorem irure adipisicing nisi aute adipisicing reprehenderit sunt cupidatat incididunt. Do magna nisi amet nostrud eiusmod. Eiusmod enim nisi dolor irure dolor dolor ipsum deserunt eiusmod veniam ipsum incididunt eu. Proident nisi duis proident proident duis velit tempor et do cillum.
|
||||
|
||||
Ipsum eu enim anim non excepteur quis. Proident et ea minim. Duis velit exercitation occaecat adipisicing adipisicing proident reprehenderit aliqua ea. Cillum sint anim qui eiusmod consectetur sint. Nostrud in pariatur eu proident nisi nostrud nisi et adipisicing sit. Consectetur ad consectetur ipsum sit.
|
||||
|
||||
In culpa irure in esse in duis consectetur amet ullamco reprehenderit sit elit culpa velit. Cillum ipsum enim officia nisi cillum adipisicing commodo anim fugiat qui quis. Excepteur aliqua adipisicing excepteur sit elit cillum velit et pariatur. Labore sint anim voluptate. Aliquip ad proident reprehenderit Lorem voluptate voluptate fugiat ea laborum ipsum.
|
||||
|
||||
Et proident nostrud nisi. Nisi exercitation dolor incididunt ipsum sunt consequat consequat aliquip cupidatat reprehenderit tempor. Ea excepteur sint aliqua consequat culpa proident consequat incididunt incididunt ex proident exercitation occaecat. Proident dolore dolor pariatur proident anim qui commodo enim nisi nulla anim dolore qui. Velit fugiat et commodo voluptate incididunt aliqua aute. Eiusmod anim magna commodo enim fugiat.
|
||||
|
||||
Eiusmod eiusmod dolore cillum tempor sit in. Cillum non ex elit velit esse exercitation amet ipsum et sunt sunt velit in ullamco. Enim sit aliquip mollit ea occaecat qui sit deserunt. Aute adipisicing sit non aliquip pariatur dolor ad minim culpa laborum consectetur fugiat.
|
||||
|
||||
Est incididunt exercitation consectetur occaecat veniam magna sint ea ad irure qui. Id cupidatat ut ex laboris sint anim adipisicing cillum ex in minim commodo reprehenderit duis. Cillum duis consequat occaecat et commodo ullamco exercitation sint reprehenderit deserunt dolor ipsum culpa non aute. Est cupidatat laboris proident in voluptate laborum minim officia eiusmod ipsum. Irure labore mollit esse adipisicing proident magna sit reprehenderit nisi cupidatat. Excepteur id ullamco adipisicing pariatur.
|
||||
|
||||
Ullamco officia et dolor consequat exercitation. Nisi aute ad aliquip nostrud deserunt nostrud. Esse reprehenderit eu cillum ullamco excepteur tempor non excepteur deserunt esse. Nisi nulla esse elit. Nulla enim consequat esse do cillum anim velit eu nulla esse amet laborum tempor occaecat. Proident voluptate exercitation cillum minim proident quis mollit mollit aliqua deserunt nostrud.
|
||||
|
||||
Veniam ea sit duis incididunt sint nostrud labore exercitation et dolor reprehenderit amet. Esse laborum sit veniam dolor anim amet mollit aliquip id. Laborum voluptate eiusmod consectetur. Laborum cillum voluptate enim nisi culpa ullamco cillum non aliquip nostrud do ex. Lorem elit laborum eiusmod velit id exercitation do dolor laboris et nisi et sit.
|
||||
BIN
e2e-tests/playwright/asset/small-image.png
Normal file
|
After Width: | Height: | Size: 720 B |
5
e2e-tests/playwright/asset/vector_image.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
|
||||
<rect width="120" height="120" fill="#0058cc"/>
|
||||
<circle cx="60" cy="60" r="40" fill="#ffffff"/>
|
||||
<rect x="45" y="45" width="30" height="30" fill="#0058cc"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 263 B |
@@ -0,0 +1,131 @@
|
||||
# Automated Accessibility Scan Testing Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
This guide focuses on **automated accessibility testing** using a **simplified, comprehensive approach**. Use axe-core's default rule set and scope testing to specific elements, disabling rules only when necessary. This maximizes coverage while minimizing maintenance overhead.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
### **Core Principle: Scope and Analyze**
|
||||
|
||||
Use axe-core's comprehensive default rule set and scope testing to specific elements.
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- **Maximum Coverage** - All WCAG 2.1 AA rules by default
|
||||
- **Low Maintenance** - No rule lists to maintain
|
||||
- **Future-Proof** - New axe-core rules automatically included
|
||||
- **Clear Intent** - Disabled rules are explicit and documented
|
||||
|
||||
### **Basic Pattern**
|
||||
|
||||
```typescript
|
||||
test('page accessibility', async ({axe, page}) => {
|
||||
const results = await axe.builder(page).analyze(); // All applicable rules
|
||||
|
||||
expect(results.violations).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
test('component accessibility', async ({axe, page}) => {
|
||||
const results = await axe
|
||||
.builder(page)
|
||||
.include('#element') // Scope to element
|
||||
.analyze(); // All applicable rules
|
||||
|
||||
expect(results.violations).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
## What Can Be Automated
|
||||
|
||||
### **Fully Automatable**
|
||||
|
||||
- **HTML Structure & Semantics** - Markup validity, semantic elements, heading hierarchy
|
||||
- **ARIA Implementation** - Attributes, states, relationships, roles
|
||||
- **Form Accessibility** - Labels, validation, error messages
|
||||
- **Interactive Elements** - Focus capability, accessible names
|
||||
- **Color and Contrast** - WCAG AA/AAA contrast ratios
|
||||
- **Alternative Text** - Image alt text presence
|
||||
- **Table Structure** - Headers, captions, relationships
|
||||
- **Navigation** - Skip links, landmarks, focus order
|
||||
|
||||
### **Cannot Be Fully Automated**
|
||||
|
||||
- **Cognitive Accessibility** - Content readability, comprehension
|
||||
- **Contextual Appropriateness** - Alt text quality, meaningful link text
|
||||
- **Real User Experience** - Actual keyboard workflows, screen reader UX
|
||||
- **Content Quality** - Clear instructions, effective error messaging
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### **1. Component Testing**
|
||||
|
||||
```typescript
|
||||
test('modal accessibility', async ({axe, page}) => {
|
||||
await page.getByRole('button', {name: 'Open modal'}).click();
|
||||
|
||||
const results = await axe.builder(page).include('[role="dialog"]').analyze();
|
||||
|
||||
expect(results.violations).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
### **2. State Testing**
|
||||
|
||||
```typescript
|
||||
test('form error state accessibility', async ({axe, page}) => {
|
||||
// Test normal state
|
||||
let results = await axe.builder(page).include('form').analyze();
|
||||
expect(results.violations).toHaveLength(0);
|
||||
|
||||
// Test error state
|
||||
await page.getByRole('button', {name: 'Submit'}).click();
|
||||
results = await axe.builder(page).include('form').analyze();
|
||||
expect(results.violations).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Rule Management
|
||||
|
||||
### **When to Disable Rules**
|
||||
|
||||
**Disable Rules Only When:**
|
||||
|
||||
- **Known UI Framework Limitations** - Theme-related contrast issues
|
||||
- **Context-Inappropriate Rules** - Page-level rules in modal dialogs
|
||||
- **Temporary Workarounds** - With clear documentation and tracking
|
||||
|
||||
### **Examples**
|
||||
|
||||
```typescript
|
||||
// Good - Documented limitation
|
||||
test('dark theme modal', async ({axe, page}) => {
|
||||
const results = await axe
|
||||
.builder(page)
|
||||
.include('[role="dialog"]')
|
||||
.disableRules([
|
||||
'color-contrast', // TODO: MM-nnn - Color contrast improvement
|
||||
])
|
||||
.analyze();
|
||||
});
|
||||
|
||||
// Good - Context on comment
|
||||
test('modal dialog', async ({axe, page}) => {
|
||||
const results = await axe
|
||||
.builder(page)
|
||||
.include('[role="dialog"]')
|
||||
.disableRules([
|
||||
'page-has-heading-one', // Not applicable to modals
|
||||
'landmark-one-main', // Not applicable to modals
|
||||
])
|
||||
.analyze();
|
||||
});
|
||||
```
|
||||
|
||||
**Don't Disable Rules For:**
|
||||
|
||||
- **Convenience** - "This rule is annoying"
|
||||
- **Lack of Understanding** - "I don't know what this rule does"
|
||||
- **Time Pressure** - "We'll fix it later" (without tracking)
|
||||
206
e2e-tests/playwright/docs/accessibility/index.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Accessibility Testing Guidelines
|
||||
|
||||
Welcome to Mattermost's accessibility testing documentation. This guide covers how to write comprehensive accessibility tests that ensure our application meets WCAG 2.1 AA compliance standards and provides an inclusive experience for all users.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Location**: Place accessibility tests in `specs/accessibility/`
|
||||
2. **Structure**: Organize by product → page → component
|
||||
3. **Tags**: Include `@accessibility` and feature-specific tags
|
||||
4. **Tools**: Use `axe` fixture for scanning, `toMatchAriaSnapshot` for semantic structure
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
- **[This Page]**: Overview and folder organization
|
||||
- **[Automated Scan Testing](automated_scan_testing.md)**: Using axe-core for automated accessibility scanning
|
||||
- **[Interaction Testing](interaction_testing.md)**: Keyboard navigation, focus management, and screen reader testing
|
||||
|
||||
## Folder Structure
|
||||
|
||||
Accessibility tests follow a hierarchical organization by product area:
|
||||
|
||||
```
|
||||
specs/accessibility/
|
||||
├── common/ # Shared components across products
|
||||
│ ├── login.spec.ts
|
||||
│ ├── reset_password.spec.ts
|
||||
│ └── signup_user_complete.spec.ts
|
||||
├── channels/ # Channels product area
|
||||
│ ├── settings_dialog/ # Settings dialog page/component
|
||||
│ │ ├── notifications.spec.ts
|
||||
│ │ ├── settings.spec.ts
|
||||
│ │ └── notifications.spec.ts-snapshots-a11y/ # Aria snapshots
|
||||
│ │ ├── desktop-and-mobile-section.yml
|
||||
│ │ ├── email-notifications-section.yml
|
||||
│ │ └── keywords-that-get-highlighted-section.yml
|
||||
│ ├── account_menu_keyboard.spec.ts
|
||||
│ ├── intro_channel.spec.ts
|
||||
│ └── theme_settings.spec.ts
|
||||
└── [future-products]/ # Boards, Playbooks, etc.
|
||||
└── [page-or-component]/
|
||||
└── test.spec.ts
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Products**: `channels/`, `boards/`, `playbooks/`
|
||||
- **Pages**: `settings_dialog/`, `channel_header/`, `post_menu/`
|
||||
- **Components**: `notifications.spec.ts`, `theme_picker.spec.ts`
|
||||
- **Snapshots**: `[component-name]-section.yml`, `[feature-name]-modal.yml`
|
||||
|
||||
## Test Categories
|
||||
|
||||
Accessibility tests should cover these key areas:
|
||||
|
||||
### 1. Keyboard Navigation
|
||||
|
||||
- Tab order and focus management
|
||||
- Enter/Space key activation
|
||||
- Escape key handling
|
||||
- Arrow key navigation for menus and lists
|
||||
|
||||
### 2. Screen Reader Support
|
||||
|
||||
- Proper ARIA labels and roles
|
||||
- Meaningful announcements for state changes
|
||||
- Alternative text for images and icons
|
||||
- Semantic HTML structure
|
||||
|
||||
### 3. Focus Management
|
||||
|
||||
- Visible focus indicators
|
||||
- Focus trapping in modals and dialogs
|
||||
- Logical focus restoration
|
||||
- Skip links and landmarks
|
||||
|
||||
### 4. Color and Contrast
|
||||
|
||||
- WCAG AA compliance (4.5:1 normal, 3:1 large text)
|
||||
- High contrast mode compatibility
|
||||
- Color not as sole information indicator
|
||||
|
||||
### 5. Zoom and Responsive
|
||||
|
||||
- 200% zoom without horizontal scrolling
|
||||
- Mobile accessibility patterns
|
||||
- Touch target sizes (44x44px minimum)
|
||||
|
||||
## Test Structure Template
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* @objective Clear description of what accessibility aspect is being verified
|
||||
*
|
||||
* @precondition Special setup conditions (omit if using standard setup)
|
||||
*/
|
||||
test('descriptive test title', {tag: ['@accessibility', '@feature_tag']}, async ({pw, axe}) => {
|
||||
// # Setup user and navigate to target
|
||||
const {user} = await pw.initSetup();
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// # Navigate to component under test
|
||||
await page.getByRole('button', {name: 'Settings'}).click();
|
||||
|
||||
// # Perform accessibility interaction (keyboard navigation, etc.)
|
||||
await page.keyboard.press('Tab');
|
||||
const targetElement = page.getByRole('dialog', {name: 'Settings'});
|
||||
await pw.toBeFocusedWithFocusVisible(targetElement);
|
||||
|
||||
// * Verify accessibility compliance with automated scan
|
||||
const results = await axe.builder(page).include('[role="dialog"]').analyze();
|
||||
expect(results.violations).toHaveLength(0);
|
||||
|
||||
// * Verify semantic structure with aria snapshot
|
||||
await expect(targetElement).toMatchAriaSnapshot({
|
||||
name: 'component-state.yml',
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Aria Snapshots
|
||||
|
||||
Aria snapshots capture the accessibility tree structure, ensuring proper semantic markup and screen reader experience.
|
||||
|
||||
### When to Use External vs Inline Snapshots
|
||||
|
||||
**Use External .yml Files** (Recommended):
|
||||
|
||||
- Static content that doesn't change between test runs
|
||||
- Reusable components across multiple tests
|
||||
- Complex structures that benefit from version control and diff tracking
|
||||
|
||||
```typescript
|
||||
const notificationsPanel = page.getByRole('region', {name: 'Notifications'});
|
||||
await expect(notificationsPanel).toMatchAriaSnapshot({
|
||||
name: 'notifications-panel.yml',
|
||||
});
|
||||
```
|
||||
|
||||
**Use Inline Snapshots**:
|
||||
|
||||
- Content with dynamic data (user IDs, timestamps, server configurations)
|
||||
- Simple structures where external files add complexity
|
||||
- One-off components unlikely to be reused
|
||||
|
||||
```typescript
|
||||
await expect(element).toMatchAriaSnapshot(`
|
||||
- tabpanel "notifications":
|
||||
- heading "Notifications" [level=3]
|
||||
- link "Learn more":
|
||||
- /url: https://example.com/notifications?uid=${user.id}&sid=${clientConfig.DiagnosticId}
|
||||
- button "Edit Desktop notifications"
|
||||
`);
|
||||
```
|
||||
|
||||
### Snapshot Organization
|
||||
|
||||
- External snapshots: `[test-file].spec.ts-snapshots-a11y/`
|
||||
- Descriptive names: `desktop-notifications-section.yml`, `keywords-modal-expanded.yml`
|
||||
- Group by component or feature area
|
||||
|
||||
## Updating Snapshots
|
||||
|
||||
When UI structure changes, update aria snapshots:
|
||||
|
||||
```bash
|
||||
# Update all accessibility snapshots
|
||||
npm run test:a11y-update-snapshots
|
||||
|
||||
# Update specific test snapshots
|
||||
npm run test -- specs/accessibility/channels/settings_dialog/notifications.spec.ts --update-snapshots
|
||||
|
||||
# Update all snapshots including visual snapshots
|
||||
npm run test:update-snapshots
|
||||
```
|
||||
|
||||
## Required Tags
|
||||
|
||||
All accessibility tests must include:
|
||||
|
||||
- `@accessibility` - Primary accessibility identifier
|
||||
- Feature tags: `@settings`, `@notifications`, `@login`, etc.
|
||||
- `@snapshots` - For tests including aria snapshots
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test Real User Workflows**: Focus on actual user journeys, not isolated interactions
|
||||
2. **Cover All Input Methods**: Mouse, keyboard, touch, and assistive technology
|
||||
3. **Test Error States**: Form validation, network errors, loading states
|
||||
4. **Include Dynamic Content**: Test with various data states and configurations
|
||||
5. **Document Exceptions**: When disabling accessibility rules, document the rationale
|
||||
6. **Cross-Browser Testing**: Different browsers handle accessibility differently
|
||||
7. **Incremental Development**: Add accessibility tests alongside feature development
|
||||
|
||||
## Visual Verification
|
||||
|
||||
For visual verification needs beyond accessibility tree structure (such as focus indicators, high contrast mode, or visual layout validation), consider adding visual screenshot tests at `specs/visual/`. Visual tests complement accessibility testing by capturing the actual rendered appearance.
|
||||
|
||||
See the main [README.md](../../README.md#visual-testing) for visual testing guidelines and setup.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Review existing tests in `specs/accessibility/` for patterns
|
||||
- Check automated scan guidelines for axe-core usage
|
||||
- See interaction testing docs for keyboard navigation patterns
|
||||
- Ask questions in [~e2e-testing](https://community.mattermost.com/core/channels/e2e-testing) channels at Mattermost Community.
|
||||
223
e2e-tests/playwright/docs/accessibility/interaction_testing.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Accessibility Interaction Testing Best Practices
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers **automated accessibility testing through user interaction simulation** using Playwright. These techniques test real user experiences with assistive technologies by automating keyboard navigation, focus management, and screen reader workflows.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
### **Keyboard Navigation Testing**
|
||||
|
||||
- **Arrow Key Navigation** - Menu and list traversal
|
||||
- **Tab Navigation** - Focus order validation
|
||||
- **Enter/Space Activation** - Button and link interaction
|
||||
- **Escape Key Handling** - Modal and menu dismissal
|
||||
- **Letter Key Shortcuts** - Quick navigation patterns
|
||||
|
||||
### **Focus Management Validation**
|
||||
|
||||
- **Focus Visibility** - `:focus-visible` state verification
|
||||
- **Focus Trapping** - Modal and dialog containment
|
||||
- **Focus Restoration** - Return focus after interactions
|
||||
- **Focus Order** - Logical tab sequence validation
|
||||
|
||||
### **State and Behavior Testing**
|
||||
|
||||
- **ARIA State Changes** - `aria-expanded`, `aria-selected` verification
|
||||
- **Dynamic Content** - Live region announcements
|
||||
- **Error Handling** - Validation message accessibility
|
||||
- **Loading States** - Progress indication accessibility
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### **1. Keyboard Navigation Testing**
|
||||
|
||||
```typescript
|
||||
test('keyboard navigation through menu', async ({page}) => {
|
||||
// Open menu with keyboard
|
||||
await page.getByRole('button', {name: 'Menu'}).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Navigate through menu items
|
||||
await expect(page.getByRole('menuitem').first()).toBeFocused();
|
||||
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(page.getByRole('menuitem').nth(1)).toBeFocused();
|
||||
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(page.getByRole('menuitem').nth(2)).toBeFocused();
|
||||
|
||||
// Test wrapping behavior
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(page.getByRole('menuitem').nth(1)).toBeFocused();
|
||||
|
||||
// Close menu with Escape
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('menuitem')).toHaveCount(0);
|
||||
|
||||
// Verify focus restoration
|
||||
await expect(page.getByRole('button', {name: 'Menu'})).toBeFocused();
|
||||
});
|
||||
```
|
||||
|
||||
### **2. Focus Trapping in Modals**
|
||||
|
||||
```typescript
|
||||
test('modal focus trapping', async ({page}) => {
|
||||
// Open modal
|
||||
await page.getByRole('button', {name: 'Open modal'}).click();
|
||||
const modal = page.getByRole('dialog');
|
||||
|
||||
// Verify initial focus
|
||||
await expect(modal).toBeFocused();
|
||||
|
||||
// Find all focusable elements in modal
|
||||
const focusableElements = modal.locator('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
const count = await focusableElements.count();
|
||||
|
||||
// Tab through all elements
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(focusableElements.nth(i)).toBeFocused();
|
||||
}
|
||||
|
||||
// Verify focus wraps back to first element
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(modal).toBeFocused(); // Or first focusable element
|
||||
|
||||
// Test reverse tabbing
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(focusableElements.last()).toBeFocused();
|
||||
});
|
||||
```
|
||||
|
||||
### **3. ARIA State Management Testing**
|
||||
|
||||
```typescript
|
||||
test('expandable section ARIA states', async ({page}) => {
|
||||
const expandButton = page.getByRole('button', {name: 'Expand section', expanded: false});
|
||||
const expandableContent = page.getByRole('region', {name: 'Expandable content'});
|
||||
|
||||
// Test initial collapsed state
|
||||
await expect(expandButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(expandableContent).toBeHidden();
|
||||
|
||||
// Expand with keyboard
|
||||
await expandButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify expanded state
|
||||
await expect(expandButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(expandableContent).toBeVisible();
|
||||
|
||||
// Verify focus moves to content
|
||||
await expect(expandableContent).toBeFocused();
|
||||
|
||||
// Collapse with keyboard
|
||||
await expandButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify collapsed state
|
||||
await expect(expandButton).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(expandableContent).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### **4. Form Validation Accessibility**
|
||||
|
||||
```typescript
|
||||
test('form validation accessibility', async ({page}) => {
|
||||
const form = page.getByRole('form');
|
||||
const requiredField = form.getByRole('textbox', {name: 'Email'});
|
||||
const submitButton = form.getByRole('button', {name: 'Submit'});
|
||||
|
||||
// Submit empty form to trigger validation
|
||||
await submitButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify error state accessibility
|
||||
await expect(requiredField).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
|
||||
// Verify focus moves to invalid field
|
||||
await expect(requiredField).toBeFocused();
|
||||
|
||||
// Test error message association
|
||||
const errorId = await page.getByRole('alert').getAttribute('id');
|
||||
await expect(requiredField).toHaveAttribute('aria-describedby', errorId);
|
||||
|
||||
// Fix validation error
|
||||
await requiredField.fill('valid@example.com');
|
||||
await submitButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify error cleared
|
||||
await expect(requiredField).toHaveAttribute('aria-invalid', 'false');
|
||||
await expect(page.getByRole('alert')).toBeHidden();
|
||||
});
|
||||
```
|
||||
|
||||
### **5. Live Region Announcements**
|
||||
|
||||
```typescript
|
||||
test('dynamic content announcements', async ({page}) => {
|
||||
const liveRegion = page.getByRole('status');
|
||||
const triggerButton = page.getByRole('button', {name: 'Load content'});
|
||||
|
||||
// Verify live region setup
|
||||
await expect(liveRegion).toHaveAttribute('aria-live', 'polite');
|
||||
|
||||
// Trigger content update
|
||||
await triggerButton.click();
|
||||
|
||||
// Wait for content to appear in live region
|
||||
await expect(liveRegion).toContainText('Loading complete');
|
||||
|
||||
// Test urgent announcements
|
||||
const urgentRegion = page.getByRole('alert');
|
||||
await page.getByRole('button', {name: 'Urgent action'}).click();
|
||||
await expect(urgentRegion).toContainText('Critical update');
|
||||
});
|
||||
```
|
||||
|
||||
### **6. Focus Visibility Testing**
|
||||
|
||||
```typescript
|
||||
test('focus indicators visibility', async ({page}) => {
|
||||
const button = page.getByRole('button', {name: 'Save'});
|
||||
|
||||
// Tab to button (should show focus indicator)
|
||||
await page.keyboard.press('Tab');
|
||||
await toBeFocusedWithFocusVisible(button);
|
||||
});
|
||||
```
|
||||
|
||||
### **7. Complex Navigation Patterns**
|
||||
|
||||
```typescript
|
||||
test('hierarchical menu navigation', async ({page}) => {
|
||||
await page.getByRole('button', {name: 'Menu'}).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Navigate to submenu trigger
|
||||
const submenuTrigger = page.getByRole('menuitem', {name: /has submenu/});
|
||||
await submenuTrigger.focus();
|
||||
|
||||
// Open submenu with right arrow
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
// Verify submenu opened and focused
|
||||
const submenu = page.getByRole('menu').nth(1);
|
||||
await expect(submenu).toBeVisible();
|
||||
await expect(submenu.getByRole('menuitem').first()).toBeFocused();
|
||||
|
||||
// Navigate in submenu
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(submenu.getByRole('menuitem').nth(1)).toBeFocused();
|
||||
|
||||
// Return to parent menu with left arrow
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(submenu).toBeHidden();
|
||||
await expect(submenuTrigger).toBeFocused();
|
||||
});
|
||||
```
|
||||
76
e2e-tests/playwright/eslint.config.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import globals from 'globals';
|
||||
|
||||
import eslintPlugin from '@mattermost/eslint-plugin';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules',
|
||||
'**/dist',
|
||||
'**/playwright-report',
|
||||
'**/test-results',
|
||||
'**/results',
|
||||
'.mattermost_data/**',
|
||||
'lib/src/containers/assets/webhook/tests/**',
|
||||
],
|
||||
},
|
||||
...eslintPlugin.configs.base,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
ecmaVersion: 5,
|
||||
sourceType: 'module',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: true,
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@stylistic/dot-location': 'off', // Covered by Prettier
|
||||
'@stylistic/indent': 'off', // Covered by Prettier
|
||||
'@stylistic/lines-around-comment': 'off', // Covered by Prettier
|
||||
'@stylistic/multiline-ternary': 'off', // Covered by Prettier
|
||||
'@stylistic/no-mixed-operators': 'off',
|
||||
'@stylistic/operator-linebreak': 'off', // Covered by Prettier
|
||||
'@stylistic/space-before-function-paren': 'off', // Covered by Prettier
|
||||
'@stylistic/wrap-regex': 'off', // Covered by Prettier
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'func-names': 'off',
|
||||
'max-lines': ['warn', {max: 800, skipBlankLines: true, skipComments: true}],
|
||||
'no-await-in-loop': 'off',
|
||||
'no-console': 'error',
|
||||
'no-empty-pattern': ['error', {allowObjectPatternsAsParameters: true}],
|
||||
'no-loop-func': 0,
|
||||
'no-process-env': 0,
|
||||
'no-process-exit': 0,
|
||||
'headers/header-format': [
|
||||
'error',
|
||||
{
|
||||
source: 'string',
|
||||
style: 'line',
|
||||
content:
|
||||
'Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.\nSee LICENSE.txt for license information.',
|
||||
trailingNewlines: 2,
|
||||
},
|
||||
],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
'newlines-between': 'always',
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
},
|
||||
],
|
||||
'import/no-unresolved': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
32
e2e-tests/playwright/global_setup.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {baseGlobalSetup, startStack, stopStack, testConfig} from '@mattermost/playwright-lib';
|
||||
|
||||
async function globalSetup() {
|
||||
try {
|
||||
// With PW_USE_TESTCONTAINERS=true, bring up the server + dependencies via Testcontainers
|
||||
// before pinging it. No-op otherwise, when a server is expected to already be running.
|
||||
await startStack();
|
||||
await baseGlobalSetup();
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(chalk.cyan('[testcontainers]'), error);
|
||||
// Whatever startStack() managed to bring up (e.g. baseGlobalSetup() failed after the
|
||||
// stack itself came up fine) shouldn't linger just because we're about to throw.
|
||||
await stopStack();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const hint = testConfig.useTestContainers
|
||||
? 'Check the container named above and its logs under logs/.'
|
||||
: `Ensure the server at ${testConfig.baseURL} is running and accessible.`;
|
||||
throw new Error(chalk.red(`[testcontainers] Global setup failed: ${message}\n${hint}`));
|
||||
}
|
||||
|
||||
return async function () {
|
||||
await stopStack();
|
||||
};
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
251
e2e-tests/playwright/lib/README.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# @mattermost/playwright-lib
|
||||
|
||||
A comprehensive end-to-end testing library for Mattermost web, desktop and plugin applications using Playwright.
|
||||
|
||||
## Overview
|
||||
|
||||
This library provides:
|
||||
|
||||
- Pre-built page objects and components for common Mattermost UI elements
|
||||
- Server configuration and initialization utilities
|
||||
- Test fixtures and helpers
|
||||
- Visual testing support with Percy integration
|
||||
- Accessibility testing support with [axe-core](https://github.com/dequelabs/axe-core)
|
||||
- Browser notification mocking
|
||||
- File handling utilities
|
||||
- Common test actions and assertions
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @mattermost/playwright-lib
|
||||
```
|
||||
|
||||
### Peer Dependencies
|
||||
|
||||
This library requires `@playwright/test` version 1.55.0 or higher:
|
||||
|
||||
```bash
|
||||
npm install @playwright/test@^1.55.0
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Basic example of logging in and posting a message:
|
||||
|
||||
```typescript
|
||||
import {test, expect} from '@mattermost/playwright-lib';
|
||||
|
||||
test('user can post message', async ({pw}) => {
|
||||
// # Create and login a new user
|
||||
const {user} = await pw.initSetup();
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
|
||||
// # Navigate and post a message
|
||||
await channelsPage.goto();
|
||||
const message = 'Hello World!';
|
||||
await channelsPage.postMessage(message);
|
||||
|
||||
// * Verify message appears
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await expect(lastPost).toHaveText(message);
|
||||
});
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### Page Objects
|
||||
|
||||
Ready-to-use page objects for common Mattermost pages:
|
||||
|
||||
- Login
|
||||
- Signup
|
||||
- Channels
|
||||
- System Console
|
||||
- And more...
|
||||
|
||||
### UI Components
|
||||
|
||||
Reusable component objects for UI elements:
|
||||
|
||||
- Headers
|
||||
- Posts
|
||||
- Menus
|
||||
- Modals
|
||||
- And more...
|
||||
|
||||
### Test Utilities
|
||||
|
||||
Helper functions for common testing needs:
|
||||
|
||||
- Server setup and configuration
|
||||
- User/team creation
|
||||
- File handling
|
||||
- Visual testing
|
||||
- And more...
|
||||
|
||||
Global setup leaves only the plugins a fresh install has active (Calls, Playbooks, Agents, NPS) and
|
||||
deactivates the rest, so a plugin one spec enabled cannot alter the webapp for the next one — the
|
||||
demo plugin, for instance, replaces the attachment button with its own upload menu. A spec needing
|
||||
another plugin enables it itself with `pw.installAndEnablePlugin()` or `pw.ensurePluginsLoaded()`.
|
||||
`PW_ENSURE_PLUGINS_INSTALLED` is honoured and left active.
|
||||
|
||||
## Configuration
|
||||
|
||||
The library can be configured via optional environment variables:
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All environment variables are optional with sensible defaults.
|
||||
|
||||
#### Server Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------------------- | ------------------------------------------ | -------------------------------- |
|
||||
| `PW_BASE_URL` | Server URL | `http://localhost:8065` |
|
||||
| `PW_ADMIN_USERNAME` | Admin username | `sysadmin` |
|
||||
| `PW_ADMIN_PASSWORD` | Admin password | `Sys@dmin-sample1` |
|
||||
| `PW_ADMIN_EMAIL` | Admin email | `sysadmin@sample.mattermost.com` |
|
||||
| `PW_ENSURE_PLUGINS_INSTALLED` | Comma-separated list of plugins to install | `[]` |
|
||||
| `PW_RESET_BEFORE_TEST` | Reset server before test | `false` |
|
||||
| `PW_SMTP_URL` | Inbucket HTTP API URL | `http://localhost:9001` |
|
||||
|
||||
#### High Availability Cluster Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------------------- | ----------------------- | ---------------- |
|
||||
| `PW_HA_CLUSTER_ENABLED` | Enable HA cluster | `false` |
|
||||
| `PW_HA_CLUSTER_NODE_COUNT` | Number of cluster nodes | `2` |
|
||||
| `PW_HA_CLUSTER_NAME` | Cluster name | `mm_dev_cluster` |
|
||||
|
||||
#### Push Notifications
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------------------- | ---------------------------- | ---------------------------------- |
|
||||
| `PW_PUSH_NOTIFICATION_SERVER` | Push notification server URL | `https://push-test.mattermost.com` |
|
||||
|
||||
#### Playwright Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------- | ------------------------------- | ------- |
|
||||
| `PW_HEADLESS` | Run tests headless | `true` |
|
||||
| `PW_SLOWMO` | Add delay between actions in ms | `0` |
|
||||
| `PW_WORKERS` | Number of parallel workers | `1` |
|
||||
|
||||
#### Visual Testing
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------------------- | --------------------------- | ------- |
|
||||
| `PW_SNAPSHOT_ENABLE` | Enable snapshot testing | `false` |
|
||||
| `PW_PERCY_ENABLE` | Enable Percy visual testing | `false` |
|
||||
|
||||
#### CI Settings
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------- | ------------------------------------ | ------- |
|
||||
| `CI` | Set automatically in CI environments | N/A |
|
||||
|
||||
#### Testcontainers
|
||||
|
||||
Selects `testcontainers` mode — Playwright brings up the server + dependencies itself via [Testcontainers](https://node.testcontainers.org/) — instead of the `external` mode default, which expects a server already running at `PW_BASE_URL`.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `PW_USE_TESTCONTAINERS` | Selects `testcontainers` mode | `false` |
|
||||
| `PW_TESTCONTAINERS_SERVICES` | Comma-separated additional services to start (`openldap`, `keycloak`, `elasticsearch`, `opensearch`, `minio`, `azurite`); set to an empty string to start none | `minio,openldap,keycloak,elasticsearch` |
|
||||
| `PW_TESTCONTAINERS_REUSE` | Reuse containers across repeated local runs instead of recreating them; tear down with `npm run testcontainers:down` (requires `testcontainers.reuse.enable=true` in `~/.testcontainers.properties`) | `true` |
|
||||
| `PW_TESTCONTAINERS_CONTAINER_RUNNER` | Set when the Playwright process itself runs inside a Docker container (e.g. CI), to join it to the Testcontainers network | `false` |
|
||||
| `SERVER_IMAGE` | Prebuilt Mattermost server image `testcontainers` mode starts — also the upgrade tests' to-image | `mattermostdevelopment/mattermost-enterprise-edition:master` |
|
||||
| `PW_UPGRADE_FROM_SERVER_IMAGE` | Older server image `test:upgrade:from` boots fresh on — use a `release-X.Y` tag (see `script/resolve_upgrade_matrix.mjs`). Required; if `.env.testcontainers` already exists, run `npm run testcontainers:down` first | none — required for `--project=upgrade-from` |
|
||||
| `MM_ENV` | Comma-separated `KEY=VALUE` server config overrides, merged over the test baseline | none |
|
||||
| `PW_LDAP_HOST` / `PW_LDAP_PORT` | OpenLDAP host/port (only used when `openldap` is started) | `localhost` / `389` |
|
||||
| `PW_KEYCLOAK_URL` | Keycloak URL (only used when `keycloak` is started) | `http://localhost:8484` |
|
||||
| `PW_ELASTICSEARCH_URL` | Elasticsearch URL (only used when `elasticsearch` is started) | `http://localhost:9200` |
|
||||
| `PW_OPENSEARCH_URL` | OpenSearch URL (only used when `opensearch` is started) | `http://localhost:9201` |
|
||||
| `PW_MINIO_URL` | Minio URL (only used when `minio` is started) | `http://localhost:9000` |
|
||||
| `PW_AZURITE_URL` | Azurite URL (only used when `azurite` is started) | `http://localhost:10000` |
|
||||
|
||||
In `testcontainers` mode, these host/port defaults are never actually used — Testcontainers always assigns its own dynamic port or network alias per run, so a `testcontainers`-mode run can never collide with a same-machine `external`-mode session using the fixed defaults above. Tear a reused stack down explicitly with `npm run testcontainers:down` (from the `playwright/` package).
|
||||
|
||||
## Accessibility Testing
|
||||
|
||||
The library includes built-in accessibility testing using [axe-core](https://github.com/dequelabs/axe-core):
|
||||
|
||||
```typescript
|
||||
import {test, expect} from '@mattermost/playwright-lib';
|
||||
|
||||
test('verify login page accessibility', async ({page, axe}) => {
|
||||
// # Navigate to login page
|
||||
await page.goto('/login');
|
||||
|
||||
// # Run accessibility scan
|
||||
const results = await axe.builder(page).analyze();
|
||||
|
||||
// * Verify no accessibility violations
|
||||
expect(results.violations).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
The axe-core integration:
|
||||
|
||||
- Runs WCAG 2.0 Level A & AA rules by default
|
||||
- Provides detailed violation reports
|
||||
- Supports rule customization
|
||||
- Can be configured per-test or globally
|
||||
|
||||
## Visual Testing
|
||||
|
||||
The library supports visual testing through [Playwright's built-in visual comparisons](https://playwright.dev/docs/test-snapshots) and [Percy](https://www.browserstack.com/percy) integration:
|
||||
|
||||
```typescript
|
||||
import {test, expect} from '@mattermost/playwright-lib';
|
||||
|
||||
test('verify channel header appearance', async ({pw, browserName, viewport}, testInfo) => {
|
||||
// # Setup and login
|
||||
const {user} = await pw.initSetup();
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
|
||||
// # Navigate and prepare page
|
||||
await channelsPage.goto();
|
||||
await expect(channelsPage.appBar.playbooksIcon).toBeVisible();
|
||||
await pw.hideDynamicChannelsContent(page);
|
||||
|
||||
// * Take and verify snapshot
|
||||
await pw.matchSnapshot(testInfo, {page, browserName, viewport});
|
||||
});
|
||||
```
|
||||
|
||||
## Browser Notifications
|
||||
|
||||
Mock and verify browser notifications:
|
||||
|
||||
```typescript
|
||||
import {test, expect} from '@mattermost/playwright-lib';
|
||||
|
||||
test('verify notification on mention', async ({pw}) => {
|
||||
// # Setup users and team
|
||||
const {team, adminUser, user} = await pw.initSetup();
|
||||
|
||||
// # Setup admin browser with notifications
|
||||
const {page: adminPage, channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await adminChannelsPage.goto(team.name, 'town-square');
|
||||
await pw.stubNotification(adminPage, 'granted');
|
||||
|
||||
// # Setup user browser and post mention
|
||||
const {channelsPage: userChannelsPage} = await pw.testBrowser.login(user);
|
||||
await userChannelsPage.goto(team.name, 'off-topic');
|
||||
await userChannelsPage.postMessage(`@ALL good morning, ${team.name}!`);
|
||||
|
||||
// * Verify notification received
|
||||
const notifications = await pw.waitForNotification(adminPage);
|
||||
expect(notifications.length).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/mattermost/mattermost/blob/master/CONTRIBUTING.md) for development setup and guidelines.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE.txt](https://github.com/mattermost/mattermost/blob/master/LICENSE.txt) for license information.
|
||||
76
e2e-tests/playwright/lib/package.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "@mattermost/playwright-lib",
|
||||
"version": "11.11.0",
|
||||
"description": "A comprehensive end-to-end testing library for Mattermost web, desktop and plugin applications using Playwright",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mattermost/mattermost.git"
|
||||
},
|
||||
"author": "mattermost",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/mattermost/mattermost/issues"
|
||||
},
|
||||
"homepage": "https://github.com/mattermost/mattermost/tree/master/e2e-tests/playwright/lib#readme",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"mattermost",
|
||||
"e2e",
|
||||
"playwright",
|
||||
"test-automation"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rollup -c --bundleConfigAsCjs",
|
||||
"build:watch": "npm run build -- --watch",
|
||||
"build-tsc": "tsc --build --verbose",
|
||||
"build-tsc:watch": "tsc --watch --preserveWatchOutput",
|
||||
"clean": "rm -rf dist node_modules *.tsbuildinfo",
|
||||
"tsc": "tsc -b"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@azure/storage-blob": "12.33.0",
|
||||
"@mattermost/client": "file:../../../webapp/platform/client",
|
||||
"@mattermost/types": "file:../../../webapp/platform/types",
|
||||
"@percy/cli": "1.32.6",
|
||||
"@percy/playwright": "1.1.2",
|
||||
"@testcontainers/postgresql": "12.1.0",
|
||||
"async-wait-until": "2.0.31",
|
||||
"axe-core": "4.13.0",
|
||||
"chalk": "6.0.0",
|
||||
"deepmerge": "4.3.1",
|
||||
"dotenv": "17.4.2",
|
||||
"ldapts": "9.0.0",
|
||||
"luxon": "3.7.2",
|
||||
"mime-types": "3.0.2",
|
||||
"minio": "8.0.7",
|
||||
"testcontainers": "12.1.0",
|
||||
"uuid": "14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "12.3.0",
|
||||
"@types/luxon": "3.7.4",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/node": "26.2.0",
|
||||
"rollup": "4.62.4",
|
||||
"rollup-plugin-copy": "3.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@playwright/test": ">=1.59.0"
|
||||
}
|
||||
}
|
||||
53
e2e-tests/playwright/lib/rollup.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import copy from 'rollup-plugin-copy';
|
||||
|
||||
export default {
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
dir: 'dist',
|
||||
format: 'esm',
|
||||
sourcemap: true,
|
||||
preserveModules: true, // Keep file structure
|
||||
preserveModulesRoot: 'src',
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
typescript(),
|
||||
copy({
|
||||
targets: [
|
||||
{src: 'src/asset/**/*', dest: 'dist/asset'}, // Copy assets to dist/
|
||||
{src: 'src/containers/assets/**/*', dest: 'dist/containers/assets'},
|
||||
],
|
||||
}),
|
||||
],
|
||||
external: [
|
||||
'@playwright/test',
|
||||
'@mattermost/client',
|
||||
'@mattermost/types/config',
|
||||
'@axe-core/playwright',
|
||||
'@azure/storage-blob',
|
||||
'@percy/playwright',
|
||||
'@testcontainers/postgresql',
|
||||
'dotenv',
|
||||
'ldapts',
|
||||
'luxon',
|
||||
'minio',
|
||||
'node:child_process',
|
||||
'node:path',
|
||||
'node:fs',
|
||||
'node:fs/promises',
|
||||
'node:os',
|
||||
'node:url',
|
||||
'node:util',
|
||||
'mime-types',
|
||||
'testcontainers',
|
||||
'uuid',
|
||||
'async-wait-until',
|
||||
'chalk',
|
||||
'deepmerge',
|
||||
],
|
||||
};
|
||||
BIN
e2e-tests/playwright/lib/src/asset/mattermost-icon_128x128.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
144
e2e-tests/playwright/lib/src/autotranslation_helpers.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
|
||||
export type EnableAutotranslationOptions = {
|
||||
mockBaseUrl: string;
|
||||
targetLanguages?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable autotranslation in server config with LibreTranslate provider pointing at the mock URL.
|
||||
* Requires an enterprise license for the feature to be available.
|
||||
*/
|
||||
export async function enableAutotranslationConfig(
|
||||
adminClient: Client4,
|
||||
options: EnableAutotranslationOptions,
|
||||
): Promise<void> {
|
||||
await adminClient.patchConfig({
|
||||
FeatureFlags: {
|
||||
AutoTranslation: true,
|
||||
},
|
||||
AutoTranslationSettings: {
|
||||
Enable: true,
|
||||
Provider: 'libretranslate',
|
||||
LibreTranslate: {
|
||||
URL: options.mockBaseUrl,
|
||||
APIKey: '',
|
||||
},
|
||||
TargetLanguages: options.targetLanguages ?? ['en', 'es'],
|
||||
RestrictDMAndGM: false,
|
||||
Workers: 4,
|
||||
TimeoutMs: 5000,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable autotranslation in server config.
|
||||
* Uses patchConfig for the same reasons as enableAutotranslationConfig.
|
||||
*/
|
||||
export async function disableAutotranslationConfig(adminClient: Client4): Promise<void> {
|
||||
await adminClient.patchConfig({
|
||||
FeatureFlags: {
|
||||
AutoTranslation: false,
|
||||
},
|
||||
AutoTranslationSettings: {
|
||||
Enable: false,
|
||||
TargetLanguages: [],
|
||||
Workers: 0,
|
||||
Provider: '',
|
||||
LibreTranslate: {
|
||||
URL: '',
|
||||
APIKey: '',
|
||||
},
|
||||
TimeoutMs: 0,
|
||||
RestrictDMAndGM: false,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable autotranslation for a channel (requires permission and feature available).
|
||||
*/
|
||||
export async function enableChannelAutotranslation(adminClient: Client4, channelId: string): Promise<void> {
|
||||
await adminClient.patchChannel(channelId, {autotranslation: true} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable autotranslation for a channel.
|
||||
*/
|
||||
export async function disableChannelAutotranslation(adminClient: Client4, channelId: string): Promise<void> {
|
||||
await adminClient.patchChannel(channelId, {autotranslation: false} as any);
|
||||
}
|
||||
|
||||
export async function setMockSourceLanguage(mockBaseUrl: string, language: string): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${mockBaseUrl}/__control/source`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({language}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) {
|
||||
// 404 or other error likely means real LibreTranslate (no control endpoint)
|
||||
if (res.status === 404) {
|
||||
// Silently ignore - using real LibreTranslate with automatic language detection
|
||||
return;
|
||||
}
|
||||
throw new Error(`Mock detect failed: ${res.status}`);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore all errors - could be mock server down or real LibreTranslate without control endpoint
|
||||
// Real LibreTranslate uses automatic language detection from message content anyway
|
||||
// Expected errors: AbortError (timeout), network errors, 404 from real LibreTranslate
|
||||
// All are handled gracefully by the retry logic above
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set autotranslation opt-in for the current user in a channel.
|
||||
* The client must be logged in as the user whose setting is being changed (e.g. userClient).
|
||||
*/
|
||||
export async function setUserChannelAutotranslation(
|
||||
client: Client4,
|
||||
channelId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await client.setMyChannelAutotranslation(channelId, enabled);
|
||||
}
|
||||
|
||||
const AUTO_TRANSLATION_PERMISSIONS = [
|
||||
'manage_public_channel_auto_translation',
|
||||
'manage_private_channel_auto_translation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Call this after enableAutotranslationConfig and before any UI test that relies on
|
||||
* the autotranslation toggle appearing in Channel Settings.
|
||||
*/
|
||||
export async function ensureAutotranslationPermissions(adminClient: Client4): Promise<void> {
|
||||
const roleNames = ['channel_admin', 'team_admin', 'system_admin'];
|
||||
|
||||
await Promise.all(
|
||||
roleNames.map(async (roleName) => {
|
||||
const role = await adminClient.getRoleByName(roleName);
|
||||
const missing = AUTO_TRANSLATION_PERMISSIONS.filter((p) => !role.permissions.includes(p));
|
||||
if (missing.length > 0) {
|
||||
await adminClient.patchRole(role.id, {
|
||||
permissions: [...role.permissions, ...missing],
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
136
e2e-tests/playwright/lib/src/browser_context.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type {Browser, BrowserContext} from '@playwright/test';
|
||||
import {request} from '@playwright/test';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {testConfig} from './test_config';
|
||||
import {pages} from './ui/pages';
|
||||
import {resolvePlaywrightPath} from './util';
|
||||
|
||||
export class TestBrowser {
|
||||
readonly browser: Browser;
|
||||
private contexts: BrowserContext[] = [];
|
||||
|
||||
constructor(browser: Browser) {
|
||||
this.browser = browser;
|
||||
}
|
||||
|
||||
async login(user: UserProfile) {
|
||||
const options = {storageState: ''};
|
||||
if (user) {
|
||||
// Log in via API request and save user storage
|
||||
const storagePath = await loginByAPI(user.username, user.password);
|
||||
options.storageState = storagePath;
|
||||
}
|
||||
|
||||
// Sign in a user in new browser context
|
||||
const context = await this.browser.newContext(options);
|
||||
await routeInternalBaseUrlToHost(context);
|
||||
const page = await context.newPage();
|
||||
|
||||
const channelsPage = new pages.ChannelsPage(page);
|
||||
const systemConsolePage = new pages.SystemConsolePage(page);
|
||||
const scheduledPostsPage = new pages.ScheduledPostsPage(page);
|
||||
const draftsPage = new pages.DraftsPage(page);
|
||||
const recapsPage = new pages.RecapsPage(page);
|
||||
const threadsPage = new pages.ThreadsPage(page);
|
||||
const contentReviewPage = new pages.ContentReviewPage(page);
|
||||
|
||||
this.contexts.push(context);
|
||||
|
||||
return {
|
||||
context,
|
||||
page,
|
||||
channelsPage,
|
||||
systemConsolePage,
|
||||
scheduledPostsPage,
|
||||
draftsPage,
|
||||
recapsPage,
|
||||
threadsPage,
|
||||
contentReviewPage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the auth state of an existing context to a different user
|
||||
* without creating a new context. After switching, pages in the context
|
||||
* should be reloaded to pick up the new auth state.
|
||||
*/
|
||||
async switchUser(context: BrowserContext, user: UserProfile) {
|
||||
const storagePath = await loginByAPI(user.username, user.password);
|
||||
await (context as any).setStorageState(storagePath);
|
||||
}
|
||||
|
||||
async close() {
|
||||
for (const context of this.contexts) {
|
||||
await context.close();
|
||||
}
|
||||
this.contexts = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin webapp bundles build absolute URLs from ServiceSettings.SiteURL, which in `testcontainers`
|
||||
* mode is a Docker network alias that only other containers can resolve. Left alone those fetches
|
||||
* hang from the host browser, so anything waiting on network idle never settles. Rewrite them onto
|
||||
* the host-mapped URL, which serves the same server. No-op in `external` mode, where the two match.
|
||||
*/
|
||||
async function routeInternalBaseUrlToHost(context: BrowserContext) {
|
||||
const {internalBaseURL, baseURL} = testConfig;
|
||||
if (internalBaseURL === baseURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.route(`${internalBaseURL}/**`, async (route) => {
|
||||
await route.continue({url: route.request().url().replace(internalBaseURL, baseURL)});
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginByAPI(loginId: string, password: string, token = '', ldapOnly = false) {
|
||||
const requestContext = await request.newContext();
|
||||
|
||||
const data: any = {
|
||||
login_id: loginId,
|
||||
password,
|
||||
token,
|
||||
deviceId: '',
|
||||
};
|
||||
|
||||
if (ldapOnly) {
|
||||
data.ldap_only = 'true';
|
||||
}
|
||||
|
||||
// Log in via API
|
||||
await requestContext.post(`${testConfig.baseURL}/api/v4/users/login`, {
|
||||
data,
|
||||
headers: {'X-Requested-With': 'XMLHttpRequest'},
|
||||
});
|
||||
|
||||
// Save signed-in state to a folder
|
||||
const storageStateDir = resolvePlaywrightPath('storage_state');
|
||||
|
||||
// Ensure storage_state directory exists
|
||||
if (!fs.existsSync(storageStateDir)) {
|
||||
fs.mkdirSync(storageStateDir, {recursive: true});
|
||||
}
|
||||
|
||||
const filename = `${Date.now()}_${loginId}_${password}${token ? '_' + token : ''}${ldapOnly ? '_ldap' : ''}.json`;
|
||||
const storagePath = path.join(storageStateDir, filename);
|
||||
const storageState = await requestContext.storageState({path: storagePath});
|
||||
await requestContext.dispose();
|
||||
|
||||
// Append origins to bypass seeing landing page then write to file
|
||||
storageState.origins.push({
|
||||
origin: testConfig.baseURL,
|
||||
localStorage: [{name: '__landingPageSeen__', value: 'true'}],
|
||||
});
|
||||
await writeFile(storagePath, JSON.stringify(storageState));
|
||||
|
||||
return storagePath;
|
||||
}
|
||||
23
e2e-tests/playwright/lib/src/constant.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const aiPluginId = 'mattermost-ai';
|
||||
export const appsPluginId = 'com.mattermost.apps';
|
||||
export const callsPluginId = 'com.mattermost.calls';
|
||||
export const npsPluginId = 'com.mattermost.nps';
|
||||
export const playbooksPluginId = 'playbooks';
|
||||
|
||||
// License SKU short names — mirrored from webapp/channels/src/utils/constants.tsx LicenseSkus
|
||||
export const LicenseSkus = {
|
||||
E10: 'E10',
|
||||
E20: 'E20',
|
||||
Starter: 'starter',
|
||||
Professional: 'professional',
|
||||
Enterprise: 'enterprise',
|
||||
EnterpriseAdvanced: 'advanced',
|
||||
Entry: 'entry',
|
||||
} as const;
|
||||
|
||||
// Remote users hour limit taken from webapp/channels/src/utils/constants.ts
|
||||
export const REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY = 22;
|
||||
export const REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY = 6;
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copied from server/build/Dockerfile.elasticsearch on 2026-07-19.
|
||||
ARG ELASTICSEARCH_VERSION=9.0.0
|
||||
FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION}
|
||||
|
||||
RUN /usr/share/elasticsearch/bin/elasticsearch-plugin install --batch analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
@@ -0,0 +1,5 @@
|
||||
# Copied from server/build/Dockerfile.opensearch on 2026-07-19.
|
||||
ARG OPENSEARCH_VERSION=3.0.0
|
||||
FROM opensearchproject/opensearch:$OPENSEARCH_VERSION
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
13
e2e-tests/playwright/lib/src/containers/assets/README.md
Normal file
@@ -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 |
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GenericContainer, Wait} from 'testcontainers';
|
||||
import type {StartedNetwork, StartedTestContainer} from 'testcontainers';
|
||||
|
||||
import {TESTCONTAINERS_LABELS, WEBHOOK_ALIAS, WEBHOOK_PORT} from './constants';
|
||||
import {containerAssetPath} from './paths';
|
||||
import {startWithRetry} from './retry';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// Vendored from e2e-tests/cypress/webhook_serve.js — the interactive-message/dialog callback
|
||||
// sidecar tests point PW_WEBHOOK_BASE_URL at. Always started, unlike the other optional services,
|
||||
// since testConfig.webhookBaseUrl defaults to localhost:3000 outside Testcontainers mode too.
|
||||
export async function startWebhookContainer(network: StartedNetwork): Promise<StartedTestContainer> {
|
||||
return startWithRetry('webhook', async () => {
|
||||
// deleteOnExit: false — otherwise the built image bakes in this session's Ryuk id, so Ryuk
|
||||
// reaps the "reused" container the moment this session ends, defeating withReuse() below.
|
||||
const image = await GenericContainer.fromDockerfile(containerAssetPath('webhook'), 'Dockerfile.webhook').build(
|
||||
undefined,
|
||||
{deleteOnExit: false},
|
||||
);
|
||||
|
||||
let builder = image
|
||||
.withExposedPorts(WEBHOOK_PORT)
|
||||
.withNetwork(network)
|
||||
.withNetworkAliases(WEBHOOK_ALIAS)
|
||||
.withLabels(TESTCONTAINERS_LABELS)
|
||||
.withWaitStrategy(Wait.forHttp('/', WEBHOOK_PORT).forStatusCode(200));
|
||||
|
||||
if (testConfig.testcontainersReuse) {
|
||||
builder = builder.withReuse();
|
||||
}
|
||||
|
||||
return builder.start();
|
||||
});
|
||||
}
|
||||
93
e2e-tests/playwright/lib/src/file.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import mime from 'mime-types';
|
||||
|
||||
import {resolvePlaywrightPath} from './util';
|
||||
|
||||
const commonAssetPath = path.resolve(__dirname, 'asset');
|
||||
export const assetPath = resolvePlaywrightPath('asset');
|
||||
|
||||
type AvailableFilename = 'mattermost-icon_128x128.png';
|
||||
|
||||
/**
|
||||
* Reads file data and creates a File object.
|
||||
* @param filePath - The path to the file.
|
||||
* @returns A File object containing the file data.
|
||||
* @throws If the file does not exist.
|
||||
*/
|
||||
export function getFileData(filePath: string): File {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found at path: ${filePath}`);
|
||||
}
|
||||
|
||||
const mimeType = mime.lookup(filePath) || undefined;
|
||||
const fileName = path.basename(filePath);
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
|
||||
return new File([fileBuffer], fileName, {type: mimeType});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file data and creates a Blob object.
|
||||
* @param filePath - The path to the file.
|
||||
* @returns A Blob object containing the file data.
|
||||
* @throws If the file does not exist.
|
||||
*/
|
||||
export function getBlobData(filePath: string): Blob {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found at path: ${filePath}`);
|
||||
}
|
||||
|
||||
const mimeType = mime.lookup(filePath) || undefined;
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
|
||||
return new Blob([fileBuffer], {type: mimeType});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file data from the "asset" directory and creates a File object.
|
||||
* @param filename - The name of the file in the "asset" directory.
|
||||
* @returns An object containing a File object
|
||||
*/
|
||||
export function getFileFromAsset(filename: string) {
|
||||
const filePath = path.join(assetPath, filename);
|
||||
|
||||
return getFileData(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file data from the "asset" directory and creates a Blob object.
|
||||
* @param filename - The name of the file in the "asset" directory.
|
||||
* @returns An object containing a Blob object
|
||||
*/
|
||||
export function getBlobFromAsset(filename: string) {
|
||||
const filePath = path.join(assetPath, filename);
|
||||
|
||||
return getBlobData(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file data from the lib "asset" directory and creates a File object.
|
||||
* @param filename - The name of the file in the "asset" directory.
|
||||
* @returns An object containing a File object
|
||||
*/
|
||||
export function getFileFromCommonAsset(filename: AvailableFilename) {
|
||||
const filePath = path.join(commonAssetPath, filename);
|
||||
|
||||
return getFileData(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads file data from the lib "asset" directory and creates a Blob object.
|
||||
* @param filename - The name of the file in the "asset" directory.
|
||||
* @returns An object containing a Blob object
|
||||
*/
|
||||
export function getBlobFromCommonAsset(filename: AvailableFilename) {
|
||||
const filePath = path.join(commonAssetPath, filename);
|
||||
|
||||
return getBlobData(filePath);
|
||||
}
|
||||
83
e2e-tests/playwright/lib/src/file_server.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fork, type ChildProcess} from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import {test} from './test_fixture';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
/** Hostname containers use to reach the Docker host. */
|
||||
export const DOCKER_HOST_INTERNAL = 'host.docker.internal';
|
||||
|
||||
/**
|
||||
* Starts a server that serves files from ./asset. When run from the monorepo, this will serve files from
|
||||
* e2e-tests/playwright/asset.
|
||||
*
|
||||
* The server will be started automatically by beforeAll and then cleaned up by afterAll.
|
||||
*
|
||||
* @returns A promise that resolves to the URL of the file server
|
||||
*/
|
||||
export function setupFileServer(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let fileServer: ChildProcess;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
try {
|
||||
resolve(await startFileServer());
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
fileServer?.kill();
|
||||
});
|
||||
|
||||
function startFileServer(): Promise<string> {
|
||||
const playwrightRoot = process.cwd();
|
||||
const serverPath = path.join(playwrightRoot, 'mock_file_server.js');
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
fileServer = fork(serverPath, [], {
|
||||
env: {...process.env, PORT: '0', ASSET_DIR: path.join(playwrightRoot, 'asset')},
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
});
|
||||
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for the mock file server to start')),
|
||||
10000,
|
||||
);
|
||||
|
||||
fileServer.once('message', (message: {type?: string; port?: number}) => {
|
||||
if (message?.type === 'listening' && message.port) {
|
||||
clearTimeout(timeout);
|
||||
resolve(`http://${fileServerHost()}:${message.port}`);
|
||||
}
|
||||
});
|
||||
|
||||
fileServer.once('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Host used in file-server URLs embedded in posts. Must be reachable from both the host-side
|
||||
* browser (Markdown images load directly) and the Mattermost container (link previews / metadata).
|
||||
* On macOS/Windows the bridge gateway does not reach host listeners, so use host.docker.internal;
|
||||
* on Linux use the bridge gateway IP (works both sides; host.docker.internal is often absent from host DNS).
|
||||
*/
|
||||
export function fileServerHost(): string {
|
||||
if (!testConfig.useTestContainers) {
|
||||
return 'localhost';
|
||||
}
|
||||
if (process.platform === 'darwin' || process.platform === 'win32') {
|
||||
return DOCKER_HOST_INTERNAL;
|
||||
}
|
||||
return testConfig.testcontainersNetworkGatewayIp || DOCKER_HOST_INTERNAL;
|
||||
}
|
||||
158
e2e-tests/playwright/lib/src/flag.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import os from 'node:os';
|
||||
|
||||
import {expect, test} from '@playwright/test';
|
||||
import type {PluginManifest} from '@mattermost/types/plugins';
|
||||
|
||||
import {callsPluginId} from './constant';
|
||||
import {getAdminClient} from './server/init';
|
||||
import {testConfig} from './test_config';
|
||||
|
||||
export async function shouldHaveCallsEnabled(enabled = true) {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const config = await adminClient.getConfig();
|
||||
|
||||
const callsEnabled = config.PluginSettings.PluginStates[callsPluginId].Enable;
|
||||
|
||||
const matched = callsEnabled === enabled;
|
||||
expect(matched, matched ? '' : `Calls expect "${enabled}" but actual "${callsEnabled}"`).toBeTruthy();
|
||||
}
|
||||
|
||||
export async function shouldHaveFeatureFlag(name: string, value: string | boolean) {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const config = await adminClient.getConfig();
|
||||
|
||||
const matched = config.FeatureFlags[name] === value;
|
||||
expect(
|
||||
matched,
|
||||
matched ? '' : `FeatureFlags["${name}'] expect "${value}" but actual "${config.FeatureFlags[name]}"`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
export async function shouldRunInLinux() {
|
||||
const platform = os.platform();
|
||||
expect(platform, 'Run in Linux or Playwright docker image only').toBe('linux');
|
||||
}
|
||||
|
||||
export async function ensureLicense() {
|
||||
const {adminClient} = await getAdminClient();
|
||||
let license = await adminClient.getClientLicenseOld();
|
||||
|
||||
if (license?.IsLicensed !== 'true') {
|
||||
const config = await adminClient.getClientConfig();
|
||||
expect(
|
||||
config.ServiceEnvironment === 'dev',
|
||||
'The trial license request fails in the local development environment. Please manually upload the test license.',
|
||||
).toBeFalsy();
|
||||
|
||||
await requestTrialLicense();
|
||||
|
||||
license = await adminClient.getClientLicenseOld();
|
||||
}
|
||||
|
||||
expect(license?.IsLicensed === 'true', 'Ensure server has license').toBeTruthy();
|
||||
}
|
||||
|
||||
export async function requestTrialLicense() {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const admin = await adminClient.getMe();
|
||||
try {
|
||||
await adminClient.requestTrialLicense({
|
||||
receive_emails_accepted: true,
|
||||
terms_accepted: true,
|
||||
users: 100,
|
||||
contact_name: admin.first_name + ' ' + admin.last_name,
|
||||
contact_email: admin.email,
|
||||
company_name: 'Mattermost Playwright E2E Tests',
|
||||
company_size: '101-250',
|
||||
company_country: 'United States',
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error, 'Failed to request trial license').toBeFalsy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function skipIfNoLicense() {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
|
||||
test.skip(license.IsLicensed === 'false', 'Skipping test - server not licensed');
|
||||
}
|
||||
|
||||
export async function skipIfFeatureFlagNotSet(name: string, value: string | boolean) {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const cfg = await adminClient.getConfig();
|
||||
|
||||
test.skip(cfg.FeatureFlags[name] !== value, `Skipping test - Feature Flag ${name} needs to be set to ${value}`);
|
||||
}
|
||||
|
||||
// ensureServerDeployment is used to ensure server deployment type is as expected.
|
||||
// If server is not deployed as expected, test will fail.
|
||||
export async function ensureServerDeployment() {
|
||||
const {adminClient} = await getAdminClient();
|
||||
|
||||
// Based on test config, ensure server is on an HA cluster.
|
||||
if (testConfig.haClusterEnabled) {
|
||||
const {haClusterNodeCount, haClusterName} = testConfig;
|
||||
|
||||
const {Enable, ClusterName} = (await adminClient.getConfig()).ClusterSettings;
|
||||
expect(Enable, Enable ? '' : 'Should have cluster enabled').toBe(true);
|
||||
|
||||
const sameClusterName = ClusterName === haClusterName;
|
||||
expect(
|
||||
sameClusterName,
|
||||
sameClusterName
|
||||
? ''
|
||||
: `Should have cluster name set and as expected. Got "${ClusterName}" but expected "${haClusterName}"`,
|
||||
).toBe(true);
|
||||
|
||||
const clusterInfo = await adminClient.getClusterStatus();
|
||||
const sameCount = clusterInfo?.length === haClusterNodeCount;
|
||||
expect(
|
||||
sameCount,
|
||||
sameCount
|
||||
? ''
|
||||
: `Should match number of nodes in a cluster as expected. Got "${clusterInfo?.length}" but expected "${haClusterNodeCount}"`,
|
||||
).toBe(true);
|
||||
|
||||
clusterInfo.forEach((info) =>
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`hostname: ${info.hostname}, version: ${info.version}, config_hash: ${info.config_hash}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ensurePluginsLoaded is used to ensure all pluginIds including from testConfig.ensurePluginsInstalled are installed and active.
|
||||
// If any pluginId is not installed, test will fail.
|
||||
// testConfig.ensurePluginsInstalled is derived from `PW_ENSURE_PLUGINS_INSTALLED` environment variable.
|
||||
export async function ensurePluginsLoaded(pluginIds: string[] = []) {
|
||||
const {adminClient} = await getAdminClient();
|
||||
|
||||
const pluginStatus = await adminClient.getPluginStatuses();
|
||||
const plugins = await adminClient.getPlugins();
|
||||
|
||||
// Ensure all plugins are installed and active.
|
||||
testConfig.ensurePluginsInstalled
|
||||
.concat(pluginIds)
|
||||
.filter((pluginId) => Boolean(pluginId))
|
||||
.forEach(async (pluginId) => {
|
||||
const isInstalled = pluginStatus.some((plugin) => plugin.plugin_id === pluginId);
|
||||
|
||||
// If not installed, test will fail.
|
||||
expect(isInstalled, `${pluginId} is not installed. Related test will fail.`).toBe(true);
|
||||
|
||||
const isActive = plugins.active.some((plugin: PluginManifest) => plugin.id === pluginId);
|
||||
if (isActive) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${pluginId} is installed and active.`);
|
||||
} else {
|
||||
await adminClient.enablePlugin(pluginId);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${pluginId} is installed and has been activated.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
233
e2e-tests/playwright/lib/src/global_setup.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client4} from '@mattermost/client';
|
||||
import type {PluginManifest} from '@mattermost/types/plugins';
|
||||
import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {
|
||||
createNewTeam,
|
||||
disableUnexpectedPlugins,
|
||||
getAdminClient,
|
||||
getDefaultAdminUser,
|
||||
makeClient,
|
||||
runMmctlLocal,
|
||||
} from './server';
|
||||
import {testConfig} from './test_config';
|
||||
import {isUpgradePathProjectSelected} from './upgrade_env';
|
||||
import {defaultTeam} from './util';
|
||||
|
||||
export async function baseGlobalSetup() {
|
||||
let adminClient: Client4;
|
||||
let adminUser: UserProfile | null;
|
||||
({adminClient, adminUser} = await getAdminClient({skipLog: true}));
|
||||
|
||||
if (!adminUser) {
|
||||
await enableEmailNotifications();
|
||||
|
||||
const firstClient = new Client4();
|
||||
firstClient.setUrl(testConfig.baseURL);
|
||||
const defaultAdmin = getDefaultAdminUser();
|
||||
await firstClient.createUser(defaultAdmin, '', '');
|
||||
|
||||
({client: adminClient, user: adminUser} = await makeClient(defaultAdmin));
|
||||
}
|
||||
|
||||
// Print playwright configs
|
||||
printPlaywrightTestConfig();
|
||||
|
||||
await sysadminSetup(adminClient, adminUser);
|
||||
}
|
||||
|
||||
// SendEmailNotifications is off in the server's own defaults, so creating the very first sysadmin
|
||||
// logs "Failed to send welcome email on create user from signup". No admin exists yet to
|
||||
// authenticate patchConfig with, so this goes through mmctl's --local socket rather than boot env —
|
||||
// the server's env surface stays the same as any other run.
|
||||
async function enableEmailNotifications(): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {exitCode, output} = await runMmctlLocal([
|
||||
'config',
|
||||
'set',
|
||||
'EmailSettings.SendEmailNotifications',
|
||||
'true',
|
||||
]);
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`mmctl exited with code ${exitCode}: ${output}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Costs only a warn-level log line during first-user creation — never worth failing setup over.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Could not enable email notifications before creating the first user', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sysadminSetup(client: Client4, user: UserProfile | null) {
|
||||
// Ensure admin's email is verified.
|
||||
if (!user) {
|
||||
await client.verifyUserEmail(client.token);
|
||||
}
|
||||
|
||||
// Log license and config info
|
||||
await printLicenseInfo(client);
|
||||
await printClientInfo(client);
|
||||
|
||||
// Create default team if not present.
|
||||
// Otherwise, create other teams and channels other than the default team cna channels (town-square and off-topic).
|
||||
const myTeams = await client.getMyTeams();
|
||||
const myDefaultTeam = myTeams && myTeams.length > 0 && myTeams.find((team) => team.name === defaultTeam.name);
|
||||
if (!myDefaultTeam) {
|
||||
await createNewTeam(client, {name: defaultTeam.name, displayName: defaultTeam.displayName});
|
||||
} else if (myDefaultTeam && testConfig.resetBeforeTest) {
|
||||
await Promise.all(
|
||||
myTeams.filter((team) => team.name !== defaultTeam.name).map((team) => client.deleteTeam(team.id)),
|
||||
);
|
||||
|
||||
const myChannels = await client.getMyChannels(myDefaultTeam.id);
|
||||
await Promise.all(
|
||||
myChannels
|
||||
.filter((channel) => {
|
||||
return (
|
||||
channel.team_id === myDefaultTeam.id &&
|
||||
channel.name !== 'town-square' &&
|
||||
channel.name !== 'off-topic'
|
||||
);
|
||||
})
|
||||
.map((channel) => client.deleteChannel(channel.id)),
|
||||
);
|
||||
}
|
||||
|
||||
// Set default preferences
|
||||
await savePreferences(client, user?.id ?? '');
|
||||
|
||||
await resetPluginState(client);
|
||||
await printPluginDetails(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates plugins a previous run left enabled, so they cannot alter the webapp for the specs
|
||||
* that follow. Skipped for the upgrade-path projects, where surviving plugin state is under test.
|
||||
*/
|
||||
async function resetPluginState(client: Client4) {
|
||||
if (isUpgradePathProjectSelected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const disabled = await disableUnexpectedPlugins(client, testConfig.ensurePluginsInstalled);
|
||||
if (disabled.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Disabled plugins left active by an earlier run: ${disabled.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Could not reset plugin state', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function printPlaywrightTestConfig() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Playwright Test Config:
|
||||
- Headless = ${testConfig.headless}
|
||||
- SlowMo = ${testConfig.slowMo}
|
||||
- Workers = ${testConfig.workers}`);
|
||||
}
|
||||
|
||||
async function printLicenseInfo(client: Client4) {
|
||||
const license = await client.getClientLicenseOld();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Server License:
|
||||
- IsLicensed = ${license.IsLicensed}
|
||||
- IsTrial = ${license.IsTrial}
|
||||
- SkuName = ${license.SkuName}
|
||||
- SkuShortName = ${license.SkuShortName}
|
||||
- Cloud = ${license.Cloud}
|
||||
- Users = ${license.Users}`);
|
||||
}
|
||||
|
||||
async function printClientInfo(client: Client4) {
|
||||
const config = await client.getClientConfig();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Build Info:
|
||||
- BuildNumber = ${config.BuildNumber}
|
||||
- BuildDate = ${config.BuildDate}
|
||||
- Version = ${config.Version}
|
||||
- BuildHash = ${config.BuildHash}
|
||||
- BuildHashEnterprise = ${config.BuildHashEnterprise}
|
||||
- BuildEnterpriseReady = ${config.BuildEnterpriseReady}
|
||||
- TelemetryId = ${config.TelemetryId}
|
||||
- ServiceEnvironment = ${config.ServiceEnvironment}`);
|
||||
|
||||
const {LogSettings, ServiceSettings, PluginSettings, FeatureFlags} = await client.getConfig();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Notable Server Config:
|
||||
- ServiceSettings.EnableSecurityFixAlert = ${ServiceSettings?.EnableSecurityFixAlert}
|
||||
- LogSettings.EnableDiagnostics = ${LogSettings?.EnableDiagnostics}`);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Feature Flags:');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
Object.entries(FeatureFlags)
|
||||
.map(([key, value]) => ` - ${key} = ${value}`)
|
||||
.join('\n'),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Plugin Settings:
|
||||
- Enable = ${PluginSettings?.Enable}
|
||||
- EnableUploads = ${PluginSettings?.EnableUploads}
|
||||
- AutomaticPrepackagedPlugins = ${PluginSettings?.AutomaticPrepackagedPlugins}`);
|
||||
}
|
||||
|
||||
async function printPluginDetails(client: Client4) {
|
||||
const plugins = await client.getPlugins();
|
||||
|
||||
if (plugins.active.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Active plugins:');
|
||||
}
|
||||
|
||||
plugins.active.forEach((plugin: PluginManifest) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
|
||||
});
|
||||
|
||||
if (plugins.inactive.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Inactive plugins:');
|
||||
}
|
||||
|
||||
plugins.inactive.forEach((plugin: PluginManifest) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` - ${plugin.id}@${plugin.version} | min_server@${plugin.min_server_version}`);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function savePreferences(client: Client4, userId: UserProfile['id']) {
|
||||
try {
|
||||
if (!userId) {
|
||||
throw new Error('userId is not defined');
|
||||
}
|
||||
|
||||
const preferences: PreferenceType[] = [
|
||||
{user_id: userId, category: 'tutorial_step', name: userId, value: '999'},
|
||||
{user_id: userId, category: 'crt_thread_pane_step', name: userId, value: '999'},
|
||||
{user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
|
||||
{user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
|
||||
];
|
||||
|
||||
await client.savePreferences(userId, preferences);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error saving preferences', error);
|
||||
}
|
||||
}
|
||||
211
e2e-tests/playwright/lib/src/ime.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {CDPSession, Page} from '@playwright/test';
|
||||
|
||||
/**
|
||||
* This is Korean for "Test if Hangul is typed well".
|
||||
*
|
||||
* When testing manually, if you want to type this on a US English Qwerty keyboard with your OS language set to Korean,
|
||||
* this can be typed as "gksrmfdl wkf dlqfurehlsmswl xptmxm"
|
||||
*/
|
||||
export const koreanTestPhrase = '한글이 잘 입력되는지 테스트';
|
||||
|
||||
/**
|
||||
* Simulates typing a phrase containing Korean Hangul characters using an Input Method Editor that composes characters
|
||||
* from multiple keypresses as the user types. This isn't completely realistic because it's missing keyboard events, but
|
||||
* it's sufficient to reproduce composition bugs like MM-66937.
|
||||
*
|
||||
* This finishes by ending composition on the final character typed, so this can't be used to test anything involving
|
||||
* the character that's actively being composed (such as autocompleting partial characters).
|
||||
*
|
||||
* Note: This only works on Chrome-based browsers because it relies on the Chrome Devtools Protocol (CDP).
|
||||
*/
|
||||
export async function typeHangulWithIme(page: Page, text: string) {
|
||||
const client = await page.context().newCDPSession(page);
|
||||
|
||||
const decomposed = decomposeKorean(text);
|
||||
for (let i = 0; i < decomposed.length; i++) {
|
||||
await typeHangulCharacterWithIme(client, decomposed[i], decomposed[i - 1]);
|
||||
}
|
||||
|
||||
const lastCharacter = decomposed.at(-1);
|
||||
if (lastCharacter && 'jamo' in lastCharacter) {
|
||||
// # End composition by inserting the final character into the textbox
|
||||
await client.send('Input.insertText', {
|
||||
text: lastCharacter.character,
|
||||
});
|
||||
}
|
||||
|
||||
await client.detach();
|
||||
}
|
||||
|
||||
export async function typeHangulCharacterWithIme(
|
||||
client: CDPSession,
|
||||
current: HangleCharacter,
|
||||
previous: HangleCharacter | undefined,
|
||||
) {
|
||||
if (previous && previous.jamo) {
|
||||
// # End composition of the previous character by inserting it into the textbox
|
||||
await client.send('Input.insertText', {
|
||||
text: previous.character,
|
||||
});
|
||||
}
|
||||
|
||||
if (current.jamo) {
|
||||
// # Type the individual jamo
|
||||
|
||||
// The first one is typed as-is
|
||||
await client.send('Input.imeSetComposition', {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
text: current.jamo[0],
|
||||
});
|
||||
|
||||
// When you type the second one, the IME combines the two into the resulting character. Instead of reversing
|
||||
// the math, we can do that by concatenating them and then normalizing the Unicode.
|
||||
await client.send('Input.imeSetComposition', {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
text: (current.jamo[0] + current.jamo[1]).normalize('NFKD'),
|
||||
});
|
||||
|
||||
// For the third one, we can't normalize the Unicode because there are some initial and final jamo which
|
||||
// look identical and normalize to the same value, so just use the original character
|
||||
await client.send('Input.imeSetComposition', {
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
text: current.character,
|
||||
});
|
||||
} else {
|
||||
// # Insert the character
|
||||
await client.send('Input.insertText', {
|
||||
text: current.character,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HangleCharacter is an object containing either:
|
||||
* - An non-Korean character
|
||||
* - A Korean character and the array of jamo (syllables) that make up that character
|
||||
*/
|
||||
export interface HangleCharacter {
|
||||
character: string;
|
||||
jamo?: string[];
|
||||
}
|
||||
|
||||
export function decomposeKorean(text: string): HangleCharacter[] {
|
||||
// Adapted from https://useless-factor.blogspot.com/2007/08/unicode-implementers-guide-part-3.html and
|
||||
// https://web.archive.org/web/20190512031142/http://www.programminginkorean.com/programming/hangul-in-unicode/composing-syllables-in-unicode/
|
||||
|
||||
// All Korean Hangul characters/syllables are in this range of Unicode
|
||||
const hangulStart = 0xac00;
|
||||
const hangulEnd = 0xd7a3;
|
||||
|
||||
// Hangul characters are made up of an initial consonant, a medial vowel, and an optional final vowel
|
||||
const initial = [
|
||||
'ㄱ',
|
||||
'ㄲ',
|
||||
'ㄴ',
|
||||
'ㄷ',
|
||||
'ㄸ',
|
||||
'ㄹ',
|
||||
'ㅁ',
|
||||
'ㅂ',
|
||||
'ㅃ',
|
||||
'ㅅ',
|
||||
'ㅆ',
|
||||
'ㅇ',
|
||||
'ㅈ',
|
||||
'ㅉ',
|
||||
'ㅊ',
|
||||
'ㅋ',
|
||||
'ㅌ',
|
||||
'ㅍ',
|
||||
'ㅎ',
|
||||
];
|
||||
const medial = [
|
||||
'ㅏ',
|
||||
'ㅐ',
|
||||
'ㅑ',
|
||||
'ㅒ',
|
||||
'ㅓ',
|
||||
'ㅔ',
|
||||
'ㅕ',
|
||||
'ㅖ',
|
||||
'ㅗ',
|
||||
'ㅘ',
|
||||
'ㅙ',
|
||||
'ㅚ',
|
||||
'ㅛ',
|
||||
'ㅜ',
|
||||
'ㅝ',
|
||||
'ㅞ',
|
||||
'ㅟ',
|
||||
'ㅠ',
|
||||
'ㅡ',
|
||||
'ㅢ',
|
||||
'ㅣ',
|
||||
];
|
||||
const final = [
|
||||
'',
|
||||
'ㄱ',
|
||||
'ㄲ',
|
||||
'ㄳ',
|
||||
'ㄴ',
|
||||
'ㄵ',
|
||||
'ㄶ',
|
||||
'ㄷ',
|
||||
'ㄹ',
|
||||
'ㄺ',
|
||||
'ㄻ',
|
||||
'ㄼ',
|
||||
'ㄽ',
|
||||
'ㄾ',
|
||||
'ㄿ',
|
||||
'ㅀ',
|
||||
'ㅁ',
|
||||
'ㅂ',
|
||||
'ㅄ',
|
||||
'ㅅ',
|
||||
'ㅆ',
|
||||
'ㅇ',
|
||||
'ㅈ',
|
||||
'ㅊ',
|
||||
'ㅋ',
|
||||
'ㅌ',
|
||||
'ㅍ',
|
||||
'ㅎ',
|
||||
];
|
||||
|
||||
const result = [];
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const character = text[i];
|
||||
const code = character.charCodeAt(0);
|
||||
|
||||
if (code >= hangulStart && code <= hangulEnd) {
|
||||
// This is a Hangul character, so we can break it down into the individual constants and vowel
|
||||
const syllableIndex = code - hangulStart;
|
||||
|
||||
// See the linked blog posts for more information on this math
|
||||
const initialIndex = Math.floor(syllableIndex / (21 * 28));
|
||||
const medialIndex = Math.floor((syllableIndex % (21 * 28)) / 28);
|
||||
const finalIndex = syllableIndex % 28;
|
||||
|
||||
const jamo = [];
|
||||
jamo.push(initial[initialIndex]);
|
||||
jamo.push(medial[medialIndex]);
|
||||
if (final[finalIndex]) {
|
||||
jamo.push(final[finalIndex]);
|
||||
}
|
||||
result.push({character, jamo});
|
||||
} else {
|
||||
// This is some other character, so just add it separately
|
||||
result.push({character});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
167
e2e-tests/playwright/lib/src/index.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {test, expect, PlaywrightExtended} from './test_fixture';
|
||||
export type {ExtendedFixtures} from './test_fixture';
|
||||
export {testConfig, TESTCONTAINERS_SERVICE_NAMES} from './test_config';
|
||||
export type {TestContainersServiceName} from './test_config';
|
||||
export {
|
||||
assertUpgradeFromFreshStart,
|
||||
assertUpgradeToRequiresPriorFromRun,
|
||||
getUpgradeFromServerImage,
|
||||
getUpgradeToServerImage,
|
||||
isUpgradeFromProjectSelected,
|
||||
isUpgradePathProjectSelected,
|
||||
isUpgradeToPhaseProjectSelected,
|
||||
logUpgradeFromServerImage,
|
||||
logUpgradeToServerImage,
|
||||
UPGRADE_FROM_SERVER_IMAGE_ENV,
|
||||
} from './upgrade_env';
|
||||
export {baseGlobalSetup} from './global_setup';
|
||||
export {TestBrowser} from './browser_context';
|
||||
export {assetPath, getBlobFromAsset, getFileFromAsset, getFileData, getBlobData} from './file';
|
||||
export {setupFileServer} from './file_server';
|
||||
export {decomposeKorean, koreanTestPhrase, typeHangulCharacterWithIme, typeHangulWithIme} from './ime';
|
||||
export {type SizeObservation, type SizeWatcher, watchElementSize} from './layout_shift';
|
||||
export {duration, getRandomId, wait, newTestPassword} from './util';
|
||||
export {LicenseSkus, appsPluginId, callsPluginId, playbooksPluginId} from './constant';
|
||||
|
||||
export {
|
||||
getAdminClient,
|
||||
getOnPremServerConfig,
|
||||
getRecentEmail,
|
||||
extractEmailLink,
|
||||
isWebhookTestServerReachable,
|
||||
setupWebhookTestServer,
|
||||
PlaywrightClient4,
|
||||
generateLdapUser,
|
||||
createLdapUser,
|
||||
updateLdapUser,
|
||||
deleteLdapUser,
|
||||
ldapServerConfig,
|
||||
ensureOpenldap,
|
||||
createKeycloakUser,
|
||||
deleteKeycloakUser,
|
||||
listMinioObjectKeys,
|
||||
ensureMinio,
|
||||
samlServerConfig,
|
||||
ensureKeycloak,
|
||||
elasticsearchServerConfig,
|
||||
opensearchServerConfig,
|
||||
ensureElasticsearch,
|
||||
ensureOpensearch,
|
||||
ensureAzurite,
|
||||
listAzuriteBlobNames,
|
||||
ensureLocalFile,
|
||||
listMattermostDataFiles,
|
||||
ensurePostgresSearch,
|
||||
ensureFeatureFlag,
|
||||
runMmctl,
|
||||
ensureMmctl,
|
||||
upgradeServerImage,
|
||||
saveUpgradePhaseLogs,
|
||||
installAndEnablePlugin,
|
||||
getPluginStatus,
|
||||
isPluginActive,
|
||||
} from './server';
|
||||
export type {InbucketEmail, LdapUser, KeycloakUser, MmctlResult, UpgradeLogPhase} from './server';
|
||||
|
||||
export {startStack, stopStack} from './containers';
|
||||
|
||||
export {
|
||||
ChannelsPage,
|
||||
LandingLoginPage,
|
||||
LoginPage,
|
||||
RecapsPage,
|
||||
ResetPasswordPage,
|
||||
SignupPage,
|
||||
ScheduledPostsPage,
|
||||
SystemConsolePage,
|
||||
DraftsPage,
|
||||
} from './ui/pages';
|
||||
|
||||
export {
|
||||
components,
|
||||
AboutBuildModal,
|
||||
GlobalHeader,
|
||||
SearchBox,
|
||||
ChannelsCenterView,
|
||||
ChannelsSidebarLeft,
|
||||
ChannelsSidebarRight,
|
||||
ChannelsAppBar,
|
||||
ChannelsHeader,
|
||||
ChannelsPostCreate,
|
||||
ChannelsPostEdit,
|
||||
ChannelsPost,
|
||||
ChannelSettingsModal,
|
||||
DraftPost,
|
||||
FindChannelsModal,
|
||||
DeletePostModal,
|
||||
DeleteScheduledPostModal,
|
||||
SettingsModal,
|
||||
PostDotMenu,
|
||||
PostMenu,
|
||||
ThreadFooter,
|
||||
Footer,
|
||||
MainHeader,
|
||||
PostReminderMenu,
|
||||
EmojiGifPicker,
|
||||
GenericConfirmModal,
|
||||
ScheduleMessageMenu,
|
||||
ScheduleMessageModal,
|
||||
ScheduledPostIndicator,
|
||||
ScheduledDraftModal,
|
||||
ScheduledPost,
|
||||
SendMessageNowModal,
|
||||
SystemConsoleFeatureDiscovery,
|
||||
MessagePriority,
|
||||
UserProfilePopover,
|
||||
UserAccountMenu,
|
||||
DeletePostConfirmationDialog,
|
||||
RestorePostConfirmationDialog,
|
||||
ProfileModal,
|
||||
WysiwygEditor,
|
||||
} from './ui/components';
|
||||
|
||||
export {setWysiwygUserPreference, WYSIWYG_PREF_CATEGORY, WYSIWYG_PREF_NAME} from './wysiwyg_helpers';
|
||||
|
||||
export {TextInputSetting} from './ui/components/system_console/base_components';
|
||||
|
||||
export {expectFilesVisible, expectFilesRedacted} from './ui/components/channels/post';
|
||||
|
||||
export {TestArgs, ScreenshotOptions} from './types';
|
||||
|
||||
export {
|
||||
enableAutotranslationConfig,
|
||||
disableAutotranslationConfig,
|
||||
enableChannelAutotranslation,
|
||||
disableChannelAutotranslation,
|
||||
setUserChannelAutotranslation,
|
||||
setMockSourceLanguage,
|
||||
ensureAutotranslationPermissions,
|
||||
} from './autotranslation_helpers';
|
||||
export type {EnableAutotranslationOptions} from './autotranslation_helpers';
|
||||
export {
|
||||
hasAutotranslationLicense,
|
||||
hasSharedChannelsLicense,
|
||||
hasCustomPermissionsSchemesLicense,
|
||||
licenseTier,
|
||||
} from './license_helpers';
|
||||
|
||||
// ABAC (Attribute-Based Access Control) helpers
|
||||
export {
|
||||
createUserWithAttributes,
|
||||
enableABAC,
|
||||
disableABAC,
|
||||
navigateToABACPage,
|
||||
navigateToPermissionPoliciesPage,
|
||||
navigateToAttributeBasedAccessPage,
|
||||
createBasicPolicy,
|
||||
createAdvancedPolicy,
|
||||
editPolicy,
|
||||
deletePolicy,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
verifyUserNotInChannel,
|
||||
updateUserAttributes,
|
||||
} from './server';
|
||||
113
e2e-tests/playwright/lib/src/layout_shift.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
|
||||
export type SizeObservation = {width: number; height: number; at: number};
|
||||
export type SizeWatcher = {
|
||||
/** Callback to clean up and remove the SizeWatcher */
|
||||
cleanup: () => Promise<void>;
|
||||
/** Returns an array of all height observations made since the last time the page loaded. */
|
||||
getObservations: () => Promise<SizeObservation[]>;
|
||||
/** Callback that returns true if the watched element has been found. */
|
||||
isWatchingElement: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers and returns an object that can be used to observe how the height of an element changes over its lifetime
|
||||
* The watcher persists through navigation and can be registered before the page loads, but the list of observed
|
||||
* heights will be reset when that happens.
|
||||
*/
|
||||
export async function watchElementSize(page: Page, elementId: string): Promise<SizeWatcher> {
|
||||
const selector = `#${elementId}`;
|
||||
const watcherKey = `watchElementSize-${elementId}`;
|
||||
|
||||
type SizeWatcherInternal = {
|
||||
element?: Element;
|
||||
observations: SizeObservation[];
|
||||
mutationObserver?: MutationObserver;
|
||||
resizeObserver?: ResizeObserver;
|
||||
};
|
||||
|
||||
const disposeWatcher = await page.addInitScript(
|
||||
([selector, watcherKey]) => {
|
||||
const sizeWatcher: SizeWatcherInternal = {
|
||||
observations: [],
|
||||
};
|
||||
(window as any)[watcherKey] = sizeWatcher;
|
||||
|
||||
const attachResizeObserver = (el: Element) => {
|
||||
sizeWatcher.element = el;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const width = e.borderBoxSize[0].inlineSize;
|
||||
const height = e.borderBoxSize[0].blockSize;
|
||||
|
||||
sizeWatcher.observations.push({width, height, at: performance.now()});
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(el);
|
||||
sizeWatcher.resizeObserver = resizeObserver;
|
||||
};
|
||||
|
||||
const existingEl = document.querySelector(selector);
|
||||
if (existingEl) {
|
||||
attachResizeObserver(existingEl);
|
||||
} else {
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el || sizeWatcher.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
attachResizeObserver(el);
|
||||
|
||||
mutationObserver.disconnect();
|
||||
sizeWatcher.mutationObserver = undefined;
|
||||
});
|
||||
|
||||
mutationObserver.observe(document, {childList: true, subtree: true});
|
||||
sizeWatcher.mutationObserver = mutationObserver;
|
||||
}
|
||||
},
|
||||
[selector, watcherKey],
|
||||
);
|
||||
|
||||
const cleanupWatcher = async () => {
|
||||
await page.evaluate((watcherKey) => {
|
||||
const sizeWatcher: SizeWatcherInternal = (window as any)[watcherKey];
|
||||
|
||||
sizeWatcher.element = undefined;
|
||||
sizeWatcher.mutationObserver?.disconnect();
|
||||
sizeWatcher.mutationObserver = undefined;
|
||||
sizeWatcher.resizeObserver?.disconnect();
|
||||
sizeWatcher.resizeObserver = undefined;
|
||||
}, watcherKey);
|
||||
|
||||
await disposeWatcher.dispose();
|
||||
};
|
||||
|
||||
const getObservations = async () => {
|
||||
return page.evaluate((watcherKey) => {
|
||||
const sizeWatcher: SizeWatcherInternal = (window as any)[watcherKey];
|
||||
|
||||
return sizeWatcher.observations;
|
||||
}, watcherKey);
|
||||
};
|
||||
|
||||
const isWatchingElement = async () => {
|
||||
return page.evaluate((watcherKey) => {
|
||||
const sizeWatcher: SizeWatcherInternal = (window as any)[watcherKey];
|
||||
|
||||
return Boolean(sizeWatcher.element);
|
||||
}, watcherKey);
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup: cleanupWatcher,
|
||||
getObservations,
|
||||
isWatchingElement,
|
||||
};
|
||||
}
|
||||
55
e2e-tests/playwright/lib/src/license_helpers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* License helpers for E2E tests.
|
||||
* Logic mirrors server (model/license.go) and webapp (licensedForFeature, license_utils).
|
||||
*/
|
||||
|
||||
export type ClientLicense = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Returns true if the server has a license that includes autotranslation
|
||||
* Use with test.skip(!hasAutotranslationLicense(license.SkuShortName), '...') in autotranslation specs.
|
||||
*/
|
||||
export function hasAutotranslationLicense(skuShortName: string): boolean {
|
||||
return skuShortName === 'enterprise' || skuShortName === 'entry' || skuShortName === 'advanced';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the server has a license that includes Shared Channels.
|
||||
* The client receives SharedChannels === 'true' when the server's HasSharedChannels() is true
|
||||
* (see server/public/model/license.go and server/channels/utils/license.go).
|
||||
* Use with test.skip(!hasSharedChannelsLicense(license), '...') in shared channel specs.
|
||||
*/
|
||||
export function hasSharedChannelsLicense(license: ClientLicense | null | undefined): boolean {
|
||||
return license?.SharedChannels === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the server has a license that includes Custom Permission Schemes
|
||||
* (required to create/patch/delete permission schemes and assign custom roles via API,
|
||||
* e.g. shared_channel_manager or roles in a custom team scheme).
|
||||
* The client receives CustomPermissionsSchemes === 'true' when the feature is enabled
|
||||
* (see server/public/model/license.go and server/channels/utils/license.go).
|
||||
*/
|
||||
export function hasCustomPermissionsSchemesLicense(license: ClientLicense | null | undefined): boolean {
|
||||
return license?.CustomPermissionsSchemes === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors webapp `getLicenseTier` (utils/constants) for client `SkuShortName` values.
|
||||
*/
|
||||
export function licenseTier(skuShortName: string): number {
|
||||
switch (skuShortName) {
|
||||
case 'professional':
|
||||
return 10;
|
||||
case 'enterprise':
|
||||
return 20;
|
||||
case 'entry':
|
||||
case 'advanced':
|
||||
return 30;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
230
e2e-tests/playwright/lib/src/mock_browser_api.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
|
||||
type NotificationData = {title: string} & NotificationOptions;
|
||||
|
||||
type MockWebSocket = {
|
||||
wrappedSocket: WebSocket | null;
|
||||
onopen: ((ev: Event) => void) | null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null;
|
||||
onerror: ((ev: Event) => void) | null;
|
||||
onclose: ((ev: CloseEvent) => void) | null;
|
||||
readyState: number;
|
||||
send(data: string | ArrayBuffer): void;
|
||||
close(): void;
|
||||
connect(): void;
|
||||
};
|
||||
|
||||
// Extend the Window interface to add custom properties
|
||||
declare global {
|
||||
interface Window {
|
||||
originalNotification: typeof Notification;
|
||||
capturedNotifications: NotificationData[];
|
||||
capturedNotificationInstances: Notification[];
|
||||
getNotifications: () => NotificationData[];
|
||||
mockWebsockets: MockWebSocket[];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed in the browser via addInitScript and page.evaluate. Must be
|
||||
* self-contained (no closure over module scope) so Playwright can serialize it.
|
||||
*/
|
||||
function installNotificationStub(notificationPermission: NotificationPermission) {
|
||||
window.Notification.requestPermission = () => Promise.resolve(notificationPermission);
|
||||
|
||||
if (!window.originalNotification) {
|
||||
window.originalNotification = window.Notification;
|
||||
}
|
||||
|
||||
window.capturedNotifications = window.capturedNotifications ?? [];
|
||||
|
||||
// Keep the live notification instances too, so a test can invoke their
|
||||
// onclick handler to simulate a user clicking a desktop notification.
|
||||
window.capturedNotificationInstances = window.capturedNotificationInstances ?? [];
|
||||
|
||||
class CustomNotification extends window.originalNotification {
|
||||
constructor(title: string, options?: NotificationOptions) {
|
||||
super(title, options);
|
||||
const notification = {title, ...options};
|
||||
window.capturedNotifications.push(notification);
|
||||
window.capturedNotificationInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperties(CustomNotification, {
|
||||
permission: {
|
||||
get: () => notificationPermission,
|
||||
},
|
||||
requestPermission: {
|
||||
value: () => Promise.resolve(notificationPermission),
|
||||
},
|
||||
});
|
||||
|
||||
window.Notification = CustomNotification as unknown as typeof Notification;
|
||||
window.getNotifications = () => window.capturedNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* `stubNotification` intercepts the Notification API to capture notifications.
|
||||
*
|
||||
* Note:
|
||||
* - Works across browsers and devices, except in headless mode, where stubbing the Notification API is supported only in Firefox and WebKit.
|
||||
* - Uses addInitScript so the stub survives channel navigations after login without clearing captured notifications.
|
||||
*
|
||||
* @param page Page object
|
||||
* @param permission Permission setting for notifications, with possible values: "default" | "granted" | "denied". Note: A notification sound may still occur even when set to "denied", as the browser might attempt to trigger system notifications.
|
||||
*/
|
||||
export async function stubNotification(page: Page, permission: NotificationPermission) {
|
||||
await page.addInitScript(installNotificationStub, permission);
|
||||
await page.evaluate(installNotificationStub, permission);
|
||||
}
|
||||
|
||||
/** Clears notifications captured by {@link stubNotification}. */
|
||||
export async function clearCapturedNotifications(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
window.capturedNotifications = [];
|
||||
window.capturedNotificationInstances = [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `waitForNotification` waits for a specified number of notifications to be received on the page within a given timeout.
|
||||
* @param page Page object
|
||||
* @param expectedCount Number of notifications to wait for before returning. (default: 1)
|
||||
* @param timeout Wait time in milliseconds. (default: 5000ms)
|
||||
* @returns An array of notifications received
|
||||
*/
|
||||
export async function waitForNotification(
|
||||
page: Page,
|
||||
expectedCount = 1,
|
||||
timeout: number = 5000,
|
||||
): Promise<NotificationData[]> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const notifications = await page.evaluate(() => window.getNotifications());
|
||||
if (notifications.length >= expectedCount) {
|
||||
return notifications;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Notification not received within the timeout period of ${timeout}ms`);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* `clickNotification` simulates a user clicking a captured desktop notification by invoking the
|
||||
* `onclick` handler the app assigned to it (which focuses the window and navigates to the post).
|
||||
* Requires `stubNotification` to have been called on the same page first.
|
||||
* @param page Page object
|
||||
* @param index Index of the captured notification to click (default: the first one)
|
||||
*/
|
||||
export async function clickNotification(page: Page, index = 0) {
|
||||
await page.evaluate((notificationIndex) => {
|
||||
const instance = window.capturedNotificationInstances?.[notificationIndex];
|
||||
if (!instance || typeof instance.onclick !== 'function') {
|
||||
throw new Error(`No clickable notification captured at index ${notificationIndex}`);
|
||||
}
|
||||
instance.onclick(new Event('click'));
|
||||
}, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* `mockWebsockets` wraps `window.WebSocket` so the test can close and reopen the underlying
|
||||
* socket connection(s) on demand, to simulate a socket disconnect/reconnect without navigating
|
||||
* away from the page. Mocked sockets do not auto-connect on construction — call
|
||||
* `connectWebsockets` to open them.
|
||||
*
|
||||
* Must be called on a `page` that has not yet navigated (e.g. right after `pw.testBrowser.login()`,
|
||||
* before `channelsPage.goto(...)`), since it installs the override via `addInitScript` so it's in
|
||||
* place before the app's own bundle constructs its WebSocket client.
|
||||
*
|
||||
* @param page Page object, not yet navigated
|
||||
*/
|
||||
export async function mockWebsockets(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const RealWebSocket = window.WebSocket;
|
||||
window.mockWebsockets = [];
|
||||
|
||||
class MockWebSocketImpl {
|
||||
// Match the standard WebSocket.readyState values so client code comparing
|
||||
// against `WebSocket.OPEN` (now this class, since it replaces window.WebSocket)
|
||||
// sees real state transitions instead of coincidental `undefined === undefined`.
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
wrappedSocket: WebSocket | null = null;
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
readyState: number = MockWebSocketImpl.CONNECTING;
|
||||
private readonly args: [string | URL, (string | string[])?];
|
||||
|
||||
constructor(...args: [string | URL, (string | string[])?]) {
|
||||
this.args = args;
|
||||
window.mockWebsockets.push(this);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBuffer) {
|
||||
if (this.wrappedSocket) {
|
||||
this.wrappedSocket.send(data);
|
||||
} else if (this.onerror) {
|
||||
this.onerror(new Event('error'));
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.wrappedSocket) {
|
||||
this.readyState = MockWebSocketImpl.CLOSING;
|
||||
this.wrappedSocket.close(1000);
|
||||
} else {
|
||||
this.readyState = MockWebSocketImpl.CLOSED;
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
const socket = new RealWebSocket(...this.args);
|
||||
this.readyState = MockWebSocketImpl.CONNECTING;
|
||||
socket.onopen = (ev) => {
|
||||
this.readyState = MockWebSocketImpl.OPEN;
|
||||
this.onopen?.(ev);
|
||||
};
|
||||
socket.onmessage = (ev) => this.onmessage?.(ev);
|
||||
socket.onerror = (ev) => this.onerror?.(ev);
|
||||
socket.onclose = (ev) => {
|
||||
this.readyState = MockWebSocketImpl.CLOSED;
|
||||
this.onclose?.(ev);
|
||||
};
|
||||
this.wrappedSocket = socket;
|
||||
}
|
||||
}
|
||||
|
||||
window.WebSocket = MockWebSocketImpl as unknown as typeof WebSocket;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens (or reopens) every mocked WebSocket tracked by `mockWebsockets`, simulating a reconnect.
|
||||
* @param page Page object
|
||||
*/
|
||||
export async function connectWebsockets(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
window.mockWebsockets.forEach((ws) => ws.connect());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes every mocked WebSocket tracked by `mockWebsockets`, simulating a disconnect.
|
||||
* @param page Page object
|
||||
*/
|
||||
export async function closeWebsockets(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
window.mockWebsockets.forEach((ws) => ws.close());
|
||||
});
|
||||
}
|
||||
376
e2e-tests/playwright/lib/src/server/abac_helpers.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {getRandomId, newTestPassword} from '../util';
|
||||
|
||||
/**
|
||||
* Create a user with custom profile attributes
|
||||
* IMPORTANT: This creates the user first, then sets attributes by field ID
|
||||
*/
|
||||
export async function createUserWithAttributes(
|
||||
client: Client4,
|
||||
attributes: Record<string, string>,
|
||||
): Promise<UserProfile> {
|
||||
const randomId = getRandomId();
|
||||
// Ensure username starts with a letter (Mattermost requirement)
|
||||
const username = `user${randomId}`.toLowerCase();
|
||||
|
||||
// Create user without attributes first
|
||||
const user = await client.createUser(
|
||||
{
|
||||
email: `${username}@example.com`,
|
||||
username,
|
||||
password: newTestPassword(),
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
|
||||
// Set attributes using field IDs (if any provided)
|
||||
if (Object.keys(attributes).length > 0) {
|
||||
const fields = await client.getCustomProfileAttributeFields();
|
||||
|
||||
// Convert attribute names to field IDs
|
||||
const valuesByFieldId: Record<string, string> = {};
|
||||
for (const [attrName, attrValue] of Object.entries(attributes)) {
|
||||
const field = fields.find((f: any) => f.name === attrName);
|
||||
if (field) {
|
||||
valuesByFieldId[field.id] = attrValue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Attribute field "${attrName}" not found. Available fields: ${fields.map((f: any) => f.name).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the attribute values
|
||||
if (Object.keys(valuesByFieldId).length > 0) {
|
||||
await client.updateUserCustomProfileAttributesValues(user.id, valuesByFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable ABAC in System Console.
|
||||
* Always navigates to the Attribute-Based Access settings page (where the toggle lives)
|
||||
* before interacting, then navigates to Membership Policies afterward so callers land
|
||||
* on the correct page for policy management.
|
||||
*/
|
||||
export async function enableABAC(page: Page): Promise<void> {
|
||||
await page.goto('/admin_console/system_attributes/attribute_based_access_control');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const enableRadio = page.locator('#AccessControlSettings\\.EnableAttributeBasedAccessControltrue');
|
||||
const saveButton = page.getByRole('button', {name: 'Save'});
|
||||
|
||||
await enableRadio.click();
|
||||
await saveButton.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
if (!(await saveButton.isDisabled())) {
|
||||
await saveButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Always land on Membership Policies so callers can immediately create/manage policies
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable ABAC in System Console.
|
||||
* Always navigates to the Attribute-Based Access settings page first.
|
||||
*/
|
||||
export async function disableABAC(page: Page): Promise<void> {
|
||||
await page.goto('/admin_console/system_attributes/attribute_based_access_control');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const disableRadio = page.locator('#AccessControlSettings\\.EnableAttributeBasedAccessControlfalse');
|
||||
const saveButton = page.getByRole('button', {name: 'Save'});
|
||||
|
||||
await disableRadio.click();
|
||||
await saveButton.waitFor({state: 'visible', timeout: 5000});
|
||||
|
||||
if (!(await saveButton.isDisabled())) {
|
||||
await saveButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Membership Policies page in System Console
|
||||
*/
|
||||
export async function navigateToABACPage(page: Page): Promise<void> {
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Permission Policies page in System Console
|
||||
*/
|
||||
export async function navigateToPermissionPoliciesPage(page: Page): Promise<void> {
|
||||
await page.goto('/admin_console/system_attributes/permission_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the ABAC enable/disable settings page
|
||||
*/
|
||||
export async function navigateToAttributeBasedAccessPage(page: Page): Promise<void> {
|
||||
await page.goto('/admin_console/system_attributes/attribute_based_access_control');
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic policy using Table Editor (Basic mode)
|
||||
*/
|
||||
export async function createBasicPolicy(
|
||||
page: Page,
|
||||
options: {
|
||||
name: string;
|
||||
attribute: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
autoSync?: boolean;
|
||||
channels?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
|
||||
await addPolicyButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill policy name
|
||||
const nameInput = page.locator('input[name="name"]').first();
|
||||
await nameInput.fill(options.name);
|
||||
|
||||
// Set auto-sync if specified
|
||||
if (options.autoSync) {
|
||||
const autoSyncToggle = page
|
||||
.locator('input[type="checkbox"]')
|
||||
.filter({hasText: /auto|sync/i})
|
||||
.first();
|
||||
if (await autoSyncToggle.isVisible({timeout: 1000})) {
|
||||
await autoSyncToggle.check();
|
||||
}
|
||||
}
|
||||
|
||||
// Set policy expression using table editor
|
||||
const attributeDropdown = page.locator('select').first();
|
||||
const operatorDropdown = page.locator('select').nth(1);
|
||||
const valueInput = page.locator('input[type="text"]').last();
|
||||
|
||||
await attributeDropdown.selectOption(options.attribute);
|
||||
await operatorDropdown.selectOption(options.operator);
|
||||
await valueInput.fill(options.value);
|
||||
|
||||
// Add channels if specified
|
||||
if (options.channels && options.channels.length > 0) {
|
||||
const addChannelsButton = page.getByRole('button', {name: /add.*channel/i});
|
||||
if (await addChannelsButton.isVisible({timeout: 1000})) {
|
||||
await addChannelsButton.click();
|
||||
|
||||
const channelModal = page.locator('.modal, [role="dialog"]').first();
|
||||
for (const channelName of options.channels) {
|
||||
await channelModal.getByText(channelName, {exact: false}).click();
|
||||
}
|
||||
|
||||
const modalSaveButton = channelModal.getByRole('button', {name: 'Save'});
|
||||
await modalSaveButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Save policy
|
||||
const saveButton = page.getByRole('button', {name: 'Save'}).last();
|
||||
await saveButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an advanced policy using CEL Editor (Advanced mode)
|
||||
*/
|
||||
export async function createAdvancedPolicy(
|
||||
page: Page,
|
||||
options: {
|
||||
name: string;
|
||||
celExpression: string;
|
||||
autoSync?: boolean;
|
||||
channels?: string[];
|
||||
},
|
||||
): Promise<void> {
|
||||
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
|
||||
await addPolicyButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill policy name
|
||||
const nameInput = page.locator('input[name="name"]').first();
|
||||
await nameInput.fill(options.name);
|
||||
|
||||
// Set auto-sync if specified
|
||||
if (options.autoSync) {
|
||||
const autoSyncToggle = page
|
||||
.locator('input[type="checkbox"]')
|
||||
.filter({hasText: /auto|sync/i})
|
||||
.first();
|
||||
if (await autoSyncToggle.isVisible({timeout: 1000})) {
|
||||
await autoSyncToggle.check();
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to Advanced mode
|
||||
const modeToggle = page.getByRole('button', {name: /advanced|basic/i});
|
||||
if (await modeToggle.isVisible({timeout: 1000})) {
|
||||
await modeToggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Fill CEL expression
|
||||
const celEditor = page.locator('textarea').first();
|
||||
await celEditor.fill(options.celExpression);
|
||||
|
||||
// Add channels if specified
|
||||
if (options.channels && options.channels.length > 0) {
|
||||
const addChannelsButton = page.getByRole('button', {name: /add.*channel/i});
|
||||
if (await addChannelsButton.isVisible({timeout: 1000})) {
|
||||
await addChannelsButton.click();
|
||||
|
||||
const channelModal = page.locator('.modal, [role="dialog"]').first();
|
||||
for (const channelName of options.channels) {
|
||||
await channelModal.getByText(channelName, {exact: false}).click();
|
||||
}
|
||||
|
||||
const modalSaveButton = channelModal.getByRole('button', {name: 'Save'});
|
||||
await modalSaveButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Save policy
|
||||
const saveButton = page.getByRole('button', {name: 'Save'}).last();
|
||||
await saveButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing policy
|
||||
*/
|
||||
export async function editPolicy(page: Page, policyName: string): Promise<void> {
|
||||
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).locator('..').locator('..');
|
||||
const menuButton = policyRow.locator('button[id*="policy-menu"]');
|
||||
await menuButton.click();
|
||||
|
||||
const editButton = page.getByRole('menuitem', {name: 'Edit'});
|
||||
await editButton.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a policy
|
||||
*/
|
||||
export async function deletePolicy(page: Page, policyName: string): Promise<void> {
|
||||
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).locator('..').locator('..');
|
||||
const menuButton = policyRow.locator('button[id*="policy-menu"]');
|
||||
await menuButton.click();
|
||||
|
||||
const deleteButton = page.getByRole('menuitem', {name: 'Delete'});
|
||||
await deleteButton.click();
|
||||
|
||||
// Confirm deletion if modal appears
|
||||
const confirmButton = page.getByRole('button', {name: /delete|confirm/i});
|
||||
if (await confirmButton.isVisible({timeout: 1000})) {
|
||||
await confirmButton.click();
|
||||
}
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Run Sync Job" button and return the new job ID immediately.
|
||||
*
|
||||
* Intercepts the POST /api/v4/jobs response so the caller gets the exact job ID
|
||||
* without a polling round-trip. Pass the returned ID to waitForLatestSyncJob as
|
||||
* the `jobId` argument to skip Phase 1 and poll the specific job directly.
|
||||
*
|
||||
* Throws if the server returns a non-2xx status or if the response body has no
|
||||
* id field. Returns null only if the interception times out (network hiccup),
|
||||
* allowing callers to fall back to list-based Phase 1 polling.
|
||||
*/
|
||||
export async function runSyncJob(page: Page): Promise<string | null> {
|
||||
// Do NOT filter by resp.ok() here — capture the response regardless of status
|
||||
// so we can surface API errors explicitly instead of silently swallowing them.
|
||||
const jobResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/v4/jobs') && resp.request().method() === 'POST',
|
||||
{timeout: 10000},
|
||||
);
|
||||
// "Run Channel Sync" when team ABAC is on, else "Run Sync Job".
|
||||
await page.getByRole('button', {name: /Run (Sync Job|Channel Sync)/}).click();
|
||||
try {
|
||||
const response = await jobResponsePromise;
|
||||
if (!response.ok()) {
|
||||
throw new Error(`POST /api/v4/jobs failed with status ${response.status()}`);
|
||||
}
|
||||
const job = await response.json();
|
||||
if (!job.id) {
|
||||
throw new Error('POST /api/v4/jobs response missing id field');
|
||||
}
|
||||
return job.id as string;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('POST /api/v4/jobs')) {
|
||||
throw err;
|
||||
}
|
||||
// Interception timed out — callers fall back to list-based polling in Phase 1.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is member of channel
|
||||
*/
|
||||
export async function verifyUserInChannel(client: Client4, userId: string, channelId: string): Promise<boolean> {
|
||||
try {
|
||||
const members = await client.getChannelMembers(channelId);
|
||||
return members.some((m: any) => m.user_id === userId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user is NOT member of channel
|
||||
*/
|
||||
export async function verifyUserNotInChannel(client: Client4, userId: string, channelId: string): Promise<boolean> {
|
||||
return !(await verifyUserInChannel(client, userId, channelId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user's custom profile attributes
|
||||
* Converts attribute names to field IDs and uses the correct API
|
||||
*/
|
||||
export async function updateUserAttributes(
|
||||
client: Client4,
|
||||
userId: string,
|
||||
attributes: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const fields = await client.getCustomProfileAttributeFields();
|
||||
|
||||
// Convert attribute names to field IDs
|
||||
const valuesByFieldId: Record<string, string> = {};
|
||||
for (const [attrName, attrValue] of Object.entries(attributes)) {
|
||||
const field = fields.find((f: any) => f.name === attrName);
|
||||
if (field) {
|
||||
valuesByFieldId[field.id] = attrValue;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Attribute field "${attrName}" not found. Available fields: ${fields.map((f: any) => f.name).join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update attributes using field IDs
|
||||
if (Object.keys(valuesByFieldId).length > 0) {
|
||||
await client.updateUserCustomProfileAttributesValues(userId, valuesByFieldId);
|
||||
}
|
||||
}
|
||||
194
e2e-tests/playwright/lib/src/server/ai_bridge.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {AdminConfig} from '@mattermost/types/config';
|
||||
|
||||
import {getRandomId} from '@/util';
|
||||
|
||||
type AIBridgeOperation = 'recap_summary' | 'rewrite';
|
||||
|
||||
export type AIBridgeMockStatus = {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type AIBridgeMockAgent = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
username: string;
|
||||
service_id: string;
|
||||
service_type: string;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
export type AIBridgeMockService = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type AIBridgeMockCompletion = {
|
||||
completion?: string;
|
||||
error?: string;
|
||||
status_code?: number;
|
||||
};
|
||||
|
||||
export type AIBridgeMockMessage = {
|
||||
role: string;
|
||||
message: string;
|
||||
file_ids?: string[];
|
||||
};
|
||||
|
||||
export type AIBridgeMockRecordedRequest = {
|
||||
operation: AIBridgeOperation | string;
|
||||
client_operation?: string;
|
||||
operation_sub_type?: string;
|
||||
session_user_id?: string;
|
||||
user_id?: string;
|
||||
channel_id?: string;
|
||||
agent_id?: string;
|
||||
service_id?: string;
|
||||
messages: AIBridgeMockMessage[];
|
||||
json_output_format?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AIBridgeMockConfig = {
|
||||
status?: AIBridgeMockStatus;
|
||||
agents?: AIBridgeMockAgent[];
|
||||
services?: AIBridgeMockService[];
|
||||
agent_completions?: Partial<Record<AIBridgeOperation | string, AIBridgeMockCompletion[]>>;
|
||||
feature_flags?: {
|
||||
enable_ai_plugin_bridge?: boolean;
|
||||
enable_ai_recaps?: boolean;
|
||||
};
|
||||
record_requests?: boolean;
|
||||
};
|
||||
|
||||
export type AIBridgeMockState = AIBridgeMockConfig & {
|
||||
record_requests: boolean;
|
||||
recorded_requests: AIBridgeMockRecordedRequest[];
|
||||
};
|
||||
|
||||
type EnableAIBridgeTestModeOptions = {
|
||||
enableRecaps?: boolean;
|
||||
};
|
||||
|
||||
type CreateMockAIAgentOverrides = {
|
||||
agent?: Partial<AIBridgeMockAgent>;
|
||||
service?: Partial<AIBridgeMockService>;
|
||||
status?: AIBridgeMockStatus;
|
||||
record_requests?: boolean;
|
||||
};
|
||||
|
||||
const AI_BRIDGE_TEST_HELPER_ROUTE = '/system/e2e/ai_bridge';
|
||||
|
||||
async function doAdminFetch<T>(adminClient: Client4, method: 'GET' | 'PUT' | 'DELETE', body?: unknown): Promise<T> {
|
||||
const route = `${adminClient.getBaseRoute()}${AI_BRIDGE_TEST_HELPER_ROUTE}`;
|
||||
|
||||
return (adminClient as any).doFetch(route, {
|
||||
method,
|
||||
...(body === undefined ? {} : {body: JSON.stringify(body)}),
|
||||
});
|
||||
}
|
||||
|
||||
function upsertById<T extends {id: string}>(items: T[], item: T): T[] {
|
||||
const existingIndex = items.findIndex((existingItem) => existingItem.id === item.id);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return [...items, item];
|
||||
}
|
||||
|
||||
const nextItems = [...items];
|
||||
nextItems[existingIndex] = item;
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
export async function enableAIBridgeTestMode(
|
||||
adminClient: Client4,
|
||||
{enableRecaps = false}: EnableAIBridgeTestModeOptions = {},
|
||||
): Promise<AdminConfig> {
|
||||
await adminClient.patchConfig({
|
||||
ServiceSettings: {
|
||||
EnableTesting: true,
|
||||
},
|
||||
});
|
||||
|
||||
await configureAIBridgeMock(adminClient, {
|
||||
feature_flags: {
|
||||
enable_ai_plugin_bridge: true,
|
||||
...(enableRecaps ? {enable_ai_recaps: true} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return adminClient.getConfig();
|
||||
}
|
||||
|
||||
export async function configureAIBridgeMock(
|
||||
adminClient: Client4,
|
||||
config: AIBridgeMockConfig,
|
||||
): Promise<AIBridgeMockState> {
|
||||
return doAdminFetch<AIBridgeMockState>(adminClient, 'PUT', config);
|
||||
}
|
||||
|
||||
export async function getAIBridgeMock(adminClient: Client4): Promise<AIBridgeMockState> {
|
||||
return doAdminFetch<AIBridgeMockState>(adminClient, 'GET');
|
||||
}
|
||||
|
||||
export async function resetAIBridgeMock(adminClient: Client4): Promise<void> {
|
||||
await doAdminFetch(adminClient, 'DELETE');
|
||||
}
|
||||
|
||||
export async function createMockAIAgent(
|
||||
adminClient: Client4,
|
||||
overrides: CreateMockAIAgentOverrides = {},
|
||||
): Promise<{agent: AIBridgeMockAgent; service: AIBridgeMockService; state: AIBridgeMockState}> {
|
||||
const state = await getAIBridgeMock(adminClient);
|
||||
const randomId = getRandomId();
|
||||
|
||||
const service: AIBridgeMockService = {
|
||||
id: overrides.service?.id ?? overrides.agent?.service_id ?? `mock-service-${randomId}`,
|
||||
name: overrides.service?.name ?? 'Mock AI Service',
|
||||
type: overrides.service?.type ?? overrides.agent?.service_type ?? 'openaicompatible',
|
||||
};
|
||||
|
||||
const agent: AIBridgeMockAgent = {
|
||||
id: overrides.agent?.id ?? `mock-agent-${randomId}`,
|
||||
displayName: overrides.agent?.displayName ?? 'Mock AI Agent',
|
||||
username: overrides.agent?.username ?? `mock.ai.${randomId}`,
|
||||
service_id: overrides.agent?.service_id ?? service.id,
|
||||
service_type: overrides.agent?.service_type ?? service.type,
|
||||
is_default: overrides.agent?.is_default ?? (state.agents ?? []).length === 0,
|
||||
};
|
||||
|
||||
const nextState = await configureAIBridgeMock(adminClient, {
|
||||
status: overrides.status ?? state.status,
|
||||
agents: upsertById(state.agents ?? [], agent),
|
||||
services: upsertById(state.services ?? [], service),
|
||||
agent_completions: state.agent_completions,
|
||||
record_requests: overrides.record_requests ?? state.record_requests,
|
||||
});
|
||||
|
||||
return {agent, service, state: nextState};
|
||||
}
|
||||
|
||||
export function rewriteCompletion(text: string): AIBridgeMockCompletion {
|
||||
return {
|
||||
completion: JSON.stringify({rewritten_text: text}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recapCompletion({
|
||||
highlights,
|
||||
actionItems,
|
||||
}: {
|
||||
highlights: string[];
|
||||
actionItems: string[];
|
||||
}): AIBridgeMockCompletion {
|
||||
return {
|
||||
completion: JSON.stringify({
|
||||
highlights,
|
||||
action_items: actionItems,
|
||||
}),
|
||||
};
|
||||
}
|
||||
94
e2e-tests/playwright/lib/src/server/azurite.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {BlobServiceClient, StorageSharedKeyCredential} from '@azure/storage-blob';
|
||||
import {test} from '@playwright/test';
|
||||
|
||||
import {
|
||||
AZURITE_ACCOUNT_KEY,
|
||||
AZURITE_ACCOUNT_NAME,
|
||||
AZURITE_ALIAS,
|
||||
AZURITE_BLOB_PORT,
|
||||
AZURITE_CONTAINER,
|
||||
} from '../containers/constants';
|
||||
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
|
||||
|
||||
import {uploadProbeImage} from './filestore';
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
function getBlobServiceClient(): BlobServiceClient {
|
||||
const credential = new StorageSharedKeyCredential(AZURITE_ACCOUNT_NAME, AZURITE_ACCOUNT_KEY);
|
||||
return new BlobServiceClient(`${testConfig.azuriteUrl}/${AZURITE_ACCOUNT_NAME}`, credential);
|
||||
}
|
||||
|
||||
// Only used internally by ensureAzurite() — not a spec-facing entry point.
|
||||
async function ensureAzuriteContainer(container: string = AZURITE_CONTAINER): Promise<void> {
|
||||
const client = getBlobServiceClient().getContainerClient(container);
|
||||
const exists = await client.exists();
|
||||
if (!exists) {
|
||||
await client.create();
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists every blob name in the container, to confirm the server actually wrote to Azurite. */
|
||||
export async function listAzuriteBlobNames(container: string = AZURITE_CONTAINER): Promise<string[]> {
|
||||
const client = getBlobServiceClient().getContainerClient(container);
|
||||
const names: string[] = [];
|
||||
for await (const blob of client.listBlobsFlat()) {
|
||||
names.push(blob.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// FileSettings' backend is chosen once when the Mattermost process boots and never re-read from a
|
||||
// running server's config, so pointing the server at Azurite can only happen via the env vars the
|
||||
// container starts with — always via the network alias, since the server itself always runs
|
||||
// inside the Testcontainers network. AzureEndpoint under the "custom" cloud is the full service
|
||||
// URL (path-style, account name included), unlike the vhost-style URLs real Azure uses.
|
||||
function azuriteServerEnv(): Record<string, string> {
|
||||
return {
|
||||
MM_FILESETTINGS_DRIVERNAME: 'azureblob',
|
||||
MM_FILESETTINGS_AZURESTORAGEACCOUNT: AZURITE_ACCOUNT_NAME,
|
||||
MM_FILESETTINGS_AZUREAUTHMODE: 'shared_key',
|
||||
MM_FILESETTINGS_AZUREACCESSKEY: AZURITE_ACCOUNT_KEY,
|
||||
MM_FILESETTINGS_AZURECONTAINER: AZURITE_CONTAINER,
|
||||
MM_FILESETTINGS_AZURECLOUD: 'custom',
|
||||
MM_FILESETTINGS_AZUREENDPOINT: `http://${AZURITE_ALIAS}:${AZURITE_BLOB_PORT}/${AZURITE_ACCOUNT_NAME}`,
|
||||
MM_FILESETTINGS_AZURESSL: 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Azurite was started this run, restarts the server onto it if it isn't already the active
|
||||
* file storage backend, and confirms a real upload actually lands in Azurite — skipping the test
|
||||
* otherwise, instead of failing on an unmet precondition.
|
||||
*/
|
||||
export async function ensureAzurite(): Promise<void> {
|
||||
if (!testConfig.testcontainersServices.includes('azurite')) {
|
||||
test.skip(true, 'Skipping test - azurite not started (set PW_TESTCONTAINERS_SERVICES=azurite)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureAzuriteContainer();
|
||||
const env = azuriteServerEnv();
|
||||
if (!bootEnvMatches(env)) {
|
||||
await restartMattermostContainer(env);
|
||||
}
|
||||
|
||||
const {adminClient, adminUser} = await getAdminClient();
|
||||
await uploadProbeImage(adminClient, adminUser);
|
||||
|
||||
const blobNames = await listAzuriteBlobNames();
|
||||
if (blobNames.length === 0) {
|
||||
throw new Error(
|
||||
'Azurite container is still empty after a real upload — the server is not actually using ' +
|
||||
'Azurite as its file backend.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - Azurite connection test failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
38
e2e-tests/playwright/lib/src/server/channel.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Channel, ChannelType} from '@mattermost/types/channels';
|
||||
|
||||
type ChannelInput = {
|
||||
teamId: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
type?: ChannelType;
|
||||
purpose?: string;
|
||||
header?: string;
|
||||
unique?: boolean;
|
||||
};
|
||||
|
||||
function getRandomSuffix(): string {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
}
|
||||
|
||||
export function createRandomChannel(channelInput: ChannelInput): Channel {
|
||||
const channel = {
|
||||
team_id: channelInput.teamId,
|
||||
name: channelInput.name,
|
||||
display_name: channelInput.displayName,
|
||||
type: channelInput.type || 'O',
|
||||
purpose: channelInput.type || '',
|
||||
header: channelInput.type || '',
|
||||
};
|
||||
|
||||
if (channelInput.unique) {
|
||||
const randomSuffix = getRandomSuffix();
|
||||
|
||||
channel.name = `${channelInput.name}-${randomSuffix}`;
|
||||
channel.display_name = `${channelInput.displayName} ${randomSuffix}`;
|
||||
}
|
||||
|
||||
return channel as Channel;
|
||||
}
|
||||
70
e2e-tests/playwright/lib/src/server/client.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {PlaywrightClient4} from './playwright_client';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// Variable to hold cache
|
||||
const clients: Record<string, ClientCache> = {};
|
||||
|
||||
/**
|
||||
* Drops every cached client, so the next makeClient()/getAdminClient() call logs in again instead
|
||||
* of reusing a session pointed at a Mattermost container that no longer exists — needed after
|
||||
* restartMattermostContainer() swaps in a fresh container (new base URL, new session).
|
||||
*/
|
||||
export function clearClientCache(): void {
|
||||
for (const key of Object.keys(clients)) {
|
||||
delete clients[key];
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeClient(
|
||||
userRequest?: UserRequest,
|
||||
opts: {useCache?: boolean; skipLog?: boolean} = {useCache: true, skipLog: false},
|
||||
): Promise<ClientCache> {
|
||||
const client = new PlaywrightClient4();
|
||||
client.setUrl(testConfig.baseURL);
|
||||
|
||||
try {
|
||||
if (!userRequest) {
|
||||
return {client, user: null};
|
||||
}
|
||||
|
||||
const cacheKey = userRequest.username + userRequest.password;
|
||||
if (opts?.useCache && clients[cacheKey] != null) {
|
||||
return clients[cacheKey];
|
||||
}
|
||||
|
||||
const userProfile = await client.login(userRequest.username, userRequest.password);
|
||||
const user = {...userProfile, password: userRequest.password};
|
||||
|
||||
if (opts?.useCache) {
|
||||
clients[cacheKey] = {client, user};
|
||||
}
|
||||
|
||||
return {client, user};
|
||||
} catch (err) {
|
||||
if (!opts?.skipLog) {
|
||||
// log an error for debugging
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('makeClient', err);
|
||||
}
|
||||
return {client, user: null};
|
||||
}
|
||||
}
|
||||
|
||||
// Client types
|
||||
|
||||
type UserRequest = {
|
||||
username: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type ClientCache = {
|
||||
client: PlaywrightClient4;
|
||||
user: UserProfile | null;
|
||||
};
|
||||
918
e2e-tests/playwright/lib/src/server/default_config.ts
Normal file
@@ -0,0 +1,918 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import merge from 'deepmerge';
|
||||
import type {
|
||||
AccessControlSettings,
|
||||
AdminConfig,
|
||||
AnnouncementSettings,
|
||||
ClusterSettings,
|
||||
EmailSettings,
|
||||
ExperimentalSettings,
|
||||
LogSettings,
|
||||
PasswordSettings,
|
||||
ServiceSettings,
|
||||
TeamSettings,
|
||||
} from '@mattermost/types/config';
|
||||
import {CollapsedThreads} from '@mattermost/types/config';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export function getOnPremServerConfig(): AdminConfig {
|
||||
return merge<AdminConfig>(defaultServerConfig, onPremServerConfig() as AdminConfig);
|
||||
}
|
||||
|
||||
/** Live-reloadable on-prem overrides only — safe for patchConfig (no restart-required keys). */
|
||||
export function getOnPremServerConfigPatch(): Partial<AdminConfig> {
|
||||
return onPremServerConfig() as Partial<AdminConfig>;
|
||||
}
|
||||
|
||||
type TestAdminConfig = {
|
||||
AccessControlSettings: Partial<AccessControlSettings>;
|
||||
AnnouncementSettings: Partial<AnnouncementSettings>;
|
||||
ClusterSettings: Partial<ClusterSettings>;
|
||||
EmailSettings: Partial<EmailSettings>;
|
||||
ExperimentalSettings: Partial<ExperimentalSettings>;
|
||||
LogSettings: Partial<LogSettings>;
|
||||
PasswordSettings: Partial<PasswordSettings>;
|
||||
ServiceSettings: Partial<ServiceSettings>;
|
||||
TeamSettings: Partial<TeamSettings>;
|
||||
};
|
||||
|
||||
// On-prem setting that is different from the default.
|
||||
//
|
||||
// Carries no PluginSettings: patchConfig replaces the PluginStates map wholesale, which would
|
||||
// clobber the plugins a running spec enabled. Specs enable and disable plugins per id instead.
|
||||
const onPremServerConfig = (): Partial<TestAdminConfig> => {
|
||||
return {
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
AnnouncementSettings: {
|
||||
// An in-product notice opens a modal over the channel view and swallows clicks near it.
|
||||
AdminNoticesEnabled: false,
|
||||
UserNoticesEnabled: false,
|
||||
},
|
||||
ClusterSettings: {
|
||||
Enable: testConfig.haClusterEnabled,
|
||||
ClusterName: testConfig.haClusterName,
|
||||
},
|
||||
EmailSettings: {
|
||||
FeedbackName: 'Mattermost',
|
||||
PushNotificationServer: testConfig.pushNotificationServer,
|
||||
},
|
||||
LogSettings: {
|
||||
EnableDiagnostics: false,
|
||||
},
|
||||
PasswordSettings: {
|
||||
MinimumLength: 14,
|
||||
Lowercase: false,
|
||||
Number: false,
|
||||
Uppercase: false,
|
||||
Symbol: false,
|
||||
EnableForgotLink: true,
|
||||
},
|
||||
ServiceSettings: {
|
||||
// SiteURL is the server's own view of itself (e.g. for building plugin callback
|
||||
// URLs), so it must use an address the server can reach itself with. In `testcontainers` mode
|
||||
// testConfig.baseURL is a host-mapped port the server's own container can't reach;
|
||||
// internalBaseURL is the Docker network alias there, and the same as baseURL in
|
||||
// `external` mode — correct in both cases.
|
||||
SiteURL: testConfig.internalBaseURL,
|
||||
EnableOnboardingFlow: false,
|
||||
EnableSecurityFixAlert: false,
|
||||
GiphySdkKey: 's0glxvzVg9azvPipKxcPLpXV0q1x1fVP',
|
||||
},
|
||||
TeamSettings: {
|
||||
EnableOpenServer: true,
|
||||
MaxUsersPerTeam: 2000,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Should be based only from the generated default config from ./server via "make config-reset"
|
||||
// Based on v11.11 server
|
||||
const defaultServerConfig: AdminConfig = {
|
||||
ServiceSettings: {
|
||||
SiteURL: '',
|
||||
WebsocketURL: '',
|
||||
LicenseFileLocation: '',
|
||||
ListenAddress: ':8065',
|
||||
ConnectionSecurity: '',
|
||||
TLSCertFile: '',
|
||||
TLSKeyFile: '',
|
||||
TLSMinVer: '1.2',
|
||||
TLSStrictTransport: false,
|
||||
TLSStrictTransportMaxAge: 63072000,
|
||||
TLSOverwriteCiphers: [],
|
||||
UseLetsEncrypt: false,
|
||||
LetsEncryptCertificateCacheFile: './config/letsencrypt.cache',
|
||||
Forward80To443: false,
|
||||
TrustedProxyIPHeader: [],
|
||||
ReadTimeout: 300,
|
||||
WriteTimeout: 300,
|
||||
IdleTimeout: 60,
|
||||
MaximumLoginAttempts: 10,
|
||||
GoroutineHealthThreshold: -1,
|
||||
EnableOAuthServiceProvider: true,
|
||||
EnableDynamicClientRegistration: false,
|
||||
DCRRedirectURIAllowlist: [],
|
||||
EnableIncomingWebhooks: true,
|
||||
EnableOutgoingWebhooks: true,
|
||||
EnableOutgoingOAuthConnections: false,
|
||||
EnableCommands: true,
|
||||
OutgoingIntegrationRequestsTimeout: 30,
|
||||
EnablePostUsernameOverride: false,
|
||||
EnablePostIconOverride: false,
|
||||
GoogleDeveloperKey: '',
|
||||
EnableLinkPreviews: true,
|
||||
EnablePermalinkPreviews: true,
|
||||
RestrictLinkPreviews: '',
|
||||
EnableTesting: false,
|
||||
EnableDeveloper: false,
|
||||
DeveloperFlags: '',
|
||||
EnableClientPerformanceDebugging: false,
|
||||
EnableSecurityFixAlert: true,
|
||||
EnableInsecureOutgoingConnections: false,
|
||||
AllowedUntrustedInternalConnections: '',
|
||||
EnableMultifactorAuthentication: false,
|
||||
EnforceMultifactorAuthentication: false,
|
||||
EnableUserAccessTokens: false,
|
||||
MaximumPersonalAccessTokenLifetimeDays: 0,
|
||||
AllowCorsFrom: '',
|
||||
CorsExposedHeaders: '',
|
||||
CorsAllowCredentials: false,
|
||||
CorsDebug: false,
|
||||
AllowCookiesForSubdomains: false,
|
||||
ExtendSessionLengthWithActivity: true,
|
||||
TerminateSessionsOnPasswordChange: true,
|
||||
SessionLengthWebInDays: 30,
|
||||
SessionLengthWebInHours: 720,
|
||||
SessionLengthMobileInDays: 30,
|
||||
SessionLengthMobileInHours: 720,
|
||||
SessionLengthSSOInDays: 30,
|
||||
SessionLengthSSOInHours: 720,
|
||||
SessionCacheInMinutes: 10,
|
||||
SessionIdleTimeoutInMinutes: 43200,
|
||||
WebsocketSecurePort: 443,
|
||||
WebsocketPort: 80,
|
||||
WebserverMode: 'gzip',
|
||||
EnableGifPicker: true,
|
||||
GiphySdkKey: '',
|
||||
EnableCustomEmoji: true,
|
||||
EnableEmojiPicker: true,
|
||||
PostEditTimeLimit: -1,
|
||||
TimeBetweenUserTypingUpdatesMilliseconds: 5000,
|
||||
EnableCrossTeamSearch: true,
|
||||
EnablePostSearch: true,
|
||||
EnableFileSearch: true,
|
||||
MinimumHashtagLength: 3,
|
||||
EnableUserTypingMessages: true,
|
||||
EnableChannelViewedMessages: true,
|
||||
EnableUserStatuses: true,
|
||||
ExperimentalEnableAuthenticationTransfer: true,
|
||||
ClusterLogTimeoutMilliseconds: 2000,
|
||||
EnableTutorial: true,
|
||||
EnableOnboardingFlow: true,
|
||||
ExperimentalEnableDefaultChannelLeaveJoinMessages: true,
|
||||
ExperimentalGroupUnreadChannels: 'disabled',
|
||||
EnableAPITeamDeletion: false,
|
||||
EnableAPITriggerAdminNotifications: false,
|
||||
EnableAPIUserDeletion: false,
|
||||
EnableAPIPostDeletion: false,
|
||||
EnableDesktopLandingPage: true,
|
||||
MinimumDesktopAppVersion: '',
|
||||
EnableHardenedMode: false,
|
||||
ExperimentalStrictCSRFEnforcement: false,
|
||||
EnableEmailInvitations: false,
|
||||
DisableBotsWhenOwnerIsDeactivated: true,
|
||||
EnableBotAccountCreation: false,
|
||||
EnableSVGs: false,
|
||||
EnableLatex: false,
|
||||
EnableInlineLatex: true,
|
||||
PostPriority: true,
|
||||
AllowPersistentNotifications: true,
|
||||
AllowPersistentNotificationsForGuests: false,
|
||||
PersistentNotificationIntervalMinutes: 5,
|
||||
PersistentNotificationMaxCount: 6,
|
||||
PersistentNotificationMaxRecipients: 5,
|
||||
EnableBurnOnRead: true,
|
||||
BurnOnReadDurationSeconds: 600,
|
||||
BurnOnReadMaximumTimeToLiveSeconds: 604800,
|
||||
BurnOnReadSchedulerFrequencySeconds: 600,
|
||||
EnableAPIChannelDeletion: false,
|
||||
EnableLocalMode: false,
|
||||
LocalModeSocketLocation: '/var/tmp/mattermost_local.socket',
|
||||
EnableAWSMetering: false,
|
||||
AWSMeteringTimeoutSeconds: 30,
|
||||
SplitKey: '',
|
||||
FeatureFlagSyncIntervalSeconds: 30,
|
||||
DebugSplit: false,
|
||||
ThreadAutoFollow: true,
|
||||
CollapsedThreads: CollapsedThreads.ALWAYS_ON,
|
||||
ManagedResourcePaths: '',
|
||||
EnableCustomGroups: true,
|
||||
AllowSyncedDrafts: true,
|
||||
UniqueEmojiReactionLimitPerPost: 50,
|
||||
RefreshPostStatsRunTime: '00:00',
|
||||
MaximumPayloadSizeBytes: 300000,
|
||||
MaximumURLLength: 2048,
|
||||
ScheduledPosts: true,
|
||||
EnableWebHubChannelIteration: false,
|
||||
FrameAncestors: '',
|
||||
DeleteAccountLink: '',
|
||||
},
|
||||
TeamSettings: {
|
||||
SiteName: 'Mattermost',
|
||||
MaxUsersPerTeam: 50,
|
||||
EnableJoinLeaveMessageByDefault: true,
|
||||
EnableUserCreation: true,
|
||||
EnableOpenServer: false,
|
||||
EnableUserDeactivation: false,
|
||||
RestrictCreationToDomains: '',
|
||||
EnableCustomUserStatuses: true,
|
||||
EnableCustomBrand: false,
|
||||
CustomBrandText: '',
|
||||
CustomDescriptionText: '',
|
||||
RestrictDirectMessage: 'any',
|
||||
EnableLastActiveTime: true,
|
||||
UserStatusAwayTimeout: 300,
|
||||
MaxChannelsPerTeam: 2000,
|
||||
EnableChannelCategorySorting: true,
|
||||
MaxNotificationsPerChannel: 1000,
|
||||
EnableConfirmNotificationsToChannel: true,
|
||||
TeammateNameDisplay: 'username',
|
||||
ExperimentalEnableAutomaticReplies: false,
|
||||
LockTeammateNameDisplay: false,
|
||||
LockProfileFieldsForEmailUsers: 'none',
|
||||
ExperimentalPrimaryTeam: '',
|
||||
ExperimentalDefaultChannels: [],
|
||||
},
|
||||
ClientRequirements: {
|
||||
AndroidLatestVersion: '',
|
||||
AndroidMinVersion: '',
|
||||
IosLatestVersion: '',
|
||||
IosMinVersion: '',
|
||||
},
|
||||
SqlSettings: {
|
||||
DriverName: 'postgres',
|
||||
DataSource:
|
||||
'postgres://mmuser:mostest_password@localhost/mattermost_test?sslmode=disable\u0026connect_timeout=10\u0026binary_parameters=yes',
|
||||
DataSourceReplicas: [],
|
||||
DataSourceSearchReplicas: [],
|
||||
MaxIdleConns: 50,
|
||||
ConnMaxLifetimeMilliseconds: 3600000,
|
||||
ConnMaxIdleTimeMilliseconds: 300000,
|
||||
MaxOpenConns: 100,
|
||||
Trace: false,
|
||||
QueryTimeout: 30,
|
||||
AnalyticsQueryTimeout: 300,
|
||||
DisableDatabaseSearch: false,
|
||||
MigrationsStatementTimeoutSeconds: 100000,
|
||||
ReplicaLagSettings: [],
|
||||
ReplicaMonitorIntervalSeconds: 5,
|
||||
},
|
||||
LogSettings: {
|
||||
EnableConsole: true,
|
||||
ConsoleLevel: 'DEBUG',
|
||||
ConsoleJson: true,
|
||||
EnableColor: false,
|
||||
EnableFile: true,
|
||||
FileLevel: 'INFO',
|
||||
FileJson: true,
|
||||
FileLocation: '',
|
||||
EnableWebhookDebugging: true,
|
||||
EnableDiagnostics: true,
|
||||
EnableSentry: true,
|
||||
AdvancedLoggingJSON: {},
|
||||
MaxFieldSize: 2048,
|
||||
},
|
||||
ExperimentalAuditSettings: {
|
||||
FileEnabled: false,
|
||||
FileName: '',
|
||||
AdvancedLoggingJSON: {},
|
||||
Certificate: '',
|
||||
},
|
||||
PasswordSettings: {
|
||||
MinimumLength: 8,
|
||||
Lowercase: false,
|
||||
Number: false,
|
||||
Uppercase: false,
|
||||
Symbol: false,
|
||||
EnableForgotLink: true,
|
||||
},
|
||||
FileSettings: {
|
||||
EnableFileAttachments: true,
|
||||
EnableMobileUpload: true,
|
||||
EnableMobileDownload: true,
|
||||
MaxFileSize: 104857600,
|
||||
MaxImageResolution: 33177600,
|
||||
MaxImageDecoderConcurrency: -1,
|
||||
DriverName: 'local',
|
||||
Directory: './data/',
|
||||
EnablePublicLink: false,
|
||||
ExtractContent: true,
|
||||
ExtractContentTimeout: 10,
|
||||
ArchiveRecursion: false,
|
||||
PublicLinkSalt: '',
|
||||
InitialFont: 'nunito-bold.ttf',
|
||||
AmazonS3AccessKeyId: '',
|
||||
AmazonS3SecretAccessKey: '',
|
||||
AmazonS3Bucket: '',
|
||||
AmazonS3PathPrefix: '',
|
||||
AmazonS3Region: '',
|
||||
AmazonS3Endpoint: 's3.amazonaws.com',
|
||||
AmazonS3SSL: true,
|
||||
AmazonS3SignV2: false,
|
||||
AmazonS3SSE: false,
|
||||
AmazonS3Trace: false,
|
||||
AmazonS3RequestTimeoutMilliseconds: 30000,
|
||||
AmazonS3UploadPartSizeBytes: 5242880,
|
||||
AmazonS3StorageClass: '',
|
||||
AzureStorageAccount: '',
|
||||
AzureAuthMode: 'shared_key',
|
||||
AzureAccessKey: '',
|
||||
AzureContainer: '',
|
||||
AzurePathPrefix: '',
|
||||
AzureCloud: 'commercial',
|
||||
AzureEndpoint: '',
|
||||
AzureSSL: true,
|
||||
AzureRequestTimeoutMilliseconds: 30000,
|
||||
DedicatedExportStore: false,
|
||||
ExportDriverName: 'local',
|
||||
ExportDirectory: './data/',
|
||||
ExportAmazonS3AccessKeyId: '',
|
||||
ExportAmazonS3SecretAccessKey: '',
|
||||
ExportAmazonS3Bucket: '',
|
||||
ExportAmazonS3PathPrefix: '',
|
||||
ExportAmazonS3Region: '',
|
||||
ExportAmazonS3Endpoint: 's3.amazonaws.com',
|
||||
ExportAmazonS3SSL: true,
|
||||
ExportAmazonS3SignV2: false,
|
||||
ExportAmazonS3SSE: false,
|
||||
ExportAmazonS3Trace: false,
|
||||
ExportAmazonS3RequestTimeoutMilliseconds: 30000,
|
||||
ExportAmazonS3PresignExpiresSeconds: 21600,
|
||||
ExportAmazonS3UploadPartSizeBytes: 104857600,
|
||||
ExportAmazonS3StorageClass: '',
|
||||
ExportAzureStorageAccount: '',
|
||||
ExportAzureAuthMode: 'shared_key',
|
||||
ExportAzureAccessKey: '',
|
||||
ExportAzureContainer: '',
|
||||
ExportAzurePathPrefix: '',
|
||||
ExportAzureCloud: 'commercial',
|
||||
ExportAzureEndpoint: '',
|
||||
ExportAzureSSL: true,
|
||||
ExportAzureRequestTimeoutMilliseconds: 30000,
|
||||
ExportAzurePresignExpiresSeconds: 21600,
|
||||
},
|
||||
EmailSettings: {
|
||||
EnableSignUpWithEmail: true,
|
||||
EnableSignInWithEmail: true,
|
||||
EnableSignInWithUsername: true,
|
||||
SendEmailNotifications: true,
|
||||
UseChannelInEmailNotifications: false,
|
||||
RequireEmailVerification: false,
|
||||
FeedbackName: '',
|
||||
FeedbackEmail: 'test@example.com',
|
||||
ReplyToAddress: 'test@example.com',
|
||||
FeedbackOrganization: '',
|
||||
EnableSMTPAuth: false,
|
||||
SMTPUsername: '',
|
||||
SMTPPassword: '',
|
||||
SMTPServer: 'localhost',
|
||||
SMTPPort: '10025',
|
||||
SMTPServerTimeout: 10,
|
||||
ConnectionSecurity: '',
|
||||
SendPushNotifications: true,
|
||||
PushNotificationServer: 'https://push-test.mattermost.com',
|
||||
PushNotificationServerType: 'custom',
|
||||
PushNotificationServerLocation: 'global',
|
||||
PushNotificationContents: 'full',
|
||||
PushNotificationBuffer: 1000,
|
||||
EnableEmailBatching: false,
|
||||
EmailBatchingBufferSize: 256,
|
||||
EmailBatchingInterval: 30,
|
||||
EnablePreviewModeBanner: true,
|
||||
SkipServerCertificateVerification: false,
|
||||
EmailNotificationContentsType: 'full',
|
||||
},
|
||||
RateLimitSettings: {
|
||||
Enable: false,
|
||||
PerSec: 10,
|
||||
MaxBurst: 100,
|
||||
MemoryStoreSize: 10000,
|
||||
VaryByRemoteAddr: true,
|
||||
VaryByUser: false,
|
||||
VaryByHeader: '',
|
||||
},
|
||||
PrivacySettings: {
|
||||
ShowEmailAddress: true,
|
||||
ShowFullName: true,
|
||||
UseAnonymousURLs: false,
|
||||
},
|
||||
SupportSettings: {
|
||||
TermsOfServiceLink: 'https://mattermost.com/pl/terms-of-use/',
|
||||
PrivacyPolicyLink: 'https://mattermost.com/pl/privacy-policy/',
|
||||
AboutLink: 'https://mattermost.com/pl/about-mattermost',
|
||||
HelpLink: 'https://mattermost.com/pl/help/',
|
||||
ReportAProblemLink: 'https://mattermost.com/pl/report-a-bug',
|
||||
ReportAProblemType: 'default',
|
||||
ReportAProblemMail: '',
|
||||
AllowDownloadLogs: true,
|
||||
ForgotPasswordLink: '',
|
||||
SupportEmail: '',
|
||||
CustomTermsOfServiceEnabled: false,
|
||||
CustomTermsOfServiceReAcceptancePeriod: 365,
|
||||
EnableAskCommunityLink: true,
|
||||
},
|
||||
AnnouncementSettings: {
|
||||
EnableBanner: false,
|
||||
BannerText: '',
|
||||
BannerColor: '#f2a93b',
|
||||
BannerTextColor: '#333333',
|
||||
AllowBannerDismissal: true,
|
||||
AdminNoticesEnabled: true,
|
||||
UserNoticesEnabled: true,
|
||||
NoticesURL: 'https://notices.mattermost.com/',
|
||||
NoticesFetchFrequency: 3600,
|
||||
NoticesSkipCache: false,
|
||||
},
|
||||
ThemeSettings: {
|
||||
EnableThemeSelection: true,
|
||||
DefaultTheme: 'default',
|
||||
AllowCustomThemes: true,
|
||||
AllowedThemes: [],
|
||||
},
|
||||
GitLabSettings: {
|
||||
Enable: false,
|
||||
Secret: '',
|
||||
Id: '',
|
||||
Scope: '',
|
||||
AuthEndpoint: '',
|
||||
TokenEndpoint: '',
|
||||
UserAPIEndpoint: '',
|
||||
DiscoveryEndpoint: '',
|
||||
ButtonText: '',
|
||||
ButtonColor: '',
|
||||
UsePreferredUsername: false,
|
||||
},
|
||||
GoogleSettings: {
|
||||
Enable: false,
|
||||
Secret: '',
|
||||
Id: '',
|
||||
Scope: 'profile email',
|
||||
AuthEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
TokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token',
|
||||
UserAPIEndpoint:
|
||||
'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses,nicknames,metadata',
|
||||
DiscoveryEndpoint: '',
|
||||
ButtonText: '',
|
||||
ButtonColor: '',
|
||||
UsePreferredUsername: false,
|
||||
},
|
||||
Office365Settings: {
|
||||
Enable: false,
|
||||
Secret: '',
|
||||
Id: '',
|
||||
Scope: 'User.Read',
|
||||
AuthEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
||||
TokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||
UserAPIEndpoint: 'https://graph.microsoft.com/v1.0/me',
|
||||
DiscoveryEndpoint: '',
|
||||
DirectoryId: '',
|
||||
UsePreferredUsername: false,
|
||||
},
|
||||
OpenIdSettings: {
|
||||
Enable: false,
|
||||
Secret: '',
|
||||
Id: '',
|
||||
Scope: 'profile openid email',
|
||||
AuthEndpoint: '',
|
||||
TokenEndpoint: '',
|
||||
UserAPIEndpoint: '',
|
||||
DiscoveryEndpoint: '',
|
||||
ButtonText: '',
|
||||
ButtonColor: '#145DBF',
|
||||
UsePreferredUsername: false,
|
||||
},
|
||||
LdapSettings: {
|
||||
Enable: false,
|
||||
EnableSync: false,
|
||||
LdapServer: '',
|
||||
LdapPort: 389,
|
||||
ConnectionSecurity: '',
|
||||
BaseDN: '',
|
||||
BindUsername: '',
|
||||
BindPassword: '',
|
||||
MaximumLoginAttempts: 10,
|
||||
UserFilter: '',
|
||||
GroupFilter: '',
|
||||
GuestFilter: '',
|
||||
EnableAdminFilter: false,
|
||||
AdminFilter: '',
|
||||
GroupDisplayNameAttribute: '',
|
||||
GroupIdAttribute: '',
|
||||
FirstNameAttribute: '',
|
||||
LastNameAttribute: '',
|
||||
EmailAttribute: '',
|
||||
UsernameAttribute: '',
|
||||
NicknameAttribute: '',
|
||||
IdAttribute: '',
|
||||
PositionAttribute: '',
|
||||
LoginIdAttribute: '',
|
||||
PictureAttribute: '',
|
||||
SyncIntervalMinutes: 60,
|
||||
ReAddRemovedMembers: false,
|
||||
SkipCertificateVerification: false,
|
||||
PublicCertificateFile: '',
|
||||
PrivateKeyFile: '',
|
||||
QueryTimeout: 60,
|
||||
MaxPageSize: 0,
|
||||
LoginFieldName: '',
|
||||
},
|
||||
ComplianceSettings: {
|
||||
Enable: false,
|
||||
Directory: './data/',
|
||||
EnableDaily: false,
|
||||
BatchSize: 30000,
|
||||
},
|
||||
LocalizationSettings: {
|
||||
DefaultServerLocale: 'en',
|
||||
DefaultClientLocale: 'en',
|
||||
AvailableLocales: '',
|
||||
},
|
||||
SamlSettings: {
|
||||
Enable: false,
|
||||
EnableSyncWithLdap: false,
|
||||
EnableSyncWithLdapIncludeAuth: false,
|
||||
IgnoreGuestsLdapSync: false,
|
||||
Verify: true,
|
||||
Encrypt: true,
|
||||
SignRequest: false,
|
||||
IdpURL: '',
|
||||
IdpDescriptorURL: '',
|
||||
IdpMetadataURL: '',
|
||||
ServiceProviderIdentifier: '',
|
||||
AssertionConsumerServiceURL: '',
|
||||
SignatureAlgorithm: 'RSAwithSHA256',
|
||||
CanonicalAlgorithm: 'Canonical1.0',
|
||||
ScopingIDPProviderId: '',
|
||||
ScopingIDPName: '',
|
||||
IdpCertificateFile: '',
|
||||
PublicCertificateFile: '',
|
||||
PrivateKeyFile: '',
|
||||
IdAttribute: '',
|
||||
GuestAttribute: '',
|
||||
EnableAdminAttribute: false,
|
||||
AdminAttribute: '',
|
||||
FirstNameAttribute: '',
|
||||
LastNameAttribute: '',
|
||||
EmailAttribute: '',
|
||||
UsernameAttribute: '',
|
||||
NicknameAttribute: '',
|
||||
LocaleAttribute: '',
|
||||
PositionAttribute: '',
|
||||
LoginButtonText: 'SAML',
|
||||
},
|
||||
NativeAppSettings: {
|
||||
AppCustomURLSchemes: ['mmauth://', 'mmauthbeta://'],
|
||||
AppDownloadLink: 'https://mattermost.com/pl/download-apps',
|
||||
AndroidAppDownloadLink: 'https://mattermost.com/pl/android-app/',
|
||||
IosAppDownloadLink: 'https://mattermost.com/pl/ios-app/',
|
||||
MobileExternalBrowser: false,
|
||||
MobileEnableBiometrics: false,
|
||||
MobilePreventScreenCapture: false,
|
||||
MobileJailbreakProtection: false,
|
||||
MobileEnableSecureFilePreview: false,
|
||||
MobileAllowPdfLinkNavigation: false,
|
||||
},
|
||||
IntuneSettings: {
|
||||
Enable: false,
|
||||
TenantId: '',
|
||||
ClientId: '',
|
||||
AuthService: '',
|
||||
},
|
||||
CacheSettings: {
|
||||
CacheType: 'lru',
|
||||
RedisAddress: '',
|
||||
RedisPassword: '',
|
||||
RedisDB: -1,
|
||||
RedisCachePrefix: '',
|
||||
DisableClientCache: false,
|
||||
},
|
||||
ClusterSettings: {
|
||||
Enable: false,
|
||||
ClusterName: '',
|
||||
OverrideHostname: '',
|
||||
NetworkInterface: '',
|
||||
BindAddress: '',
|
||||
AdvertiseAddress: '',
|
||||
UseIPAddress: true,
|
||||
EnableGossipCompression: true,
|
||||
EnableGossipEncryption: true,
|
||||
ReadOnlyConfig: true,
|
||||
GossipPort: 8074,
|
||||
},
|
||||
MetricsSettings: {
|
||||
Enable: false,
|
||||
BlockProfileRate: 0,
|
||||
ListenAddress: ':8067',
|
||||
EnableClientMetrics: true,
|
||||
EnableNotificationMetrics: true,
|
||||
ClientSideUserIds: [],
|
||||
},
|
||||
ExperimentalSettings: {
|
||||
ClientSideCertEnable: false,
|
||||
LinkMetadataTimeoutMilliseconds: 5000,
|
||||
RestrictSystemAdmin: false,
|
||||
EnableSharedChannels: false,
|
||||
EnableRemoteClusterService: false,
|
||||
DisableAppBar: false,
|
||||
DisableRefetchingOnBrowserFocus: false,
|
||||
DelayChannelAutocomplete: false,
|
||||
DisableWakeUpReconnectHandler: false,
|
||||
UsersStatusAndProfileFetchingPollIntervalMilliseconds: 3000,
|
||||
YoutubeReferrerPolicy: false,
|
||||
EnableWatermark: false,
|
||||
},
|
||||
AnalyticsSettings: {
|
||||
MaxUsersForStatistics: 2500,
|
||||
},
|
||||
ElasticsearchSettings: {
|
||||
ConnectionURL: 'http://localhost:9200',
|
||||
Backend: 'elasticsearch',
|
||||
Username: 'elastic',
|
||||
Password: 'changeme',
|
||||
EnableIndexing: false,
|
||||
EnableSearching: false,
|
||||
EnableCJKAnalyzers: false,
|
||||
EnableAutocomplete: false,
|
||||
Sniff: true,
|
||||
PostIndexReplicas: 1,
|
||||
PostIndexShards: 1,
|
||||
ChannelIndexReplicas: 1,
|
||||
ChannelIndexShards: 1,
|
||||
UserIndexReplicas: 1,
|
||||
UserIndexShards: 1,
|
||||
AggregatePostsAfterDays: 365,
|
||||
PostsAggregatorJobStartTime: '03:00',
|
||||
IndexPrefix: '',
|
||||
GlobalSearchPrefix: '',
|
||||
LiveIndexingBatchSize: 10,
|
||||
BatchSize: 10000,
|
||||
RequestTimeoutSeconds: 30,
|
||||
SkipTLSVerification: false,
|
||||
CA: '',
|
||||
ClientCert: '',
|
||||
ClientKey: '',
|
||||
Trace: '',
|
||||
IgnoredPurgeIndexes: '',
|
||||
EnableSearchPublicChannelsWithoutMembership: true,
|
||||
},
|
||||
DataRetentionSettings: {
|
||||
EnableMessageDeletion: false,
|
||||
EnableFileDeletion: false,
|
||||
EnableBoardsDeletion: false,
|
||||
MessageRetentionDays: 365,
|
||||
MessageRetentionHours: 0,
|
||||
FileRetentionDays: 365,
|
||||
FileRetentionHours: 0,
|
||||
BoardsRetentionDays: 365,
|
||||
DeletionJobStartTime: '02:00',
|
||||
BatchSize: 3000,
|
||||
TimeBetweenBatchesMilliseconds: 100,
|
||||
RetentionIdsBatchSize: 100,
|
||||
PreservePinnedPosts: false,
|
||||
},
|
||||
MobileEphemeralModeSettings: {
|
||||
Enable: false,
|
||||
DisconnectionTimeoutSeconds: 60,
|
||||
OfflinePersistenceTimerHours: 24,
|
||||
AutoCacheCleanupDays: 7,
|
||||
},
|
||||
MessageExportSettings: {
|
||||
EnableExport: false,
|
||||
ExportFormat: 'actiance',
|
||||
DailyRunTime: '01:00',
|
||||
ExportFromTimestamp: 0,
|
||||
BatchSize: 10000,
|
||||
DownloadExportResults: false,
|
||||
ChannelBatchSize: 100,
|
||||
ChannelHistoryBatchSize: 10,
|
||||
GlobalRelaySettings: {
|
||||
CustomerType: 'A9',
|
||||
SMTPUsername: '',
|
||||
SMTPPassword: '',
|
||||
EmailAddress: '',
|
||||
SMTPServerTimeout: 1800,
|
||||
CustomSMTPServerName: '',
|
||||
CustomSMTPPort: '25',
|
||||
CustomHeaderName: '',
|
||||
CustomHeaderValue: '',
|
||||
SenderAddress: '',
|
||||
},
|
||||
},
|
||||
JobSettings: {
|
||||
RunJobs: true,
|
||||
RunScheduler: true,
|
||||
CleanupJobsThresholdDays: -1,
|
||||
CleanupConfigThresholdDays: -1,
|
||||
},
|
||||
PluginSettings: {
|
||||
Enable: true,
|
||||
EnableUploads: false,
|
||||
AllowInsecureDownloadURL: false,
|
||||
EnableHealthCheck: true,
|
||||
Directory: './plugins',
|
||||
ClientDirectory: './client/plugins',
|
||||
Plugins: {},
|
||||
PluginStates: {
|
||||
'com.mattermost.calls': {
|
||||
Enable: true,
|
||||
},
|
||||
'com.mattermost.nps': {
|
||||
Enable: true,
|
||||
},
|
||||
'mattermost-ai': {
|
||||
Enable: true,
|
||||
},
|
||||
playbooks: {
|
||||
Enable: true,
|
||||
},
|
||||
},
|
||||
EnableMarketplace: true,
|
||||
EnableRemoteMarketplace: true,
|
||||
AutomaticPrepackagedPlugins: true,
|
||||
RequirePluginSignature: false,
|
||||
MarketplaceURL: 'https://api.integrations.mattermost.com',
|
||||
SignaturePublicKeyFiles: [],
|
||||
ChimeraOAuthProxyURL: '',
|
||||
},
|
||||
DisplaySettings: {
|
||||
CustomURLSchemes: [],
|
||||
MaxMarkdownNodes: 0,
|
||||
},
|
||||
GuestAccountsSettings: {
|
||||
Enable: false,
|
||||
HideTags: false,
|
||||
EnforceMultifactorAuthentication: false,
|
||||
RestrictCreationToDomains: '',
|
||||
EnableGuestMagicLink: false,
|
||||
},
|
||||
ImageProxySettings: {
|
||||
Enable: false,
|
||||
ImageProxyType: 'local',
|
||||
},
|
||||
CloudSettings: {
|
||||
CWSURL: 'https://customers.mattermost.com',
|
||||
CWSAPIURL: 'https://portal.internal.prod.cloud.mattermost.com',
|
||||
CWSMock: false,
|
||||
Disable: false,
|
||||
PreviewModalBucketURL: '',
|
||||
},
|
||||
FeatureFlags: {
|
||||
TestFeature: 'off',
|
||||
TestBoolFeature: false,
|
||||
EnableSharedChannelsDMs: false,
|
||||
EnableSyncAllUsersForRemoteCluster: false,
|
||||
AppsEnabled: false,
|
||||
NormalizeLdapDNs: false,
|
||||
WysiwygEditor: false,
|
||||
MoveThreadsEnabled: false,
|
||||
NotificationMonitoring: true,
|
||||
AttributeValueMasking: true,
|
||||
PermissionPolicies: true,
|
||||
ChannelPermissionPolicies: true,
|
||||
PolicySimulation: true,
|
||||
ContentFlagging: true,
|
||||
EnableMattermostEntry: true,
|
||||
MobileSSOCodeExchange: false,
|
||||
EnableShiftEscapeToMarkAllRead: false,
|
||||
AutoTranslation: true,
|
||||
ClassificationMarkings: true,
|
||||
GlobalAttributes: false,
|
||||
BurnOnRead: true,
|
||||
EnableAIPluginBridge: false,
|
||||
EnableAIRecaps: false,
|
||||
IntegratedBoards: false,
|
||||
EnableDocs: false,
|
||||
CJKSearch: true,
|
||||
AggregatePluginMetrics: false,
|
||||
ManagedChannelCategories: false,
|
||||
SessionAttributes: false,
|
||||
PostAttributes: false,
|
||||
DiscoverableChannels: false,
|
||||
MobileEphemeralMode: false,
|
||||
PropertyFieldRank: true,
|
||||
TeamMembershipAccessControl: true,
|
||||
MmBlocksEnabled: true,
|
||||
ClusterGracefulDrain: true,
|
||||
ChannelBookmarks: true,
|
||||
EnableMFIPluginSignaturePublicKey: true,
|
||||
RecurringScheduledPosts: false,
|
||||
},
|
||||
ImportSettings: {
|
||||
Directory: './import',
|
||||
RetentionDays: 30,
|
||||
},
|
||||
ExportSettings: {
|
||||
Directory: './export',
|
||||
RetentionDays: 30,
|
||||
},
|
||||
WranglerSettings: {
|
||||
PermittedWranglerRoles: [],
|
||||
AllowedEmailDomain: [],
|
||||
MoveThreadMaxCount: 100,
|
||||
MoveThreadToAnotherTeamEnable: false,
|
||||
MoveThreadFromPrivateChannelEnable: false,
|
||||
MoveThreadFromDirectMessageChannelEnable: false,
|
||||
MoveThreadFromGroupMessageChannelEnable: false,
|
||||
},
|
||||
ConnectedWorkspacesSettings: {
|
||||
EnableSharedChannels: false,
|
||||
EnableRemoteClusterService: false,
|
||||
DisableSharedChannelsStatusSync: false,
|
||||
SyncUsersOnConnectionOpen: false,
|
||||
GlobalUserSyncBatchSize: 25,
|
||||
MaxPostsPerSync: 50,
|
||||
MemberSyncBatchSize: 20,
|
||||
},
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: false,
|
||||
EnableUserManagedAttributes: false,
|
||||
EnableChannelPolicyIndicators: true,
|
||||
TrustProxyDeviceIdentityHeader: false,
|
||||
EnforceDeviceIDConsistency: false,
|
||||
EnableAccessControlAuditLogging: false,
|
||||
SyncJobIntervalSeconds: 3600,
|
||||
AttributeRefreshIntervalSeconds: 30,
|
||||
},
|
||||
ContentFlaggingSettings: {
|
||||
EnableContentFlagging: false,
|
||||
NotificationSettings: {
|
||||
EventTargetMapping: {
|
||||
assigned: ['reviewers'],
|
||||
dismissed: ['reviewers', 'reporter'],
|
||||
flagged: ['reviewers'],
|
||||
removed: ['reviewers', 'author', 'reporter'],
|
||||
},
|
||||
},
|
||||
AdditionalSettings: {
|
||||
Reasons: [
|
||||
'Classification mismatch',
|
||||
'Need-to-know violation',
|
||||
'Personally identifiable information (PII) exposure',
|
||||
'Operational security (OPSEC) concern',
|
||||
'Controlled Unclassified Information (CUI) violation',
|
||||
'Unauthorized disclosure',
|
||||
'Other',
|
||||
],
|
||||
ReporterCommentRequired: true,
|
||||
ReviewerCommentRequired: true,
|
||||
HideFlaggedContent: true,
|
||||
},
|
||||
ReviewerSettings: {
|
||||
CommonReviewers: true,
|
||||
CommonReviewerIds: [],
|
||||
TeamReviewersSetting: {},
|
||||
SystemAdminsAsReviewers: false,
|
||||
TeamAdminsAsReviewers: true,
|
||||
},
|
||||
},
|
||||
AutoTranslationSettings: {
|
||||
Enable: false,
|
||||
RestrictDMAndGM: false,
|
||||
Provider: '',
|
||||
TargetLanguages: ['en'],
|
||||
Workers: 6,
|
||||
TimeoutMs: 5000,
|
||||
LibreTranslate: {
|
||||
URL: '',
|
||||
APIKey: '',
|
||||
},
|
||||
Agents: {
|
||||
LLMServiceID: '',
|
||||
},
|
||||
},
|
||||
AIRecapSettings: {
|
||||
Enable: true,
|
||||
DefaultLimits: {
|
||||
MaxRecapsPerDay: 10,
|
||||
MaxScheduledRecaps: 5,
|
||||
MaxChannelsPerRecap: -1,
|
||||
MaxPostsPerRecap: 500,
|
||||
MaxTokensPerRecap: 100000,
|
||||
MaxPostsPerDay: 5000,
|
||||
CooldownMinutes: 60,
|
||||
},
|
||||
EnforceRecapsPerDay: true,
|
||||
EnforceScheduledRecaps: true,
|
||||
EnforceChannelsPerRecap: true,
|
||||
EnforcePostsPerRecap: true,
|
||||
EnforceTokensPerRecap: true,
|
||||
EnforcePostsPerDay: true,
|
||||
EnforceCooldown: true,
|
||||
},
|
||||
};
|
||||
52
e2e-tests/playwright/lib/src/server/elasticsearch.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
import type {AdminConfig} from '@mattermost/types/config';
|
||||
|
||||
import {ELASTICSEARCH_ALIAS, ELASTICSEARCH_PORT} from '../containers/constants';
|
||||
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// The Mattermost server always connects to Elasticsearch itself (indexing/search requests), so it
|
||||
// needs the Testcontainers network alias, not a host-mapped address.
|
||||
export function elasticsearchServerConfig(): Partial<AdminConfig['ElasticsearchSettings']> {
|
||||
return {
|
||||
ConnectionURL: `http://${ELASTICSEARCH_ALIAS}:${ELASTICSEARCH_PORT}`,
|
||||
EnableIndexing: true,
|
||||
EnableSearching: true,
|
||||
EnableAutocomplete: true,
|
||||
Sniff: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Elasticsearch was started this run, restarts the server onto it if the Elasticsearch Go
|
||||
* client isn't already the registered search engine, and confirms the server can actually reach
|
||||
* it — skipping the test otherwise, instead of failing on an unmet precondition. Backend picks
|
||||
* which of two Go implementations (Elasticsearch vs OpenSearch client) the server registers — a
|
||||
* factory invoked once at startup, never re-invoked by the config-change watcher — so switching it
|
||||
* needs a restart, unlike the rest of ElasticsearchSettings, which the watcher does pick up live.
|
||||
*/
|
||||
export async function ensureElasticsearch(): Promise<void> {
|
||||
if (!testConfig.testcontainersServices.includes('elasticsearch')) {
|
||||
test.skip(true, 'Skipping test - elasticsearch not started (set PW_TESTCONTAINERS_SERVICES=elasticsearch)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const env = {MM_ELASTICSEARCHSETTINGS_BACKEND: 'elasticsearch'};
|
||||
if (!bootEnvMatches(env)) {
|
||||
await restartMattermostContainer(env);
|
||||
}
|
||||
|
||||
const {adminClient} = await getAdminClient();
|
||||
await adminClient.patchConfig({ElasticsearchSettings: elasticsearchServerConfig()});
|
||||
await adminClient.testElasticsearch();
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - Elasticsearch connection test failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
112
e2e-tests/playwright/lib/src/server/email.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export type InbucketEmail = {
|
||||
id: string;
|
||||
to: string[];
|
||||
date: string;
|
||||
subject: string;
|
||||
body: {
|
||||
text: string;
|
||||
html: string;
|
||||
};
|
||||
};
|
||||
|
||||
type InbucketEmailSummary = Pick<InbucketEmail, 'id' | 'date' | 'subject' | 'to'>;
|
||||
|
||||
type GetRecentEmailOptions = {
|
||||
receivedAfter?: Date;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_EMAIL_TIMEOUT = 30_000;
|
||||
const EMAIL_POLL_INTERVAL = 500;
|
||||
|
||||
/**
|
||||
* Returns the newest email addressed to the exact recipient, waiting for Inbucket
|
||||
* when mail delivery is still in progress.
|
||||
*/
|
||||
export async function getRecentEmail(
|
||||
recipient: string,
|
||||
{receivedAfter, timeout = DEFAULT_EMAIL_TIMEOUT}: GetRecentEmailOptions = {},
|
||||
): Promise<InbucketEmail> {
|
||||
const mailbox = recipient.split('@')[0];
|
||||
const mailboxURL = `${testConfig.smtpURL}/api/v1/mailbox/${encodeURIComponent(mailbox)}`;
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const email = await getNewestMatchingEmail(mailboxURL, recipient, receivedAfter);
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, EMAIL_POLL_INTERVAL));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for email to ${recipient}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a link matching `pathname` from the email body and rewrites its origin to
|
||||
* testConfig.baseURL. Links come back carrying ServiceSettings.SiteURL's origin, which in
|
||||
* `testcontainers` mode is the Docker network alias the server uses to reach itself — not reachable from this
|
||||
* test process or its browser.
|
||||
*/
|
||||
export function extractEmailLink(email: InbucketEmail, pathname: string): string {
|
||||
const escapedPathname = pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const link = email.body.text.match(new RegExp(`https?://[^\\s<>"')]+${escapedPathname}[^\\s<>"')]+`))?.[0];
|
||||
|
||||
if (!link) {
|
||||
throw new Error(`Email to ${email.to.join(', ')} does not contain a link for ${pathname}`);
|
||||
}
|
||||
|
||||
const unescaped = link.replaceAll('&', '&');
|
||||
const rewritten = new URL(unescaped);
|
||||
const reachable = new URL(testConfig.baseURL);
|
||||
rewritten.protocol = reachable.protocol;
|
||||
rewritten.host = reachable.host;
|
||||
|
||||
return rewritten.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the bare email address from a mailbox `to` entry, which Inbucket
|
||||
* reports as `addr@host`, `<addr@host>`, or `Display Name <addr@host>`.
|
||||
*/
|
||||
function parseEmailAddress(entry: string): string {
|
||||
const angleBracketMatch = entry.match(/<([^<>]+)>\s*$/);
|
||||
return (angleBracketMatch ? angleBracketMatch[1] : entry).trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function getNewestMatchingEmail(
|
||||
mailboxURL: string,
|
||||
recipient: string,
|
||||
receivedAfter?: Date,
|
||||
): Promise<InbucketEmail | undefined> {
|
||||
const response = await fetch(mailboxURL);
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summaries = (await response.json()) as InbucketEmailSummary[];
|
||||
const normalizedRecipient = recipient.toLowerCase();
|
||||
const receivedAfterTime = receivedAfter?.getTime();
|
||||
const matchingSummaries = summaries.filter((summary) => {
|
||||
const addressedToRecipient = summary.to.some((address) => parseEmailAddress(address) === normalizedRecipient);
|
||||
const arrivedInTime = receivedAfterTime === undefined || new Date(summary.date).getTime() >= receivedAfterTime;
|
||||
|
||||
return addressedToRecipient && arrivedInTime;
|
||||
});
|
||||
|
||||
for (const summary of matchingSummaries.reverse()) {
|
||||
const messageResponse = await fetch(`${mailboxURL}/${encodeURIComponent(summary.id)}`);
|
||||
if (!messageResponse.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return (await messageResponse.json()) as InbucketEmail;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
47
e2e-tests/playwright/lib/src/server/feature_flags.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
|
||||
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
/**
|
||||
* Restarts the server with the given feature flag set to `value` if it isn't already, and
|
||||
* confirms the running server actually reports that value — skipping the test otherwise, instead
|
||||
* of failing on an unmet precondition.
|
||||
*
|
||||
* FeatureFlags can't be changed via patchConfig on a running server at all: with no Split key
|
||||
* configured (this test setup never sets one), the config store's readOnlyFF handling reverts any
|
||||
* FeatureFlags patch back to its prior value before it's even persisted
|
||||
* (server/config/store.go's Set()/Load()), so only a boot-time MM_FEATUREFLAGS_* env var actually
|
||||
* takes effect.
|
||||
*/
|
||||
export async function ensureFeatureFlag(flagName: string, value: boolean): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
test.skip(true, 'Skipping test - feature flag restart requires PW_USE_TESTCONTAINERS=true');
|
||||
return;
|
||||
}
|
||||
|
||||
const envKey = `MM_FEATUREFLAGS_${flagName.toUpperCase()}`;
|
||||
const envValue = String(value);
|
||||
|
||||
try {
|
||||
const env = {[envKey]: envValue};
|
||||
if (!bootEnvMatches(env)) {
|
||||
await restartMattermostContainer(env);
|
||||
}
|
||||
|
||||
const {adminClient} = await getAdminClient();
|
||||
const config = await adminClient.getConfig();
|
||||
const actual = config.FeatureFlags?.[flagName];
|
||||
if (String(actual) !== envValue) {
|
||||
throw new Error(`Feature flag "${flagName}" is "${String(actual)}" after restart, expected "${envValue}".`);
|
||||
}
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - feature flag "${flagName}" check failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
101
e2e-tests/playwright/lib/src/server/filestore.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {MATTERMOST_DATA_DIR} from '../containers/constants';
|
||||
import {POSTGRES_IMAGE} from '../containers/default_images';
|
||||
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// A 1x1 transparent PNG, just to exercise a real write through the server's file backend.
|
||||
const PROBE_IMAGE_BASE64_PNG =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
|
||||
/** Uploads a throwaway profile image, exercising the server's real, live file backend. */
|
||||
export async function uploadProbeImage(adminClient: Client4, adminUser: UserProfile | null): Promise<void> {
|
||||
if (!adminUser) {
|
||||
throw new Error('No admin user available to probe a real upload with.');
|
||||
}
|
||||
const probeImage = new File([Buffer.from(PROBE_IMAGE_BASE64_PNG, 'base64')], 'probe.png', {type: 'image/png'});
|
||||
await adminClient.uploadProfileImage(adminUser.id, probeImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the server onto local disk storage if it isn't already there, and confirms a real
|
||||
* upload actually works — skipping the test otherwise, instead of failing on an unmet
|
||||
* precondition. The counterpart to ensureMinio()/ensureAzurite() for specs that specifically need
|
||||
* local storage active (e.g. after another spec in the same run switched it away).
|
||||
*/
|
||||
export async function ensureLocalFile(): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
test.skip(true, 'Skipping test - local file storage restart requires PW_USE_TESTCONTAINERS=true');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const env = {MM_FILESETTINGS_DRIVERNAME: 'local'};
|
||||
if (!bootEnvMatches(env)) {
|
||||
await restartMattermostContainer(env);
|
||||
}
|
||||
|
||||
const {adminClient, adminUser} = await getAdminClient();
|
||||
await uploadProbeImage(adminClient, adminUser);
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - local file storage check failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mattermost runs as UID 2000 and creates dated storage dirs mode 0750 (localstore.go). The host
|
||||
* runner cannot scandir those on Linux CI. Distroless server images have no chmod, so open the
|
||||
* bind mount via a one-shot helper container (postgres is always pulled with the stack).
|
||||
*/
|
||||
function makeMattermostDataHostReadable(): void {
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--user',
|
||||
'0',
|
||||
'-v',
|
||||
`${MATTERMOST_DATA_DIR}:/data`,
|
||||
POSTGRES_IMAGE,
|
||||
'chmod',
|
||||
'-R',
|
||||
'a+rX',
|
||||
'/data',
|
||||
],
|
||||
{stdio: 'pipe'},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists every file the server wrote to its bind-mounted data directory (see MATTERMOST_DATA_DIR),
|
||||
* confirming the server actually wrote to disk — the same "check the backend itself, not just
|
||||
* that the API says so" signal listMinioObjectKeys()/listAzuriteBlobNames() already provide for
|
||||
* their own backends.
|
||||
*/
|
||||
export function listMattermostDataFiles(): string[] {
|
||||
if (!fs.existsSync(MATTERMOST_DATA_DIR)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return fs.readdirSync(MATTERMOST_DATA_DIR, {recursive: true}) as string[];
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EACCES') {
|
||||
throw error;
|
||||
}
|
||||
makeMattermostDataHostReadable();
|
||||
return fs.readdirSync(MATTERMOST_DATA_DIR, {recursive: true}) as string[];
|
||||
}
|
||||
}
|
||||
69
e2e-tests/playwright/lib/src/server/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {makeClient} from './client';
|
||||
export {PlaywrightClient4} from './playwright_client';
|
||||
export {createRandomChannel} from './channel';
|
||||
export {getOnPremServerConfig} from './default_config';
|
||||
export {initSetup, getAdminClient} from './init';
|
||||
export {createRandomPost} from './post';
|
||||
export {createNewTeam, createRandomTeam} from './team';
|
||||
export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user';
|
||||
export {extractEmailLink, getRecentEmail} from './email';
|
||||
export type {InbucketEmail} from './email';
|
||||
export {
|
||||
enableAIBridgeTestMode,
|
||||
configureAIBridgeMock,
|
||||
getAIBridgeMock,
|
||||
resetAIBridgeMock,
|
||||
createMockAIAgent,
|
||||
rewriteCompletion,
|
||||
recapCompletion,
|
||||
} from './ai_bridge';
|
||||
export {
|
||||
createUserWithAttributes,
|
||||
enableABAC,
|
||||
disableABAC,
|
||||
navigateToABACPage,
|
||||
navigateToPermissionPoliciesPage,
|
||||
navigateToAttributeBasedAccessPage,
|
||||
createBasicPolicy,
|
||||
createAdvancedPolicy,
|
||||
editPolicy,
|
||||
deletePolicy,
|
||||
runSyncJob,
|
||||
verifyUserInChannel,
|
||||
verifyUserNotInChannel,
|
||||
updateUserAttributes,
|
||||
} from './abac_helpers';
|
||||
export {
|
||||
defaultEnabledPluginIds,
|
||||
disableUnexpectedPlugins,
|
||||
installAndEnablePlugin,
|
||||
isPluginActive,
|
||||
getPluginStatus,
|
||||
} from './plugin';
|
||||
export {isWebhookTestServerReachable, setupWebhookTestServer} from './webhook_server';
|
||||
export {
|
||||
generateLdapUser,
|
||||
createLdapUser,
|
||||
updateLdapUser,
|
||||
deleteLdapUser,
|
||||
ldapServerConfig,
|
||||
ensureOpenldap,
|
||||
} from './openldap';
|
||||
export type {LdapUser} from './openldap';
|
||||
export {createKeycloakUser, deleteKeycloakUser, samlServerConfig, ensureKeycloak} from './keycloak';
|
||||
export type {KeycloakUser} from './keycloak';
|
||||
export {listMinioObjectKeys, ensureMinio} from './minio';
|
||||
export {elasticsearchServerConfig, ensureElasticsearch} from './elasticsearch';
|
||||
export {opensearchServerConfig, ensureOpensearch} from './opensearch';
|
||||
export {ensureAzurite, listAzuriteBlobNames} from './azurite';
|
||||
export {ensureLocalFile, listMattermostDataFiles} from './filestore';
|
||||
export {ensurePostgresSearch} from './postgres_search';
|
||||
export {ensureFeatureFlag} from './feature_flags';
|
||||
export {runMmctl, runMmctlLocal, ensureMmctl} from './mmctl';
|
||||
export type {MmctlResult} from './mmctl';
|
||||
export {upgradeServerImage} from './version';
|
||||
export {saveUpgradePhaseLogs} from './upgrade_logs';
|
||||
export type {UpgradeLogPhase} from './upgrade_logs';
|
||||
88
e2e-tests/playwright/lib/src/server/init.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect} from '@playwright/test';
|
||||
import type {TeamType} from '@mattermost/types/teams';
|
||||
|
||||
import {makeClient} from './client';
|
||||
import {getOnPremServerConfigPatch} from './default_config';
|
||||
import {createNewTeam} from './team';
|
||||
import {createNewUserProfile} from './user';
|
||||
|
||||
import {getFileFromCommonAsset} from '@/file';
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
type InitSetupOptions = {
|
||||
userOptions?: Partial<Parameters<typeof createNewUserProfile>[1]>;
|
||||
teamsOptions?: Partial<Parameters<typeof createNewTeam>[1]>;
|
||||
withDefaultProfileImage?: boolean;
|
||||
};
|
||||
|
||||
export async function initSetup({
|
||||
userOptions = {prefix: 'user', disableTutorial: true, disableOnboarding: true},
|
||||
teamsOptions = {name: 'team', displayName: 'Team', type: 'O' as TeamType, unique: true},
|
||||
withDefaultProfileImage = true,
|
||||
}: Partial<InitSetupOptions> = {}) {
|
||||
try {
|
||||
// Login the admin user via API
|
||||
const {adminClient, adminUser} = await getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Failed to setup admin: Admin user not found.');
|
||||
}
|
||||
if (!adminClient) {
|
||||
throw new Error(
|
||||
"Failed to setup admin: Check that you're able to access the server using the same admin credential.",
|
||||
);
|
||||
}
|
||||
|
||||
// The on-prem overrides only. Sending the full defaultServerConfig snapshot instead would
|
||||
// 403 on PluginSettings.EnableUploads and replace the PluginStates map of concurrent specs.
|
||||
// Settings outside this patch keep their current value, so specs patch what they depend on.
|
||||
const adminConfig = await adminClient.patchConfig(getOnPremServerConfigPatch() as any);
|
||||
|
||||
// Create new team
|
||||
const team = await createNewTeam(adminClient, teamsOptions);
|
||||
|
||||
// Create new user and add to newly created team
|
||||
const user = await createNewUserProfile(adminClient, userOptions);
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
|
||||
// Log in new user via API
|
||||
const {client: userClient} = await makeClient(user);
|
||||
|
||||
if (withDefaultProfileImage) {
|
||||
const file = getFileFromCommonAsset('mattermost-icon_128x128.png');
|
||||
await userClient.uploadProfileImage(user.id, file);
|
||||
}
|
||||
|
||||
return {
|
||||
adminClient,
|
||||
adminUser,
|
||||
adminConfig,
|
||||
user,
|
||||
userClient,
|
||||
team,
|
||||
offTopicUrl: getUrl(team.name, 'off-topic'),
|
||||
townSquareUrl: getUrl(team.name, 'town-square'),
|
||||
};
|
||||
} catch (error) {
|
||||
expect(error, 'Should not throw an error').toBeFalsy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminClient(opts: {skipLog: boolean} = {skipLog: false}) {
|
||||
const {client: adminClient, user: adminUser} = await makeClient(
|
||||
{
|
||||
username: testConfig.adminUsername,
|
||||
password: testConfig.adminPassword,
|
||||
},
|
||||
opts,
|
||||
);
|
||||
|
||||
return {adminClient, adminUser};
|
||||
}
|
||||
|
||||
function getUrl(teamName: string, channelName: string) {
|
||||
return `/${teamName}/channels/${channelName}`;
|
||||
}
|
||||
151
e2e-tests/playwright/lib/src/server/keycloak.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {AdminConfig} from '@mattermost/types/config';
|
||||
|
||||
import {
|
||||
KEYCLOAK_ADMIN_PASSWORD,
|
||||
KEYCLOAK_ADMIN_USER,
|
||||
KEYCLOAK_ALIAS,
|
||||
KEYCLOAK_PORT,
|
||||
KEYCLOAK_REALM,
|
||||
} from '../containers/constants';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export type KeycloakUser = {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
async function getAdminToken(): Promise<string> {
|
||||
const response = await fetch(`${testConfig.keycloakUrl}/realms/master/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: 'admin-cli',
|
||||
username: KEYCLOAK_ADMIN_USER,
|
||||
password: KEYCLOAK_ADMIN_PASSWORD,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get Keycloak admin token: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {access_token: string};
|
||||
return body.access_token;
|
||||
}
|
||||
|
||||
/** Creates the user in Keycloak and returns its Keycloak user id. */
|
||||
export async function createKeycloakUser(user: KeycloakUser): Promise<string> {
|
||||
const token = await getAdminToken();
|
||||
const response = await fetch(`${testConfig.keycloakUrl}/admin/realms/${KEYCLOAK_REALM}/users`, {
|
||||
method: 'POST',
|
||||
headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
enabled: true,
|
||||
credentials: [{type: 'password', value: user.password, temporary: false}],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Keycloak user: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const location = response.headers.get('Location');
|
||||
const userId = location?.split('/').pop();
|
||||
if (!userId) {
|
||||
throw new Error('Keycloak user creation response had no Location header to read the new user id from.');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function deleteKeycloakUser(userId: string): Promise<void> {
|
||||
const token = await getAdminToken();
|
||||
const response = await fetch(`${testConfig.keycloakUrl}/admin/realms/${KEYCLOAK_REALM}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {Authorization: `Bearer ${token}`},
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`Failed to delete Keycloak user: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the SAML client's clientId in keycloak-realm-export.json.
|
||||
const SAML_SERVICE_PROVIDER_ID = 'mattermost';
|
||||
|
||||
// The Mattermost server fetches this URL itself (via POST /saml/metadatafromidp), so it must be
|
||||
// reachable from inside the Testcontainers network — unlike the IdpURL/IdpDescriptorURL below,
|
||||
// which the browser follows directly and so must be reachable from the host instead. Only the
|
||||
// certificate from this response is used; its embedded URLs reflect the alias host, not the one
|
||||
// the browser needs.
|
||||
function keycloakSamlDescriptorUrl(): string {
|
||||
return `http://${KEYCLOAK_ALIAS}:${KEYCLOAK_PORT}/realms/${KEYCLOAK_REALM}/protocol/saml/descriptor`;
|
||||
}
|
||||
|
||||
// The metadata response's certificate is the raw base64 DER content straight out of the SAML
|
||||
// metadata XML's <X509Certificate> element — no PEM armor — but IdpCertificateFile parsing
|
||||
// requires a proper PEM block.
|
||||
function toPemCertificate(base64Der: string): string {
|
||||
const lines = base64Der.replace(/\s+/g, '').match(/.{1,64}/g) ?? [];
|
||||
return `-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Keycloak's SAML IdP certificate (via the server's own metadata-from-IdP call) and
|
||||
* uploads it, then returns a `SamlSettings` patch pointing the server at Keycloak's SAML IdP and
|
||||
* the users `createKeycloakUser` creates.
|
||||
*/
|
||||
export async function samlServerConfig(adminClient: Client4): Promise<Partial<AdminConfig['SamlSettings']>> {
|
||||
const metadata = await adminClient.getSamlMetadataFromIdp(keycloakSamlDescriptorUrl());
|
||||
const certificate = toPemCertificate(metadata.idp_public_certificate);
|
||||
await adminClient.uploadIdpSamlCertificate(new File([certificate], 'idp-certificate.crt'));
|
||||
|
||||
return {
|
||||
Enable: true,
|
||||
Verify: true,
|
||||
Encrypt: false,
|
||||
SignRequest: false,
|
||||
IdpURL: `${testConfig.keycloakUrl}/realms/${KEYCLOAK_REALM}/protocol/saml`,
|
||||
IdpDescriptorURL: `${testConfig.keycloakUrl}/realms/${KEYCLOAK_REALM}`,
|
||||
ServiceProviderIdentifier: SAML_SERVICE_PROVIDER_ID,
|
||||
AssertionConsumerServiceURL: `${testConfig.baseURL}/login/sso/saml`,
|
||||
IdAttribute: 'id',
|
||||
EmailAttribute: 'email',
|
||||
UsernameAttribute: 'username',
|
||||
FirstNameAttribute: 'givenName',
|
||||
LastNameAttribute: 'surname',
|
||||
LoginButtonText: 'Keycloak SAML',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Keycloak was started this run, points the server's SAML settings at it (fetching its IdP
|
||||
* metadata/certificate along the way), and skips the test if either step fails, instead of
|
||||
* failing on an unmet precondition.
|
||||
*/
|
||||
export async function ensureKeycloak(): Promise<void> {
|
||||
if (!testConfig.testcontainersServices.includes('keycloak')) {
|
||||
test.skip(true, 'Skipping test - keycloak not started (set PW_TESTCONTAINERS_SERVICES=keycloak)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const config = await samlServerConfig(adminClient);
|
||||
await adminClient.patchConfig({SamlSettings: config});
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - Keycloak SAML setup failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
93
e2e-tests/playwright/lib/src/server/minio.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client as MinioClient} from 'minio';
|
||||
import {test} from '@playwright/test';
|
||||
|
||||
import {MINIO_ACCESS_KEY, MINIO_ALIAS, MINIO_BUCKET, MINIO_PORT, MINIO_SECRET_KEY} from '../containers/constants';
|
||||
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
|
||||
|
||||
import {uploadProbeImage} from './filestore';
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
function getMinioClient(): MinioClient {
|
||||
const url = new URL(testConfig.minioUrl);
|
||||
return new MinioClient({
|
||||
endPoint: url.hostname,
|
||||
port: Number(url.port),
|
||||
useSSL: url.protocol === 'https:',
|
||||
accessKey: MINIO_ACCESS_KEY,
|
||||
secretKey: MINIO_SECRET_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
// Only used internally by ensureMinio() — not a spec-facing entry point.
|
||||
async function ensureMinioBucket(bucket: string = MINIO_BUCKET): Promise<void> {
|
||||
const client = getMinioClient();
|
||||
const exists = await client.bucketExists(bucket);
|
||||
if (!exists) {
|
||||
await client.makeBucket(bucket);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists every object key in the bucket, to confirm the server actually wrote to Minio. */
|
||||
export async function listMinioObjectKeys(bucket: string = MINIO_BUCKET): Promise<string[]> {
|
||||
const client = getMinioClient();
|
||||
const keys: string[] = [];
|
||||
for await (const item of client.listObjectsV2(bucket, '', true)) {
|
||||
if (item.name) {
|
||||
keys.push(item.name);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// FileSettings' backend is chosen once when the Mattermost process boots and never re-read from a
|
||||
// running server's config, so pointing the server at Minio can only happen via the env vars the
|
||||
// container starts with — always via the network alias, since the server itself always runs
|
||||
// inside the Testcontainers network.
|
||||
function minioServerEnv(): Record<string, string> {
|
||||
return {
|
||||
MM_FILESETTINGS_DRIVERNAME: 'amazons3',
|
||||
MM_FILESETTINGS_AMAZONS3ENDPOINT: `${MINIO_ALIAS}:${MINIO_PORT}`,
|
||||
MM_FILESETTINGS_AMAZONS3ACCESSKEYID: MINIO_ACCESS_KEY,
|
||||
MM_FILESETTINGS_AMAZONS3SECRETACCESSKEY: MINIO_SECRET_KEY,
|
||||
MM_FILESETTINGS_AMAZONS3BUCKET: MINIO_BUCKET,
|
||||
MM_FILESETTINGS_AMAZONS3SSL: 'false',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Minio was started this run, restarts the server onto it if it isn't already the active
|
||||
* file storage backend, and confirms a real upload actually lands in Minio — skipping the test
|
||||
* otherwise, instead of failing on an unmet precondition.
|
||||
*/
|
||||
export async function ensureMinio(): Promise<void> {
|
||||
if (!testConfig.testcontainersServices.includes('minio')) {
|
||||
test.skip(true, 'Skipping test - minio not started (set PW_TESTCONTAINERS_SERVICES=minio)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureMinioBucket();
|
||||
const env = minioServerEnv();
|
||||
if (!bootEnvMatches(env)) {
|
||||
await restartMattermostContainer(env);
|
||||
}
|
||||
|
||||
const {adminClient, adminUser} = await getAdminClient();
|
||||
await uploadProbeImage(adminClient, adminUser);
|
||||
|
||||
const objectKeys = await listMinioObjectKeys();
|
||||
if (objectKeys.length === 0) {
|
||||
throw new Error(
|
||||
'Minio bucket is still empty after a real upload — the server is not actually using Minio ' +
|
||||
'as its file backend.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - Minio connection test failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
47
e2e-tests/playwright/lib/src/server/mmctl.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
|
||||
import {runMmctl as runMmctlContainer, runMmctlLocal} from '../containers/mmctl_container';
|
||||
import type {MmctlResult} from '../containers/mmctl_container';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
export type {MmctlResult};
|
||||
export {runMmctlLocal};
|
||||
|
||||
/**
|
||||
* Runs an mmctl command as a real remote client: a separate container built from the same server
|
||||
* image, authenticated with the current admin session, reaching the server over the
|
||||
* Testcontainers network rather than the `--local` unix socket the server's own container uses.
|
||||
*/
|
||||
export async function runMmctl(args: string[]): Promise<MmctlResult> {
|
||||
const {adminClient, adminUser} = await getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('No admin user available to authenticate mmctl with.');
|
||||
}
|
||||
return runMmctlContainer(args, adminUser.username, adminClient.getToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks full (Testcontainers) mode is active and a real remote mmctl invocation actually reaches
|
||||
* the server — skipping the test otherwise, instead of failing on an unmet precondition.
|
||||
*/
|
||||
export async function ensureMmctl(): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
test.skip(true, 'Skipping test - remote mmctl container requires PW_USE_TESTCONTAINERS=true');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runMmctl(['version']);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`mmctl exited with code ${result.exitCode}: ${result.output}`);
|
||||
}
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - mmctl connectivity check failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
162
e2e-tests/playwright/lib/src/server/openldap.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {AlreadyExistsError, Attribute, Change, Client} from 'ldapts';
|
||||
import {test} from '@playwright/test';
|
||||
import type {AdminConfig} from '@mattermost/types/config';
|
||||
|
||||
import {
|
||||
OPENLDAP_ADMIN_DN,
|
||||
OPENLDAP_ADMIN_PASSWORD,
|
||||
OPENLDAP_ALIAS,
|
||||
OPENLDAP_BASE_DN,
|
||||
OPENLDAP_PORT,
|
||||
} from '../containers/constants';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
import {getRandomId} from '@/util';
|
||||
|
||||
// Typed in-process LDAP client — avoids depending on external ldapadd/ldapmodify binaries.
|
||||
|
||||
export type LdapUser = {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
};
|
||||
|
||||
const ORG_UNIT = 'e2etest';
|
||||
const ORG_UNIT_DN = `ou=${ORG_UNIT},${OPENLDAP_BASE_DN}`;
|
||||
|
||||
export function generateLdapUser(prefix = 'ldap'): LdapUser {
|
||||
const randomId = getRandomId();
|
||||
const username = `${prefix}user${randomId}`;
|
||||
return {
|
||||
username,
|
||||
password: 'Password1',
|
||||
email: `${username}@mmtest.com`,
|
||||
firstname: `Firstname-${randomId}`,
|
||||
lastname: `Lastname-${randomId}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function withAdminClient<T>(fn: (client: Client) => Promise<T>): Promise<T> {
|
||||
const client = new Client({url: `ldap://${testConfig.ldapHost}:${testConfig.ldapPort}`});
|
||||
try {
|
||||
await client.bind(OPENLDAP_ADMIN_DN, OPENLDAP_ADMIN_PASSWORD);
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureOrgUnit(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.add(ORG_UNIT_DN, {objectClass: 'organizationalUnit', ou: ORG_UNIT});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AlreadyExistsError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the user (and the shared org unit, if it doesn't exist yet) and returns it. */
|
||||
export async function createLdapUser(user: LdapUser = generateLdapUser()): Promise<LdapUser> {
|
||||
await withAdminClient(async (client) => {
|
||||
await ensureOrgUnit(client);
|
||||
await client.add(`uid=${user.username},${ORG_UNIT_DN}`, {
|
||||
objectClass: 'inetOrgPerson',
|
||||
cn: user.firstname,
|
||||
sn: user.lastname,
|
||||
uid: user.username,
|
||||
mail: user.email,
|
||||
userPassword: user.password,
|
||||
});
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function updateLdapUser(username: string, changes: Partial<Omit<LdapUser, 'username'>>): Promise<void> {
|
||||
await withAdminClient(async (client) => {
|
||||
const dn = `uid=${username},${ORG_UNIT_DN}`;
|
||||
const attributeByType: Array<[string, string | undefined]> = [
|
||||
['cn', changes.firstname],
|
||||
['sn', changes.lastname],
|
||||
['mail', changes.email],
|
||||
['userPassword', changes.password],
|
||||
];
|
||||
|
||||
const modifications = attributeByType
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
.map(
|
||||
([type, value]) =>
|
||||
new Change({operation: 'replace', modification: new Attribute({type, values: [value]})}),
|
||||
);
|
||||
|
||||
if (modifications.length > 0) {
|
||||
await client.modify(dn, modifications);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLdapUser(username: string): Promise<void> {
|
||||
await withAdminClient(async (client) => {
|
||||
await client.del(`uid=${username},${ORG_UNIT_DN}`);
|
||||
});
|
||||
}
|
||||
|
||||
// In `testcontainers` mode the Mattermost server is itself a container on the Testcontainers network, so it
|
||||
// must reach OpenLDAP via its network alias — unlike testConfig.ldapHost/ldapPort, which resolve to
|
||||
// a host-mapped address for this module's own ldapts client (a separate, non-containerized process).
|
||||
function ldapServerAddress(): [string, number] {
|
||||
return testConfig.useTestContainers ? [OPENLDAP_ALIAS, OPENLDAP_PORT] : [testConfig.ldapHost, testConfig.ldapPort];
|
||||
}
|
||||
|
||||
/** `LdapSettings` patch pointing the server at the OpenLDAP container and the users this module creates. */
|
||||
export function ldapServerConfig(): Partial<AdminConfig['LdapSettings']> {
|
||||
const [ldapServer, ldapPort] = ldapServerAddress();
|
||||
return {
|
||||
Enable: true,
|
||||
LdapServer: ldapServer,
|
||||
LdapPort: ldapPort,
|
||||
BaseDN: ORG_UNIT_DN,
|
||||
BindUsername: OPENLDAP_ADMIN_DN,
|
||||
BindPassword: OPENLDAP_ADMIN_PASSWORD,
|
||||
UserFilter: '(objectClass=inetOrgPerson)',
|
||||
IdAttribute: 'uid',
|
||||
LoginIdAttribute: 'uid',
|
||||
UsernameAttribute: 'uid',
|
||||
EmailAttribute: 'mail',
|
||||
FirstNameAttribute: 'cn',
|
||||
LastNameAttribute: 'sn',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks OpenLDAP was started this run, points the server at it, and confirms the server can
|
||||
* actually reach it — skipping the test otherwise, instead of failing on an unmet precondition.
|
||||
*/
|
||||
export async function ensureOpenldap(): Promise<void> {
|
||||
if (!testConfig.testcontainersServices.includes('openldap')) {
|
||||
test.skip(true, 'Skipping test - openldap not started (set PW_TESTCONTAINERS_SERVICES=openldap)');
|
||||
return;
|
||||
}
|
||||
|
||||
const {adminClient} = await getAdminClient();
|
||||
await adminClient.patchConfig({LdapSettings: ldapServerConfig()});
|
||||
|
||||
// testLdap() searches under BaseDN and requires at least one matching user to succeed, so
|
||||
// probe with a throwaway user rather than depending on whatever the spec creates afterward.
|
||||
const probeUser = generateLdapUser('ensureprobe');
|
||||
try {
|
||||
await createLdapUser(probeUser);
|
||||
await adminClient.testLdap();
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - LDAP connection test failed: ${String(error)}`);
|
||||
} finally {
|
||||
await deleteLdapUser(probeUser.username).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
54
e2e-tests/playwright/lib/src/server/opensearch.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
import type {AdminConfig} from '@mattermost/types/config';
|
||||
|
||||
import {OPENSEARCH_ADMIN_PASSWORD, OPENSEARCH_ALIAS, OPENSEARCH_PORT} from '../containers/constants';
|
||||
import {bootEnvMatches, restartMattermostContainer} from '../containers/stack';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
// The Mattermost server always connects to OpenSearch itself (indexing/search requests), so it
|
||||
// needs the Testcontainers network alias, not a host-mapped address.
|
||||
export function opensearchServerConfig(): Partial<AdminConfig['ElasticsearchSettings']> {
|
||||
return {
|
||||
ConnectionURL: `http://${OPENSEARCH_ALIAS}:${OPENSEARCH_PORT}`,
|
||||
Username: 'admin',
|
||||
Password: OPENSEARCH_ADMIN_PASSWORD,
|
||||
EnableIndexing: true,
|
||||
EnableSearching: true,
|
||||
EnableAutocomplete: true,
|
||||
Sniff: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks OpenSearch was started this run, restarts the server onto it if the OpenSearch Go client
|
||||
* isn't already the registered search engine, and confirms the server can actually reach it —
|
||||
* skipping the test otherwise, instead of failing on an unmet precondition. Backend picks which of
|
||||
* two Go implementations (Elasticsearch vs OpenSearch client) the server registers — a factory
|
||||
* invoked once at startup, never re-invoked by the config-change watcher — so switching it needs a
|
||||
* restart, unlike the rest of ElasticsearchSettings, which the watcher does pick up live.
|
||||
*/
|
||||
export async function ensureOpensearch(): Promise<void> {
|
||||
if (!testConfig.testcontainersServices.includes('opensearch')) {
|
||||
test.skip(true, 'Skipping test - opensearch not started (set PW_TESTCONTAINERS_SERVICES=opensearch)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const env = {MM_ELASTICSEARCHSETTINGS_BACKEND: 'opensearch'};
|
||||
if (!bootEnvMatches(env)) {
|
||||
await restartMattermostContainer(env);
|
||||
}
|
||||
|
||||
const {adminClient} = await getAdminClient();
|
||||
await adminClient.patchConfig({ElasticsearchSettings: opensearchServerConfig()});
|
||||
await adminClient.testElasticsearch();
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - OpenSearch connection test failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
92
e2e-tests/playwright/lib/src/server/playwright_client.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Client4} from '@mattermost/client';
|
||||
import type {Channel, ChannelType} from '@mattermost/types/channels';
|
||||
import type {ClientLicense} from '@mattermost/types/config';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {PartialExcept} from '@mattermost/types/utilities';
|
||||
|
||||
import {createRandomChannel} from './channel';
|
||||
import {createNewUserProfile} from './user';
|
||||
|
||||
import {getFileFromAsset} from '@/file';
|
||||
|
||||
/**
|
||||
* Client4 extended with Playwright test-setup helpers.
|
||||
* Prefer not adding general Mattermost API wrappers here — keep those on Client4 —
|
||||
* except for test-only compatibility shims needed against older server images.
|
||||
*/
|
||||
export class PlaywrightClient4 extends Client4 {
|
||||
/**
|
||||
* Same as Client4.getClientLicenseOld, but keeps `format=old` for older from-images
|
||||
* that still require it. Current servers ignore the parameter.
|
||||
*/
|
||||
getClientLicenseOld = () => {
|
||||
return this.doFetch<ClientLicense>(`${this.getBaseRoute()}/license/client?format=old`, {method: 'get'});
|
||||
};
|
||||
|
||||
private createChannelOfType(
|
||||
teamId: string,
|
||||
displayName: string,
|
||||
type: ChannelType,
|
||||
name?: string,
|
||||
): Promise<Channel> {
|
||||
return this.createChannel(
|
||||
createRandomChannel({
|
||||
teamId,
|
||||
name: name ?? displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
displayName,
|
||||
type,
|
||||
unique: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createPublicChannel(teamId: string, displayName = 'Public', name?: string): Promise<Channel> {
|
||||
return this.createChannelOfType(teamId, displayName, 'O', name);
|
||||
}
|
||||
|
||||
async createPrivateChannel(teamId: string, displayName = 'Private', name?: string): Promise<Channel> {
|
||||
return this.createChannelOfType(teamId, displayName, 'P', name);
|
||||
}
|
||||
|
||||
async createUsers(teamId: string, count: number, prefix = 'user'): Promise<UserProfile[]> {
|
||||
const users: UserProfile[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const user = await createNewUserProfile(this, {prefix});
|
||||
await this.addToTeam(teamId, user.id);
|
||||
users.push(user);
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a post with a default message and any number of files from the assets folder.
|
||||
*/
|
||||
async createTestPost(override: PartialExcept<Post, 'channel_id'>, files: string[] = []) {
|
||||
const post = {
|
||||
message: 'test post',
|
||||
...override,
|
||||
};
|
||||
|
||||
if (!post.file_ids) {
|
||||
post.file_ids = await Promise.all(
|
||||
files.map((filename) => {
|
||||
return new Promise<string>((resolve) => {
|
||||
const formData = new FormData();
|
||||
formData.set('channel_id', post.channel_id);
|
||||
formData.set('files', getFileFromAsset(filename), filename);
|
||||
|
||||
this.uploadFile(formData).then((data) => {
|
||||
resolve(data.file_infos[0].id);
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.createPost(post);
|
||||
}
|
||||
}
|
||||
74
e2e-tests/playwright/lib/src/server/plugin.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {PluginManifest} from '@mattermost/types/plugins';
|
||||
|
||||
import {aiPluginId, callsPluginId, npsPluginId, playbooksPluginId} from '@/constant';
|
||||
|
||||
/** The prepackaged plugins model.Config.SetDefaults() enables, so active on a fresh install. */
|
||||
export const defaultEnabledPluginIds = [aiPluginId, callsPluginId, npsPluginId, playbooksPluginId];
|
||||
|
||||
/** Deactivates every active plugin outside the default set plus `keep`, returning what it disabled. */
|
||||
export async function disableUnexpectedPlugins(client: Client4, keep: string[] = []): Promise<string[]> {
|
||||
const expected = new Set([...defaultEnabledPluginIds, ...keep]);
|
||||
const {active} = await client.getPlugins();
|
||||
const unexpected = active.filter((plugin: PluginManifest) => !expected.has(plugin.id)).map((plugin) => plugin.id);
|
||||
|
||||
for (const pluginId of unexpected) {
|
||||
await client.disablePlugin(pluginId);
|
||||
}
|
||||
|
||||
return unexpected;
|
||||
}
|
||||
|
||||
export async function isPluginActive(client: Client4, pluginId: string): Promise<boolean> {
|
||||
const plugins = await client.getPlugins();
|
||||
return plugins.active.some((plugin: PluginManifest) => plugin.id === pluginId);
|
||||
}
|
||||
|
||||
export async function getPluginStatus(
|
||||
client: Client4,
|
||||
pluginId: string,
|
||||
): Promise<{isInstalled: boolean; isActive: boolean}> {
|
||||
const plugins = await client.getPlugins();
|
||||
|
||||
const isActive = plugins.active.some((plugin: PluginManifest) => plugin.id === pluginId);
|
||||
const isInactive = plugins.inactive.some((plugin: PluginManifest) => plugin.id === pluginId);
|
||||
|
||||
return {
|
||||
isInstalled: isActive || isInactive,
|
||||
isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs and enables a plugin with smart status checking
|
||||
* - If already active: does nothing
|
||||
* - If already installed: just enables it
|
||||
* - Otherwise: installs from URL, then enables
|
||||
*/
|
||||
export async function installAndEnablePlugin(
|
||||
client: Client4,
|
||||
pluginUrl: string,
|
||||
pluginId: string,
|
||||
force = true,
|
||||
): Promise<void> {
|
||||
// Check current status
|
||||
const status = await getPluginStatus(client, pluginId);
|
||||
|
||||
// If already active, nothing to do
|
||||
if (status.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If already installed but not active, just enable it
|
||||
if (status.isInstalled) {
|
||||
await client.enablePlugin(pluginId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not installed - install from URL then enable
|
||||
await client.installPluginFromUrl(pluginUrl, force);
|
||||
await client.enablePlugin(pluginId);
|
||||
}
|
||||
33
e2e-tests/playwright/lib/src/server/post.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Post, PostMetadata} from '@mattermost/types/posts';
|
||||
|
||||
import {getRandomId} from '@/util';
|
||||
|
||||
export function createRandomPost(post?: Partial<Post>): Post {
|
||||
if (post && post.channel_id && post.user_id) {
|
||||
const time = Date.now();
|
||||
|
||||
const defaultPost = {
|
||||
create_at: time,
|
||||
user_id: post.user_id,
|
||||
channel_id: post.channel_id,
|
||||
root_id: post.root_id || '',
|
||||
message: `${post?.message ?? ''}${getRandomId()}`,
|
||||
pending_post_id: `${post.user_id}:${time}`,
|
||||
props: post?.props || {},
|
||||
file_ids: post?.file_ids || [],
|
||||
metadata: {} as PostMetadata,
|
||||
};
|
||||
|
||||
Reflect.deleteProperty(post, 'user_id');
|
||||
Reflect.deleteProperty(post, 'channel_id');
|
||||
Reflect.deleteProperty(post, 'message');
|
||||
Reflect.deleteProperty(post, 'pending_post_id');
|
||||
|
||||
return {...defaultPost, ...post} as Post;
|
||||
}
|
||||
|
||||
throw new Error('Post is missing channel_id or user_id or both');
|
||||
}
|
||||
27
e2e-tests/playwright/lib/src/server/postgres_search.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {test} from '@playwright/test';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
/**
|
||||
* Disables Elasticsearch/OpenSearch indexing/searching so search falls back to the database —
|
||||
* skipping the test otherwise, instead of failing on an unmet precondition. The counterpart to
|
||||
* ensureElasticsearch()/ensureOpensearch() for specs that specifically need database-backed search
|
||||
* active (e.g. after another spec in the same run switched it away).
|
||||
*
|
||||
* No restart involved, unlike most other ensure*() functions: which search engine (if any) is
|
||||
* active is decided at runtime from these enable flags, not from the boot-time Backend setting, so
|
||||
* disabling them here is enough to make the server fall through to Postgres-backed search.
|
||||
*/
|
||||
export async function ensurePostgresSearch(): Promise<void> {
|
||||
try {
|
||||
const {adminClient} = await getAdminClient();
|
||||
await adminClient.patchConfig({
|
||||
ElasticsearchSettings: {EnableIndexing: false, EnableSearching: false, EnableAutocomplete: false},
|
||||
});
|
||||
} catch (error) {
|
||||
test.skip(true, `Skipping test - database search check failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
39
e2e-tests/playwright/lib/src/server/team.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {Team, TeamType} from '@mattermost/types/teams';
|
||||
|
||||
import {getRandomId} from '@/util';
|
||||
|
||||
export async function createNewTeam(
|
||||
client: Client4,
|
||||
options: {name?: string; displayName?: string; type?: TeamType; unique?: boolean} = {
|
||||
name: 'team',
|
||||
displayName: 'Team',
|
||||
type: 'O' as TeamType,
|
||||
unique: true,
|
||||
},
|
||||
) {
|
||||
const randomTeam = await createRandomTeam(options.name, options.displayName, options.type, options.unique);
|
||||
const newTeam = await client.createTeam(randomTeam);
|
||||
|
||||
return newTeam;
|
||||
}
|
||||
|
||||
export async function createRandomTeam(
|
||||
name = 'team',
|
||||
displayName = 'Team',
|
||||
type: TeamType = 'O',
|
||||
unique = true,
|
||||
): Promise<Team> {
|
||||
const randomSuffix = getRandomId();
|
||||
|
||||
const team = {
|
||||
name: unique ? `${name}-${randomSuffix}` : name,
|
||||
display_name: unique ? `${displayName} ${randomSuffix}` : displayName,
|
||||
type,
|
||||
};
|
||||
|
||||
return team as Team;
|
||||
}
|
||||
121
e2e-tests/playwright/lib/src/server/upgrade_logs.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {execFile} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {promisify} from 'node:util';
|
||||
|
||||
import {TESTCONTAINERS_LABEL_KEY, TESTCONTAINERS_LABEL_VALUE} from '../containers/constants';
|
||||
import {logTestcontainers} from '../containers/log';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type UpgradeLogPhase = 'from' | 'to';
|
||||
|
||||
const UPGRADE_LOG_DIR = path.resolve(process.cwd(), 'logs', 'upgrade');
|
||||
const BASELINE_PATH = path.resolve(process.cwd(), '.upgrade_baseline.json');
|
||||
|
||||
/** Lines worth skimming in CI when diagnosing a failed rolling upgrade. */
|
||||
const HIGHLIGHT_PATTERNS: RegExp[] = [
|
||||
/All migrations are complete/i,
|
||||
/\bmigration\b/i,
|
||||
/\bschema\b/i,
|
||||
/\blicense\b/i,
|
||||
/\bpanic\b/i,
|
||||
/\bfatal\b/i,
|
||||
/failed to/i,
|
||||
/error.*upgrade/i,
|
||||
/current version is/i,
|
||||
/database.*version/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Snapshots Mattermost (and Postgres) container logs for an upgrade phase into
|
||||
* `logs/upgrade/`, which CI already uploads via `ci/upload-debug-artifacts`.
|
||||
*
|
||||
* Must run for `from` before upgrade-swap-to replaces the Mattermost container —
|
||||
* teardown-only collection would only ever see the to-image.
|
||||
*/
|
||||
export async function saveUpgradePhaseLogs(phase: UpgradeLogPhase): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(UPGRADE_LOG_DIR, {recursive: true});
|
||||
|
||||
if (testConfig.mattermostContainerId) {
|
||||
await writeDockerLogs(
|
||||
testConfig.mattermostContainerId,
|
||||
path.join(UPGRADE_LOG_DIR, `${phase}-mattermost.log`),
|
||||
path.join(UPGRADE_LOG_DIR, `${phase}-mattermost.highlights.txt`),
|
||||
);
|
||||
} else {
|
||||
logTestcontainers(`saveUpgradePhaseLogs(${phase}): no Mattermost container id — skipped`);
|
||||
}
|
||||
|
||||
const postgresId = await findLabeledContainerId(/postgres/i);
|
||||
if (postgresId) {
|
||||
await writeDockerLogs(postgresId, path.join(UPGRADE_LOG_DIR, `${phase}-postgres.log`));
|
||||
}
|
||||
|
||||
if (phase === 'from' && fs.existsSync(BASELINE_PATH)) {
|
||||
fs.copyFileSync(BASELINE_PATH, path.join(UPGRADE_LOG_DIR, 'from-baseline.json'));
|
||||
}
|
||||
|
||||
logTestcontainers(`saveUpgradePhaseLogs(${phase}): wrote artifacts under logs/upgrade/`);
|
||||
}
|
||||
|
||||
async function writeDockerLogs(containerId: string, outPath: string, highlightsPath?: string): Promise<void> {
|
||||
try {
|
||||
const {stdout, stderr} = await execFileAsync('docker', ['logs', containerId], {
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const body = [stdout, stderr].filter(Boolean).join('\n');
|
||||
fs.writeFileSync(outPath, body, 'utf-8');
|
||||
|
||||
if (highlightsPath) {
|
||||
const highlights = body
|
||||
.split('\n')
|
||||
.filter((line) => HIGHLIGHT_PATTERNS.some((pattern) => pattern.test(line)));
|
||||
fs.writeFileSync(
|
||||
highlightsPath,
|
||||
highlights.length
|
||||
? highlights.join('\n') + '\n'
|
||||
: '(no migration/license/panic/fatal highlight lines matched)\n',
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logTestcontainers(`saveUpgradePhaseLogs: docker logs failed for ${containerId.slice(0, 12)}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds a Testcontainers-labeled container, including stopped ones, whose image matches `imagePattern`. */
|
||||
async function findLabeledContainerId(imagePattern: RegExp): Promise<string | undefined> {
|
||||
try {
|
||||
const {stdout} = await execFileAsync('docker', [
|
||||
'ps',
|
||||
'-aq',
|
||||
'--filter',
|
||||
`label=${TESTCONTAINERS_LABEL_KEY}=${TESTCONTAINERS_LABEL_VALUE}`,
|
||||
]);
|
||||
const ids = stdout
|
||||
.split('\n')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const id of ids) {
|
||||
const {stdout: image} = await execFileAsync('docker', ['inspect', '-f', '{{.Config.Image}}', id]);
|
||||
if (imagePattern.test(image.trim())) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — missing postgres must not fail the upgrade suite.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
80
e2e-tests/playwright/lib/src/server/user.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserProfile, UserTimezone} from '@mattermost/types/users';
|
||||
import {DateTime} from 'luxon';
|
||||
|
||||
import {getRandomId, newTestPassword} from '@/util';
|
||||
import {testConfig} from '@/test_config';
|
||||
import {REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY, REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY} from '@/constant';
|
||||
|
||||
export async function createNewUserProfile(
|
||||
client: Client4,
|
||||
options: {prefix?: string; disableTutorial?: boolean; disableOnboarding?: boolean} = {
|
||||
prefix: 'user',
|
||||
disableTutorial: true,
|
||||
disableOnboarding: true,
|
||||
},
|
||||
) {
|
||||
const randomUser = await createRandomUser(options.prefix);
|
||||
|
||||
const newUser = await client.createUser(randomUser, '', '');
|
||||
// Set password to the created user profile so it can be used to login later
|
||||
newUser.password = randomUser.password;
|
||||
|
||||
if (options.disableTutorial) {
|
||||
await client.savePreferences(newUser.id, [
|
||||
{user_id: newUser.id, category: 'tutorial_step', name: newUser.id, value: '999'},
|
||||
{user_id: newUser.id, category: 'crt_thread_pane_step', name: newUser.id, value: '999'},
|
||||
]);
|
||||
}
|
||||
|
||||
if (options.disableOnboarding) {
|
||||
await client.savePreferences(newUser.id, [
|
||||
{user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
|
||||
{user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
|
||||
]);
|
||||
}
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
export async function createRandomUser(prefix = 'user') {
|
||||
const randomId = getRandomId();
|
||||
|
||||
const user = {
|
||||
email: `${prefix}${randomId}@sample.mattermost.com`,
|
||||
username: `${prefix}${randomId}`,
|
||||
password: newTestPassword(),
|
||||
first_name: `First${randomId}`,
|
||||
last_name: `Last${randomId}`,
|
||||
nickname: `Nickname${randomId}`,
|
||||
};
|
||||
|
||||
return user as UserProfile;
|
||||
}
|
||||
|
||||
export function getDefaultAdminUser() {
|
||||
const admin = {
|
||||
username: testConfig.adminUsername,
|
||||
password: testConfig.adminPassword,
|
||||
first_name: 'Kenneth',
|
||||
last_name: 'Moreno',
|
||||
email: testConfig.adminEmail,
|
||||
};
|
||||
|
||||
return admin as UserProfile;
|
||||
}
|
||||
|
||||
export function isOutsideRemoteUserHour(userTz: UserTimezone | undefined) {
|
||||
const timezone = (userTz?.useAutomaticTimezone ? userTz?.automaticTimezone : userTz?.manualTimezone) || 'UTC';
|
||||
|
||||
const teammateUserDate = DateTime.local().setZone(timezone);
|
||||
|
||||
const currentHour = teammateUserDate.hour;
|
||||
return (
|
||||
currentHour >= REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY ||
|
||||
currentHour < REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY
|
||||
);
|
||||
}
|
||||
74
e2e-tests/playwright/lib/src/server/version.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {execFile} from 'node:child_process';
|
||||
import {promisify} from 'node:util';
|
||||
|
||||
import {restartMattermostContainer} from '../containers/stack';
|
||||
import {logTestcontainers} from '../containers/log';
|
||||
|
||||
import {getAdminClient} from './init';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
import {duration} from '@/util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Swaps the running Testcontainers-managed Mattermost server to `image`, keeping the same
|
||||
* network and Postgres database — i.e. an in-place upgrade (or downgrade) test scenario.
|
||||
*
|
||||
* Unlike ensureFeatureFlag()/ensureMinio()/etc., which skip the test on a failed precondition
|
||||
* (the version/flag/service is incidental to whatever else that test is checking), this throws:
|
||||
* for an upgrade spec, the upgrade itself is the thing under test, so failure must fail the test,
|
||||
* not silently skip it.
|
||||
*/
|
||||
export async function upgradeServerImage(image: string, extraEnv: Record<string, string> = {}): Promise<void> {
|
||||
if (!testConfig.useTestContainers) {
|
||||
throw new Error('upgradeServerImage requires PW_USE_TESTCONTAINERS=true.');
|
||||
}
|
||||
|
||||
const fromImage = testConfig.serverImage;
|
||||
logTestcontainers(`upgrade swap: ${fromImage} → ${image}`);
|
||||
|
||||
testConfig.serverImage = image;
|
||||
await restartMattermostContainer(extraEnv);
|
||||
await assertRunningContainerImage(image);
|
||||
await waitForServerApiReady();
|
||||
logTestcontainers(`upgrade swap complete — server ready at ${testConfig.baseURL} (${image}).`);
|
||||
}
|
||||
|
||||
async function assertRunningContainerImage(expectedImage: string): Promise<void> {
|
||||
if (!testConfig.mattermostContainerId) {
|
||||
throw new Error('Missing Mattermost container id after image swap.');
|
||||
}
|
||||
|
||||
const {stdout} = await execFileAsync('docker', [
|
||||
'inspect',
|
||||
testConfig.mattermostContainerId,
|
||||
'--format',
|
||||
'{{.Config.Image}}',
|
||||
]);
|
||||
// Compare the full reference: matching on the tag alone would accept a same-tag image from
|
||||
// a different repository, so a mis-set image could pass the swap assertion.
|
||||
const normalizeRef = (image: string) => image.replace(/^docker\.io\/(library\/)?/, '');
|
||||
const runningImage = stdout.trim();
|
||||
|
||||
if (normalizeRef(runningImage) !== normalizeRef(expectedImage)) {
|
||||
throw new Error(
|
||||
`Upgrade swap expected container image "${expectedImage}" but docker reports "${runningImage}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServerApiReady(): Promise<void> {
|
||||
const deadline = Date.now() + duration.four_min;
|
||||
while (Date.now() < deadline) {
|
||||
const {adminUser} = await getAdminClient({skipLog: true});
|
||||
if (adminUser) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
throw new Error('Server API did not become ready after image swap.');
|
||||
}
|
||||
52
e2e-tests/playwright/lib/src/server/webhook_server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {APIRequestContext} from '@playwright/test';
|
||||
|
||||
import {testConfig} from '@/test_config';
|
||||
|
||||
/**
|
||||
* Health check for the webhook sidecar used by Cypress and Playwright integration tests
|
||||
* (`e2e-tests/cypress`: `npm run start:webhook`, default http://localhost:3000).
|
||||
*/
|
||||
export async function isWebhookTestServerReachable(
|
||||
request: APIRequestContext,
|
||||
webhookBaseUrl: string = testConfig.webhookBaseUrl,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await request.get(webhookBaseUrl, {timeout: 5000});
|
||||
return res.ok();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /setup on the webhook sidecar so it can call back into Mattermost (dialogs, OAuth, etc.).
|
||||
* Required before routes that use `baseUrl` / admin credentials.
|
||||
*/
|
||||
export async function setupWebhookTestServer(
|
||||
request: APIRequestContext,
|
||||
opts: {
|
||||
mattermostBaseUrl: string;
|
||||
adminUsername: string;
|
||||
adminPassword: string;
|
||||
webhookBaseUrl?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const webhookBaseUrl = opts.webhookBaseUrl ?? testConfig.webhookBaseUrl;
|
||||
const res = await request.post(`${webhookBaseUrl}/setup`, {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
data: {
|
||||
baseUrl: opts.mattermostBaseUrl,
|
||||
webhookBaseUrl,
|
||||
adminUsername: opts.adminUsername,
|
||||
adminPassword: opts.adminPassword,
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Webhook test server /setup failed: HTTP ${res.status()} ${body}`);
|
||||
}
|
||||
}
|
||||
60
e2e-tests/playwright/lib/src/test_action.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Locator, Page} from '@playwright/test';
|
||||
import {expect} from '@playwright/test';
|
||||
export {waitUntil} from 'async-wait-until';
|
||||
|
||||
const visibilityHidden = 'visibility: hidden !important;';
|
||||
const hideTeamHeader = `#sidebarTeamMenuButton {${visibilityHidden}} `;
|
||||
const hidePostHeaderTime = `.post__time {${visibilityHidden}} `;
|
||||
const hidePostProfileIcon = `.profile-icon {${visibilityHidden}} `;
|
||||
const hideKeywordsAndMentionsDesc = `#keywordsAndMentionsDesc {${visibilityHidden}} `;
|
||||
|
||||
export async function hideDynamicChannelsContent(page: Page) {
|
||||
await page.addStyleTag({
|
||||
content: hideTeamHeader + hidePostHeaderTime + hidePostProfileIcon + hideKeywordsAndMentionsDesc,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForAnimationEnd(locator: Locator) {
|
||||
return locator.evaluate((element) =>
|
||||
Promise.all(element.getAnimations({subtree: true}).map((animation) => animation.finished)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function toBeFocusedWithFocusVisible(locator: Locator) {
|
||||
await expect(locator).toBeVisible();
|
||||
await expect(locator).toBeFocused();
|
||||
return locator.evaluate((element) => element.matches(':focus-visible'));
|
||||
}
|
||||
|
||||
export async function logFocusedElement(page: Page) {
|
||||
const focusedElementInfo = await page.evaluate(() => {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) {
|
||||
return 'No element has focus';
|
||||
}
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
const id = activeElement.id ? `#${activeElement.id}` : '(none)';
|
||||
const className = activeElement.className ? `.${Array.from(activeElement.classList).join('.')}` : '(none)';
|
||||
const role = activeElement.getAttribute('role') || '';
|
||||
const ariaLabel = activeElement.getAttribute('aria-label') || '';
|
||||
const text = activeElement.textContent?.slice(0, 50) || '';
|
||||
const htmlElement = activeElement as HTMLElement;
|
||||
|
||||
return {
|
||||
selector: `tag: ${tagName} | id: ${id} | class: ${className}`,
|
||||
role,
|
||||
ariaLabel,
|
||||
text: text.length > 50 ? `${text}...` : text,
|
||||
nodeName: activeElement.nodeName,
|
||||
isVisible: htmlElement.offsetWidth > 0 && htmlElement.offsetHeight > 0,
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Currently focused element:', focusedElementInfo);
|
||||
return focusedElementInfo;
|
||||
}
|
||||
297
e2e-tests/playwright/lib/src/test_config.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
import {MATTERMOST_ALIAS, MATTERMOST_PORT, WEBHOOK_ALIAS, WEBHOOK_PORT} from './containers/constants';
|
||||
import {MATTERMOST_SERVER_IMAGE} from './containers/default_images';
|
||||
import {SERVER_ENV_BASELINE} from './containers/env_baseline';
|
||||
import {
|
||||
getUpgradeFromServerImage,
|
||||
getUpgradeToServerImage,
|
||||
isUpgradeFromProjectSelected,
|
||||
isUpgradeToPhaseProjectSelected,
|
||||
} from './upgrade_env';
|
||||
|
||||
dotenv.config({quiet: true});
|
||||
// Capture before .env.testcontainers override — that file persists the *running* image under
|
||||
// PW_TESTCONTAINERS_SERVER_IMAGE and must not clobber the process/CI SERVER_IMAGE (to-image).
|
||||
const configuredServerImage = process.env.SERVER_IMAGE;
|
||||
dotenv.config({path: '.env.testcontainers', quiet: true, override: true});
|
||||
if (configuredServerImage === undefined) {
|
||||
delete process.env.SERVER_IMAGE;
|
||||
} else {
|
||||
process.env.SERVER_IMAGE = configuredServerImage;
|
||||
}
|
||||
|
||||
// The set of additional services `testcontainers` mode knows how to start.
|
||||
// Single source of truth for `requirements.ts` and for validating PW_TESTCONTAINERS_SERVICES.
|
||||
export const TESTCONTAINERS_SERVICE_NAMES = [
|
||||
'openldap',
|
||||
'keycloak',
|
||||
'elasticsearch',
|
||||
'opensearch',
|
||||
'minio',
|
||||
'azurite',
|
||||
] as const;
|
||||
export type TestContainersServiceName = (typeof TESTCONTAINERS_SERVICE_NAMES)[number];
|
||||
|
||||
// Started whenever PW_TESTCONTAINERS_SERVICES is unset
|
||||
// opensearch and azurite are opt-in only
|
||||
const DEFAULT_TESTCONTAINERS_SERVICES: TestContainersServiceName[] = ['minio', 'openldap', 'keycloak', 'elasticsearch'];
|
||||
|
||||
// All process.env should be defined here
|
||||
export class TestConfig {
|
||||
baseURL: string;
|
||||
adminUsername: string;
|
||||
adminPassword: string;
|
||||
adminEmail: string;
|
||||
ensurePluginsInstalled: string[];
|
||||
haClusterEnabled: boolean;
|
||||
haClusterNodeCount: number;
|
||||
haClusterName: string;
|
||||
pushNotificationServer: string;
|
||||
resetBeforeTest: boolean;
|
||||
isCI: boolean;
|
||||
headless: boolean;
|
||||
slowMo: number;
|
||||
workers: number;
|
||||
snapshotEnabled: boolean;
|
||||
percyEnabled: boolean;
|
||||
smtpURL: string;
|
||||
/**
|
||||
* Postgres connection string for specs needing direct DB access to bypass API-level
|
||||
* validation. In `testcontainers` mode the default port is a placeholder — startStack() overwrites
|
||||
* it with the actual Testcontainers-assigned host-mapped port once the container is up.
|
||||
*/
|
||||
postgresUrl: string;
|
||||
|
||||
/** Base URL of the Cypress/Playwright webhook sidecar (`e2e-tests/cypress`: `npm run start:webhook`). */
|
||||
webhookBaseUrl: string;
|
||||
/**
|
||||
* How OTHER containers (not the test process) reach Mattermost — used when a URL is embedded
|
||||
* in a request body the server itself later dereferences (e.g. a webhook callback URL). In
|
||||
* `external` mode this equals baseURL. In `testcontainers` mode it's the fixed Docker network alias,
|
||||
* since a container can't reach another container's host-mapped port via `localhost`.
|
||||
*/
|
||||
internalBaseURL: string;
|
||||
/** Same distinction as internalBaseURL, for the webhook sidecar's own address. */
|
||||
webhookInternalUrl: string;
|
||||
/**
|
||||
* Gateway IP (e.g. 172.18.0.1) of the Testcontainers bridge network in `testcontainers` mode — an
|
||||
* address on the Docker host itself, reachable both from the browser and from inside the
|
||||
* Mattermost container, so a single mock file server URL works for both without a network
|
||||
* alias or mapped port. Empty in `external` mode, where plain localhost already works for
|
||||
* both since neither runs in a container.
|
||||
*/
|
||||
testcontainersNetworkGatewayIp: string;
|
||||
|
||||
// Testcontainers (`testcontainers` mode)
|
||||
|
||||
/** Selects `testcontainers` mode (Testcontainers manages the server + dependencies) over `external` mode (default). */
|
||||
useTestContainers: boolean;
|
||||
/** Prebuilt Mattermost server image `testcontainers` mode starts. Same env var Compose-based CI already uses. */
|
||||
serverImage: string;
|
||||
/** Arbitrary MM_* config overrides merged over the test-oriented baseline, comma-separated KEY=VALUE pairs. */
|
||||
serverEnv: Record<string, string>;
|
||||
/**
|
||||
* Which additional services `testcontainers` mode starts, validated against
|
||||
* `TESTCONTAINERS_SERVICE_NAMES`. Defaults to `DEFAULT_TESTCONTAINERS_SERVICES` when
|
||||
* PW_TESTCONTAINERS_SERVICES is unset; set it to an empty string to start none.
|
||||
*/
|
||||
testcontainersServices: TestContainersServiceName[];
|
||||
/** Reuse containers across repeated local `testcontainers`-mode runs instead of recreating them every time. */
|
||||
testcontainersReuse: boolean;
|
||||
/** Playwright itself runs inside a container (e.g. CI); join it to the Testcontainers network instead of using mapped ports. */
|
||||
containerRunner: boolean;
|
||||
/**
|
||||
* Name of the Docker network the stack runs on. getNetwork()'s in-process cache only helps
|
||||
* within the process that called startStack() (global setup) — worker processes need this to
|
||||
* join a container (e.g. the mmctl runner) to that same network instead of creating their own.
|
||||
*/
|
||||
testcontainersNetworkName: string;
|
||||
/**
|
||||
* ID of the currently-running Mattermost container. Global setup and worker processes are
|
||||
* separate OS processes — this is how a worker's restartMattermostContainer() (e.g. to switch
|
||||
* file storage backends) finds the container to replace without an in-process object handle.
|
||||
*/
|
||||
mattermostContainerId: string;
|
||||
/**
|
||||
* The full set of env vars the current Mattermost container actually booted with — baseline
|
||||
* plus every boot-time-only setting a pw.ensure*() has switched via
|
||||
* restartMattermostContainer() so far. Each ensure*() checks this before restarting, so it
|
||||
* only restarts when something actually needs to change, and restartMattermostContainer()
|
||||
* merges into this record rather than replacing it, so a restart for one setting doesn't
|
||||
* undo another restart's change.
|
||||
*
|
||||
* Persisted to .env.testcontainers and read back rather than recomputed, because a container
|
||||
* can outlive the process that booted it (e.g. reused across specs in CI) — a later process
|
||||
* must see the server's actual current state, not assume defaults.
|
||||
*/
|
||||
bootEnvOverrides: Record<string, string>;
|
||||
|
||||
/** Used in every mode; defaults match the fixed ports the Testcontainers stack publishes. */
|
||||
ldapHost: string;
|
||||
ldapPort: number;
|
||||
keycloakUrl: string;
|
||||
elasticsearchUrl: string;
|
||||
opensearchUrl: string;
|
||||
minioUrl: string;
|
||||
azuriteUrl: string;
|
||||
|
||||
constructor() {
|
||||
// Server
|
||||
this.baseURL = process.env.PW_BASE_URL || 'http://localhost:8065';
|
||||
this.adminUsername = process.env.PW_ADMIN_USERNAME || 'sysadmin';
|
||||
this.adminPassword = process.env.PW_ADMIN_PASSWORD || 'Sys@dmin-sample1';
|
||||
this.adminEmail = process.env.PW_ADMIN_EMAIL || 'sysadmin@sample.mattermost.com';
|
||||
this.ensurePluginsInstalled =
|
||||
typeof process.env?.PW_ENSURE_PLUGINS_INSTALLED === 'string'
|
||||
? process.env.PW_ENSURE_PLUGINS_INSTALLED.split(',').filter((plugin) => Boolean(plugin))
|
||||
: [];
|
||||
this.haClusterEnabled = parseBool(process.env.PW_HA_CLUSTER_ENABLED, false);
|
||||
this.haClusterNodeCount = parseNumber(process.env.PW_HA_CLUSTER_NODE_COUNT, 2);
|
||||
this.haClusterName = process.env.PW_HA_CLUSTER_NAME || 'mm_dev_cluster';
|
||||
this.pushNotificationServer = process.env.PW_PUSH_NOTIFICATION_SERVER || 'https://push-test.mattermost.com';
|
||||
this.resetBeforeTest = parseBool(process.env.PW_RESET_BEFORE_TEST, false);
|
||||
// CI
|
||||
this.isCI = Boolean(process.env.CI);
|
||||
// Playwright
|
||||
this.headless = parseBool(process.env.PW_HEADLESS, true);
|
||||
this.slowMo = parseNumber(process.env.PW_SLOWMO, 0);
|
||||
this.workers = parseNumber(process.env.PW_WORKERS, 1);
|
||||
// Visual tests
|
||||
this.snapshotEnabled = parseBool(process.env.PW_SNAPSHOT_ENABLE, false);
|
||||
this.percyEnabled = parseBool(process.env.PW_PERCY_ENABLE, false);
|
||||
// Email
|
||||
this.smtpURL = process.env.PW_SMTP_URL || 'http://localhost:9001';
|
||||
this.postgresUrl =
|
||||
process.env.PW_POSTGRES_URL ||
|
||||
'postgres://mmuser:mostest_password@localhost:5432/mattermost_test?sslmode=disable&connect_timeout=10&binary_parameters=yes';
|
||||
this.webhookBaseUrl = process.env.PW_WEBHOOK_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
// Testcontainers
|
||||
this.useTestContainers = parseBool(process.env.PW_USE_TESTCONTAINERS, false);
|
||||
// Fixed Docker network aliases, not derived from any started container object — stable
|
||||
// across a restartMattermostContainer() call, unlike baseURL's host-mapped port.
|
||||
this.internalBaseURL = this.useTestContainers ? `http://${MATTERMOST_ALIAS}:${MATTERMOST_PORT}` : this.baseURL;
|
||||
this.webhookInternalUrl = this.useTestContainers
|
||||
? `http://${WEBHOOK_ALIAS}:${WEBHOOK_PORT}`
|
||||
: this.webhookBaseUrl;
|
||||
this.testcontainersNetworkGatewayIp = process.env.PW_TESTCONTAINERS_NETWORK_GATEWAY_IP || '';
|
||||
// upgrade-from → PW_UPGRADE_FROM_SERVER_IMAGE; upgrade-to/swap-to → SERVER_IMAGE (never the
|
||||
// from-image persisted as PW_TESTCONTAINERS_SERVER_IMAGE); otherwise prefer persisted image.
|
||||
if (isUpgradeFromProjectSelected()) {
|
||||
this.serverImage = getUpgradeFromServerImage();
|
||||
} else if (isUpgradeToPhaseProjectSelected()) {
|
||||
this.serverImage = getUpgradeToServerImage();
|
||||
} else {
|
||||
this.serverImage =
|
||||
process.env.PW_TESTCONTAINERS_SERVER_IMAGE || process.env.SERVER_IMAGE || MATTERMOST_SERVER_IMAGE;
|
||||
}
|
||||
this.serverEnv = parseKeyValueList(process.env.MM_ENV);
|
||||
this.testcontainersServices = parseTestContainersServices(process.env.PW_TESTCONTAINERS_SERVICES);
|
||||
// Defaults to true so a local Ctrl+C (or just finishing a run) doesn't throw away the
|
||||
// stack you were likely about to inspect — tear down explicitly with `npm run testcontainers:down`.
|
||||
this.testcontainersReuse = parseBool(process.env.PW_TESTCONTAINERS_REUSE, true);
|
||||
this.containerRunner = parseBool(process.env.PW_TESTCONTAINERS_CONTAINER_RUNNER, false);
|
||||
this.testcontainersNetworkName = process.env.PW_TESTCONTAINERS_NETWORK_NAME || '';
|
||||
this.mattermostContainerId = process.env.PW_TESTCONTAINERS_MATTERMOST_CONTAINER_ID || '';
|
||||
// Prefer a previously-persisted boot env, since it reflects the container's actual
|
||||
// current state; only compute the default when it's genuinely absent (first boot). A
|
||||
// stale file pointing at a container that's no longer running is detected and reset
|
||||
// elsewhere, before a fresh container starts.
|
||||
this.bootEnvOverrides =
|
||||
parsePersistedBootEnv(process.env.PW_TESTCONTAINERS_BOOT_ENV) ?? computeDefaultBootEnv(this.serverEnv);
|
||||
this.ldapHost = process.env.PW_LDAP_HOST || 'localhost';
|
||||
this.ldapPort = parseNumber(process.env.PW_LDAP_PORT, 389);
|
||||
this.keycloakUrl = process.env.PW_KEYCLOAK_URL || 'http://localhost:8484';
|
||||
this.elasticsearchUrl = process.env.PW_ELASTICSEARCH_URL || 'http://localhost:9200';
|
||||
this.opensearchUrl = process.env.PW_OPENSEARCH_URL || 'http://localhost:9201';
|
||||
this.minioUrl = process.env.PW_MINIO_URL || 'http://localhost:9000';
|
||||
this.azuriteUrl = process.env.PW_AZURITE_URL || 'http://localhost:10000';
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const testConfig = new TestConfig();
|
||||
|
||||
function parseBool(actualValue: string | undefined, defaultValue: boolean) {
|
||||
return actualValue ? actualValue === 'true' : defaultValue;
|
||||
}
|
||||
|
||||
function parseNumber(actualValue: string | undefined, defaultValue: number) {
|
||||
return actualValue ? parseInt(actualValue, 10) : defaultValue;
|
||||
}
|
||||
|
||||
function parseKeyValueList(actualValue: string | undefined): Record<string, string> {
|
||||
if (!actualValue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const entry of actualValue
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)) {
|
||||
const separatorIndex = entry.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(`Invalid MM_ENV entry "${entry}" — expected KEY=VALUE.`);
|
||||
}
|
||||
result[entry.slice(0, separatorIndex)] = entry.slice(separatorIndex + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Mirrors startMattermostContainer()'s actual default boot env: baseline plus MM_ENV overrides
|
||||
// (same precedence), plus the two settings it never sets itself, so the server falls back to
|
||||
// its own defaults for those.
|
||||
function computeDefaultBootEnv(serverEnv: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
...SERVER_ENV_BASELINE,
|
||||
MM_FILESETTINGS_DRIVERNAME: 'local',
|
||||
MM_ELASTICSEARCHSETTINGS_BACKEND: 'elasticsearch',
|
||||
...serverEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot env a genuinely fresh container starts with. Used to reset bootEnvOverrides when a
|
||||
* persisted boot env turns out to reference a container that's no longer running.
|
||||
*/
|
||||
export function defaultBootEnv(): Record<string, string> {
|
||||
return computeDefaultBootEnv(testConfig.serverEnv);
|
||||
}
|
||||
|
||||
function parsePersistedBootEnv(actualValue: string | undefined): Record<string, string> | undefined {
|
||||
if (!actualValue) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(actualValue);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTestContainersServices(actualValue: string | undefined): TestContainersServiceName[] {
|
||||
if (actualValue === undefined) {
|
||||
return DEFAULT_TESTCONTAINERS_SERVICES;
|
||||
}
|
||||
|
||||
// An explicit empty string is a deliberate opt-out (e.g. a spec that wants only the core
|
||||
// stack), distinct from not setting the var at all.
|
||||
if (!actualValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requested = actualValue.split(',').filter(Boolean);
|
||||
const unknown = requested.filter((name) => !(TESTCONTAINERS_SERVICE_NAMES as readonly string[]).includes(name));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown PW_TESTCONTAINERS_SERVICES entr${unknown.length > 1 ? 'ies' : 'y'}: ${unknown.join(', ')}. ` +
|
||||
`Valid services are: ${TESTCONTAINERS_SERVICE_NAMES.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return requested as TestContainersServiceName[];
|
||||
}
|
||||
389
e2e-tests/playwright/lib/src/test_fixture.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Browser, Page} from '@playwright/test';
|
||||
import {test as base} from '@playwright/test';
|
||||
import type {AxeResults} from 'axe-core';
|
||||
import {AxeBuilder} from '@axe-core/playwright';
|
||||
|
||||
import {TestBrowser} from './browser_context';
|
||||
import {
|
||||
ensureLicense,
|
||||
ensurePluginsLoaded,
|
||||
ensureServerDeployment,
|
||||
shouldHaveCallsEnabled,
|
||||
shouldHaveFeatureFlag,
|
||||
shouldRunInLinux,
|
||||
skipIfFeatureFlagNotSet,
|
||||
skipIfNoLicense,
|
||||
} from './flag';
|
||||
import {getBlobFromAsset, getFileFromAsset} from './file';
|
||||
import {
|
||||
configureAIBridgeMock,
|
||||
createKeycloakUser,
|
||||
createLdapUser,
|
||||
createMockAIAgent,
|
||||
createNewUserProfile,
|
||||
createNewTeam,
|
||||
createRandomChannel,
|
||||
createRandomPost,
|
||||
createRandomTeam,
|
||||
createRandomUser,
|
||||
createUserWithAttributes,
|
||||
deleteKeycloakUser,
|
||||
deleteLdapUser,
|
||||
enableAIBridgeTestMode,
|
||||
listMinioObjectKeys,
|
||||
ensureMinio,
|
||||
ensureAzurite,
|
||||
listAzuriteBlobNames,
|
||||
ensureLocalFile,
|
||||
listMattermostDataFiles,
|
||||
ensurePostgresSearch,
|
||||
ensureFeatureFlag,
|
||||
generateLdapUser,
|
||||
getAIBridgeMock,
|
||||
getAdminClient,
|
||||
initSetup,
|
||||
isOutsideRemoteUserHour,
|
||||
ldapServerConfig,
|
||||
ensureOpenldap,
|
||||
makeClient,
|
||||
recapCompletion,
|
||||
resetAIBridgeMock,
|
||||
rewriteCompletion,
|
||||
installAndEnablePlugin,
|
||||
isPluginActive,
|
||||
samlServerConfig,
|
||||
ensureKeycloak,
|
||||
elasticsearchServerConfig,
|
||||
opensearchServerConfig,
|
||||
ensureElasticsearch,
|
||||
ensureOpensearch,
|
||||
runMmctl,
|
||||
ensureMmctl,
|
||||
updateLdapUser,
|
||||
upgradeServerImage,
|
||||
saveUpgradePhaseLogs,
|
||||
} from './server';
|
||||
import {
|
||||
toBeFocusedWithFocusVisible,
|
||||
hideDynamicChannelsContent,
|
||||
waitForAnimationEnd,
|
||||
waitUntil,
|
||||
logFocusedElement,
|
||||
} from './test_action';
|
||||
import {pages} from './ui/pages';
|
||||
import {matchSnapshot} from './visual';
|
||||
import {
|
||||
clearCapturedNotifications,
|
||||
clickNotification,
|
||||
closeWebsockets,
|
||||
connectWebsockets,
|
||||
mockWebsockets,
|
||||
stubNotification,
|
||||
waitForNotification,
|
||||
} from './mock_browser_api';
|
||||
import {duration, getRandomId, newTestPassword, simpleEmailRe, wait} from './util';
|
||||
|
||||
export {expect} from '@playwright/test';
|
||||
|
||||
export type ExtendedFixtures = {
|
||||
axe: AxeBuilderExtended;
|
||||
pw: PlaywrightExtended;
|
||||
};
|
||||
|
||||
type AxeBuilderOptions = {
|
||||
disableColorContrast?: boolean;
|
||||
disableLinkInTextBlock?: boolean;
|
||||
};
|
||||
|
||||
export const test = base.extend<ExtendedFixtures>({
|
||||
axe: async ({}, use) => {
|
||||
const ab = new AxeBuilderExtended();
|
||||
await use(ab);
|
||||
},
|
||||
pw: async ({browser, page, isMobile}, use) => {
|
||||
const pw = new PlaywrightExtended(browser, page, isMobile);
|
||||
await use(pw);
|
||||
await pw.testBrowser.close();
|
||||
},
|
||||
});
|
||||
|
||||
export class PlaywrightExtended {
|
||||
// ./browser_context
|
||||
readonly testBrowser;
|
||||
|
||||
// ./flag
|
||||
readonly shouldHaveCallsEnabled;
|
||||
readonly shouldHaveFeatureFlag;
|
||||
readonly shouldRunInLinux;
|
||||
readonly ensureLicense;
|
||||
readonly ensureServerDeployment;
|
||||
readonly skipIfNoLicense;
|
||||
readonly skipIfFeatureFlagNotSet;
|
||||
|
||||
// ./file
|
||||
readonly getBlobFromAsset;
|
||||
readonly getFileFromAsset;
|
||||
|
||||
// ./server
|
||||
readonly ensurePluginsLoaded;
|
||||
readonly getAdminClient;
|
||||
readonly initSetup;
|
||||
readonly enableAIBridgeTestMode;
|
||||
readonly configureAIBridgeMock;
|
||||
readonly getAIBridgeMock;
|
||||
readonly resetAIBridgeMock;
|
||||
readonly createMockAIAgent;
|
||||
readonly rewriteCompletion;
|
||||
readonly recapCompletion;
|
||||
readonly installAndEnablePlugin;
|
||||
readonly isPluginActive;
|
||||
|
||||
// ./server/openldap, ./server/keycloak, ./server/elasticsearch, ./server/opensearch, ./server/minio
|
||||
readonly generateLdapUser;
|
||||
readonly createLdapUser;
|
||||
readonly updateLdapUser;
|
||||
readonly deleteLdapUser;
|
||||
readonly ldapServerConfig;
|
||||
readonly ensureOpenldap;
|
||||
readonly createKeycloakUser;
|
||||
readonly deleteKeycloakUser;
|
||||
readonly samlServerConfig;
|
||||
readonly ensureKeycloak;
|
||||
readonly elasticsearchServerConfig;
|
||||
readonly opensearchServerConfig;
|
||||
readonly ensureElasticsearch;
|
||||
readonly ensureOpensearch;
|
||||
readonly listMinioObjectKeys;
|
||||
readonly ensureMinio;
|
||||
readonly ensureAzurite;
|
||||
readonly ensureLocalFile;
|
||||
readonly listMattermostDataFiles;
|
||||
readonly ensurePostgresSearch;
|
||||
readonly ensureFeatureFlag;
|
||||
readonly listAzuriteBlobNames;
|
||||
readonly runMmctl;
|
||||
readonly ensureMmctl;
|
||||
|
||||
// ./server/version
|
||||
readonly upgradeServerImage;
|
||||
// ./server/upgrade_logs
|
||||
readonly saveUpgradePhaseLogs;
|
||||
|
||||
// ./test_action
|
||||
readonly toBeFocusedWithFocusVisible;
|
||||
readonly hideDynamicChannelsContent;
|
||||
readonly waitForAnimationEnd;
|
||||
readonly waitUntil;
|
||||
readonly logFocusedElement;
|
||||
|
||||
// ./mock_browser_api
|
||||
readonly stubNotification;
|
||||
readonly clearCapturedNotifications;
|
||||
readonly waitForNotification;
|
||||
readonly clickNotification;
|
||||
readonly mockWebsockets;
|
||||
readonly connectWebsockets;
|
||||
readonly closeWebsockets;
|
||||
|
||||
// ./server
|
||||
readonly createNewUserProfile;
|
||||
readonly createNewTeam;
|
||||
readonly isOutsideRemoteUserHour;
|
||||
readonly makeClient;
|
||||
|
||||
// ./visual
|
||||
readonly matchSnapshot;
|
||||
|
||||
// ./util
|
||||
readonly duration;
|
||||
readonly newTestPassword;
|
||||
readonly simpleEmailRe;
|
||||
readonly wait;
|
||||
|
||||
// random
|
||||
readonly random;
|
||||
|
||||
// unauthenticated page
|
||||
readonly loginPage;
|
||||
readonly landingLoginPage;
|
||||
readonly signupPage;
|
||||
readonly resetPasswordPage;
|
||||
|
||||
readonly hasSeenLandingPage;
|
||||
|
||||
constructor(browser: Browser, page: Page, isMobile: boolean) {
|
||||
// ./browser_context
|
||||
this.testBrowser = new TestBrowser(browser);
|
||||
|
||||
// ./flag
|
||||
this.shouldHaveCallsEnabled = shouldHaveCallsEnabled;
|
||||
this.shouldHaveFeatureFlag = shouldHaveFeatureFlag;
|
||||
this.shouldRunInLinux = shouldRunInLinux;
|
||||
this.ensureLicense = ensureLicense;
|
||||
this.ensureServerDeployment = ensureServerDeployment;
|
||||
this.skipIfNoLicense = skipIfNoLicense;
|
||||
this.skipIfFeatureFlagNotSet = skipIfFeatureFlagNotSet;
|
||||
|
||||
// ./file
|
||||
this.getBlobFromAsset = getBlobFromAsset;
|
||||
this.getFileFromAsset = getFileFromAsset;
|
||||
|
||||
// ./server
|
||||
this.ensurePluginsLoaded = ensurePluginsLoaded;
|
||||
this.initSetup = initSetup;
|
||||
this.getAdminClient = getAdminClient;
|
||||
this.enableAIBridgeTestMode = enableAIBridgeTestMode;
|
||||
this.configureAIBridgeMock = configureAIBridgeMock;
|
||||
this.getAIBridgeMock = getAIBridgeMock;
|
||||
this.resetAIBridgeMock = resetAIBridgeMock;
|
||||
this.createMockAIAgent = createMockAIAgent;
|
||||
this.rewriteCompletion = rewriteCompletion;
|
||||
this.recapCompletion = recapCompletion;
|
||||
this.isOutsideRemoteUserHour = isOutsideRemoteUserHour;
|
||||
this.installAndEnablePlugin = installAndEnablePlugin;
|
||||
this.isPluginActive = isPluginActive;
|
||||
|
||||
// ./server/openldap, ./server/keycloak, ./server/elasticsearch, ./server/opensearch, ./server/minio
|
||||
this.generateLdapUser = generateLdapUser;
|
||||
this.createLdapUser = createLdapUser;
|
||||
this.updateLdapUser = updateLdapUser;
|
||||
this.deleteLdapUser = deleteLdapUser;
|
||||
this.ldapServerConfig = ldapServerConfig;
|
||||
this.ensureOpenldap = ensureOpenldap;
|
||||
this.createKeycloakUser = createKeycloakUser;
|
||||
this.deleteKeycloakUser = deleteKeycloakUser;
|
||||
this.samlServerConfig = samlServerConfig;
|
||||
this.ensureKeycloak = ensureKeycloak;
|
||||
this.elasticsearchServerConfig = elasticsearchServerConfig;
|
||||
this.opensearchServerConfig = opensearchServerConfig;
|
||||
this.ensureElasticsearch = ensureElasticsearch;
|
||||
this.ensureOpensearch = ensureOpensearch;
|
||||
this.listMinioObjectKeys = listMinioObjectKeys;
|
||||
this.ensureMinio = ensureMinio;
|
||||
this.ensureAzurite = ensureAzurite;
|
||||
this.ensureLocalFile = ensureLocalFile;
|
||||
this.listMattermostDataFiles = listMattermostDataFiles;
|
||||
this.ensurePostgresSearch = ensurePostgresSearch;
|
||||
this.ensureFeatureFlag = ensureFeatureFlag;
|
||||
this.listAzuriteBlobNames = listAzuriteBlobNames;
|
||||
this.runMmctl = runMmctl;
|
||||
this.ensureMmctl = ensureMmctl;
|
||||
|
||||
// ./server/version
|
||||
this.upgradeServerImage = upgradeServerImage;
|
||||
// ./server/upgrade_logs
|
||||
this.saveUpgradePhaseLogs = saveUpgradePhaseLogs;
|
||||
|
||||
// ./test_action
|
||||
this.toBeFocusedWithFocusVisible = toBeFocusedWithFocusVisible;
|
||||
this.hideDynamicChannelsContent = hideDynamicChannelsContent;
|
||||
this.waitForAnimationEnd = waitForAnimationEnd;
|
||||
this.waitUntil = waitUntil;
|
||||
this.logFocusedElement = logFocusedElement;
|
||||
|
||||
// unauthenticated page
|
||||
this.loginPage = new pages.LoginPage(page);
|
||||
this.landingLoginPage = new pages.LandingLoginPage(page, isMobile);
|
||||
this.signupPage = new pages.SignupPage(page);
|
||||
this.resetPasswordPage = new pages.ResetPasswordPage(page);
|
||||
|
||||
// ./mock_browser_api
|
||||
this.stubNotification = stubNotification;
|
||||
this.clearCapturedNotifications = clearCapturedNotifications;
|
||||
this.waitForNotification = waitForNotification;
|
||||
this.clickNotification = clickNotification;
|
||||
this.mockWebsockets = mockWebsockets;
|
||||
this.connectWebsockets = connectWebsockets;
|
||||
this.closeWebsockets = closeWebsockets;
|
||||
|
||||
// ./server
|
||||
this.createNewUserProfile = createNewUserProfile;
|
||||
this.createNewTeam = createNewTeam;
|
||||
this.makeClient = makeClient;
|
||||
|
||||
// ./visual
|
||||
this.matchSnapshot = matchSnapshot;
|
||||
|
||||
// ./util
|
||||
this.duration = duration;
|
||||
this.wait = wait;
|
||||
this.newTestPassword = newTestPassword;
|
||||
this.simpleEmailRe = simpleEmailRe;
|
||||
|
||||
this.random = {
|
||||
id: getRandomId,
|
||||
channel: createRandomChannel,
|
||||
post: createRandomPost,
|
||||
team: createRandomTeam,
|
||||
user: createRandomUser,
|
||||
userWithAttributes: createUserWithAttributes,
|
||||
};
|
||||
|
||||
this.hasSeenLandingPage = async () => {
|
||||
// Visit the base URL to be able to set the localStorage
|
||||
await page.goto('/');
|
||||
return waitUntilLocalStorageIsSet(page, '__landingPageSeen__', 'true');
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AxeBuilderExtended {
|
||||
readonly builder: (page: Page, options?: AxeBuilderOptions) => AxeBuilder;
|
||||
|
||||
// See https://github.com/dequelabs/axe-core/blob/master/doc/API.md#axe-core-tags
|
||||
readonly tags: string[] = ['wcag2a', 'wcag2aa', 'wcag21aa'];
|
||||
|
||||
constructor() {
|
||||
this.builder = (page: Page, options: AxeBuilderOptions = {}) => {
|
||||
// See https://github.com/dequelabs/axe-core/blob/master/doc/rule-descriptions.md#wcag-20-level-a--aa-rules
|
||||
const disabledRules: string[] = [];
|
||||
|
||||
if (options.disableColorContrast) {
|
||||
// Disabled in pages due to impact to overall theme of Mattermost.
|
||||
// Option: make use of custom theme to improve color contrast.
|
||||
disabledRules.push('color-contrast');
|
||||
}
|
||||
|
||||
if (options.disableLinkInTextBlock) {
|
||||
// Disabled in pages due to impact to overall theme of Mattermost.
|
||||
// Option: make use of custom theme to improve color contrast.
|
||||
disabledRules.push('link-in-text-block');
|
||||
}
|
||||
|
||||
return new AxeBuilder({page}).withTags(this.tags).disableRules(disabledRules);
|
||||
};
|
||||
}
|
||||
|
||||
violationFingerprints(accessibilityScanResults: AxeResults) {
|
||||
const fingerprints = accessibilityScanResults.violations.map((violation) => ({
|
||||
rule: violation.id,
|
||||
description: violation.description,
|
||||
helpUrl: violation.helpUrl,
|
||||
targets: violation.nodes.map((node) => {
|
||||
return {target: node.target, impact: node.impact, html: node.html};
|
||||
}),
|
||||
}));
|
||||
|
||||
return JSON.stringify(fingerprints, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilLocalStorageIsSet(page: Page, key: string, value: string, timeout = duration.ten_sec) {
|
||||
await waitUntil(
|
||||
() =>
|
||||
page.evaluate(
|
||||
({key, value}) => {
|
||||
if (localStorage.getItem(key) === value) {
|
||||
return true;
|
||||
}
|
||||
localStorage.setItem(key, value);
|
||||
return false;
|
||||
},
|
||||
{key, value},
|
||||
),
|
||||
{timeout},
|
||||
);
|
||||
}
|
||||
112
e2e-tests/playwright/lib/src/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Locator, Page, ViewportSize} from '@playwright/test';
|
||||
|
||||
export type TestArgs = {
|
||||
page: Page;
|
||||
locator?: Locator;
|
||||
browserName: string;
|
||||
viewport?: ViewportSize | null;
|
||||
};
|
||||
|
||||
// Based on https://github.com/microsoft/playwright/blob/d6ec1ae3994f127e38b866a231a34efc6a4cac0d/packages/playwright/types/test.d.ts#L5692-L5759
|
||||
export type ScreenshotOptions = {
|
||||
/**
|
||||
* When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different
|
||||
* treatment depending on their duration:
|
||||
* - finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.
|
||||
* - infinite animations are canceled to initial state, and then played over after the screenshot.
|
||||
*
|
||||
* Defaults to `"disabled"` that disables animations.
|
||||
*/
|
||||
animations?: 'disabled' | 'allow';
|
||||
|
||||
/**
|
||||
* When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be
|
||||
* changed. Defaults to `"hide"`.
|
||||
*/
|
||||
caret?: 'hide' | 'initial';
|
||||
|
||||
/**
|
||||
* An object which specifies clipping of the resulting image.
|
||||
*/
|
||||
clip?: {
|
||||
/**
|
||||
* x-coordinate of top-left corner of clip area
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* y-coordinate of top-left corner of clip area
|
||||
*/
|
||||
y: number;
|
||||
|
||||
/**
|
||||
* width of clipping area
|
||||
*/
|
||||
width: number;
|
||||
|
||||
/**
|
||||
* height of clipping area
|
||||
*/
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to
|
||||
* `false`.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
|
||||
/**
|
||||
* Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink
|
||||
* box `#FF00FF` (customized by `maskColor`) that completely covers its bounding box.
|
||||
*/
|
||||
mask?: Locator[];
|
||||
|
||||
/**
|
||||
* Specify the color of the overlay box for masked elements, in
|
||||
* [CSS color format](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). Default color is pink `#FF00FF`.
|
||||
*/
|
||||
maskColor?: string;
|
||||
|
||||
/**
|
||||
* An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1`. Default is
|
||||
* configurable with `TestConfig.expect`. Unset by default.
|
||||
*/
|
||||
maxDiffPixelRatio?: number;
|
||||
|
||||
/**
|
||||
* An acceptable amount of pixels that could be different. Default is configurable with `TestConfig.expect`. Unset by
|
||||
* default.
|
||||
*/
|
||||
maxDiffPixels?: number;
|
||||
|
||||
/**
|
||||
* Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
|
||||
* Defaults to `false`.
|
||||
*/
|
||||
omitBackground?: boolean;
|
||||
|
||||
/**
|
||||
* When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this
|
||||
* will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel, so
|
||||
* screenshots of high-dpi devices will be twice as large or even larger.
|
||||
*
|
||||
* Defaults to `"css"`.
|
||||
*/
|
||||
scale?: 'css' | 'device';
|
||||
|
||||
/**
|
||||
* An acceptable perceived color difference in the [YIQ color space](https://en.wikipedia.org/wiki/YIQ) between the
|
||||
* same pixel in compared images, between zero (strict) and one (lax), default is configurable with
|
||||
* `TestConfig.expect`. Defaults to `0.2`.
|
||||
*/
|
||||
threshold?: number;
|
||||
|
||||
/**
|
||||
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
|
||||
*/
|
||||
timeout?: number;
|
||||
};
|
||||