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

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

View File

@@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'node:path';
import type {Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import {ClientError} from '@mattermost/client';
import {expect} from '@mattermost/playwright-lib';
const assetPath = path.resolve(__dirname, '../../../../asset');
const DEMO_PLUGIN_ID = 'com.mattermost.demo-plugin';
const DEMO_PLUGIN_URL =
'https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.11.0/mattermost-plugin-demo-v0.11.0.tar.gz';
export {DEMO_PLUGIN_ID, DEMO_PLUGIN_URL};
// Repeated in all Root Modal tests — avoids duplicating the long trigger string
const ROOT_MODAL_TRIGGER_TEXT = 'You have triggered the root component of the demo plugin.';
/**
* Asserts the Root Modal is visible with its 3 base lines.
* Pass elementClicked to also assert the "Element clicked in the menu: X" line.
* Note: "Element clicked in the menu: " and the item name render in separate <span> elements,
* so they are asserted individually.
*/
export async function assertRootModal(page: Page, elementClicked?: string): Promise<void> {
await expect(page.getByText(ROOT_MODAL_TRIGGER_TEXT, {exact: true})).toBeVisible();
await expect(page.getByText('Click anywhere to close.', {exact: true})).toBeVisible();
await expect(page.getByText('This is the English String', {exact: true})).toBeVisible();
if (elementClicked) {
await expect(page.getByText(/Element clicked in the menu:/)).toBeVisible();
await expect(page.getByText(elementClicked, {exact: true})).toBeVisible();
}
}
/**
* Closes the Root Modal by clicking its trigger text and verifies it is gone.
*/
export async function closeRootModal(page: Page): Promise<void> {
await page.getByText(ROOT_MODAL_TRIGGER_TEXT).click();
await expect(page.getByText(ROOT_MODAL_TRIGGER_TEXT)).not.toBeVisible();
}
/**
* Run `send` (typically fill slash command + click Send) while waiting for
* POST /api/v4/commands/execute so the server finishes the slash handler before assertions.
*/
/**
* Upload a file via the UI attachment menu when the demo plugin is active.
* The demo plugin intercepts the attachment button and shows a submenu — this
* helper clicks "Your computer" from that submenu to reach the native file chooser.
*/
export async function uploadFileViaYourComputer(
page: Page,
attachmentButton: {click: () => Promise<void>},
filename: string,
): Promise<void> {
const filePath = path.join(assetPath, filename);
const uploadResponsePromise = page.waitForResponse(
(r) =>
r.url().includes('/api/v4/files') &&
r.request().method() === 'POST' &&
r.status() >= 200 &&
r.status() < 300,
{timeout: 60_000},
);
const fileChooserPromise = page.waitForEvent('filechooser');
await attachmentButton.click();
await page.getByText('Your computer').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(filePath);
await uploadResponsePromise;
}
export async function sendDemoSlashCommand(page: Page, send: () => Promise<void>) {
// Accept any response status (including 5xx) so the 45 s timeout does not fire when the
// plugin is transiently inactive and the server returns HTTP 500. The caller is responsible
// for detecting a failed command (e.g. via a retry loop or explicit status check).
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/v4/commands/execute') && r.request().method() === 'POST',
{timeout: 45_000},
);
await Promise.all([send(), responsePromise]);
}
/**
* installPluginFromUrl can fail with "Unable to restart plugin on upgrade" when activation
* races (server thinks plugin is still active). Retry once after disable + brief settle.
*/
async function installAndEnableDemoPlugin(
adminClient: Client4,
pw: {
installAndEnablePlugin: (client: Client4, pluginUrl: string, pluginId: string) => Promise<void>;
isPluginActive: (client: Client4, pluginId: string) => Promise<boolean>;
},
) {
try {
await pw.installAndEnablePlugin(adminClient, DEMO_PLUGIN_URL, DEMO_PLUGIN_ID);
} catch (err) {
const msg = err instanceof ClientError ? err.message : String(err);
if (!msg.includes('Unable to restart plugin on upgrade')) {
throw err;
}
try {
await adminClient.disablePlugin(DEMO_PLUGIN_ID);
} catch {
// Already inactive or transitional — continue.
}
await new Promise((r) => setTimeout(r, 2000));
await pw.installAndEnablePlugin(adminClient, DEMO_PLUGIN_URL, DEMO_PLUGIN_ID);
}
}
export async function setupDemoPlugin(
adminClient: Client4,
pw: {
installAndEnablePlugin: (client: Client4, pluginUrl: string, pluginId: string) => Promise<void>;
isPluginActive: (client: Client4, pluginId: string) => Promise<boolean>;
},
) {
// No PluginStates here — patchConfig replaces that map wholesale. Enablement goes through
// installAndEnablePlugin's enablePlugin call, which the server applies to this id alone.
// EnableUploads is likewise absent: SERVER_ENV_BASELINE owns it and the API 403s on change.
await adminClient.patchConfig({
FileSettings: {EnablePublicLink: true},
ServiceSettings: {EnableGifPicker: true},
PluginSettings: {
Enable: true,
AllowInsecureDownloadURL: true,
Plugins: {
'com.mattermost.demo-plugin': {
username: 'demouser',
channelname: 'demo_plugin',
lastname: 'User',
},
},
},
});
if (!(await pw.isPluginActive(adminClient, DEMO_PLUGIN_ID))) {
await installAndEnableDemoPlugin(adminClient, pw);
}
// Activation is asynchronous server-side, so poll rather than assert immediately.
await expect.poll(() => pw.isPluginActive(adminClient, DEMO_PLUGIN_ID), {timeout: 30_000}).toBe(true);
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {sendDemoSlashCommand, setupDemoPlugin} from '../../helpers';
test('should open /dialog and post submit confirmation on submit', async ({pw}) => {
// Plugin installation can take up to 60 s; extend the test timeout to avoid
// a premature timeout before the dialog even opens.
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Demo Plugin channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /dialog command (with one retry if the dialog doesn't appear).
// Under CI load the plugin's slash-command handler can be slow to respond;
// a single re-send recovers transient timeouts without masking real failures.
// Re-apply guard: concurrent initSetup() resets PluginSettings (Plugins: {}) which
// clears the demo plugin config; re-running setupDemoPlugin is fast when the plugin
// is already active (alreadyActive guard skips reinstall).
await setupDemoPlugin(adminClient, pw);
const dialog = channelsPage.page.getByRole('dialog');
for (let attempt = 0; attempt < 4; attempt++) {
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/dialog');
await channelsPage.centerView.postCreate.sendMessage();
});
try {
// 5. Confirm dialog opens with title "Test Title"
await expect(dialog).toBeVisible({timeout: 45000});
break; // dialog appeared — proceed
} catch (err) {
if (attempt === 3) {
throw err; // exhausted retries — let the error surface naturally
}
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(2000);
// attempt timed out — retry the slash command
}
}
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Test Title');
// 6. Fill required fields
// Display Name already has default "default text" — overwrite
await dialog.getByTestId('realnameinput').fill('Test Input');
// Email and Password are required
await dialog.getByTestId('someemailemail').fill('test@example.com');
await dialog.getByTestId('somepasswordpassword').fill('testpassword123');
// Number is required
await dialog.getByTestId('somenumbernumber').fill('42');
// Option Selector — required, no default (3rd combobox: User Selector, Channel Selector, Option Selector)
await dialog.getByRole('combobox').nth(2).click();
await channelsPage.page.getByRole('option', {name: 'Option1'}).click();
// Required checkboxes
await dialog.getByRole('checkbox', {name: 'Agree to the terms of service'}).check();
await dialog.getByRole('checkbox', {name: 'Agree to the annoying terms of service'}).check();
// Radio Option Selector — required
await dialog.getByRole('radio', {name: 'Option1'}).click();
// 7. Submit the dialog
await dialog.getByRole('button', {name: 'Submit'}).click();
await expect(dialog).not.toBeVisible();
// 8. Verify the submit post appears in the channel
// Note: "Interative" is a typo in the demo plugin — not a test error
await expect(
channelsPage.centerView.container.locator('p').filter({hasText: 'submitted an Interative Dialog'}),
).toBeVisible();
});
test('should post cancellation notification when /dialog is cancelled', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Demo Plugin channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /dialog command (with one retry if the dialog doesn't appear).
// Re-apply guard: concurrent initSetup() resets PluginSettings.
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(6000);
const dialog = channelsPage.page.getByRole('dialog');
for (let attempt = 0; attempt < 4; attempt++) {
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/dialog');
await channelsPage.centerView.postCreate.sendMessage();
});
try {
// 5. Confirm dialog opens
await expect(dialog).toBeVisible({timeout: 45000});
break;
} catch (err) {
if (attempt === 3) {
throw err;
}
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(2000);
}
}
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Test Title');
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Submit'})).toBeVisible();
// 6. Cancel the dialog
await dialog.getByRole('button', {name: 'Cancel'}).click();
await expect(dialog).not.toBeVisible();
// 7. Verify the cancellation post appears in the channel
// Note: "Interative" is a typo in the demo plugin — not a test error
await expect(
channelsPage.centerView.container.locator('p').filter({hasText: 'canceled an Interative Dialog'}),
).toBeVisible();
});
test('should show validation errors when required fields are submitted empty', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Demo Plugin channel
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /dialog command (with one retry if the dialog doesn't appear).
// Re-apply guard: concurrent initSetup() resets PluginSettings.
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(6000);
const dialog = channelsPage.page.getByRole('dialog');
for (let attempt = 0; attempt < 4; attempt++) {
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/dialog');
await channelsPage.centerView.postCreate.sendMessage();
});
try {
// 5. Confirm dialog opens
await expect(dialog).toBeVisible({timeout: 45000});
break;
} catch (err) {
if (attempt === 3) {
throw err;
}
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(2000);
}
}
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Test Title');
// 6. Clear the Number field and submit
await dialog.getByTestId('somenumbernumber').clear();
await dialog.getByRole('button', {name: 'Submit'}).click();
// 7. Verify dialog stays open with validation errors
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Please fix all field errors', {exact: true})).toBeVisible();
await expect(dialog.getByTestId('somenumber').getByText('This field is required.', {exact: true})).toBeVisible();
});
test('should show general error and keep dialog open on /dialog error submit', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Town Square
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /dialog error command (with one retry if the dialog doesn't appear).
// Re-apply guard: concurrent initSetup() resets PluginSettings.
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(6000);
const dialog = channelsPage.page.getByRole('dialog');
for (let attempt = 0; attempt < 4; attempt++) {
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/dialog error');
await channelsPage.centerView.postCreate.sendMessage();
});
try {
// 5. Confirm dialog opens with title "Simple Dialog Test"
await expect(dialog).toBeVisible({timeout: 45000});
break;
} catch (err) {
if (attempt === 3) {
throw err;
}
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(2000);
}
}
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Simple Dialog Test');
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Submit Test'})).toBeVisible();
// 6. Fill the optional field and submit
await dialog.getByPlaceholder('Enter some text (optional)...').fill('sample test input');
await dialog.getByRole('button', {name: 'Submit Test'}).click();
// 7. Verify general error appears and dialog stays open
await expect(dialog.getByText('some error', {exact: true})).toBeVisible();
await expect(dialog).toBeVisible();
await expect(dialog.getByPlaceholder('Enter some text (optional)...')).toHaveValue('sample test input');
});
test('should show general error on /dialog error-no-elements confirm', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Town Square
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /dialog error-no-elements command (with one retry if the dialog doesn't appear).
// Re-apply guard: concurrent initSetup() resets PluginSettings.
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(6000);
const dialog = channelsPage.page.getByRole('dialog');
for (let attempt = 0; attempt < 4; attempt++) {
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/dialog error-no-elements');
await channelsPage.centerView.postCreate.sendMessage();
});
try {
// 5. Confirm dialog opens with title "Sample Confirmation Dialog" and no form fields
await expect(dialog).toBeVisible({timeout: 45000});
break;
} catch (err) {
if (attempt === 3) {
throw err;
}
await setupDemoPlugin(adminClient, pw);
await channelsPage.page.waitForTimeout(2000);
}
}
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Sample Confirmation Dialog');
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Confirm'})).toBeVisible();
await expect(dialog.getByRole('textbox')).not.toBeVisible();
// 6. Click Confirm
await dialog.getByRole('button', {name: 'Confirm'}).click();
// 7. Verify general error appears and dialog stays open
await expect(dialog.getByText('some error', {exact: true})).toBeVisible();
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Confirm'})).toBeVisible();
});

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../../helpers';
test('should open /dialog date and post submit confirmation after selecting dates', async ({pw}) => {
// Plugin installation can take up to 60 s; extend the test timeout to avoid
// a premature timeout before the dialog even opens.
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Town Square
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /dialog date command (retry if the dialog doesn't appear — plugin races are common under PW_WORKERS=8).
const dialog = channelsPage.page.getByRole('dialog');
for (let attempt = 0; attempt < 3; attempt++) {
await channelsPage.centerView.postCreate.input.fill('/dialog date');
await channelsPage.centerView.postCreate.sendMessage();
try {
await expect(dialog).toBeVisible({timeout: 45000});
break;
} catch (err) {
if (attempt === 2) {
throw err;
}
try {
await adminClient.enablePlugin('com.mattermost.demo-plugin');
} catch {
// Already enabled or transient error — ignore.
}
await expect
.poll(() => pw.isPluginActive(adminClient, 'com.mattermost.demo-plugin'), {
timeout: 45_000,
intervals: [2000],
})
.toBe(true);
await new Promise((resolve) => setTimeout(resolve, 6000));
}
}
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Date & DateTime Test Dialog');
// 6. Verify field labels and Event Title default value
await expect(dialog.getByText('Meeting Date *', {exact: true})).toBeVisible();
await expect(dialog.getByText('Meeting Date & Time *', {exact: true})).toBeVisible();
await expect(dialog.getByText('Event Title *', {exact: true})).toBeVisible();
await expect(dialog.getByRole('textbox', {name: 'Event Title *'})).toHaveValue('Team Meeting');
// 7. Select a date using the Meeting Date picker
await dialog.getByRole('button', {name: /Select a meeting date/i}).click();
await expect(channelsPage.page.getByRole('grid')).toBeVisible();
// Click day 20 — reliably available in any month
await channelsPage.page.getByRole('grid').getByText('20', {exact: true}).click();
// 8. Select date and time using the Meeting Date & Time picker.
// The datetime field renders via DateTimeInput which wraps its date part in
// <div class="dateTime__date"> — a class unique to DateTimeInput and absent
// from the date-only "Meeting Date" field (AppsFormDateField → DatePicker directly).
// Scoping by that wrapper is more reliable than accessible-name matching on the
// role="button" div, whose name includes a CSS icon-font glyph that browsers
// include in accname but which is invisible to textContent inspection.
await dialog.locator('.dateTime__date').getByRole('button').click();
await expect(channelsPage.page.getByRole('grid')).toBeVisible();
await channelsPage.page.getByRole('grid').getByText('22', {exact: true}).click();
// Select a time from the time picker. The time button carries aria-label="Time"
// (set explicitly in DateTimeInput), so the name-based locator is reliable here.
await dialog
.getByRole('button', {name: /Time|Select a time/i})
.first()
.click();
await channelsPage.page.getByRole('menuitem', {name: '3:00 PM'}).click();
// 9. Submit — button is labelled "Create Event"
await dialog.getByRole('button', {name: 'Create Event'}).click();
await expect(dialog).not.toBeVisible();
// 10. Verify submit post appears in the channel
await expect(
channelsPage.centerView.container.locator('p').filter({hasText: 'submitted a Date Dialog'}),
).toBeVisible();
});

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../../helpers';
test('should update form fields dynamically when project type changes via /dialog field-refresh', async ({pw}) => {
// Plugin installation can take up to 60 s; extend the test timeout to avoid
// a premature timeout before the dialog even opens.
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Town Square
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /dialog field-refresh command (with one retry if the dialog doesn't appear).
// Re-apply guard: concurrent initSetup() resets PluginSettings (Plugins: {}) which
// clears the demo plugin config; re-running setupDemoPlugin is fast when the plugin
// is already active (alreadyActive guard skips reinstall).
await setupDemoPlugin(adminClient, pw);
const dialog = channelsPage.page.getByRole('dialog');
for (let attempt = 0; attempt < 2; attempt++) {
await channelsPage.centerView.postCreate.input.fill('/dialog field-refresh');
await channelsPage.centerView.postCreate.sendMessage();
try {
// 5. Confirm dialog opens with title "Project Configuration"
await expect(dialog).toBeVisible({timeout: 15000});
break; // dialog appeared — proceed
} catch (err) {
if (attempt === 1) {
throw err; // exhausted retries — let the error surface naturally
}
// attempt 0 timed out — retry the slash command once
}
}
await expect(dialog.getByRole('heading', {level: 1})).toContainText('Project Configuration');
// 6. Verify initial state — only Project Type dropdown visible
await expect(dialog.getByText('Project Type *')).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Create Project'})).toBeVisible();
await expect(dialog.getByText('Frontend Framework')).not.toBeVisible();
await expect(dialog.getByText('Platform')).not.toBeVisible();
await expect(dialog.getByText('API Type')).not.toBeVisible();
// 7. Select "Web Application" — new fields should appear
// Click the react-select control (not the hidden input) to open the dropdown
await dialog.locator('[class*="Select__control"], [class*="react-select__control"]').first().click();
await channelsPage.page.getByRole('option', {name: 'Web Application'}).click();
await expect(dialog.getByText('Frontend Framework *')).toBeVisible();
await expect(dialog.getByText('Enable PWA')).toBeVisible();
await expect(dialog.getByText('Project Name *')).toBeVisible();
await expect(dialog.getByText('Platform')).not.toBeVisible();
await expect(dialog.getByText('API Type')).not.toBeVisible();
// 8. Change to "Mobile Application" — fields update
await dialog.locator('[class*="Select__control"], [class*="react-select__control"]').first().click();
await channelsPage.page.getByRole('option', {name: 'Mobile Application'}).click();
await expect(dialog.getByText('Platform *')).toBeVisible();
await expect(dialog.getByText('Minimum OS Version *')).toBeVisible();
await expect(dialog.getByText('Project Name *')).toBeVisible();
await expect(dialog.getByText('Frontend Framework')).not.toBeVisible();
await expect(dialog.getByText('Enable PWA')).not.toBeVisible();
await expect(dialog.getByText('API Type')).not.toBeVisible();
// 9. Change to "API Service" — fields update again
await dialog.locator('[class*="Select__control"], [class*="react-select__control"]').first().click();
await channelsPage.page.getByRole('option', {name: 'API Service'}).click();
await expect(dialog.getByText('API Type *')).toBeVisible();
await expect(dialog.getByRole('radio', {name: 'REST API'})).toBeVisible();
await expect(dialog.getByRole('radio', {name: 'GraphQL API'})).toBeVisible();
await expect(dialog.getByRole('radio', {name: 'gRPC Service'})).toBeVisible();
await expect(dialog.getByText('Database *')).toBeVisible();
await expect(dialog.getByText('Project Name *')).toBeVisible();
await expect(dialog.getByText('Platform')).not.toBeVisible();
await expect(dialog.getByText('Minimum OS Version')).not.toBeVisible();
// 10. Fill required fields and submit
await dialog.getByPlaceholder('Enter project name...').fill('Test Project');
await dialog.getByRole('radio', {name: 'REST API'}).click();
// Select PostgreSQL from Database dropdown
await dialog.locator('[class*="Select__control"], [class*="react-select__control"]').last().click();
await channelsPage.page.getByRole('option', {name: 'PostgreSQL'}).click();
await dialog.getByRole('button', {name: 'Create Project'}).click();
await expect(dialog).not.toBeVisible();
// 11. Verify response post in the channel
await expect(
channelsPage.centerView.container.locator('p').filter({hasText: 'api project: Test Project'}),
).toBeVisible();
});

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../../helpers';
test('should send ephemeral post with Update and Delete actions via /ephemeral command', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Town Square — avoids noise from demo plugin's own ephemeral messages
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 4. Send /ephemeral command (retry once if the plugin is not yet ready)
const ephemeralPost = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: 'test ephemeral actions'})
.last();
for (let attempt = 0; attempt < 3; attempt++) {
await channelsPage.centerView.postCreate.input.fill('/ephemeral');
await channelsPage.centerView.postCreate.sendMessage();
try {
await expect(ephemeralPost.getByText('(Only visible to you)', {exact: true})).toBeVisible({timeout: 45000});
break;
} catch (err) {
if (attempt === 2) {
throw err;
}
await setupDemoPlugin(adminClient, pw);
await new Promise((resolve) => setTimeout(resolve, 6000));
}
}
// 5. Verify ephemeral post appears with correct content and action buttons
await expect(ephemeralPost.getByText('(Only visible to you)', {exact: true})).toBeVisible();
await expect(ephemeralPost.getByText('test ephemeral actions', {exact: true})).toBeVisible();
await expect(ephemeralPost.getByRole('button', {name: 'Update', exact: true})).toBeVisible();
await expect(ephemeralPost.getByRole('button', {name: 'Delete', exact: true})).toBeVisible();
// 6. Click Update and verify post text and button label change
// After clicking Update the text changes — re-find the post by its new content.
// The virtual list can re-render immediately after the click, causing a brief DOM
// detachment window; wrap the assertion in toPass to ride out that re-render.
await ephemeralPost.getByRole('button', {name: 'Update', exact: true}).click();
const updatedPost = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: 'updated ephemeral action'})
.last();
await expect
.poll(async () => updatedPost.getByText('updated ephemeral action', {exact: true}).isVisible(), {
timeout: 30000,
intervals: [500, 1000, 2000],
})
.toBe(true);
await expect(updatedPost.getByRole('button', {name: 'Update 1', exact: true})).toBeVisible({timeout: 15000});
await expect(updatedPost.getByRole('button', {name: 'Delete', exact: true})).toBeVisible({timeout: 15000});
// 7. Click Delete and verify post content is removed and buttons are gone
// After delete the text changes again — re-find by the new content
await updatedPost.getByRole('button', {name: 'Delete', exact: true}).click();
const deletedPost = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: '(message deleted)'})
.last();
await expect(deletedPost.getByText('(message deleted)', {exact: true})).toBeVisible();
await expect(deletedPost.getByRole('button', {name: 'Update 1', exact: true})).not.toBeVisible();
await expect(deletedPost.getByRole('button', {name: 'Delete', exact: true})).not.toBeVisible();
// 8. Send /ephemeral_override command (still in Town Square)
await channelsPage.centerView.postCreate.input.fill('/ephemeral_override');
await channelsPage.centerView.postCreate.sendMessage();
// 9. Verify the override ephemeral post appears
const overridePost = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: 'This is a demo of overriding an ephemeral post.'})
.last();
await expect(overridePost.getByText('(Only visible to you)', {exact: true})).toBeVisible();
await expect(
overridePost.getByText('This is a demo of overriding an ephemeral post.', {exact: true}),
).toBeVisible();
});

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {sendDemoSlashCommand, setupDemoPlugin} from '../../helpers';
test('should post interactive button and respond with click attribution via /interactive command', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Town Square
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// Re-apply setupDemoPlugin: concurrent initSetup() resets PluginSettings.Plugins = {}
await setupDemoPlugin(adminClient, pw);
// 4. Send /interactive command (retry once if plugin not yet ready)
const interactivePost = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: 'Test interactive button'})
.last();
for (let attempt = 0; attempt < 4; attempt++) {
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/interactive');
await channelsPage.centerView.postCreate.sendMessage();
});
try {
await expect(interactivePost).toBeVisible({timeout: 15000});
break;
} catch (err) {
if (attempt === 3) {
throw err;
}
await setupDemoPlugin(adminClient, pw);
}
}
// 5. Confirm post appears with 'Test interactive button' and an 'Interactive Button' button
await expect(interactivePost).toBeVisible();
await expect(interactivePost.getByRole('button', {name: 'Interactive Button'})).toBeVisible();
// 6. Click the Interactive Button
await interactivePost.getByRole('button', {name: 'Interactive Button'}).click();
// 7. Wait for thread reply indicator and open the thread
await expect(interactivePost.getByRole('button', {name: /1 reply/})).toBeVisible();
await interactivePost.getByRole('button', {name: /1 reply/}).click();
// 8. Confirm bot response in the thread panel
const threadPanel = channelsPage.page.getByRole('region', {name: /Thread/});
await expect(threadPanel).toBeVisible();
// Verify response credits the user who clicked
await expect(
threadPanel.locator('p').filter({hasText: `${user.username} clicked an interactive button.`}),
).toBeVisible();
// Verify JSON payload contains expected static fields
await expect(
threadPanel.locator('code').filter({hasText: new RegExp(`"user_name"\\s*:\\s*"${user.username}"`)}),
).toBeVisible();
await expect(threadPanel.locator('code').filter({hasText: '"type": "button"'})).toBeVisible();
});

View File

@@ -0,0 +1,139 @@
// 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 {expect, getFileFromAsset, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin, DEMO_PLUGIN_ID} from '../../helpers';
async function sendSlashCommand(page: Page, send: () => Promise<void>, adminClient: Client4): Promise<void> {
// Slash commands hit POST /api/v4/commands/execute — not POST /posts (see web client executeCommand).
// Retry once if the server returns 500 (plugin transiently inactive between setup and first use).
for (let attempt = 0; attempt < 2; attempt++) {
const responsePromise = page.waitForResponse(
(r) => r.url().includes('/api/v4/commands/execute') && r.request().method() === 'POST',
{timeout: 30_000},
);
const [, response] = await Promise.all([send(), responsePromise]);
if (response.ok()) {
return;
}
if (attempt === 0 && response.status() === 500) {
// Plugin may have been deactivated by a concurrent initSetup() — re-enable and retry.
try {
await adminClient.enablePlugin(DEMO_PLUGIN_ID);
await new Promise((r) => setTimeout(r, 1500));
} catch {
// Ignore; retry the slash command anyway.
}
continue;
}
expect(response.ok(), `slash command failed: HTTP ${response.status()}`).toBeTruthy();
}
}
/**
* Uploads a batch of files to the channel via API and posts them as a single message.
* Using the API avoids the demo plugin's custom file upload menu which intercepts
* the attachment button in the UI.
*/
async function uploadAndPostFiles(client: Client4, channelId: string, filenames: string[]): Promise<void> {
const fileIds: string[] = [];
for (const filename of filenames) {
const file = getFileFromAsset(filename);
const formData = new FormData();
formData.set('files', file, filename);
formData.set('channel_id', channelId);
const result = await client.uploadFile(formData);
fileIds.push(result.file_infos[0].id);
}
await client.createPost({
channel_id: channelId,
message: '',
file_ids: fileIds,
});
}
test('should list uploaded files with running total via /list_files command', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Create a dedicated channel for file isolation
const channel = pw.random.channel({
teamId: team.id,
name: 'list-files-test',
displayName: 'List Files Test',
});
const createdChannel = await adminClient.createChannel(channel);
await adminClient.addToChannel(user.id, createdChannel.id);
// 3. Login and navigate to the channel
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'list-files-test');
await channelsPage.toBeVisible();
const page = channelsPage.page;
// 4. /list_files with no files — wait for server round-trip, then assert bot reply
await sendSlashCommand(
page,
async () => {
await channelsPage.centerView.postCreate.input.fill('/list_files');
await channelsPage.centerView.postCreate.sendMessage();
},
adminClient,
);
await expect(
channelsPage.centerView.container.getByText('Last 0 Files uploaded to this channel', {exact: true}),
).toBeVisible();
// 5. Upload first batch of 2 files via API
// (avoids demo plugin's custom attachment menu intercepting the UI)
await uploadAndPostFiles(adminClient, createdChannel.id, ['sample_text_file.txt', 'mattermost-icon_128x128.png']);
// 6. Send /list_files — expect count of 2 and both file names
await sendSlashCommand(
page,
async () => {
await channelsPage.centerView.postCreate.input.fill('/list_files');
await channelsPage.centerView.postCreate.sendMessage();
},
adminClient,
);
const response2 = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: 'Last 2 Files uploaded to this channel'})
.last();
await expect(response2).toBeVisible();
await expect(response2.getByRole('link', {name: 'mattermost-icon_128x128.png'})).toBeVisible();
await expect(response2.getByRole('link', {name: 'sample_text_file.txt'})).toBeVisible();
// 7. Upload second batch of 2 more files via API
await uploadAndPostFiles(adminClient, createdChannel.id, ['mattermost.png', 'archive.zip']);
// 8. Send /list_files — expect count of 4 and all file names
await sendSlashCommand(
page,
async () => {
await channelsPage.centerView.postCreate.input.fill('/list_files');
await channelsPage.centerView.postCreate.sendMessage();
},
adminClient,
);
const response4 = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: 'Last 4 Files uploaded to this channel'})
.last();
await expect(response4).toBeVisible();
await expect(response4.getByRole('link', {name: 'mattermost.png'})).toBeVisible();
await expect(response4.getByRole('link', {name: 'archive.zip'})).toBeVisible();
await expect(response4.getByRole('link', {name: 'mattermost-icon_128x128.png'})).toBeVisible();
await expect(response4.getByRole('link', {name: 'sample_text_file.txt'})).toBeVisible();
});

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {sendDemoSlashCommand, setupDemoPlugin} from '../../helpers';
test.fixme('should toggle hooks on and off via /demo_plugin command', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup: install and activate the demo plugin
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// Add test user to the demo_plugin private channel (it's private; not joined by default).
// The plugin creates this channel asynchronously on activation, so poll until it exists.
let demoChannel: any = null;
for (let i = 0; i < 25; i++) {
try {
demoChannel = await adminClient.getChannelByName(team.id, 'demo_plugin');
if (demoChannel?.id) {
break;
}
} catch {
// Channel not yet created — wait and retry
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!demoChannel?.id) {
throw new Error('demo_plugin channel was not created within 30s of plugin activation');
}
await adminClient.addToChannel(user.id, demoChannel.id);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to the Demo Plugin channel
await channelsPage.goto(team.name, 'demo_plugin');
await channelsPage.toBeVisible();
const hookStatus = channelsPage.page
.locator('span')
.filter({hasText: 'Demo Plugin:'})
.locator('..')
.locator('span')
.last();
// 4. Confirm last post contains login event
const lastPost = await channelsPage.centerView.getLastPost();
await expect(lastPost.container).not.toContainText('ChannelHasBeenCreated');
await channelsPage.page.waitForTimeout(6000);
// 5. Disable hooks (retry if plugin not yet ready)
for (let attempt = 0; attempt < 4; attempt++) {
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/demo_plugin false');
await channelsPage.centerView.postCreate.sendMessage();
});
try {
await expect(hookStatus).toHaveText('Disabled', {timeout: 45000});
break;
} catch (err) {
if (attempt === 3) {
throw err;
}
// Re-enable without patchConfig to avoid triggering a plugin restart that
// posts new "Demo Plugin: Enabled" messages after our disable command.
try {
await adminClient.enablePlugin('com.mattermost.demo-plugin');
} catch {
// Already enabled or transient error — ignore.
}
await expect
.poll(() => pw.isPluginActive(adminClient, 'com.mattermost.demo-plugin'), {
timeout: 30_000,
intervals: [2000],
})
.toBe(true);
}
}
// 6. Create first token channel (hooks off)
const channel1 = pw.random.channel({
teamId: team.id,
name: 'hook-off-channel',
displayName: 'Hook Off Channel',
});
await adminClient.createChannel(channel1);
// 7. Confirm no ChannelHasBeenCreated post for channel1
await expect(
channelsPage.centerView.container.getByText(`ChannelHasBeenCreated: ~${channel1.name}`),
).not.toBeVisible();
// 8. Re-enable hooks
await sendDemoSlashCommand(channelsPage.page, async () => {
await channelsPage.centerView.postCreate.input.fill('/demo_plugin true');
await channelsPage.centerView.postCreate.sendMessage();
});
await expect(hookStatus).toHaveText('Enabled');
// 9. Create second token channel (hooks on)
const channel2 = pw.random.channel({
teamId: team.id,
name: 'hook-on-channel',
displayName: 'Hook On Channel',
});
await adminClient.createChannel(channel2);
// 10. Confirm ChannelHasBeenCreated post appears for channel2
await expect(
channelsPage.centerView.container.getByText(`ChannelHasBeenCreated: ~${channel2.name}`, {exact: true}),
).toBeVisible();
});

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../../helpers';
test('should parse user and channel mentions from /show_mentions command text', async ({pw}) => {
test.setTimeout(120000);
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
// 3. Navigate to Town Square
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// Re-apply setupDemoPlugin: concurrent initSetup() resets PluginSettings.Plugins = {}
await setupDemoPlugin(adminClient, pw);
// 4. Send /show_mentions (retry once if plugin not yet ready)
const responsePost = channelsPage.centerView.container
.getByRole('listitem')
.filter({hasText: 'contains the following different mentions'})
.last();
for (let attempt = 0; attempt < 2; attempt++) {
await channelsPage.centerView.postCreate.input.fill('/show_mentions @sysadmin ~town-square');
await channelsPage.centerView.postCreate.sendMessage();
try {
await expect(responsePost).toBeVisible({timeout: 15000});
break;
} catch (err) {
if (attempt === 1) {
throw err;
}
await setupDemoPlugin(adminClient, pw);
}
}
// 5. Bot response is now visible
// 6. Assert user mentions section
await expect(responsePost.getByRole('heading', {name: 'Mentions to users in the team'})).toBeVisible();
await expect(responsePost.getByRole('columnheader', {name: 'User name'})).toBeVisible();
await expect(responsePost.getByRole('cell', {name: '@sysadmin'})).toBeVisible();
// 7. Assert channel mentions section
await expect(responsePost.getByRole('heading', {name: 'Mentions to public channels'})).toBeVisible();
await expect(responsePost.getByRole('columnheader', {name: 'Channel name'})).toBeVisible();
await expect(responsePost.getByRole('cell', {name: '~Town Square'})).toBeVisible();
// 8. Assert ~Town Square is a link pointing to the town-square channel
await expect(responsePost.getByRole('link', {name: '~Town Square'})).toHaveAttribute(
'href',
/\/channels\/town-square$/,
);
});

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test, expect} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../../helpers';
test('should crash plugin via /crash command and verify recovery', async ({pw}) => {
// Setup: Install and enable the demo plugin
const {adminClient, user} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto();
await channelsPage.toBeVisible();
await setupDemoPlugin(adminClient, pw);
// Test: Use autocomplete to select /crash command
await channelsPage.centerView.postCreate.selectSlashCommandFromAutocomplete('/cr', '/crash');
// Send the crash command
await channelsPage.centerView.postCreate.sendMessage();
// Wait until post contains the crash message
await channelsPage.centerView.waitUntilLastPostContains('Crashing plugin');
// Wait for plugin to recover (polls every 500ms by default)
await expect
.poll(
async () => {
return pw.isPluginActive(adminClient, 'com.mattermost.demo-plugin');
},
{
timeout: 10000, // Max 10s to recover
intervals: [1000], // Check every 1s
},
)
.toBe(true);
// Verify recovery by using /demo_plugin command
await channelsPage.centerView.postCreate.selectSlashCommandFromAutocomplete('/demo', '/demo_plugin');
});

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../helpers';
test('should open right-hand sidebar when demo plugin App Bar button is clicked', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Locate and click the demo plugin button in the right App Bar
await expect(channelsPage.appBar.demoPluginButton).toBeVisible();
// 4. Click the App Bar button
await channelsPage.appBar.demoPluginButton.click();
// 5. Verify the RHS opens with expected content
const rhsPanel = channelsPage.page.getByRole('region', {name: 'Demo Plugin'});
await expect(rhsPanel).toBeVisible();
await expect(
rhsPanel.getByText('You have triggered the right-hand sidebar component of the demo plugin.', {exact: true}),
).toBeVisible();
await expect(rhsPanel.getByText('This is the English String', {exact: true})).toBeVisible();
// Custom route links — rendered as plain <a> tags with no href, text content is the path
await expect(rhsPanel.getByText('/plug/com.mattermost.demo-plugin/roottest')).toBeVisible();
await expect(rhsPanel.getByText(/com\.mattermost\.demo-plugin\/teamtest/)).toBeVisible();
// Pop Out section
await expect(rhsPanel.getByText('Pop Out RHS Demo', {exact: true})).toBeVisible();
await expect(rhsPanel.getByRole('button', {name: 'Pop Out RHS'})).toBeVisible();
await expect(rhsPanel.getByRole('button', {name: 'Pop Out via useEffect'})).toBeVisible();
// 6. Verify pop-out buttons are present and enabled (but do NOT click — pop-out crashes in test env)
await expect(rhsPanel.getByRole('button', {name: 'Pop Out RHS'})).toBeEnabled();
await expect(rhsPanel.getByRole('button', {name: 'Pop Out via useEffect'})).toBeEnabled();
// 7. Navigate to the /roottest custom route link and verify the page content
await rhsPanel.getByText('/plug/com.mattermost.demo-plugin/roottest').click();
await expect(channelsPage.page).toHaveURL(/\/plug\/com\.mattermost\.demo-plugin\/roottest$/);
await expect(channelsPage.page.getByText('Demo plugin route.')).toBeVisible();
await channelsPage.page.goBack();
// 8. Close the RHS and verify it dismisses
const rhsPanelAfterNav = channelsPage.page.getByRole('region', {name: 'Demo Plugin'});
await rhsPanelAfterNav.getByRole('button', {name: 'Close'}).click();
await expect(rhsPanelAfterNav).not.toBeVisible();
});

View File

@@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, getFileFromAsset, test} from '@mattermost/playwright-lib';
import {assertRootModal, closeRootModal, setupDemoPlugin, uploadFileViaYourComputer} from '../helpers';
async function uploadDemoFile(client: Client4, channelId: string): Promise<void> {
const file = getFileFromAsset('sample-file.demo');
const formData = new FormData();
formData.set('files', file, 'sample-file.demo');
formData.set('channel_id', channelId);
const result = await client.uploadFile(formData);
await client.createPost({
channel_id: channelId,
message: '',
file_ids: [result.file_infos[0].id],
});
}
test('should show "Upload using Demo Plugin" entry in attachment menu and open Root Modal', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Click the attachment button to open the upload type menu
await channelsPage.centerView.postCreate.attachmentButton.click();
// 4. Verify both upload entries are visible
await expect(channelsPage.page.getByText('Your computer')).toBeVisible();
await expect(channelsPage.page.getByText('Upload using Demo Plugin')).toBeVisible();
// 5. Click "Upload using Demo Plugin" — opens the Root Modal
await channelsPage.page.getByText('Upload using Demo Plugin').click();
// 6. Assert Root Modal (no element context for this entry point)
await assertRootModal(channelsPage.page);
await closeRootModal(channelsPage.page);
});
test('should show Demo Plugin entry in file attachment dropdown for .demo files and open Root Modal', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Create a channel, add user, upload a .demo file via API
// (API upload avoids the demo plugin's attachment submenu interception)
const channel = pw.random.channel({
teamId: team.id,
name: 'demo-file-test',
displayName: 'Demo File Test',
});
const createdChannel = await adminClient.createChannel(channel);
await adminClient.addToChannel(user.id, createdChannel.id);
await uploadDemoFile(adminClient, createdChannel.id);
// 3. Login and navigate to the channel
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'demo-file-test');
await channelsPage.toBeVisible();
// 4. Hover the file attachment to reveal the kebab menu, scoped to the post
const post = channelsPage.centerView.container.getByRole('listitem').filter({hasText: 'sample-file.demo'}).last();
await post.getByText('sample-file.demo').hover();
await post.getByRole('button', {name: 'more actions'}).click();
// 5. Verify the file attachment dropdown contains the Demo Plugin entry
await expect(channelsPage.page.getByRole('menuitem', {name: 'Get a public link'})).toBeVisible();
await expect(channelsPage.page.getByRole('menuitem', {name: 'Demo Plugin'})).toBeVisible();
// 6. Click "Demo Plugin" — opens Root Modal
await channelsPage.page.getByRole('menuitem', {name: 'Demo Plugin'}).click();
// 7. Assert Root Modal
await assertRootModal(channelsPage.page);
await closeRootModal(channelsPage.page);
});
test('should render custom preview for .demo files', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Upload a .demo file via API to a dedicated channel
const channel = pw.random.channel({
teamId: team.id,
name: 'demo-preview-test',
displayName: 'Demo Preview Test',
});
const createdChannel = await adminClient.createChannel(channel);
await adminClient.addToChannel(user.id, createdChannel.id);
await uploadDemoFile(adminClient, createdChannel.id);
// 3. Login and navigate to the channel
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'demo-preview-test');
await channelsPage.toBeVisible();
// 4. Click the file thumbnail to open the preview
await channelsPage.page.getByRole('link', {name: /file thumbnail sample-file\.demo/}).click();
// 5. Verify the preview dialog opens with the custom demo plugin content
const previewDialog = channelsPage.page.getByRole('dialog');
await expect(previewDialog).toBeVisible();
// The custom demo plugin preview renders the filename as an h3 heading
await expect(previewDialog.getByRole('heading', {name: 'sample-file.demo', level: 3})).toBeVisible();
// The plugin also renders a Close button inside the preview content area
// (distinct from the standard modal Close/X in the header)
const pluginCloseButton = previewDialog.getByRole('button', {name: 'Close'}).last();
await expect(pluginCloseButton).toBeVisible();
// Standard file preview chrome is also present
await expect(previewDialog.getByText(/Shared in ~/)).toBeVisible();
await expect(previewDialog.getByRole('link', {name: 'Download'})).toBeVisible();
// 6. Close via the plugin's Close button
await pluginCloseButton.click();
await expect(previewDialog).not.toBeVisible();
});
test('should upload a file via "Your computer" from the demo plugin attachment submenu', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Upload a file via the UI — the demo plugin intercepts the attachment button
// and shows a submenu. uploadFileViaYourComputer clicks "Your computer" to reach
// the native file chooser.
await uploadFileViaYourComputer(
channelsPage.page,
channelsPage.centerView.postCreate.attachmentButton,
'sample_text_file.txt',
);
// 4. Verify the file preview appears in the compose area before sending
await channelsPage.centerView.postCreate.waitUntilFilePreviewContains(['sample_text_file.txt']);
// 5. Send the message with file
await channelsPage.centerView.postCreate.postMessage('file upload test');
// 6. Verify the file attachment appears in the channel post
const lastPost = await channelsPage.centerView.getLastPost();
await expect(lastPost.container.getByText('sample_text_file.txt')).toBeVisible();
});

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {assertRootModal, closeRootModal, setupDemoPlugin} from '../helpers';
test('should open Root Modal from team dropdown main menu', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Open the team name dropdown in the left sidebar
await channelsPage.sidebarLeft.teamMenuButton.click();
// 4. Confirm Demo Plugin entries are visible and click "Demo Plugin"
await expect(channelsPage.page.getByRole('menuitem', {name: 'Demo Plugin'})).toBeVisible();
await channelsPage.page.getByRole('menuitem', {name: 'Demo Plugin'}).click();
// 5. Assert Root Modal (no "Element clicked" line for main menu)
await assertRootModal(channelsPage.page);
// 6. Close
await closeRootModal(channelsPage.page);
});
test('should open Root Modal from channel header dropdown More actions', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Open channel header dropdown
await channelsPage.centerView.header.openChannelMenu();
// 4. Hover "More actions" to reveal the submenu, then click "Demo Plugin"
const moreActionsItem = channelsPage.page.getByRole('menuitem', {name: 'More actions'});
await expect(moreActionsItem).toBeVisible();
await moreActionsItem.hover();
const submenu = channelsPage.page.getByRole('menu', {name: 'More actions'});
await expect(submenu).toBeVisible();
// Move mouse directly to the Demo Plugin item to avoid submenu collapsing
const demoPluginItem = submenu.getByRole('menuitem', {name: 'Demo Plugin'});
await demoPluginItem.hover();
await demoPluginItem.click();
// 5. Assert Root Modal base text
await assertRootModal(channelsPage.page);
// Channel header entry also shows "Element clicked in the menu: <channel_id>" (dynamic)
await expect(channelsPage.page.getByText(/Element clicked in the menu:/)).toBeVisible();
// 6. Close
await closeRootModal(channelsPage.page);
});
// Skipped: demo plugin v0.10.3 does not set a URL on the openInteractiveDialog call.
// The webapp logs "Interactive dialog missing URL - this is a configuration error" and no dialog renders.
// Re-enable once the demo plugin is fixed and the build URL in helpers.ts is updated.
test.skip('should open Sample Confirmation Dialog from team dropdown and respond to Confirm and Cancel', async ({
pw,
}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Open team dropdown and click "Sample Confirmation Dialog"
await channelsPage.sidebarLeft.teamMenuButton.click();
await expect(channelsPage.page.getByRole('menuitem', {name: 'Sample Confirmation Dialog'})).toBeVisible();
await channelsPage.page.getByRole('menuitem', {name: 'Sample Confirmation Dialog'}).click();
// 4. Confirm dialog opens with title and action buttons but no form fields
const dialog = channelsPage.page.getByRole('dialog', {name: 'Sample Confirmation Dialog'});
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading', {name: 'Sample Confirmation Dialog', level: 1})).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Cancel'})).toBeVisible();
await expect(dialog.getByRole('button', {name: 'Confirm'})).toBeVisible();
await expect(dialog.getByRole('textbox')).not.toBeVisible();
// 5. Click Confirm — dialog closes and a post appears
await dialog.getByRole('button', {name: 'Confirm'}).click();
await expect(dialog).not.toBeVisible();
await expect(
channelsPage.centerView.container.locator('p').filter({hasText: 'confirmed an Interactive Dialog'}),
).toBeVisible();
// Cancel test omitted due to unexpected behavior. Will re-add once issue is resolved.
});

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {assertRootModal, closeRootModal, setupDemoPlugin} from '../helpers';
test('should open Root Modal from post actions menu and all submenu items', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Post a message to use as the target post
await channelsPage.centerView.postCreate.input.fill('Test post for Root Modal validation');
await channelsPage.centerView.postCreate.sendMessage();
const post = await channelsPage.centerView.getLastPost();
// Local helper: hover the post and open the actions (bolt icon) menu via the library
// NOTE: Plugin actions are in the "actions" (⚡) button, NOT the "..." (more) button
async function openActionsMenu() {
await post.hover();
await post.postMenu.actionsButton.click();
}
// ── Top-level "Demo Plugin" action ──────────────────────────────────────
await openActionsMenu();
await channelsPage.page.getByRole('button', {name: 'Demo Plugin'}).click();
// Top-level action does NOT show "Element clicked in the menu"
await assertRootModal(channelsPage.page);
await expect(channelsPage.page.getByText(/Element clicked in the menu:/)).not.toBeVisible();
await closeRootModal(channelsPage.page);
// ── Submenu Example → First Item ────────────────────────────────────────
await openActionsMenu();
await channelsPage.page.getByRole('button', {name: /Submenu Example/}).hover();
// Submenu items have role="button" but a broken aria-label — filter by text content
await channelsPage.page.getByRole('button').filter({hasText: 'First Item'}).last().click();
await assertRootModal(channelsPage.page, 'First Item');
await closeRootModal(channelsPage.page);
// ── Submenu Example → Second Item ───────────────────────────────────────
await openActionsMenu();
await channelsPage.page.getByRole('button', {name: /Submenu Example/}).hover();
await channelsPage.page.getByRole('button').filter({hasText: 'Second Item'}).last().click();
await assertRootModal(channelsPage.page, 'Second Item');
await closeRootModal(channelsPage.page);
// ── Submenu Example → Third Item ────────────────────────────────────────
await openActionsMenu();
await channelsPage.page.getByRole('button', {name: /Submenu Example/}).hover();
await channelsPage.page.getByRole('button').filter({hasText: 'Third Item'}).last().click();
await assertRootModal(channelsPage.page, 'Third Item');
await closeRootModal(channelsPage.page);
});

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {assertRootModal, closeRootModal, setupDemoPlugin} from '../helpers';
test('should show Demo Plugin User Attributes link in profile popover and close popover on click', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Post a message so we have a post with the user's avatar to click
await channelsPage.centerView.postCreate.input.fill('Test post for user attributes');
await channelsPage.centerView.postCreate.sendMessage();
// 4. Click the user's avatar to open the profile popover
const post = await channelsPage.centerView.getLastPost();
const profileImage = await post.getProfileImage(user.username);
await profileImage.click();
const popover = channelsPage.page.getByRole('dialog', {
name: `${user.username}'s profile popover`,
});
await expect(popover).toBeVisible();
// 5. Confirm "Demo Plugin: User Attributes" link is present
await expect(popover.getByText('Demo Plugin: User Attributes', {exact: true})).toBeVisible();
// 6. Click the link — it should close the popover
await popover.getByText('Demo Plugin: User Attributes', {exact: true}).click();
await expect(popover).not.toBeVisible();
});
test('should open Root Modal from user profile popover Action button', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Post a message so we have a post with the user's avatar to click
await channelsPage.centerView.postCreate.input.fill('Test post for profile popover');
await channelsPage.centerView.postCreate.sendMessage();
// 4. Click the user's avatar on the post to open the profile popover
const post = await channelsPage.centerView.getLastPost();
const profileImage = await post.getProfileImage(user.username);
await profileImage.click();
// 5. Confirm profile popover is visible with Demo Plugin Action button
const popover = channelsPage.page.getByRole('dialog', {
name: `${user.username}'s profile popover`,
});
await expect(popover).toBeVisible();
await expect(popover.getByText('Demo Plugin: User Attributes')).toBeVisible();
await expect(popover.getByRole('button', {name: 'Action'})).toBeVisible();
// 6. Click Action button → Root Modal should appear
await popover.getByRole('button', {name: 'Action'}).click();
await expect(popover).not.toBeVisible();
// 7. Assert Root Modal
await assertRootModal(channelsPage.page);
// 8. Close modal by clicking its text
await closeRootModal(channelsPage.page);
});

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../helpers';
test('should show Demo Plugin enabled/disabled status in left sidebar header', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square — slash commands work from any channel
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// The sidebar indicator: a span containing "Demo Plugin:" with a sibling span for the status
const hookStatus = channelsPage.page
.locator('span')
.filter({hasText: 'Demo Plugin:'})
.locator('..')
.locator('span')
.last();
// 3. Verify initial state — hooks enabled by setupDemoPlugin
await expect(hookStatus).toHaveText('Enabled');
// 4. Disable hooks and verify indicator updates
await channelsPage.centerView.postCreate.input.fill('/demo_plugin false');
await channelsPage.centerView.postCreate.sendMessage();
await expect(hookStatus).toHaveText('Disabled');
// 5. Re-enable hooks and verify indicator restores
await channelsPage.centerView.postCreate.input.fill('/demo_plugin true');
await channelsPage.centerView.postCreate.sendMessage();
await expect(hookStatus).toHaveText('Enabled');
});
test('should show demo plugin plug icon at the bottom of the team sidebar', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Verify the plug icon is visible in the team sidebar
// The icon has no accessible name, role, or testid — it is a purely visual,
// non-interactive element rendered with the fa-plug CSS class.
// CSS selector is the only viable locator here.
await expect(channelsPage.page.locator('.fa.fa-plug').first()).toBeVisible();
});
test('should show Demo Plugin Item in Browse or create channels menu and trigger alert with team ID', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Click the "Browse or create channels" button in the channel sidebar header
await channelsPage.sidebarLeft.browseOrCreateChannelButton.click();
// 4. Verify the menu is open and contains the Demo Plugin Item entry
const menu = channelsPage.page.getByRole('menu', {name: 'Browse or create channels'});
await expect(menu).toBeVisible();
await expect(menu.getByRole('menuitem', {name: 'Demo Plugin Item'})).toBeVisible();
// 5. Click "Demo Plugin Item" — triggers a browser alert with the team ID
const dialogPromise = channelsPage.page.waitForEvent('dialog');
await menu.getByRole('menuitem', {name: 'Demo Plugin Item'}).click();
const dialog = await dialogPromise;
// 6. Assert alert message contains expected text and dynamic team ID
expect(dialog.type()).toBe('alert');
expect(dialog.message()).toMatch(/^Demo Plugin: Browse menu item clicked! Team ID: [a-z0-9]{26}$/);
await dialog.accept();
});

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupDemoPlugin} from '../helpers';
test('should show demo plugin settings sections and save changes with alert confirmation', async ({pw}) => {
// 1. Setup
const {adminClient, user, team} = await pw.initSetup();
await setupDemoPlugin(adminClient, pw);
// 2. Login and navigate to Town Square
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// 3. Open Settings via the library method
const settingsModal = await channelsPage.openSettings();
// 4. Navigate to the Demo Plugin settings tab
await expect(settingsModal.container.getByText('PLUGIN PREFERENCES')).toBeVisible();
await settingsModal.container.getByRole('tab', {name: /Demo Plugin/i}).click();
// 5. Verify Demo Plugin Settings panel and all four section titles
await expect(settingsModal.container.getByRole('heading', {name: 'Demo Plugin Settings', level: 3})).toBeVisible();
await expect(settingsModal.container.getByRole('heading', {name: 'Example action', level: 4})).toBeVisible();
await expect(settingsModal.container.getByRole('heading', {name: 'Test section number 1', level: 4})).toBeVisible();
await expect(settingsModal.container.getByRole('heading', {name: 'Test section number 2', level: 4})).toBeVisible();
await expect(settingsModal.container.getByRole('heading', {name: 'Test section disabled', level: 4})).toBeVisible();
// 6. Verify Example action section has its button
await expect(settingsModal.container.getByRole('button', {name: 'Here is the button text'})).toBeVisible();
// 7. Verify Edit buttons visible for active sections (disabled section has none)
const editButtons = settingsModal.container.locator('.section-min__edit');
await expect(editButtons).toHaveCount(2);
// 8. Expand Section 1, select Option 2, save
// page.on captures the synchronous alert() that fires during Save click
const alerts: string[] = [];
const dialogHandler = async (dialog: {message: () => string; accept: () => Promise<void>}) => {
alerts.push(dialog.message());
await dialog.accept();
};
channelsPage.page.on('dialog', dialogHandler);
await editButtons.first().click();
await settingsModal.container.getByRole('radio', {name: 'Option 2'}).first().click();
await channelsPage.page.getByTestId('saveSetting').click();
expect(alerts[0]).toBe('saving {setting1}: 2');
// 9. Expand Section 2, select Option 1, save
await editButtons.nth(1).click();
await settingsModal.container.getByRole('radio', {name: 'Option 1'}).first().click();
await channelsPage.page.getByTestId('saveSetting').click();
expect(alerts[1]).toBe('saving {setting3}: 1');
channelsPage.page.off('dialog', dialogHandler);
});