"
+ ]
+}
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/MM-logo-horizontal.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/MM-logo-horizontal.png
new file mode 100644
index 00000000000..4c763d9d7c1
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/MM-logo-horizontal.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/animated-gif-image-file.gif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/animated-gif-image-file.gif
new file mode 100644
index 00000000000..ea782e0ef80
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/animated-gif-image-file.gif differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/bmp-image-file.bmp b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/bmp-image-file.bmp
new file mode 100644
index 00000000000..2ec9151a9dc
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/bmp-image-file.bmp differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/bot-default-avatar.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/bot-default-avatar.png
new file mode 100644
index 00000000000..5e59c7dabf5
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/bot-default-avatar.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/client_billing.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/client_billing.json
new file mode 100644
index 00000000000..a1146b3b315
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/client_billing.json
@@ -0,0 +1,22 @@
+{
+ "mastercard":{
+ "cardNumber":"5555555555554444",
+ "expDate":"4242",
+ "cvc":"412"
+ },
+ "visa":{
+ "cardNumber":"4242424242424242",
+ "expDate":"4242",
+ "cvc":"412"
+ },
+ "unionpay":{
+ "cardNumber":"6200000000000005",
+ "expDate":"1244",
+ "cvc":"123"
+ },
+ "invalidvisa":{
+ "cardNumber":"4242424242424141",
+ "expDate":"1212",
+ "cvc":"12"
+ }
+}
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/console-example-inputs.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/console-example-inputs.json
new file mode 100644
index 00000000000..70dfa7fd62f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/console-example-inputs.json
@@ -0,0 +1,425 @@
+[
+ {
+ "section": "about.license",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/about/license",
+ "selector": "remove-button"
+ }
+ ]
+ },
+ {
+ "section": "reporting.system_analytics",
+ "disabledInputs": []
+ },
+ {
+ "section": "reporting.team_statistics",
+ "disabledInputs": []
+ },
+ {
+ "section": "reporting.server_logs",
+ "disabledInputs": []
+ },
+ {
+ "section": "user_management.system_users",
+ "disabledInputs": []
+ },
+ {
+ "section": "user_management.groups",
+ "disabledInputs": []
+ },
+ {
+ "section": "user_management.teams",
+ "disabledInputs": []
+ },
+ {
+ "section": "user_management.channel",
+ "disabledInputs": []
+ },
+ {
+ "section": "user_management.permissions",
+ "disabledInputs": []
+ },
+ {
+ "section": "environment.web_server",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/web_server",
+ "selector": "ServiceSettings.ListenAddressinput"
+ }
+ ]
+ },
+ {
+ "section": "site.customization",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/customization",
+ "selector": "TeamSettings.SiteNameinput"
+ }
+ ]
+ },
+ {
+ "section": "site.localization",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/localization",
+ "selector": "LocalizationSettings.DefaultServerLocaledropdown"
+ }
+ ]
+ },
+ {
+ "section": "site.users_and_teams",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/users_and_teams",
+ "selector": "TeamSettings.MaxUsersPerTeamnumber"
+ }
+ ]
+ },
+ {
+ "section": "site.notifications",
+ "disabledInputs": [
+ {
+ "path": "admin_console/environment/notifications",
+ "selector": "TeamSettings.EnableConfirmNotificationsToChanneltrue"
+ }
+ ]
+ },
+ {
+ "section": "site.announcement_banner",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/announcement_banner",
+ "selector": "AnnouncementSettings.EnableBannertrue"
+ }
+ ]
+ },
+ {
+ "section": "site.emoji",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/emoji",
+ "selector": "ServiceSettings.EnableEmojiPickertrue"
+ }
+ ]
+ },
+ {
+ "section": "site.posts",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/posts",
+ "selector": "ServiceSettings.EnableLinkPreviewstrue"
+ }
+ ]
+ },
+ {
+ "section": "site.file_sharing_downloads",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/file_sharing_downloads",
+ "selector": "FileSettings.EnableFileAttachmentstrue"
+ }
+ ]
+ },
+ {
+ "section": "site.public_links",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/public_links",
+ "selector": "FileSettings.EnablePublicLinktrue"
+ }
+ ]
+ },
+ {
+ "section": "site.notices",
+ "disabledInputs": [
+ {
+ "path": "admin_console/site_config/notices",
+ "selector": "AnnouncementSettings.AdminNoticesEnabledtrue"
+ }
+ ]
+ },
+ {
+ "section": "environment.database",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/database",
+ "selector": "maxIdleConnsinput"
+ }
+ ]
+ },
+ {
+ "section": "environment.elasticsearch",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/elasticsearch",
+ "selector": "enableIndexingtrue"
+ }
+ ]
+ },
+ {
+ "section": "environment.storage",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/file_storage",
+ "selector": "FileSettings.DriverNamedropdown"
+ }
+ ]
+ },
+ {
+ "section": "environment.image_proxy",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/image_proxy",
+ "selector": "ImageProxySettings.Enabletrue"
+ }
+ ]
+ },
+ {
+ "section": "environment.smtp",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/smtp",
+ "selector": "EmailSettings.EnableSMTPAuthtrue"
+ }
+ ]
+ },
+ {
+ "section": "environment.push_notification_server",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/push_notification_server",
+ "selector": "pushNotificationServerTypedropdown"
+ }
+ ]
+ },
+ {
+ "section": "environment.high_availability",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/high_availability",
+ "selector": "Enabletrue"
+ }
+ ]
+ },
+ {
+ "section": "environment.rate_limiting",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/rate_limiting",
+ "selector": "RateLimitSettings.Enabletrue"
+ }
+ ]
+ },
+ {
+ "section": "environment.logging",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/logging",
+ "selector": "LogSettings.ConsoleLeveldropdown"
+ }
+ ]
+ },
+ {
+ "section": "environment.session_lengths",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/session_lengths",
+ "selector": "sessionLengthWebInDaysinput"
+ }
+ ]
+ },
+ {
+ "section": "environment.metrics",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/performance_monitoring",
+ "selector": "MetricsSettings.ListenAddressinput"
+ }
+ ]
+ },
+ {
+ "section": "environment.developer",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/environment/developer",
+ "selector": "ServiceSettings.EnableTestingtrue"
+ }
+ ]
+ },
+ {
+ "section": "authentication.signup",
+ "disabledInputs":[
+ {
+ "path": "/admin_console/authentication/signup",
+ "selector": "TeamSettings.EnableUserCreationfalse"
+ }
+ ]
+ },
+ {
+ "section": "authentication.email",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/authentication/email",
+ "selector": "EmailSettings.EnableSignUpWithEmailfalse"
+ }
+ ]
+ },
+ {
+ "section": "authentication.password",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/authentication/password",
+ "selector": "passwordMinimumLengthinput"
+ }
+ ]
+ },
+ {
+ "section": "authentication.mfa",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/authentication/mfa",
+ "selector": "ServiceSettings.EnableMultifactorAuthenticationfalse"
+ }
+ ]
+ },
+ {
+ "section": "authentication.ldap",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/authentication/ldap",
+ "selector": "LdapSettings.Enablefalse"
+ }
+ ]
+ },
+ {
+ "section": "authentication.saml",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/authentication/saml",
+ "selector": "SamlSettings.Enablefalse"
+ }
+ ]
+ },
+ {
+ "section": "authentication.openid",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/authentication/openid",
+ "selector": "openidTypedropdown"
+ }
+ ]
+ },
+ {
+ "section": "authentication.guest_access",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/authentication/guest_access",
+ "selector": "GuestAccountsSettings.Enablefalse"
+ }
+ ]
+ },
+ {
+ "section": "plugins",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/plugins/plugin_management",
+ "selector": "marketplaceUrlinput"
+ }
+ ]
+ },
+ {
+ "section": "integrations.integration_management",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/integrations/integration_management",
+ "selector": "ServiceSettings.EnableIncomingWebhookstrue"
+ }
+ ]
+ },
+ {
+ "section": "integrations.bot_accounts",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/integrations/bot_accounts",
+ "selector": "ServiceSettings.EnableBotAccountCreationtrue"
+ }
+ ]
+ },
+ {
+ "section": "integrations.gif",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/integrations/gif",
+ "selector": "ServiceSettings.EnableGifPickertrue"
+ }
+ ]
+ },
+ {
+ "section": "integrations.cors",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/integrations/cors",
+ "selector": "ServiceSettings.AllowCorsFrominput"
+ }
+ ]
+ },
+ {
+ "section": "compliance.data_retention",
+ "disabledInputs": []
+ },
+ {
+ "section": "compliance.message_export",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/compliance/export",
+ "selector": "enableComplianceExporttrue"
+ }
+ ]
+ },
+ {
+ "section": "compliance.audits",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/compliance/monitoring",
+ "selector": "ComplianceSettings.Enabletrue"
+ }
+ ]
+ },
+ {
+ "section": "compliance.custom_terms_of_service",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/compliance/custom_terms_of_service",
+ "selector": "SupportSettings.CustomTermsOfServiceEnabledtrue"
+ }
+ ]
+ },
+ {
+ "section": "experimental.experimental_features",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/experimental/features",
+ "selector": "ExperimentalSettings.LinkMetadataTimeoutMillisecondsnumber"
+ }
+ ]
+ },
+ {
+ "section": "experimental.feature_flags",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/experimental/feature_flags",
+ "selector": ""
+ }
+ ]
+ },
+ {
+ "section": "experimental.bleve",
+ "disabledInputs": [
+ {
+ "path": "/admin_console/experimental/blevesearch",
+ "selector": "indexDirinput"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/date_time_format.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/date_time_format.js
new file mode 100644
index 00000000000..c0bb8d06fc4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/date_time_format.js
@@ -0,0 +1,7 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+module.exports = {
+ TIME_12_HOUR: 'h:mm A', // no leading zeros
+ TIME_24_HOUR: 'HH:mm', // with leading zeros
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-16x16.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-16x16.png
new file mode 100644
index 00000000000..46cbcf4a0a7
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-16x16.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-default-16x16.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-default-16x16.png
new file mode 100644
index 00000000000..e8ad78ed734
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-default-16x16.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-mentions-16x16.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-mentions-16x16.png
new file mode 100644
index 00000000000..417b05d22b3
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-mentions-16x16.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-unread-16x16.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-unread-16x16.png
new file mode 100644
index 00000000000..2b22d8f3d4e
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/favicon-unread-16x16.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/gif-image-file-resized.gif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/gif-image-file-resized.gif
new file mode 100644
index 00000000000..4261546ecb6
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/gif-image-file-resized.gif differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/gif-image-file.gif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/gif-image-file.gif
new file mode 100644
index 00000000000..133cac55643
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/gif-image-file.gif differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/hooks/message_menus.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/hooks/message_menus.json
new file mode 100644
index 00000000000..dfc5958c82a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/hooks/message_menus.json
@@ -0,0 +1,26 @@
+{
+ "attachments": [{
+ "pretext": "This is the attachment pretext.",
+ "text": "This is the attachment text.",
+ "actions": [{
+ "name": "Select an option...",
+ "integration": {
+ "url": "http://localhost:3000/message_menus",
+ "context": {
+ "action": "do_something"
+ }
+ },
+ "type": "select",
+ "options": [{
+ "text": "Option 1",
+ "value": "option1"
+ }, {
+ "text": "Option 2",
+ "value": "option2"
+ }, {
+ "text": "Option 3",
+ "value": "option3"
+ }]
+ }]
+ }]
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/hooks/message_menus_with_datasource.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/hooks/message_menus_with_datasource.json
new file mode 100644
index 00000000000..ef97d1f5a89
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/hooks/message_menus_with_datasource.json
@@ -0,0 +1,17 @@
+{
+ "attachments": [{
+ "pretext": "This is the attachment pretext.",
+ "text": "This is the attachment text.",
+ "actions": [{
+ "name": "Select an option...",
+ "integration": {
+ "url": "http://localhost:3000/message_menus_datasource",
+ "context": {
+ "action": "do_something"
+ }
+ },
+ "type": "select",
+ "data_source": "channels"
+ }]
+ }]
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/huge-image.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/huge-image.jpg
new file mode 100644
index 00000000000..5f394c051b2
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/huge-image.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-1000x40.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-1000x40.jpg
new file mode 100644
index 00000000000..900c038e81c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-1000x40.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-1600x40.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-1600x40.jpg
new file mode 100644
index 00000000000..07c1bba5987
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-1600x40.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-20x20.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-20x20.jpg
new file mode 100644
index 00000000000..ffc247e1065
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-20x20.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-400x40.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-400x40.jpg
new file mode 100644
index 00000000000..56caac2619b
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-400x40.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-400x400.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-400x400.jpg
new file mode 100644
index 00000000000..6bda57dd4e5
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-400x400.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-40x400.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-40x400.jpg
new file mode 100644
index 00000000000..fbc9fea6509
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-40x400.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-50x50.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-50x50.jpg
new file mode 100644
index 00000000000..f3d567cda86
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-50x50.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-60x60.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-60x60.jpg
new file mode 100644
index 00000000000..3c88f125ef0
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-60x60.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-small-height.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-small-height.png
new file mode 100644
index 00000000000..3d9b2e7cdb0
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-small-height.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-small-width.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-small-width.png
new file mode 100644
index 00000000000..04acb1e4e7a
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/image-small-width.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/interactive_message_menus_options.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/interactive_message_menus_options.json
new file mode 100644
index 00000000000..183c64e53e0
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/interactive_message_menus_options.json
@@ -0,0 +1,72 @@
+{
+ "many-options": [
+ {"text": "Afghanistan", "value": "AF"},
+ {"text": "Åland Islands", "value": "AX"},
+ {"text": "Albania", "value": "AL"},
+ {"text": "Algeria", "value": "DZ"},
+ {"text": "American Samoa", "value": "AS"},
+ {"text": "AndorrA", "value": "AD"},
+ {"text": "Angola", "value": "AO"},
+ {"text": "Anguilla", "value": "AI"},
+ {"text": "Antarctica", "value": "AQ"},
+ {"text": "Antigua and Barbuda", "value": "AG"},
+ {"text": "Argentina", "value": "AR"},
+ {"text": "Armenia", "value": "AM"},
+ {"text": "Aruba", "value": "AW"},
+ {"text": "Australia", "value": "AU"},
+ {"text": "Austria", "value": "AT"},
+ {"text": "Azerbaijan", "value": "AZ"},
+ {"text": "Bahamas", "value": "BS"},
+ {"text": "Bahrain", "value": "BH"},
+ {"text": "Bangladesh", "value": "BD"},
+ {"text": "Barbados", "value": "BB"},
+ {"text": "Belarus", "value": "BY"},
+ {"text": "Belgium", "value": "BE"},
+ {"text": "Belize", "value": "BZ"},
+ {"text": "Benin", "value": "BJ"},
+ {"text": "Bermuda", "value": "BM"},
+ {"text": "Bhutan", "value": "BT"},
+ {"text": "Bolivia", "value": "BO"},
+ {"text": "Bosnia and Herzegovina", "value": "BA"},
+ {"text": "Botswana", "value": "BW"},
+ {"text": "Bouvet Island", "value": "BV"},
+ {"text": "Brazil", "value": "BR"},
+ {"text": "British Indian Ocean Territory", "value": "IO"},
+ {"text": "Brunei Darussalam", "value": "BN"},
+ {"text": "Bulgaria", "value": "BG"},
+ {"text": "Burkina Faso", "value": "BF"},
+ {"text": "Burundi", "value": "BI"},
+ {"text": "Cambodia", "value": "KH"},
+ {"text": "Cameroon", "value": "CM"},
+ {"text": "Canada", "value": "CA"},
+ {"text": "Cape Verde", "value": "CV"},
+ {"text": "Cayman Islands", "value": "KY"},
+ {"text": "Central African Republic", "value": "CF"},
+ {"text": "Chad", "value": "TD"},
+ {"text": "Chile", "value": "CL"},
+ {"text": "China", "value": "CN"},
+ {"text": "Christmas Island", "value": "CX"},
+ {"text": "Cocos (Keeling) Islands", "value": "CC"},
+ {"text": "Colombia", "value": "CO"},
+ {"text": "Comoros", "value": "KM"},
+ {"text": "Congo", "value": "CG"},
+ {"text": "Congo, The Democratic Republic of the", "value": "CD"},
+ {"text": "Cook Islands", "value": "CK"},
+ {"text": "Costa Rica", "value": "CR"},
+ {"text": "Cote D\"Ivoire", "value": "CI"},
+ {"text": "Croatia", "value": "HR"},
+ {"text": "Cuba", "value": "CU"},
+ {"text": "Cyprus", "value": "CY"},
+ {"text": "Czech Republic", "value": "CZ"}
+ ],
+ "distinct-options": [
+ {"text": "Apple", "value": "apple"},
+ {"text": "Orange", "value": "orange"},
+ {"text": "Banana", "value": "banana"},
+ {"text": "Grapes", "value": "grapes"},
+ {"text": "Melon", "value": "melon"},
+ {"text": "Mango", "value": "mango"},
+ {"text": "Mango Raw", "value": "mangoraw"},
+ {"text": "Avocado", "value": "avocado"}
+ ]
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/jpg-image-file.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/jpg-image-file.jpg
new file mode 100644
index 00000000000..95a5abb3b24
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/jpg-image-file.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap-add-user.ldif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap-add-user.ldif
new file mode 100644
index 00000000000..8e2fe75d25e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap-add-user.ldif
@@ -0,0 +1,8 @@
+dn: uid=e2etest.four,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: add
+objectclass: iNetOrgPerson
+sn: FourLDAP
+cn: TestLDAP
+uid: e2etest.four
+mail: e2etest.four@mmtest.com
+userPassword: Password1
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap-reset-data.ldif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap-reset-data.ldif
new file mode 100644
index 00000000000..998193537e4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap-reset-data.ldif
@@ -0,0 +1,43 @@
+dn: uid=e2etest.one,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: delete
+
+dn: uid=e2etest.two,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: delete
+
+dn: uid=e2etest.three,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: delete
+
+dn: uid=e2etest.four,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: delete
+
+dn: ou=e2etest,dc=mm,dc=test,dc=com
+changetype: add
+objectclass: organizationalunit
+
+# generic test users
+dn: uid=e2etest.one,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: add
+objectclass: iNetOrgPerson
+sn: OneLDAP
+cn: TestLDAP
+uid: e2etest.one
+mail: e2etest.one@mmtest.com
+userPassword: Password1
+
+dn: uid=e2etest.two,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: add
+objectclass: iNetOrgPerson
+sn: TwoLDAP
+cn: TestLDAP
+uid: e2etest.two
+mail: e2etest.two@mmtest.com
+userPassword: Password1
+
+dn: uid=e2etest.three,ou=e2etest,dc=mm,dc=test,dc=com
+changetype: add
+objectclass: iNetOrgPerson
+sn: ThreeLDAP
+cn: TestLDAP
+uid: e2etest.three.ldap
+mail: e2etest.three@mmtest.com
+userPassword: Password1
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap_users.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap_users.json
new file mode 100644
index 00000000000..fd5f3557a89
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/ldap_users.json
@@ -0,0 +1,44 @@
+{
+ "dev-1": {
+ "username": "dev.one",
+ "password": "Password1",
+ "email": "success+devone@simulator.amazonses.com",
+ "userType": "Admin"
+ },
+ "dev-2": {
+ "username": "dev.two",
+ "password": "Password1",
+ "email": "success+devtwo@simulator.amazonses.com",
+ "userType": "Admin"
+ },
+ "test-1": {
+ "username": "test.one",
+ "password": "Password1",
+ "email": "success+testone@simulator.amazonses.com",
+ "userType": ""
+ },
+ "test-2": {
+ "username": "test.two",
+ "password": "Password1",
+ "email": "success+testtwo@simulator.amazonses.com",
+ "userType": ""
+ },
+ "test-3": {
+ "username": "test.three",
+ "password": "Password1",
+ "email": "success+testthree@simulator.amazonses.com",
+ "userType": ""
+ },
+ "board-1": {
+ "username": "board.one",
+ "password": "Password1",
+ "email": "success+boardone@simulator.amazonses.com",
+ "userType": ""
+ },
+ "board-2": {
+ "username": "board.two",
+ "password": "Password1",
+ "email": "success+boardtwo@simulator.amazonses.com",
+ "userType": ""
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/long_text_post.txt b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/long_text_post.txt
new file mode 100644
index 00000000000..4f4a1009d7d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/long_text_post.txt
@@ -0,0 +1,2 @@
+The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Bright vixens jump; dozy fowl quack. Quick wafting zephyrs vex bold Jim. Quick zephyrs blow, vexing daft Jim. Sex-charged fop blew my junk TV quiz. How quickly daft jumping zebras vex. Two driven jocks help fax my big quiz. Quick, Baz, get my woven flax jodhpurs! "Now fax quiz Jack!" my brave ghost pled. Five quacking zephyrs jolt my wax bed. Flummoxed by job, kvetching W. zaps Iraq. Cozy sphinx waves quart jug of bad milk. A very bad quack might jinx zippy fowls. Few quips galvanized the mock jury box. Quick brown dogs jump over the lazy fox. The jay, pig, fox, zebra, and my wolves quack! Blowzy red vixens fight for a quick jump. Joaquin Phoenix was gazed by MTV for luck. A wizard’s job is to vex chumps quickly in fog. Watch "Jeopardy!", Alex Trebek's fun TV quiz game. Woven silk pyjamas exchanged for blue quartz. Brawny gods just flocked up to quiz and vex him. Adjusting quiver and bow, Zompyc[1] killed the fox. My faxed joke won a pager in the cable TV quiz show. Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid jackets before she quit. Six big devils from Japan quickly forgot how to waltz. Big July earthquakes confound zany experimental vow. Foxy parsons quiz and cajole the lovably dim wiki-girl. Have a pick: twenty six letters - no forcing a jumbled quiz! Crazy Fredericka bought many very exquisite opal jewels. Sixty zippers were quickly picked from the woven jute bag. A quick movement of the enemy will jeopardize six gunboats. All questions asked by five watch experts amazed the judge. Jack quietly moved up front and seized the big ball of wax. The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Bright vixens jump; dozy fowl quack. Quick wafting zephyrs vex bold Jim. Quick zephyrs blow, vexing daft Jim. Sex-charged fop blew my junk TV quiz. How quickly daft jumping zebras vex. Two driven jocks help fax my big quiz. Quick, Baz, get my woven flax jodhpurs! "Now fax quiz Jack!" my brave ghost pled. Five quacking zephyrs jolt my wax bed. Flummoxed by job, kvetching W. zaps Iraq. Cozy sphinx waves quart jug of bad milk. A very bad quack might jinx zippy fowls. Few quips galvanized the mock jury box. Quick brown dogs jump over the lazy fox. The jay, pig, fox, zebra, and my wolves quack! Blowzy red vixens fight for a quick jump. Joaquin Phoenix was gazed by MTV for luck. A wizard’s job is to vex chumps quickly in fog. Watch "Jeopardy!", Alex Trebek's fun TV quiz game. Woven silk pyjamas exchanged for blue quartz. Brawny gods just flocked up to quiz and vex him. Adjusting quiver and bow, Zompyc[1] killed the fox. My faxed joke won a pager in the cable TV quiz show. Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid jackets before she quit. Six big devils from Japan quickly forgot how to waltz. Big July earthquakes confound zany experimental vow. Foxy parsons quiz and cajole the lovably dim wiki-girl. Have a pick: twenty six letters - no forcing a jumbled quiz! Crazy Fredericka bought many very exquisite opal jewels. Sixty zippers were quickly picked from the woven jute bag. A quick movement of the enemy will jeopardize six gunboats. All questions asked by five watch experts amazed the judge. Jack quietly moved up front and seized the big ball of wax. The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Hello this is a long post, with more than 4000 characters, plus multiple attachments.
+The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Bright vixens jump; dozy fowl quack. Quick wafting zephyrs vex bold Jim. Quick zephyrs blow, vexing daft Jim. Sex-charged fop blew my junk TV quiz. How quickly daft jumping zebras vex. Two driven jocks help fax my big quiz. Quick, Baz, get my woven flax jodhpurs! "Now fax quiz Jack!" my brave ghost pled. Five quacking zephyrs jolt my wax bed. Flummoxed by job, kvetching W. zaps Iraq. Cozy sphinx waves quart jug of bad milk. A very bad quack might jinx zippy fowls. Few quips galvanized the mock jury box. Quick brown dogs jump over the lazy fox. The jay, pig, fox, zebra, and my wolves quack! Blowzy red vixens fight for a quick jump. Joaquin Phoenix was gazed by MTV for luck. A wizard’s job is to vex chumps quickly in fog. Watch "Jeopardy!", Alex Trebek's fun TV quiz game. Woven silk pyjamas exchanged for blue quartz. Brawny gods just flocked up to quiz and vex him. Adjusting quiver and bow, Zompyc[1] killed the fox. My faxed joke won a pager in the cable TV quiz show. Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid jackets before she quit. Six big devils from Japan quickly forgot how to waltz. Big July earthquakes confound zany experimental vow. Foxy parsons quiz and cajole the lovably dim wiki-girl. Have a pick: twenty six letters - no forcing a jumbled quiz! Crazy Fredericka bought many very exquisite opal jewels. Sixty zippers were quickly picked from the woven jute bag. A quick movement of the enemy will jeopardize six gunboats. All questions asked by five watch experts amazed the judge. Jack quietly moved up front and seized the big ball of wax. The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Bright vixens jump; dozy fowl quack. Quick wafting zephyrs vex bold Jim. Quick zephyrs blow, vexing daft Jim. Sex-charged fop blew my junk TV quiz. How quickly daft jumping zebras vex. Two driven jocks help fax my big quiz. Quick, Baz, get my woven flax jodhpurs! "Now fax quiz Jack!" my brave ghost pled. Five quacking zephyrs jolt my wax bed. Flummoxed by job, kvetching W. zaps Iraq. Cozy sphinx waves quart jug of bad milk. A very bad quack might jinx zippy fowls. Few quips galvanized the mock jury box. Quick brown dogs jump over the lazy fox. The jay, pig, fox, zebra, and my wolves quack! Blowzy red vixens fight for a quick jump. Joaquin Phoenix was gazed by MTV for luck. A wizard’s job is to vex chumps quickly in fog. Watch "Jeopardy!", Alex Trebek's fun TV quiz game. Woven silk pyjamas exchanged for blue quartz. Brawny gods just flocked up to quiz and vex him. Adjusting quiver and bow, Zompyc[1] killed the fox. My faxed joke won a pager in the cable TV quiz show. Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid jackets before she quit. Six big devils from Japan quickly forgot how to waltz. Big July earthquakes confound zany experimental vow. Foxy parsons quiz and cajole the lovably dim wiki-girl. Have a pick: twenty six letters - no forcing a jumbled quiz! Crazy Fredericka bought many very exquisite opal jewels. Sixty zippers were quickly picked from the woven jute bag. A quick movement of the enemy will jeopardize six gunboats. All questions asked by five watch experts amazed the judge. Jack quietly moved up front and seized the big ball of wax. The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Hello this is a long post, with more than 4000 characters, plus multiple attachments.
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/m4a-audio-file.m4a b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/m4a-audio-file.m4a
new file mode 100644
index 00000000000..889c15dc23c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/m4a-audio-file.m4a differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_basic.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_basic.html
new file mode 100644
index 00000000000..d0747f6e32c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_basic.html
@@ -0,0 +1 @@
+Basic Markdown Testing Tests for text style, code blocks, in-line code and images, lines, block quotes, and headings.
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_basic.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_basic.md
new file mode 100644
index 00000000000..b0f3690e086
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_basic.md
@@ -0,0 +1,2 @@
+# Basic Markdown Testing
+Tests for text style, code blocks, in-line code and images, lines, block quotes, and headings.
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_1.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_1.html
new file mode 100644
index 00000000000..aa2081fd29a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_1.html
@@ -0,0 +1,11 @@
+Block Quotes
+This text should render in a block quote.
+
+The following text should render in two block quotes separated by one line of text:
+
+Block quote 1
+
+Text between block quotes
+
+Block quote 2
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_1.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_1.md
new file mode 100644
index 00000000000..1c2ad83201b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_1.md
@@ -0,0 +1,10 @@
+### Block Quotes
+
+>This text should render in a block quote.
+
+**The following text should render in two block quotes separated by one line of text:**
+> Block quote 1
+
+Text between block quotes
+
+> Block quote 2
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_2.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_2.md
new file mode 100644
index 00000000000..05ad6df8d7d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_block_quotes_2.md
@@ -0,0 +1,6 @@
+### Block Quotes
+
+**The following markdown should render within the block quote:**
+> #### Heading 4
+> _Italics_, *Italics*, **Bold**, ***Bold-italics***, **_Bold-italics_**, ~~Strikethrough~~
+> :) :-) ;) :-O :bamboo: :gift_heart: :dolls:
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return.html
new file mode 100644
index 00000000000..86b98414d0d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return.html
@@ -0,0 +1,4 @@
+Carriage Return Line #1 followed by one blank line
+Line #2 followed by one blank line
+Line #3 followed by Line #4
+Line #4
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return.md
new file mode 100644
index 00000000000..4fb7a36990d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return.md
@@ -0,0 +1,8 @@
+### Carriage Return
+
+Line #1 followed by one blank line
+
+Line #2 followed by one blank line
+
+Line #3 followed by Line #4
+Line #4
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return_two_lines.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return_two_lines.html
new file mode 100644
index 00000000000..903c488de2f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return_two_lines.html
@@ -0,0 +1,4 @@
+The following should appear as a carriage return separating two lines of text:
+
Line #1 followed by a blank line
+
+Line #2 following a blank line
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return_two_lines.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return_two_lines.md
new file mode 100644
index 00000000000..e7f4c9af749
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_carriage_return_two_lines.md
@@ -0,0 +1,6 @@
+**The following should appear as a carriage return separating two lines of text:**
+```
+Line #1 followed by a blank line
+
+Line #2 following a blank line
+```
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_block.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_block.html
new file mode 100644
index 00000000000..26c6644678b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_block.html
@@ -0,0 +1 @@
+Code Blocks
This text should render in a code block
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_block.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_block.md
new file mode 100644
index 00000000000..9f342ae3b58
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_block.md
@@ -0,0 +1,5 @@
+### Code Blocks
+
+```
+This text should render in a code block
+```
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_syntax.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_syntax.html
new file mode 100644
index 00000000000..77d8040d8bc
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_code_syntax.html
@@ -0,0 +1,116 @@
+Code Syntax Highlighting Verify the following code blocks render as code blocks and highlight properly.
+Diff Diff 1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+
+
+
+! This is a line.
+
+! This is a replacement line.
+It is important to spell
+-removed line
++new line Makefile Makefile 1
+2
+3
+4
+5
CC=gcc
+CFLAGS=-I.
+
+hellomake: hellomake.o hellofunc.o
+ $(CC) -o hellomake hellomake.o hellofunc.o -I.JSON JSON 1
+2
+3
{"employees" :[
+ {"firstName" :"John" , "lastName" :"Doe" },
+]}Markdown Markdown 1
+2
+3
**bold**
+*italics*
+[link ](www.example.com )JavaScript JavaScript 1
document .write('Hello, world!' );CSS CSS 1
+2
+3
body {
+ background-color : red;
+}Objective C Objective C 1
+2
+3
+4
+5
+6
#import <stdio.h>
+
+int main (void )
+{
+ printf ("Hello world!\n" );
+}Python XML HTML, XML 1
+2
+3
+4
+5
<employees >
+ <employee >
+ <firstName > John</firstName > <lastName > Doe</lastName >
+ </employee >
+</employees > Perl Perl 1
print "Hello, World!\n" ;Bash PHP PHP 1
<?php echo '<p>Hello World</p>' ; ?> CoffeeScript CoffeeScript 1
console .log(“Hello world!”);C C# 1
+2
+3
+4
+5
+6
+7
+8
using System;
+class Program
+{
+ public static void Main (string [] args )
+ {
+ Console.WriteLine("Hello, world!" );
+ }
+}C++ C/C++ 1
+2
+3
+4
+5
+6
+7
#include <iostream.h>
+
+main()
+{
+ cout << "Hello World!" ;
+ return 0 ;
+}SQL SQL 1
+2
SELECT column_name,column_name
+FROM table_name;Go Go 1
+2
+3
+4
+5
package main
+import "fmt"
+func main () {
+ fmt.Println("Hello, 世界" )
+}Ruby Java Java 1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
import javax.swing.JFrame;
+import javax.swing.JLabel;
+public class HelloWorld {
+ public static void main (String[] args) {
+ JFrame frame = new JFrame();
+ frame.setTitle("Hi!" );
+ frame.add(new JLabel("Hello, world!" ));
+ frame.pack();
+ frame.setLocationRelativeTo(null );
+ frame.setVisible(true );
+ }
+}Latex Equation d d x ( ∫ 0 x f ( u ) d u ) = f ( x ) . \frac{d}{dx}\left( \int_{0}^{x} f(u)\,du\right)=f(x). d x d ( ∫ 0 x f ( u ) d u ) = f ( x ) .
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_escape_characters.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_escape_characters.html
new file mode 100644
index 00000000000..e5dc0d180de
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_escape_characters.html
@@ -0,0 +1,7 @@
+Escaped Characters The following text should render the same as the raw text:
+Raw: \\teamlinux\IT-Stuff\WorkingStuff
+Markdown: \\teamlinux\IT-Stuff\WorkingStuff
+The following text should escape out the first backslash so only one backslash appears:
+Raw: \\()#
+Markdown: \()#
+The end of this long post will be hidden until you choose to Show More .
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_escape_characters.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_escape_characters.md
new file mode 100644
index 00000000000..240b2b456de
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_escape_characters.md
@@ -0,0 +1,11 @@
+### Escaped Characters
+
+**The following text should render the same as the raw text:**
+Raw: `\\teamlinux\IT-Stuff\WorkingStuff`
+Markdown: \\teamlinux\IT-Stuff\WorkingStuff
+
+**The following text should escape out the first backslash so only one backslash appears:**
+Raw: `\\()#`
+Markdown: \\()#
+
+The end of this long post will be hidden until you choose to `Show More`.
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_headings.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_headings.html
new file mode 100644
index 00000000000..339554bdf61
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_headings.html
@@ -0,0 +1 @@
+Headings Heading 1 font size Heading 2 font size Heading 3 font size Heading 4 font size Heading 5 font size Heading 6 font size
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_headings.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_headings.md
new file mode 100644
index 00000000000..14ee6b0fd10
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_headings.md
@@ -0,0 +1,8 @@
+### Headings
+
+# Heading 1 font size
+## Heading 2 font size
+### Heading 3 font size
+#### Heading 4 font size
+##### Heading 5 font size
+###### Heading 6 font size
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_code.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_code.html
new file mode 100644
index 00000000000..8781588bd1f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_code.html
@@ -0,0 +1,6 @@
+In-line Code The word monospace should render as in-line code.
+The following markdown in-line code should not render:
+_Italics_ , *Italics* , **Bold** , ***Bold-italics*** , **Bold-italics_** , ~~Strikethrough~~ , :) , :-) , ;) , :-O , :bamboo: , :gift_heart: , :dolls: , # Heading 1 , ## Heading 2 , ### Heading 3 , #### Heading 4 , ##### Heading 5 , ###### Heading 6
+This GIF link should not preview: http://i.giphy.com/xNrM4cGJ8u3ao.gif
+This link should not auto-link: https://en.wikipedia.org/wiki/Dolphin
+This sentence with in-line code should appear on one line.
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_code.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_code.md
new file mode 100644
index 00000000000..a40b23d351b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_code.md
@@ -0,0 +1,13 @@
+### In-line Code
+
+The word `monospace` should render as in-line code.
+
+The following markdown in-line code should not render:
+`_Italics_`, `*Italics*`, `**Bold**`, `***Bold-italics***`, `**Bold-italics_**`, `~~Strikethrough~~`, `:)` , `:-)` , `;)` , `:-O` , `:bamboo:` , `:gift_heart:` , `:dolls:` , `# Heading 1`, `## Heading 2`, `### Heading 3`, `#### Heading 4`, `##### Heading 5`, `###### Heading 6`
+
+This GIF link should not preview: `http://i.giphy.com/xNrM4cGJ8u3ao.gif`
+This link should not auto-link: `https://en.wikipedia.org/wiki/Dolphin`
+
+This sentence with `
+in-line code
+` should appear on one line.
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_1.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_1.md
new file mode 100644
index 00000000000..4a6cf3dcf42
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_1.md
@@ -0,0 +1,3 @@
+### In-line Images
+
+Mattermost/platform build status: [](https://docs.mattermost.com/_images/icon-76x76.png)
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_2.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_2.md
new file mode 100644
index 00000000000..4de6fedbf59
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_2.md
@@ -0,0 +1,3 @@
+### In-line Images
+
+GitHub favicon: 
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_3.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_3.md
new file mode 100644
index 00000000000..e581d3cd0e2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_3.md
@@ -0,0 +1,4 @@
+### In-line Images
+
+GIF Image:
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_4.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_4.md
new file mode 100644
index 00000000000..9bf3bf50239
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_4.md
@@ -0,0 +1,4 @@
+### In-line Images
+
+4K Wallpaper Image (11Mb):
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_5.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_5.md
new file mode 100644
index 00000000000..ee9fab4ec44
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_5.md
@@ -0,0 +1,4 @@
+### In-line Images
+
+Panorama Image:
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_6.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_6.md
new file mode 100644
index 00000000000..a82e0b74d89
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_inline_images_6.md
@@ -0,0 +1,3 @@
+### In-line Images
+
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_latex.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_latex.html
new file mode 100644
index 00000000000..05c1451bec8
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_latex.html
@@ -0,0 +1,26 @@
+LaTeX 1
+2
+3
+4
+5
+6
+7
\documentclass {article}
+
+\begin {document}
+
+Hello World!
+
+\end {document}AND/OR
+LaTeX 1
+2
+3
+4
+5
+6
+7
\documentclass {article}
+
+\begin {document}
+
+Hello World!
+
+\end {document}
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_latex.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_latex.md
new file mode 100644
index 00000000000..263dad60a25
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_latex.md
@@ -0,0 +1,21 @@
+```texcode
+\documentclass{article}
+
+\begin{document}
+
+Hello World!
+
+\end{document}
+```
+
+AND/OR
+
+```latexcode
+\documentclass{article}
+
+\begin{document}
+
+Hello World!
+
+\end{document}
+```
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_lines.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_lines.html
new file mode 100644
index 00000000000..8352138b9e6
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_lines.html
@@ -0,0 +1,8 @@
+Lines Three lines should render with text between them:
+Text above line
+
+Text between lines
+
+Text between lines
+
+Text below line
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_lines.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_lines.md
new file mode 100644
index 00000000000..4a4c9ea59ee
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_lines.md
@@ -0,0 +1,16 @@
+### Lines
+
+Three lines should render with text between them:
+
+Text above line
+
+***
+
+Text between lines
+
+---
+
+Text between lines
+___
+
+Text below line
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_list.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_list.html
new file mode 100644
index 00000000000..31429fab85c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_list.html
@@ -0,0 +1,137 @@
+Markdown List Testing Verify that all list types render as expected.
+Single-Item Ordered List Expected:
+Actual:
+
+Single Item Multi-Item Ordered List Expected:
+Actual:
+
+One Two Three Nested Ordered List Expected:
+1. Alpha
+ 1. Bravo
+2. Charlie
+3. Delta
+ 1. Echo
+ 2. Foxtrot
Actual:
+
+Alpha
+Bravo Charlie Delta
+Echo Foxtrot Single-Item Unordered List Expected:
+Actual:
+Multi-Item Unordered List Expected:
+Actual:
+Multi-Item Unordered List with Line Break (Break should not render) Expected:
+• Item A
+• Item B
+• Item C
+• Item D
Actual:
+
+Item A Item B
+Item C
+Item D Nested Unordered List Expected:
+• Alpha
+ • Bravo
+• Charlie
+• Delta
+ • Echo
+ • Foxtrot
Actual:
+Mixed List Starting Ordered Expected:
+Actual:
+
+One Two Three Mixed List Starting Unordered Expected:
+• Monday
+• Tuesday
+• Wednesday
Actual:
+Nested Mixed List Expected:
+• Alpha
+ 1. Bravo
+ • Charlie
+ • Delta
+• Echo
+• Foxtrot
+ • Golf
+ 1. Hotel
+ • India
+ 1. Juliet
+ 2. Kilo
+ • Lima
+• Mike
+ 1. November
+ 4. Oscar
+ 5. Papa
Actual:
+
+Alpha
+Bravo Echo Foxtrot
+Golf
+Hotel India
+Juliet Kilo Lima Mike
+November
+Oscar
+Papa Ordered Lists Separated by Carriage Returns Expected:
+1. One
+ • Two
+2. Two
+3. Three
Actual:
+
+One
+Two
+Three New Line After a List Expected:
+1. One
+2. Two
+
+This text should be on a new line.
Actual:
+
+One Two This text should be on a new line.
+Task Lists Expected:
+[ ] One
+ [ ] Subpoint one
+ - Normal Bullet
+[ ] Two
+[x] Completed item
Actual:
+Numbered Task Lists Expected:
+1. [ ] One
+2. [ ] Two
+3. [x] Completed item
Actual:
+
+ One Two Completed item Multiple Lists Expected:
+List A:
+
+1. One
+
+List B:
+
+2. Two
List A:
+
+One List B:
+
+Two Lists with blank lines before and after Expected:
+Line with blank line after
+
+Line with blank line after and before
+
+1. Bullet
+2. Bullet
+3. Bullet
+
+Line with blank line after and before
+
+Line with blank line before
Line with blank line after
+Line with blank line after and before
+
+Bullet Bullet Bullet Line with blank line after and before
+Line with blank line before
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_autolink.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_autolink.html
new file mode 100644
index 00000000000..77de2f5a934
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_autolink.html
@@ -0,0 +1,3 @@
+The following links should not auto-link or generate previews:
+
GIF: http://i.giphy.com/xNrM4cGJ8u3ao.gif
+Website: https://en.wikipedia.org/wiki/Dolphin
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_autolink.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_autolink.md
new file mode 100644
index 00000000000..927d2ae3ca7
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_autolink.md
@@ -0,0 +1,5 @@
+**The following links should not auto-link or generate previews:**
+```
+GIF: http://i.giphy.com/xNrM4cGJ8u3ao.gif
+Website: https://en.wikipedia.org/wiki/Dolphin
+```
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_in_code_block.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_in_code_block.html
new file mode 100644
index 00000000000..726d57e7015
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_in_code_block.html
@@ -0,0 +1,23 @@
+The following markdown should not render:
+
_Italics_
+*Italics*
+**Bold**
+***Bold-italics***
+**Bold-italics_**
+~~Strikethrough~~
+:) :-) ;) ;-) :o :O :-o :-O
+:bamboo: :gift_heart: :dolls: :school_satchel: :mortar_board:
+# Heading 1
+## Heading 2
+### Heading 3
+#### Heading 4
+##### Heading 5
+###### Heading 6
+> Block Quote
+- List
+ - List Sub-item
+[Link](http://i.giphy.com/xNrM4cGJ8u3ao.gif)
+[](https://github.com/mattermost/platform)
+| Left-Aligned Text | Center Aligned Text | Right Aligned Text |
+| :------------ |:---------------:| -----:|
+| Left column 1 | this text | $100 |
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_in_code_block.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_in_code_block.md
new file mode 100644
index 00000000000..e1edcdfb82b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_not_in_code_block.md
@@ -0,0 +1,25 @@
+**The following markdown should not render:**
+```
+_Italics_
+*Italics*
+**Bold**
+***Bold-italics***
+**Bold-italics_**
+~~Strikethrough~~
+:) :-) ;) ;-) :o :O :-o :-O
+:bamboo: :gift_heart: :dolls: :school_satchel: :mortar_board:
+# Heading 1
+## Heading 2
+### Heading 3
+#### Heading 4
+##### Heading 5
+###### Heading 6
+> Block Quote
+- List
+ - List Sub-item
+[Link](http://i.giphy.com/xNrM4cGJ8u3ao.gif)
+[](https://github.com/mattermost/platform)
+| Left-Aligned Text | Center Aligned Text | Right Aligned Text |
+| :------------ |:---------------:| -----:|
+| Left column 1 | this text | $100 |
+```
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_postgres.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_postgres.html
new file mode 100644
index 00000000000..f12e2ff3e92
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_postgres.html
@@ -0,0 +1,50 @@
+PostgreSQL 1
+2
+3
+4
+5
+6
+7
CREATE OR REPLACE FUNCTION snitch() RETURNS event_trigger AS $$
+BEGIN
+RAISE NOTICE 'snitch: % %' , tg_event , tg_tag ;
+END ;
+$$ LANGUAGE plpgsql;
+
+CREATE EVENT TRIGGER snitch ON ddl_command_start EXECUTE PROCEDURE snitch();and
+PostgreSQL 1
+2
+3
+4
+5
+6
+7
CREATE OR REPLACE FUNCTION snitch() RETURNS event_trigger AS $$
+BEGIN
+RAISE NOTICE 'snitch: % %' , tg_event , tg_tag ;
+END ;
+$$ LANGUAGE plpgsql;
+
+CREATE EVENT TRIGGER snitch ON ddl_command_start EXECUTE PROCEDURE snitch();and
+PostgreSQL 1
+2
+3
+4
+5
+6
+7
CREATE OR REPLACE FUNCTION snitch() RETURNS event_trigger AS $$
+BEGIN
+RAISE NOTICE 'snitch: % %' , tg_event , tg_tag ;
+END ;
+$$ LANGUAGE plpgsql;
+
+CREATE EVENT TRIGGER snitch ON ddl_command_start EXECUTE PROCEDURE snitch();or
+PostgreSQL 1
+2
+3
+4
+5
+6
CREATE OR REPLACE FUNCTION add(x int, y int)
+RETURNS int
+LANGUAGE SQL
+AS $myfunc$
+SELECT x + y
+$myfunc$
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_postgres.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_postgres.md
new file mode 100644
index 00000000000..cb877797fc2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_postgres.md
@@ -0,0 +1,41 @@
+```postgres
+CREATE OR REPLACE FUNCTION snitch() RETURNS event_trigger AS $$
+BEGIN
+RAISE NOTICE 'snitch: % %', tg_event, tg_tag;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE EVENT TRIGGER snitch ON ddl_command_start EXECUTE PROCEDURE snitch();
+```
+and
+
+```pgsql
+CREATE OR REPLACE FUNCTION snitch() RETURNS event_trigger AS $$
+BEGIN
+RAISE NOTICE 'snitch: % %', tg_event, tg_tag;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE EVENT TRIGGER snitch ON ddl_command_start EXECUTE PROCEDURE snitch();
+```
+and
+
+```postgresql
+CREATE OR REPLACE FUNCTION snitch() RETURNS event_trigger AS $$
+BEGIN
+RAISE NOTICE 'snitch: % %', tg_event, tg_tag;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE EVENT TRIGGER snitch ON ddl_command_start EXECUTE PROCEDURE snitch();
+```
+or
+
+```pgsql
+CREATE OR REPLACE FUNCTION add(x int, y int)
+RETURNS int
+LANGUAGE SQL
+AS $myfunc$
+SELECT x + y
+$myfunc$
+```
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_python.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_python.html
new file mode 100644
index 00000000000..0705d22fe05
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_python.html
@@ -0,0 +1,7 @@
+Python 1
+2
+3
+4
op.execute("""
+UPDATE events.settings
+SET name = 'paper_review_conditions'
+WHERE module = 'editing' AND name = 'review_conditions' """ )
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_python.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_python.md
new file mode 100644
index 00000000000..f5510d887a5
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_python.md
@@ -0,0 +1,6 @@
+```python
+op.execute("""
+UPDATE events.settings
+SET name = 'paper_review_conditions'
+WHERE module = 'editing' AND name = 'review_conditions' """)
+ ```
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_shell.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_shell.html
new file mode 100644
index 00000000000..639245f9a74
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_shell.html
@@ -0,0 +1,7 @@
+Bash 1
+2
+3
+4
find /path/to/whatever -type f | sed "1,$MAX_FILES d' | while read fn; do
+echo " deleting $fn "
+rm -f $fn
+done
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_shell.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_shell.md
new file mode 100644
index 00000000000..e60ca457be7
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_shell.md
@@ -0,0 +1,6 @@
+```sh
+find /path/to/whatever -type f | sed "1,$MAX_FILES d' | while read fn; do
+echo "deleting $fn"
+rm -f $fn
+done
+```
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_tables.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_tables.html
new file mode 100644
index 00000000000..10724b76849
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_tables.html
@@ -0,0 +1,21 @@
+Markdown Tables Verify that all tables render as described. First row is boldface.
+Normal Tables These tables use different raw text as inputs, but all three should render as the same table.
+Table 1 Raw text:
+First Header | Second Header
+------------- | -------------
+Content Cell | Content Cell
+Content Cell | Content Cell
Renders as:
+First Header Second Header Content Cell Content Cell Content Cell Content Cell
Table 2 Raw Text:
+| First Header | Second Header |
+| ------------- | ------------- |
+| Content Cell | Content Cell |
+| Content Cell | Content Cell |
Renders as:
+First Header Second Header Content Cell Content Cell Content Cell Content Cell
Table 3 Raw Text:
+| First Header | Second Header |
+| ------------- | ----------- |
+| Content Cell | Content Cell|
+| Content Cell | Content Cell |
Renders as:
+First Header Second Header Content Cell Content Cell Content Cell Content Cell
Tables Containing Markdown This table should contain A1: Strikethrough, A2: Bold, B1: Italics, B2: Dolphin emoticon.
+Column\Row 1 2 A StrikethroughBold B italics :dolphin:
Table with Left, Center, and Right Aligned Columns The left column should be left aligned, the center column centered and the right column should be right aligned.
+Left-Aligned Center Aligned Right Aligned 1 this text $100 2 is $10 3 centered $1
Table with Escaped Pipes First row cells: single backslash, "asdf". Second row cells: "ab" , "a|d"
+
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_test_basic.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_test_basic.html
new file mode 100644
index 00000000000..78d984cb913
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_test_basic.html
@@ -0,0 +1,70 @@
+Basic Markdown Testing Tests for text style, code blocks, in-line code and images, lines, block quotes, and headings.
+Text Style The following text should render as: Italics
+Ita_lics
+Italics
+Bold
+Bold-italics
+Bold-italics
+Strikethrough
+This sentence contains bold , italic , bold-italic , and stikethrough text.
+The following should render as normal text: Normal Text_ _Normal Text _Normal Text*
+Carriage Return Line #1 followed by one blank line
+Line #2 followed by one blank line
+Line #3 followed by Line #4
+Line #4
+Code Blocks This text should render in a code block
The following markdown should not render:
+_Italics_
+*Italics*
+**Bold**
+***Bold-italics***
+**Bold-italics_**
+~~Strikethrough~~
+:) :-) ;) ;-) :o :O :-o :-O
+:bamboo: :gift_heart: :dolls: :school_satchel: :mortar_board:
+# Heading 1
+## Heading 2
+### Heading 3
+#### Heading 4
+##### Heading 5
+###### Heading 6
+> Block Quote
+- List
+ - List Sub-item
+[Link](http://i.giphy.com/xNrM4cGJ8u3ao.gif)
+[](https://github.com/mattermost/platform)
+| Left-Aligned Text | Center Aligned Text | Right Aligned Text |
+| :------------ |:---------------:| -----:|
+| Left column 1 | this text | $100 |
The following links should not auto-link or generate previews:
+GIF: http://i.giphy.com/xNrM4cGJ8u3ao.gif
+Website: https://en.wikipedia.org/wiki/Dolphin
The following should appear as a carriage return separating two lines of text:
+Line #1 followed by a blank line
+
+Line #2 following a blank line
In-line Code The word monospace should render as in-line code.
+The following markdown in-line code should not render:_Italics_ , *Italics* , **Bold** , ***Bold-italics*** , **Bold-italics_** , ~~Strikethrough~~ , :) , :-) , ;) , :-O , :bamboo: , :gift_heart: , :dolls: , # Heading 1 , ## Heading 2 , ### Heading 3 , #### Heading 4 , ##### Heading 5 , ###### Heading 6
+This GIF link should not preview: http://i.giphy.com/xNrM4cGJ8u3ao.gif This link should not auto-link: https://en.wikipedia.org/wiki/Dolphin
+This sentence with in-line code should appear on one line.
+In-line Images (These image tests were moved into Se: MessagingMan.html)
+Lines Three lines should render with text between them:
+Text above line
+
+Text between lines
+
+Text between lines
+
+Text below line
+Block Quotes
+This text should render in a block quote.
+
+The following markdown should render within the block quote:
+
+Heading 4 Italics , Italics , Bold , Bold-italics , Bold-italics , Strikethrough:slightly_smiling_face: :slightly_smiling_face: :wink: :scream: :bamboo: :gift_heart: :dolls:
+
+The following text should render in two block quotes separated by one line of text:
+
+Block quote 1
+
+Text between block quotes
+
+Block quote 2
+
+Headings Heading 1 font size Heading 2 font size Heading 3 font size Heading 4 font size Heading 5 font size Heading 6 font size
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_text_style.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_text_style.html
new file mode 100644
index 00000000000..fc52a80083b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_text_style.html
@@ -0,0 +1,13 @@
+Text Style The following text should render as:
+Italics
+Ita_lics
+Italics
+Bold
+Bold-italics
+Bold-italics
+Strikethrough
+This sentence contains bold , italic , bold-italic , and strikethrough text.
+The following should render as normal text:
+Normal Text_
+_Normal Text
+_Normal Text*
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_text_style.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_text_style.md
new file mode 100644
index 00000000000..bc5be1e44ef
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_text_style.md
@@ -0,0 +1,17 @@
+### Text Style
+
+**The following text should render as:**
+_Italics_
+_Ita_lics_
+*Italics*
+**Bold**
+***Bold-italics***
+**_Bold-italics_**
+~~Strikethrough~~
+
+This sentence contains **bold**, _italic_, ***bold-italic***, and ~~strikethrough~~ text.
+
+**The following should render as normal text:**
+Normal Text_
+_Normal Text
+_Normal Text*
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_typescript.html b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_typescript.html
new file mode 100644
index 00000000000..0144deaa279
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_typescript.html
@@ -0,0 +1,9 @@
+TypeScript 1
+2
const message: string = 'hello world' ;
+console .log(message);and
+TypeScript 1
+2
const message: string = 'hello world' ;
+console .log(message);and
+TypeScript 1
+2
const message: string = 'hello world' ;
+console .log(message);
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_typescript.md b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_typescript.md
new file mode 100644
index 00000000000..f6b2ba8859b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/markdown/markdown_typescript.md
@@ -0,0 +1,17 @@
+```ts
+const message: string = 'hello world';
+console.log(message);
+```
+and
+
+```tsx
+const message: string = 'hello world';
+console.log(message);
+```
+
+and
+
+```typescript
+const message: string = 'hello world';
+console.log(message);
+```
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mattermost-icon.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mattermost-icon.png
new file mode 100644
index 00000000000..9cb98117b59
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mattermost-icon.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mattermost-icon_128x128.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mattermost-icon_128x128.png
new file mode 100644
index 00000000000..8170d8c52bd
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mattermost-icon_128x128.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/messages.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/messages.js
new file mode 100644
index 00000000000..b817fd8f803
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/messages.js
@@ -0,0 +1,10 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+module.exports = {
+ TINY: `${Date.now()} : Hi`,
+ SMALL: `${Date.now()} : Hello world`,
+ MEDIUM: `${Date.now()} The quick brown fox jumps over the lazy dog`,
+ LARGE: `${Date.now()} This pangram contains four As, one B, two Cs, one D, thirty Es, six Fs, five Gs, seven Hs, eleven Is, one J, one K, two Ls, two Ms, eighteen Ns, fifteen Os, two Ps, one Q, five Rs, twenty-seven Ss, eighteen Ts, two Us, seven Vs, eight Ws, two Xs, three Ys, & one Z`,
+ HUGE: `${Date.now()} The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Bright vixens jump; dozy fowl quack. Quick wafting zephyrs vex bold Jim. Quick zephyrs blow, vexing daft Jim. Sex-charged fop blew my junk TV quiz. How quickly daft jumping zebras vex. Two driven jocks help fax my big quiz. Quick, Baz, get my woven flax jodhpurs! "Now fax quiz Jack!" my brave ghost pled. Five quacking zephyrs jolt my wax bed. Flummoxed by job, kvetching W. zaps Iraq. Cozy sphinx waves quart jug of bad milk. A very bad quack might jinx zippy fowls. Few quips galvanized the mock jury box. Quick brown dogs jump over the lazy fox. The jay, pig, fox, zebra, and my wolves quack! Blowzy red vixens fight for a quick jump. Joaquin Phoenix was gazed by MTV for luck. A wizard’s job is to vex chumps quickly in fog. Watch "Jeopardy!", Alex Trebek's fun TV quiz game. Woven silk pyjamas exchanged for blue quartz. Brawny gods just flocked up to quiz and vex him. Adjusting quiver and bow, Zompyc[1] killed the fox. My faxed joke won a pager in the cable TV quiz show. Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid jackets before she quit. Six big devils from Japan quickly forgot how to waltz. Big July earthquakes confound zany experimental vow. Foxy parsons quiz and cajole the lovably dim wiki-girl. Have a pick: twenty six letters - no forcing a jumbled quiz! Crazy Fredericka bought many very exquisite opal jewels. Sixty zippers were quickly picked from the woven jute bag. A quick movement of the enemy will jeopardize six gunboats. All questions asked by five watch experts amazed the judge. Jack quietly moved up front and seized the big ball of wax. The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Bright vixens jump; dozy fowl quack. Quick wafting zephyrs vex bold Jim. Quick zephyrs blow, vexing daft Jim. Sex-charged fop blew my junk TV quiz. How quickly daft jumping zebras vex. Two driven jocks help fax my big quiz. Quick, Baz, get my woven flax jodhpurs! "Now fax quiz Jack!" my brave ghost pled. Five quacking zephyrs jolt my wax bed. Flummoxed by job, kvetching W. zaps Iraq. Cozy sphinx waves quart jug of bad milk. A very bad quack might jinx zippy fowls. Few quips galvanized the mock jury box. Quick brown dogs jump over the lazy fox. The jay, pig, fox, zebra, and my wolves quack! Blowzy red vixens fight for a quick jump. Joaquin Phoenix was gazed by MTV for luck. A wizard’s job is to vex chumps quickly in fog. Watch "Jeopardy!", Alex Trebek's fun TV quiz game. Woven silk pyjamas exchanged for blue quartz. Brawny gods just flocked up to quiz and vex him. Adjusting quiver and bow, Zompyc[1] killed the fox. My faxed joke won a pager in the cable TV quiz show. Amazingly few discotheques provide jukeboxes. My girl wove six dozen plaid jackets before she quit. Six big devils from Japan quickly forgot how to waltz. Big July earthquakes confound zany experimental vow. Foxy parsons quiz and cajole the lovably dim wiki-girl. Have a pick: twenty six letters - no forcing a jumbled quiz! Crazy Fredericka bought many very exquisite opal jewels. Sixty zippers were quickly picked from the woven jute bag. A quick movement of the enemy will jeopardize six gunboats. All questions asked by five watch experts amazed the judge. Jack quietly moved up front and seized the big ball of wax. The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Hello this is a long post, with more than 4000 characters, plus multiple attachments.`,
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/AAC.aac b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/AAC.aac
new file mode 100644
index 00000000000..b59c300bb80
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/AAC.aac differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/FLAC.flac b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/FLAC.flac
new file mode 100644
index 00000000000..203ba244ed3
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/FLAC.flac differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/M4A.m4a b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/M4A.m4a
new file mode 100644
index 00000000000..889c15dc23c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/M4A.m4a differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/M4R.m4r b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/M4R.m4r
new file mode 100644
index 00000000000..889c15dc23c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/M4R.m4r differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/MP3.mp3 b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/MP3.mp3
new file mode 100644
index 00000000000..e14c239f042
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/MP3.mp3 differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/OGG.ogg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/OGG.ogg
new file mode 100644
index 00000000000..adb5f14f63f
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/OGG.ogg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/WAV.wav b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/WAV.wav
new file mode 100644
index 00000000000..4b2b6ccbe48
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/WAV.wav differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/WMA.wma b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/WMA.wma
new file mode 100644
index 00000000000..3d3e4b89b7f
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Audio/WMA.wma differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/JSON b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/JSON
new file mode 100644
index 00000000000..6383974bca4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/JSON
@@ -0,0 +1,80 @@
+{
+ "fathers" : [
+ {
+ "id" : 0,
+ "married" : false,
+ "name" : "Gary Johnson",
+ "sons" : null,
+ "daughters" : [
+ {
+ "age" : 7,
+ "name" : "Laura"
+ },
+ {
+ "age" : 1,
+ "name" : "Karen"
+ }
+ ]
+ },
+ {
+ "id" : 1,
+ "married" : true,
+ "name" : "Michael Taylor",
+ "sons" : null,
+ "daughters" : [
+ {
+ "age" : 13,
+ "name" : "Sandra"
+ },
+ {
+ "age" : 7,
+ "name" : "Cynthia"
+ },
+ {
+ "age" : 8,
+ "name" : "Mary"
+ }
+ ]
+ },
+ {
+ "id" : 2,
+ "married" : false,
+ "name" : "Steven Martin",
+ "sons" : null,
+ "daughters" : [
+ {
+ "age" : 16,
+ "name" : "Betty"
+ }
+ ]
+ },
+ {
+ "id" : 3,
+ "married" : true,
+ "name" : "Ronald Gonzalez",
+ "sons" : null,
+ "daughters" : [
+ ]
+ },
+ {
+ "id" : 4,
+ "married" : false,
+ "name" : "Paul Taylor",
+ "sons" : null,
+ "daughters" : [
+ {
+ "age" : 27,
+ "name" : "Laura"
+ }
+ ]
+ },
+ {
+ "id" : 5,
+ "married" : false,
+ "name" : "Gary Smith",
+ "sons" : null,
+ "daughters" : [
+ ]
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/Patch.diff b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/Patch.diff
new file mode 100644
index 00000000000..fe2bafbe082
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/Patch.diff
@@ -0,0 +1,10 @@
+--- hello.c 2014-10-07 18:17:49.000000000 +0530
++++ hello_new.c 2014-10-07 18:17:54.000000000 +0530
+@@ -1,5 +1,6 @@
+ #include
+
+-int main() {
++int main(int argc, char *argv[]) {
+ printf("Hello World\n");
++ return 0;
+ }
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/Python b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/Python
new file mode 100644
index 00000000000..334e75dc418
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Code/Python
@@ -0,0 +1,18 @@
+from time import localtime
+
+activities = {8: 'Sleeping',
+ 9: 'Commuting',
+ 17: 'Working',
+ 18: 'Commuting',
+ 20: 'Eating',
+ 22: 'Resting' }
+
+time_now = localtime()
+hour = time_now.tm_hour
+
+for activity_time in sorted(activities.keys()):
+ if hour < activity_time:
+ print activities[activity_time]
+ break
+else:
+ print 'Unknown, AFK or sleeping!'
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Excel.xlsx b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Excel.xlsx
new file mode 100644
index 00000000000..cf8f3f63243
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Excel.xlsx differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/PDF.pdf b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/PDF.pdf
new file mode 100644
index 00000000000..99d31cef1ef
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/PDF.pdf differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/PPT.pptx b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/PPT.pptx
new file mode 100644
index 00000000000..abb6870ed56
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/PPT.pptx differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Text.txt b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Text.txt
new file mode 100644
index 00000000000..e72d91fd323
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Text.txt
@@ -0,0 +1,116 @@
+=== Plugin Name ===
+Contributors: (this should be a list of wordpress.org userid's)
+Donate link: http://example.com/
+Tags: comments, spam
+Requires at least: 3.0.1
+Tested up to: 3.4
+Stable tag: 4.3
+License: GPLv2 or later
+License URI: http://www.gnu.org/licenses/gpl-2.0.html
+
+Here is a short description of the plugin. This should be no more than 150 characters. No markup here.
+
+== Description ==
+
+This is the long description. No limit, and you can use Markdown (as well as in the following sections).
+
+For backwards compatibility, if this section is missing, the full length of the short description will be used, and
+Markdown parsed.
+
+A few notes about the sections above:
+
+* "Contributors" is a comma separated list of wordpress.org usernames
+* "Tags" is a comma separated list of tags that apply to the plugin
+* "Requires at least" is the lowest version that the plugin will work on
+* "Tested up to" is the highest version that you've *successfully used to test the plugin*. Note that it might work on
+higher versions... this is just the highest one you've verified.
+* Stable tag should indicate the Subversion "tag" of the latest stable version, or "trunk," if you use `/trunk/` for
+stable.
+
+ Note that the `readme.txt` of the stable tag is the one that is considered the defining one for the plugin, so
+if the `/trunk/readme.txt` file says that the stable tag is `4.3`, then it is `/tags/4.3/readme.txt` that'll be used
+for displaying information about the plugin. In this situation, the only thing considered from the trunk `readme.txt`
+is the stable tag pointer. Thus, if you develop in trunk, you can update the trunk `readme.txt` to reflect changes in
+your in-development version, without having that information incorrectly disclosed about the current stable version
+that lacks those changes -- as long as the trunk's `readme.txt` points to the correct stable tag.
+
+ If no stable tag is provided, it is assumed that trunk is stable, but you should specify "trunk" if that's where
+you put the stable version, in order to eliminate any doubt.
+
+== Installation ==
+
+This section describes how to install the plugin and get it working.
+
+e.g.
+
+1. Upload the plugin files to the `/wp-content/plugins/plugin-name` directory, or install the plugin through the WordPress plugins screen directly.
+1. Activate the plugin through the 'Plugins' screen in WordPress
+1. Use the Settings->Plugin Name screen to configure the plugin
+1. (Make your instructions match the desired user flow for activating and installing your plugin. Include any steps that might be needed for explanatory purposes)
+
+
+== Frequently Asked Questions ==
+
+= A question that someone might have =
+
+An answer to that question.
+
+= What about foo bar? =
+
+Answer to foo bar dilemma.
+
+== Screenshots ==
+
+1. This screen shot description corresponds to screenshot-1.(png|jpg|jpeg|gif). Note that the screenshot is taken from
+the /assets directory or the directory that contains the stable readme.txt (tags or trunk). Screenshots in the /assets
+directory take precedence. For example, `/assets/screenshot-1.png` would win over `/tags/4.3/screenshot-1.png`
+(or jpg, jpeg, gif).
+2. This is the second screen shot
+
+== Changelog ==
+
+= 1.0 =
+* A change since the previous version.
+* Another change.
+
+= 0.5 =
+* List versions from most recent at top to oldest at bottom.
+
+== Upgrade Notice ==
+
+= 1.0 =
+Upgrade notices describe the reason a user should upgrade. No more than 300 characters.
+
+= 0.5 =
+This version fixes a security related bug. Upgrade immediately.
+
+== Arbitrary section ==
+
+You may provide arbitrary sections, in the same format as the ones above. This may be of use for extremely complicated
+plugins where more information needs to be conveyed that doesn't fit into the categories of "description" or
+"installation." Arbitrary sections will be shown below the built-in sections outlined above.
+
+== A brief Markdown Example ==
+
+Ordered list:
+
+1. Some feature
+1. Another feature
+1. Something else about the plugin
+
+Unordered list:
+
+* something
+* something else
+* third thing
+
+Here's a link to [WordPress](http://wordpress.org/ "Your favorite software") and one to [Markdown's Syntax Documentation][markdown syntax].
+Titles are optional, naturally.
+
+[markdown syntax]: http://daringfireball.net/projects/markdown/syntax
+ "Markdown is what the parser uses to process much of the readme file"
+
+Markdown uses email style notation for blockquotes and I've been told:
+> Asterisks for *emphasis*. Double it up for **strong**.
+
+``
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Word.docx b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Word.docx
new file mode 100644
index 00000000000..273dce02b8e
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Documents/Word.docx differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/BMP.bmp b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/BMP.bmp
new file mode 100644
index 00000000000..17dcf6d244a
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/BMP.bmp differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/GIF.gif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/GIF.gif
new file mode 100644
index 00000000000..3a22b904bd7
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/GIF.gif differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/JPG.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/JPG.jpg
new file mode 100644
index 00000000000..95a5abb3b24
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/JPG.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/PNG.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/PNG.png
new file mode 100644
index 00000000000..4cc045733dd
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/PNG.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/PSD.psd b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/PSD.psd
new file mode 100644
index 00000000000..0497cd6bd4a
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/PSD.psd differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/TIFF.tif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/TIFF.tif
new file mode 100644
index 00000000000..a6ca8ab7152
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Images/TIFF.tif differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/AVI.avi b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/AVI.avi
new file mode 100644
index 00000000000..5bf4b328457
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/AVI.avi differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MKV.mkv b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MKV.mkv
new file mode 100644
index 00000000000..4c5085f2fc5
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MKV.mkv differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MOV.mov b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MOV.mov
new file mode 100644
index 00000000000..e7d7b77f091
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MOV.mov differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MP4.mp4 b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MP4.mp4
new file mode 100644
index 00000000000..ed139d6d50c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MP4.mp4 differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MPG.mpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MPG.mpg
new file mode 100644
index 00000000000..c245e1b822c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/MPG.mpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/WEBM.webm b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/WEBM.webm
new file mode 100644
index 00000000000..a6d7025e4e9
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/WEBM.webm differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/WMV.wmv b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/WMV.wmv
new file mode 100644
index 00000000000..2b7dba84e3e
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mm_file_testing/Video/WMV.wmv differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mp3-audio-file.mp3 b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mp3-audio-file.mp3
new file mode 100644
index 00000000000..e14c239f042
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mp3-audio-file.mp3 differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mp4-video-file.mp4 b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mp4-video-file.mp4
new file mode 100644
index 00000000000..ed139d6d50c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mp4-video-file.mp4 differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mpeg-video-file.mpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mpeg-video-file.mpg
new file mode 100644
index 00000000000..c245e1b822c
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/mpeg-video-file.mpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/playbook-export.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/playbook-export.json
new file mode 100644
index 00000000000..99a1ed655a1
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/playbook-export.json
@@ -0,0 +1,23 @@
+{
+ "checklists": [
+ {
+ "items": [
+ {
+ "title": "Untitled task"
+ }
+ ],
+ "title": "Default checklist"
+ }
+ ],
+ "create_channel_member_on_new_participant": true,
+ "create_channel_member_on_removed_participant": true,
+ "description": "Customize this playbook's description to give an overview of when and how this playbook is run.",
+ "message_on_join": "Welcome! This channel was automatically created as part of a playbook run.",
+ "metrics": [],
+ "reminder_timer_default_seconds": 604800,
+ "retrospective_enabled": true,
+ "retrospective_template": "### Summary\nThis should contain 2-3 sentences that give a reader an overview of what happened, what was the cause, and what was done. The briefer the better as this is what future teams will look at first for reference.\n\n### What was the impact?\nThis section describes the impact of this playbook run as experienced by internal and external customers as well as stakeholders.\n\n### What were the contributing factors?\nThis playbook may be a reactive protocol to a situation that is otherwise undesirable. If that's the case, this section explains the reasons that caused the situation in the first place. There may be multiple root causes - this helps stakeholders understand why.\n\n### What was done?\nThis section tells the story of how the team collaborated throughout the event to achieve the outcome. This will help future teams learn from this experience on what they could try.\n\n### What did we learn?\nThis section should include perspective from everyone that was involved to celebrate the victories and identify areas for improvement. For example: What went well? What didn't go well? What should be done differently next time?\n\n### Follow-up tasks\nThis section lists the action items to turn learnings into changes that help the team become more proficient with iterations. It could include tweaking the playbook, publishing the retrospective, or other improvements. The best follow-ups will have a clear owner as well as due date.\n\n### Timeline highlights\nThis section is a curated log that details the most important moments. It can contain key communications, screen shots, or other artifacts. Use the built-in timeline feature to help you retrace and replay the sequence of events.",
+ "status_update_enabled": true,
+ "title": "Example Playbook",
+ "version": 1
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/png-image-file.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/png-image-file.png
new file mode 100644
index 00000000000..4cc045733dd
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/png-image-file.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/powerpoint-file.ppt b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/powerpoint-file.ppt
new file mode 100644
index 00000000000..0cd4ac593c0
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/powerpoint-file.ppt differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/powerpointx-file.pptx b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/powerpointx-file.pptx
new file mode 100644
index 00000000000..abb6870ed56
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/powerpointx-file.pptx differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/saml_ldap_users.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/saml_ldap_users.json
new file mode 100644
index 00000000000..0b38f211a73
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/saml_ldap_users.json
@@ -0,0 +1,42 @@
+{
+ "user1": {
+ "username": "e2etest.one",
+ "password": "Password1",
+ "email": "e2etest.one@mmtest.com",
+ "firstname": "TestSaml",
+ "lastname": "OneSaml",
+ "ldapfirstname": "TestLDAP",
+ "ldaplastname": "OneLDAP",
+ "keycloakId": ""
+ },
+ "user2": {
+ "username": "e2etest.two",
+ "password": "Password1",
+ "email": "e2etest.two@mmtest.com",
+ "firstname": "TestSaml",
+ "lastname": "TwoSaml",
+ "ldapfirstname": "TestLDAP",
+ "ldaplastname": "TwoLDAP",
+ "keycloakId": ""
+ },
+ "user3": {
+ "username": "e2etest.three.saml",
+ "password": "Password1",
+ "email": "e2etest.three@mmtest.com",
+ "firstname": "FirstSaml",
+ "lastname": "ThreeSaml",
+ "ldapfirstname": "TestLDAP",
+ "ldaplastname": "ThreeLDAP",
+ "keycloakId": ""
+ },
+ "user4": {
+ "username": "e2etest.four",
+ "password": "Password1",
+ "email": "e2etest.four@mmtest.com",
+ "firstname": "TestSaml",
+ "lastname": "FourSaml",
+ "ldapfirstname": "TestLDAP",
+ "ldaplastname": "FourLDAP",
+ "keycloakId": ""
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/saml_users.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/saml_users.json
new file mode 100644
index 00000000000..9f3b92edcc4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/saml_users.json
@@ -0,0 +1,58 @@
+{
+ "regulars": {
+ "samluser-1": {
+ "username": "samluser-1",
+ "password": "Password1",
+ "email": "samluser-1@test.com",
+ "firstname": "saml1",
+ "lastname": "user",
+ "userType": ""
+ },
+ "samluser-2": {
+ "username": "samluser-2",
+ "password": "Password1",
+ "email": "samluser-2@test.com",
+ "firstname": "saml2",
+ "lastname": "user",
+ "userType": ""
+ }
+ },
+ "admins": {
+ "samladmin-1": {
+ "username": "samladmin-1",
+ "password": "Password1",
+ "email": "samladmin-1@test.com",
+ "firstname": "saml1",
+ "lastname": "admin",
+ "userType": "Admin"
+ },
+ "samladmin-2": {
+ "username": "samladmin-2",
+ "password": "Password1",
+ "email": "samladmin-2@test.com",
+ "firstname": "saml2",
+ "lastname": "admin",
+ "userType": null,
+ "isAdmin": true
+ }
+ },
+ "guests": {
+ "samlguest-1": {
+ "username": "samlguest-1",
+ "password": "Password1",
+ "email": "samlguest-1@test.com",
+ "firstname": "saml1",
+ "lastname": "guest",
+ "userType": "Guest"
+ },
+ "samlguest-2": {
+ "username": "samlguest-2",
+ "password": "Password1",
+ "email": "samlguest-2@test.com",
+ "firstname": "saml2",
+ "lastname": "guest",
+ "userType": null,
+ "isGuest": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/small-image.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/small-image.png
new file mode 100644
index 00000000000..d8c983933ce
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/small-image.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/svg.svg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/svg.svg
new file mode 100644
index 00000000000..4ec5b0a5f2f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/svg.svg
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/system-roles-console-access.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/system-roles-console-access.json
new file mode 100644
index 00000000000..739c5ecefe4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/system-roles-console-access.json
@@ -0,0 +1,320 @@
+[
+ {
+ "section": "about.license",
+ "system_manager": "read",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "reporting.system_analytics",
+ "system_manager": "read",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "reporting.team_statistics",
+ "system_manager": "read",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "reporting.server_logs",
+ "system_manager": "read",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "user_management.system_users",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "user_management.groups",
+ "system_manager": "read+write",
+ "system_user_manager": "read+write",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "user_management.teams",
+ "system_manager": "read+write",
+ "system_user_manager": "read+write",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "user_management.channel",
+ "system_manager": "read+write",
+ "system_user_manager": "read+write",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "user_management.permissions",
+ "system_manager": "read+write",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "user_management.system_roles",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "none"
+ },
+ {
+ "section": "environment.web_server",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.database",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.elasticsearch",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.storage",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.image_proxy",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.smtp",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.push_notification_server",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.high_availability",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.rate_limiting",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.logging",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.session_lengths",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.metrics",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "environment.developer",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.customization",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.localization",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.users_and_teams",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.notifications",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.announcement_banner",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.emoji",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.posts",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.file_sharing_downloads",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.public_links",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "site.notices",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.signup",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.email",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.password",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.mfa",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.ldap",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.saml",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.openid",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "authentication.guest_access",
+ "system_manager": "read",
+ "system_user_manager": "read",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "plugins",
+ "system_manager": "read",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "integrations.integration_management",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "integrations.bot_accounts",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "integrations.gif",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "integrations.cors",
+ "system_manager": "read+write",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "compliance.data_retention",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "compliance.message_export",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "compliance.audits",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "compliance.custom_terms_of_service",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "experimental.experimental_features",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "experimental.feature_flags",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ },
+ {
+ "section": "experimental.bleve",
+ "system_manager": "none",
+ "system_user_manager": "none",
+ "system_read_only_admin": "read"
+ }
+]
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/theme.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/theme.json
new file mode 100644
index 00000000000..a8fd947b725
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/theme.json
@@ -0,0 +1,56 @@
+{
+ "default": {
+ "sidebarBg":"#145dbf",
+ "sidebarText":"#ffffff",
+ "sidebarUnreadText":"#ffffff",
+ "sidebarTextHoverBg":"#4578bf",
+ "sidebarTextActiveBorder":"#579eff",
+ "sidebarTextActiveColor":"#ffffff",
+ "sidebarHeaderBg":"#1153ab",
+ "sidebarTeamBarBg": "#0b428c",
+ "sidebarHeaderTextColor":"#ffffff",
+ "onlineIndicator":"#06d6a0",
+ "awayIndicator":"#ffbc42",
+ "dndIndicator":"#f74343",
+ "mentionBj":"#ffffff",
+ "mentionColor":"#145dbf",
+ "centerChannelBg":"#ffffff",
+ "centerChannelColor":"#3d3c40",
+ "newMessageSeparator":"#ff8800",
+ "linkColor":"#2389d7",
+ "buttonBg":"#166de0",
+ "buttonColor":"#ffffff",
+ "errorTextColor":"#fd5960",
+ "mentionHighlightBg":"#ffe577",
+ "mentionHighlightLink":"#166de0",
+ "codeTheme":"github",
+ "mentionBg":"#ffffff"
+ },
+ "dark": {
+ "sidebarBg":"#171717",
+ "sidebarText":"#ffffff",
+ "sidebarUnreadText":"#ffffff",
+ "sidebarTextHoverBg":"#302e30",
+ "sidebarTextActiveBorder":"#196caf",
+ "sidebarTextActiveColor":"#ffffff",
+ "sidebarHeaderBg":"#1f1f1f",
+ "sidebarTeamBarBg": "#181818",
+ "sidebarHeaderTextColor":"#ffffff",
+ "onlineIndicator":"#399fff",
+ "awayIndicator":"#c1b966",
+ "dndIndicator":"#e81023",
+ "mentionBj":"#ffffff",
+ "mentionColor":"#ffffff",
+ "centerChannelBg":"#1f1f1f",
+ "centerChannelColor":"#dddddd",
+ "newMessageSeparator":"#cc992d",
+ "linkColor":"#0d93ff",
+ "buttonBg":"#0177e7",
+ "buttonColor":"#ffffff",
+ "errorTextColor":"#ff6461",
+ "mentionHighlightBg":"#784098",
+ "mentionHighlightLink":"#a4ffeb",
+ "codeTheme":"monokai",
+ "mentionBg":"#0177e7"
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/tiff-image-file.tif b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/tiff-image-file.tif
new file mode 100644
index 00000000000..a6ca8ab7152
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/tiff-image-file.tif differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/timeouts.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/timeouts.js
new file mode 100644
index 00000000000..67a2b068f7c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/timeouts.js
@@ -0,0 +1,28 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const MILLISECONDS_PER_SECOND = 1000;
+const SECONDS_PER_MINUTE = 60;
+
+const SECOND = MILLISECONDS_PER_SECOND;
+const MINUTE = SECOND * SECONDS_PER_MINUTE;
+
+module.exports = {
+ ONE_HUNDRED_MILLIS: 100,
+ QUARTER_SEC: SECOND / 4,
+ HALF_SEC: SECOND / 2,
+ ONE_SEC: SECOND,
+ TWO_SEC: SECOND * 2,
+ THREE_SEC: SECOND * 3,
+ FOUR_SEC: SECOND * 4,
+ FIVE_SEC: SECOND * 5,
+ TEN_SEC: SECOND * 10,
+ HALF_MIN: MINUTE / 2,
+ ONE_MIN: MINUTE,
+ TWO_MIN: MINUTE * 2,
+ THREE_MIN: MINUTE * 3,
+ FOUR_MIN: MINUTE * 4,
+ FIVE_MIN: MINUTE * 5,
+ TEN_MIN: MINUTE * 10,
+ TWENTY_MIN: MINUTE * 20,
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/txt-changed-as-png.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/txt-changed-as-png.png
new file mode 100644
index 00000000000..860e834e8da
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/txt-changed-as-png.png
@@ -0,0 +1,116 @@
+=== Plugin Name ===
+Contributors: (this should be a list of wordpress.org userid's)
+Donate link: http://example.com/
+Tags: comments, spam
+Requires at least: 3.0.1
+Tested up to: 3.4
+Stable tag: 4.3
+License: GPLv2 or later
+License URI: http://www.gnu.org/licenses/gpl-2.0.html
+
+Here is a short description of the plugin. This should be no more than 150 characters. No markup here.
+
+== Description ==
+
+This is the long description. No limit, and you can use Markdown (as well as in the following sections).
+
+For backwards compatibility, if this section is missing, the full length of the short description will be used, and
+Markdown parsed.
+
+A few notes about the sections above:
+
+* "Contributors" is a comma separated list of wordpress.org usernames
+* "Tags" is a comma separated list of tags that apply to the plugin
+* "Requires at least" is the lowest version that the plugin will work on
+* "Tested up to" is the highest version that you've *successfully used to test the plugin*. Note that it might work on
+higher versions... this is just the highest one you've verified.
+* Stable tag should indicate the Subversion "tag" of the latest stable version, or "trunk," if you use `/trunk/` for
+stable.
+
+ Note that the `readme.txt` of the stable tag is the one that is considered the defining one for the plugin, so
+if the `/trunk/readme.txt` file says that the stable tag is `4.3`, then it is `/tags/4.3/readme.txt` that'll be used
+for displaying information about the plugin. In this situation, the only thing considered from the trunk `readme.txt`
+is the stable tag pointer. Thus, if you develop in trunk, you can update the trunk `readme.txt` to reflect changes in
+your in-development version, without having that information incorrectly disclosed about the current stable version
+that lacks those changes -- as long as the trunk's `readme.txt` points to the correct stable tag.
+
+ If no stable tag is provided, it is assumed that trunk is stable, but you should specify "trunk" if that's where
+you put the stable version, in order to eliminate any doubt.
+
+== Installation ==
+
+This section describes how to install the plugin and get it working.
+
+e.g.
+
+1. Upload the plugin files to the `/wp-content/plugins/plugin-name` directory, or install the plugin through the WordPress plugins screen directly.
+1. Activate the plugin through the 'Plugins' screen in WordPress
+1. Use the Settings->Plugin Name screen to configure the plugin
+1. (Make your instructions match the desired user flow for activating and installing your plugin. Include any steps that might be needed for explanatory purposes)
+
+
+== Frequently Asked Questions ==
+
+= A question that someone might have =
+
+An answer to that question.
+
+= What about foo bar? =
+
+Answer to foo bar dilemma.
+
+== Screenshots ==
+
+1. This screen shot description corresponds to screenshot-1.(png|jpg|jpeg|gif). Note that the screenshot is taken from
+the /assets directory or the directory that contains the stable readme.txt (tags or trunk). Screenshots in the /assets
+directory take precedence. For example, `/assets/screenshot-1.png` would win over `/tags/4.3/screenshot-1.png`
+(or jpg, jpeg, gif).
+2. This is the second screen shot
+
+== Changelog ==
+
+= 1.0 =
+* A change since the previous version.
+* Another change.
+
+= 0.5 =
+* List versions from most recent at top to oldest at bottom.
+
+== Upgrade Notice ==
+
+= 1.0 =
+Upgrade notices describe the reason a user should upgrade. No more than 300 characters.
+
+= 0.5 =
+This version fixes a security related bug. Upgrade immediately.
+
+== Arbitrary section ==
+
+You may provide arbitrary sections, in the same format as the ones above. This may be of use for extremely complicated
+plugins where more information needs to be conveyed that doesn't fit into the categories of "description" or
+"installation." Arbitrary sections will be shown below the built-in sections outlined above.
+
+== A brief Markdown Example ==
+
+Ordered list:
+
+1. Some feature
+1. Another feature
+1. Something else about the plugin
+
+Unordered list:
+
+* something
+* something else
+* third thing
+
+Here's a link to [WordPress](http://wordpress.org/ "Your favorite software") and one to [Markdown's Syntax Documentation][markdown syntax].
+Titles are optional, naturally.
+
+[markdown syntax]: http://daringfireball.net/projects/markdown/syntax
+ "Markdown is what the parser uses to process much of the readme file"
+
+Markdown uses email style notation for blockquotes and I've been told:
+> Asterisks for *emphasis*. Double it up for **strong**.
+
+``
\ No newline at end of file
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/webhook_icon.jpg b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/webhook_icon.jpg
new file mode 100644
index 00000000000..f1c511e0ddb
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/webhook_icon.jpg differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/webhook_override_icon.png b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/webhook_override_icon.png
new file mode 100644
index 00000000000..0866848de8b
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/webhook_override_icon.png differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/word-file.doc b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/word-file.doc
new file mode 100644
index 00000000000..9cb3f019e85
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/word-file.doc differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/wordx-file.docx b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/wordx-file.docx
new file mode 100644
index 00000000000..273dce02b8e
Binary files /dev/null and b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/fixtures/wordx-file.docx differ
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/channels/mark_as_unread/helpers.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/channels/mark_as_unread/helpers.js
new file mode 100644
index 00000000000..fbcbea8f4b9
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/channels/mark_as_unread/helpers.js
@@ -0,0 +1,66 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../../fixtures/timeouts';
+
+export function markAsUnreadFromPost(post, rhs = false) {
+ const prefix = rhs ? 'rhsPost' : 'post';
+
+ cy.get(`#${prefix}_${post.id}`).scrollIntoView().should('be.visible');
+
+ cy.get('body').type('{alt}', {release: false});
+ cy.get(`#${prefix}_${post.id}`).click({force: true});
+ cy.get('body').type('{alt}', {release: true});
+}
+
+export function markAsUnreadShouldBeAbsent(postId, prefix = 'post', location = 'CENTER') {
+ cy.get(`#${prefix}_${postId}`).trigger('mouseover');
+ cy.clickPostDotMenu(postId, location);
+ cy.get(`#CENTER_dropdown_${postId}`).
+ should('be.visible').
+ within(() => {
+ cy.findByText('Mark as Unread').should('not.exist');
+ });
+ cy.get('body').type('esc');
+}
+
+export function switchToChannel(channel) {
+ cy.get(`#sidebarItem_${channel.name}`).click();
+
+ cy.get('#channelHeaderTitle', {timeout: TIMEOUTS.ONE_MIN}).should('contain', channel.display_name);
+
+ // # Wait some time for the channel to set state
+ cy.wait(TIMEOUTS.HALF_SEC);
+}
+
+export function verifyPostNextToNewMessageSeparator(message) {
+ cy.get('.NotificationSeparator').
+ should('exist').
+ parent().
+ parent().
+ parent().
+ next().
+ should('contain', message);
+}
+
+export function verifyTopSpaceForNewMessage(message) {
+ cy.get('.post-row__padding.top').
+ should('be.visible').
+ should('contain', message);
+}
+
+export function verifyBottomSpaceForNewMessage(message) {
+ cy.get('.post-row__padding.bottom').
+ should('be.visible').
+ should('contain', message);
+}
+
+export function showCursor(items) {
+ cy.expect(items).to.have.length(1);
+ expect(items[0].className).to.match(/cursor--pointer/);
+}
+
+export function notShowCursor(items) {
+ cy.expect(items).to.have.length(1);
+ expect(items[0].className).to.not.match(/cursor--pointer/);
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/adminconsole/analytics_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/adminconsole/analytics_spec.js
new file mode 100644
index 00000000000..f1ac8709d30
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/adminconsole/analytics_spec.js
@@ -0,0 +1,107 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('admin console', {testIsolation: true}, () => {
+ let testUser;
+ let testTeam;
+ let testPlaybook;
+ let testSysadmin;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ cy.apiCreateCustomAdmin().then(({sysadmin}) => {
+ testSysadmin = sysadmin;
+ });
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testSysddmin
+ cy.apiLogin(testSysadmin);
+ });
+
+ describe('site statistics', () => {
+ it('playbooks and runs counters are visible', () => {
+ // # Go to admin console > site statistics
+ cy.visit('/admin_console/reporting/system_analytics');
+
+ // * Check that the playbook and run counters are visible
+ cy.findByTestId('playbooks.playbook_count').should('exist');
+ cy.findByTestId('playbooks.playbook_run_count').should('exist');
+ });
+
+ it('playbook counter increases after creating a playbook', () => {
+ let counter;
+
+ // # Go to admin console > site statistics
+ cy.visit('/admin_console/reporting/system_analytics');
+
+ // # Capture current value of playbook counter
+ cy.findByTestId('playbooks.playbook_count').invoke('prop', 'innerText').then((pbCount) => {
+ counter = parseInt(pbCount, 10);
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ }).then(() => {
+ cy.apiLogin(testSysadmin);
+
+ // # Go to admin console > site statistics
+ cy.visit('/admin_console/reporting/system_analytics');
+
+ // * Verify that the Playbook Counter has been increased by 1
+ cy.findByTestId('playbooks.playbook_count').contains(String(counter + 1));
+ });
+ });
+ });
+
+ it('run counter increases after creating a run', () => {
+ let counter;
+
+ // # Go to admin console > site statistics
+ cy.visit('/admin_console/reporting/system_analytics');
+
+ // # Capture current value of run counter
+ cy.findByTestId('playbooks.playbook_run_count').invoke('prop', 'innerText').then((runCount) => {
+ counter = parseInt(runCount, 10);
+ cy.apiLogin(testUser);
+
+ // # create a run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'My run for test',
+ ownerUserId: testUser.id,
+ }).then(() => {
+ cy.apiLogin(testSysadmin);
+
+ // # Go to admin console > site statistics
+ cy.visit('/admin_console/reporting/system_analytics');
+
+ // * Verify that the Run Counter has been increased by 1
+ cy.findByTestId('playbooks.playbook_run_count').contains(String(counter + 1));
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/graphql_errors_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/graphql_errors_spec.js
new file mode 100644
index 00000000000..6f92b14c3fb
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/graphql_errors_spec.js
@@ -0,0 +1,39 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('api > graphql_errors', {testIsolation: true}, () => {
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({user}) => {
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ it('return a generic error', () => {
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {operationName: 'poc', query: 'query poc { __typename @a@a@a }'},
+ method: 'POST',
+ failOnStatusCode: false,
+ }).then((response) => {
+ expect(response.body.errors).to.have.length(1);
+ expect(response.body.errors[0].message).to.equal('Error while executing your request');
+ });
+ });
+});
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/property_fields_graphql_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/property_fields_graphql_spec.js
new file mode 100644
index 00000000000..558bb009b8e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/property_fields_graphql_spec.js
@@ -0,0 +1,734 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-underscore-dangle */ // Allow GraphQL introspection fields (__schema, __type)
+
+describe('api > property_fields_graphql', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup({
+ promoteNewUserAsAdmin: true,
+ userPrefix: 'property-test-admin',
+ }).then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Create a test playbook for property field operations
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Property Fields GraphQL Test Playbook',
+ description: 'A playbook for testing property field GraphQL operations',
+ memberIDs: [testUser.id],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('GraphQL Schema Introspection', () => {
+ it('should verify GraphQL schema includes property field operations', () => {
+ cy.task('log', '🔍 Testing GraphQL Property Field Operations Schema');
+
+ // # Test GraphQL introspection to verify operations exist
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'IntrospectionQuery',
+ query: `
+ query IntrospectionQuery {
+ __schema {
+ queryType {
+ fields {
+ name
+ }
+ }
+ mutationType {
+ fields {
+ name
+ }
+ }
+ }
+ }
+ `,
+ },
+ method: 'POST',
+ }).then((response) => {
+ // * Verify the GraphQL endpoint is working
+ expect(response.status).to.equal(200);
+ expect(response.body).to.exist;
+
+ if (!response.body.data || !response.body.data.__schema) {
+ cy.task('log', '⚠️ Introspection might be disabled. Skipping introspection tests.');
+ return;
+ }
+
+ expect(response.body.data).to.exist;
+ expect(response.body.data.__schema).to.exist;
+
+ const queryFields = response.body.data.__schema.queryType.fields.map((f) => f.name);
+ const mutationFields = response.body.data.__schema.mutationType.fields.map((f) => f.name);
+
+ // * Verify that property field operations exist in the schema
+ expect(queryFields).to.include('playbookProperty');
+ expect(mutationFields).to.include('addPlaybookPropertyField');
+ expect(mutationFields).to.include('updatePlaybookPropertyField');
+ expect(mutationFields).to.include('deletePlaybookPropertyField');
+
+ cy.task('log', '✅ PlaybookProperty query found in schema');
+ cy.task('log', '✅ addPlaybookPropertyField mutation found in schema');
+ cy.task('log', '✅ updatePlaybookPropertyField mutation found in schema');
+ cy.task('log', '✅ deletePlaybookPropertyField mutation found in schema');
+ });
+ });
+
+ it('should verify PropertyFieldType enum exists and has correct values', () => {
+ cy.task('log', '🔍 Testing PropertyFieldType enum');
+
+ // # Test PropertyFieldType enum values
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'PropertyFieldTypeQuery',
+ query: `
+ query PropertyFieldTypeQuery {
+ __type(name: "PropertyFieldType") {
+ name
+ enumValues {
+ name
+ description
+ }
+ }
+ }
+ `,
+ },
+ method: 'POST',
+ }).then((response) => {
+ // * Verify PropertyFieldType enum exists and has expected values
+ expect(response.status).to.equal(200);
+
+ if (!response.body.data || !response.body.data.__type) {
+ cy.task('log', '⚠️ Introspection might be disabled. Skipping enum validation.');
+ return;
+ }
+
+ expect(response.body.data.__type).to.exist;
+ expect(response.body.data.__type.name).to.equal('PropertyFieldType');
+
+ const enumValues = response.body.data.__type.enumValues.map((v) => v.name);
+ const expectedTypes = ['text', 'select', 'multiselect', 'date', 'user', 'multiuser'];
+
+ expectedTypes.forEach((type) => {
+ expect(enumValues).to.include(type);
+ cy.task('log', `✅ PropertyFieldType.${type} found in enum`);
+ });
+ });
+ });
+
+ it('should verify PropertyFieldInput type structure', () => {
+ cy.task('log', '🔍 Testing PropertyFieldInput input types');
+
+ // # Test input type structure via introspection
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'PropertyFieldInputQuery',
+ query: `
+ query PropertyFieldInputQuery {
+ __type(name: "PropertyFieldInput") {
+ name
+ inputFields {
+ name
+ type {
+ name
+ kind
+ }
+ }
+ }
+ }
+ `,
+ },
+ method: 'POST',
+ }).then((response) => {
+ // * Verify PropertyFieldInput type exists with correct fields
+ expect(response.status).to.equal(200);
+
+ if (!response.body.data || !response.body.data.__type) {
+ cy.task('log', '⚠️ Introspection might be disabled. Skipping input type validation.');
+ return;
+ }
+
+ expect(response.body.data.__type).to.exist;
+ expect(response.body.data.__type.name).to.equal('PropertyFieldInput');
+
+ const inputFields = response.body.data.__type.inputFields.map((f) => f.name);
+ const expectedFields = ['name', 'type', 'attrs'];
+
+ expectedFields.forEach((field) => {
+ expect(inputFields).to.include(field);
+ cy.task('log', `✅ PropertyFieldInput.${field} field found`);
+ });
+ });
+ });
+ });
+
+ describe('GraphQL Operation Validation', () => {
+ it('should validate PlaybookProperty query structure', () => {
+ cy.task('log', '🔍 Testing PlaybookProperty query syntax');
+
+ // # Test the PlaybookProperty query structure (will fail for non-existent data but syntax should be valid)
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'PlaybookProperty',
+ query: `
+ query PlaybookProperty($playbookID: String!, $propertyID: String!) {
+ playbookProperty(playbookID: $playbookID, propertyID: $propertyID) {
+ id
+ name
+ type
+ groupID
+ attrs {
+ visibility
+ sortOrder
+ options {
+ id
+ name
+ color
+ }
+ parentID
+ }
+ createAt
+ updateAt
+ deleteAt
+ }
+ }
+ `,
+ variables: {
+ playbookID: testPlaybook.id,
+ propertyID: 'test-property-id',
+ },
+ },
+ method: 'POST',
+ failOnStatusCode: false,
+ }).then((response) => {
+ // * Verify the GraphQL query structure is valid
+ expect(response.status).to.equal(200);
+ expect(response.body).to.have.property('data');
+
+ // * Should return null for non-existent data, but no syntax errors
+ if (response.body.errors) {
+ const error = response.body.errors[0];
+ expect(error.message).to.not.include('syntax');
+ expect(error.message).to.not.include('Unknown field');
+ expect(error.message).to.not.include('Cannot query field');
+ cy.task('log', `✅ PlaybookProperty query structure is valid (expected error: ${error.message})`);
+ } else {
+ cy.task('log', '✅ PlaybookProperty query structure is valid');
+ }
+ });
+ });
+
+ it('should validate AddPlaybookPropertyField mutation structure', () => {
+ cy.task('log', '🔍 Testing AddPlaybookPropertyField mutation syntax');
+
+ // # Test the AddPlaybookPropertyField mutation structure
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'AddPlaybookPropertyField',
+ query: `
+ mutation AddPlaybookPropertyField($playbookID: String!, $propertyField: PropertyFieldInput!) {
+ addPlaybookPropertyField(playbookID: $playbookID, propertyField: $propertyField)
+ }
+ `,
+ variables: {
+ playbookID: testPlaybook.id,
+ propertyField: {
+ name: 'Test Priority Field',
+ type: 'select',
+ attrs: {
+ visibility: 'always',
+ sortOrder: 1,
+ options: [
+ {name: 'High', color: 'red'},
+ {name: 'Low', color: 'green'},
+ ],
+ },
+ },
+ },
+ },
+ method: 'POST',
+ failOnStatusCode: false,
+ }).then((response) => {
+ // * Verify the GraphQL mutation structure is valid
+ expect(response.status).to.equal(200);
+ expect(response.body).to.have.property('data');
+
+ if (response.body.errors) {
+ const error = response.body.errors[0];
+
+ // * Should not be syntax errors - might be permission/data errors
+ expect(error.message).to.not.include('syntax');
+ expect(error.message).to.not.include('Unknown field');
+ expect(error.message).to.not.include('Unknown argument');
+ cy.task('log', `✅ AddPlaybookPropertyField mutation structure is valid (response: ${error.message})`);
+ } else if (response.body.data && response.body.data.addPlaybookPropertyField) {
+ cy.task('log', `✅ AddPlaybookPropertyField mutation executed successfully: ${response.body.data.addPlaybookPropertyField}`);
+ } else {
+ cy.task('log', '✅ AddPlaybookPropertyField mutation structure is valid');
+ }
+ });
+ });
+
+ it('should validate mutation argument structures', () => {
+ cy.task('log', '🔍 Testing mutation argument validation');
+
+ // # Test mutation syntax by querying mutation type structure
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'TestMutationSyntax',
+ query: `
+ query TestMutationSyntax {
+ __type(name: "Mutation") {
+ fields(includeDeprecated: true) {
+ name
+ args {
+ name
+ type {
+ name
+ kind
+ }
+ }
+ }
+ }
+ }
+ `,
+ },
+ method: 'POST',
+ }).then((response) => {
+ // * Find property field mutations in the schema
+ expect(response.status).to.equal(200);
+
+ if (!response.body.data || !response.body.data.__type) {
+ cy.task('log', '⚠️ Introspection might be disabled. Skipping mutation validation.');
+ return;
+ }
+
+ const mutationFields = response.body.data.__type.fields;
+
+ const propertyMutations = mutationFields.filter((f) =>
+ f.name.includes('PlaybookPropertyField') || f.name === 'addPlaybookPropertyField' ||
+ f.name === 'updatePlaybookPropertyField' || f.name === 'deletePlaybookPropertyField',
+ );
+
+ // * Verify mutations exist and have correct argument structure
+ expect(propertyMutations.length).to.be.greaterThan(0);
+
+ propertyMutations.forEach((mutation) => {
+ cy.task('log', `✅ ${mutation.name} mutation found with ${mutation.args.length} arguments`);
+
+ // Check common arguments
+ const argNames = mutation.args.map((arg) => arg.name);
+ expect(argNames).to.include('playbookID');
+
+ if (mutation.name.includes('add') || mutation.name.includes('update')) {
+ expect(argNames).to.include('propertyField');
+ }
+ if (mutation.name.includes('update') || mutation.name.includes('delete')) {
+ expect(argNames).to.include('propertyFieldID');
+ }
+ });
+ });
+ });
+ });
+
+ describe('PropertyField Type System', () => {
+ it('should support all PropertyFieldType enum values', () => {
+ cy.task('log', '🔍 Testing all PropertyFieldType values');
+
+ const propertyFieldTypes = [
+ {type: 'text', name: 'Text Field'},
+ {type: 'select', name: 'Select Field', options: [{name: 'Option 1', color: 'blue'}]},
+ {type: 'multiselect', name: 'Multi-Select Field', options: [{name: 'Tag 1'}, {name: 'Tag 2'}]},
+ {type: 'date', name: 'Date Field'},
+ {type: 'user', name: 'User Field'},
+ {type: 'multiuser', name: 'Multi-User Field'},
+ ];
+
+ propertyFieldTypes.forEach((fieldDef) => {
+ // # Test each property field type in a mutation structure
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'TestPropertyFieldType',
+ query: `
+ mutation TestPropertyFieldType($playbookID: String!, $propertyField: PropertyFieldInput!) {
+ addPlaybookPropertyField(playbookID: $playbookID, propertyField: $propertyField)
+ }
+ `,
+ variables: {
+ playbookID: testPlaybook.id,
+ propertyField: {
+ name: fieldDef.name,
+ type: fieldDef.type,
+ attrs: {
+ visibility: 'always',
+ sortOrder: 1,
+ ...(fieldDef.options && {options: fieldDef.options}),
+ },
+ },
+ },
+ },
+ method: 'POST',
+ failOnStatusCode: false,
+ }).then((response) => {
+ // * Verify the type is accepted (structure validation, not execution)
+ expect(response.status).to.equal(200);
+ expect(response.body).to.have.property('data');
+
+ if (response.body.errors) {
+ const error = response.body.errors[0];
+
+ // * Should not be type validation errors
+ expect(error.message).to.not.include('Invalid value');
+ expect(error.message).to.not.include('Expected type');
+ expect(error.message).to.not.include('Unknown enum value');
+ }
+
+ cy.task('log', `✅ PropertyFieldType.${fieldDef.type} is valid and accepted`);
+ });
+ });
+ });
+ });
+
+ describe('Main Playbook Query with PropertyFields', () => {
+ it('should validate Playbook query includes propertyFields field', () => {
+ cy.task('log', '🔍 Testing main Playbook query with propertyFields field');
+
+ // # Test the main Playbook query that includes propertyFields
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'PlaybookWithPropertyFields',
+ query: `
+ query PlaybookWithPropertyFields($id: String!) {
+ playbook(id: $id) {
+ id
+ title
+ propertyFields {
+ id
+ name
+ type
+ groupID
+ attrs {
+ visibility
+ sortOrder
+ options {
+ id
+ name
+ color
+ }
+ parentID
+ }
+ createAt
+ updateAt
+ deleteAt
+ }
+ }
+ }
+ `,
+ variables: {
+ id: testPlaybook.id,
+ },
+ },
+ method: 'POST',
+ failOnStatusCode: false,
+ }).then((response) => {
+ // * Verify response structure
+ expect(response.status).to.equal(200);
+ expect(response.body).to.exist;
+
+ // * Log the response for debugging
+ cy.task('log', `GraphQL Response: ${JSON.stringify(response.body, null, 2)}`);
+
+ if (response.body.errors) {
+ const error = response.body.errors[0];
+ cy.task('log', `GraphQL Error: ${error.message}`);
+
+ // * Check if it's a schema error indicating the field doesn't exist
+ if (error.message.includes('Cannot query field') || error.message.includes('Unknown field')) {
+ cy.task('log', '❌ propertyFields field not found in Playbook schema - this indicates the backend schema needs to be updated');
+ throw new Error(`Schema validation failed: ${error.message}`);
+ } else {
+ // * Other errors (like permissions) are acceptable for schema validation
+ cy.task('log', `✅ Main Playbook query structure is valid (non-schema error: ${error.message})`);
+ }
+ } else if (response.body.data) {
+ expect(response.body.data).to.have.property('playbook');
+
+ if (response.body.data.playbook) {
+ // * Should have propertyFields field (might be empty array)
+ expect(response.body.data.playbook).to.have.property('propertyFields');
+ expect(response.body.data.playbook.propertyFields).to.be.an('array');
+ cy.task('log', `✅ Main Playbook query executed successfully with ${response.body.data.playbook.propertyFields.length} property fields`);
+ } else {
+ cy.task('log', '✅ Main Playbook query structure is valid (playbook not found, but no schema errors)');
+ }
+ } else {
+ cy.task('log', '⚠️ Unexpected response structure - no data or errors field');
+ }
+ });
+ });
+
+ it('should verify propertyFields array structure in Playbook query', () => {
+ cy.task('log', '🔍 Testing propertyFields array structure validation');
+
+ // # Test introspection for Playbook type to verify propertyFields field
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'PlaybookTypeIntrospection',
+ query: `
+ query PlaybookTypeIntrospection {
+ __type(name: "Playbook") {
+ name
+ fields {
+ name
+ type {
+ name
+ kind
+ ofType {
+ name
+ kind
+ }
+ }
+ }
+ }
+ }
+ `,
+ },
+ method: 'POST',
+ failOnStatusCode: false,
+ }).then((response) => {
+ // * Verify response structure
+ expect(response.status).to.equal(200);
+ expect(response.body).to.exist;
+
+ // * Log the response for debugging
+ cy.task('log', `Introspection Response: ${JSON.stringify(response.body, null, 2)}`);
+
+ if (response.body.errors) {
+ const error = response.body.errors[0];
+ cy.task('log', `Introspection Error: ${error.message}`);
+ cy.task('log', '⚠️ Introspection might be disabled or restricted. Skipping detailed schema validation.');
+ return;
+ }
+
+ if (!response.body.data || !response.body.data.__type) {
+ cy.task('log', '⚠️ Introspection might be disabled. Skipping Playbook type validation.');
+ return;
+ }
+
+ expect(response.body.data.__type).to.exist;
+ expect(response.body.data.__type.name).to.equal('Playbook');
+
+ const fields = response.body.data.__type.fields;
+ if (!fields || !Array.isArray(fields)) {
+ cy.task('log', '⚠️ Playbook type fields not accessible via introspection.');
+ return;
+ }
+
+ const propertyFieldsField = fields.find((f) => f.name === 'propertyFields');
+
+ if (propertyFieldsField) {
+ // * Verify propertyFields field exists and is an array of PropertyField
+ expect(propertyFieldsField.type.kind).to.equal('NON_NULL');
+ expect(propertyFieldsField.type.ofType.kind).to.equal('LIST');
+ cy.task('log', '✅ Playbook type includes propertyFields: [PropertyField!]! field');
+ } else {
+ cy.task('log', '❌ propertyFields field not found in Playbook type - backend schema may need updating');
+ const fieldNames = fields.map((f) => f.name);
+ cy.task('log', `Available fields: ${fieldNames.join(', ')}`);
+ }
+ });
+ });
+ });
+
+ describe('PropertyFields Integration Flow', () => {
+ let testPropertyFieldID;
+
+ it('should test full integration flow: create field -> query playbook -> verify consistency', () => {
+ cy.task('log', '🔍 Testing end-to-end property fields integration flow');
+
+ // # Step 1: Create a property field
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'AddTestPropertyField',
+ query: `
+ mutation AddTestPropertyField($playbookID: String!, $propertyField: PropertyFieldInput!) {
+ addPlaybookPropertyField(playbookID: $playbookID, propertyField: $propertyField)
+ }
+ `,
+ variables: {
+ playbookID: testPlaybook.id,
+ propertyField: {
+ name: 'E2E Test Priority',
+ type: 'select',
+ attrs: {
+ visibility: 'always',
+ sortOrder: 1,
+ options: [
+ {name: 'Critical', color: 'red'},
+ {name: 'Normal', color: 'blue'},
+ {name: 'Low', color: 'green'},
+ ],
+ },
+ },
+ },
+ },
+ method: 'POST',
+ failOnStatusCode: false,
+ }).then((response) => {
+ if (response.body.data && response.body.data.addPlaybookPropertyField) {
+ testPropertyFieldID = response.body.data.addPlaybookPropertyField;
+ cy.task('log', `✅ Step 1: Created property field with ID: ${testPropertyFieldID}`);
+
+ // # Step 2: Query playbook to get all property fields via bulk query
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'GetPlaybookWithFields',
+ query: `
+ query GetPlaybookWithFields($id: String!) {
+ playbook(id: $id) {
+ id
+ title
+ propertyFields {
+ id
+ name
+ type
+ attrs {
+ visibility
+ sortOrder
+ options {
+ name
+ color
+ }
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ id: testPlaybook.id,
+ },
+ },
+ method: 'POST',
+ }).then((bulkResponse) => {
+ expect(bulkResponse.status).to.equal(200);
+
+ if (bulkResponse.body.data && bulkResponse.body.data.playbook) {
+ const propertyFields = bulkResponse.body.data.playbook.propertyFields;
+ expect(propertyFields).to.be.an('array');
+
+ // * Find our created field in the bulk query results
+ const createdField = propertyFields.find((f) => f.id === testPropertyFieldID);
+ if (createdField) {
+ expect(createdField.name).to.equal('E2E Test Priority');
+ expect(createdField.type).to.equal('select');
+ expect(createdField.attrs.options).to.have.length(3);
+ cy.task('log', '✅ Step 2: Found created field in bulk propertyFields query');
+
+ // # Step 3: Query the same field individually for comparison
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: {
+ operationName: 'GetIndividualProperty',
+ query: `
+ query GetIndividualProperty($playbookID: String!, $propertyID: String!) {
+ playbookProperty(playbookID: $playbookID, propertyID: $propertyID) {
+ id
+ name
+ type
+ attrs {
+ visibility
+ sortOrder
+ options {
+ name
+ color
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ playbookID: testPlaybook.id,
+ propertyID: testPropertyFieldID,
+ },
+ },
+ method: 'POST',
+ }).then((individualResponse) => {
+ expect(individualResponse.status).to.equal(200);
+
+ if (individualResponse.body.data && individualResponse.body.data.playbookProperty) {
+ const individualField = individualResponse.body.data.playbookProperty;
+
+ // * Step 4: Verify data consistency between bulk and individual queries
+ expect(individualField.id).to.equal(createdField.id);
+ expect(individualField.name).to.equal(createdField.name);
+ expect(individualField.type).to.equal(createdField.type);
+ expect(individualField.attrs.visibility).to.equal(createdField.attrs.visibility);
+ expect(individualField.attrs.sortOrder).to.equal(createdField.attrs.sortOrder);
+ expect(individualField.attrs.options).to.have.length(createdField.attrs.options.length);
+
+ cy.task('log', '✅ Step 3: Individual property query returned same data');
+ cy.task('log', '✅ Step 4: Data consistency verified between bulk and individual queries');
+ cy.task('log', '🎉 End-to-end integration flow completed successfully!');
+ } else {
+ cy.task('log', '⚠️ Individual property query did not return expected data');
+ }
+ });
+ } else {
+ cy.task('log', '⚠️ Created field not found in bulk propertyFields query');
+ }
+ } else {
+ cy.task('log', '⚠️ Bulk playbook query did not return expected data');
+ }
+ });
+ } else {
+ cy.task('log', '⚠️ Property field creation failed or returned unexpected response');
+ }
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/runs_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/runs_spec.js
new file mode 100644
index 00000000000..5af3e209571
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/api/runs_spec.js
@@ -0,0 +1,189 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('api > runs', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('creating a run', () => {
+ describe('in an existing, public channel', () => {
+ it('with no team_id specified', () => {
+ // # Create a test channel without a playbook run
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel').then(({channel}) => {
+ // # Run the testPlaybook in the previously created channel
+
+ cy.apiRunPlaybook({
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ playbookId: testPlaybook.id,
+ }, {expectedStatusCode: 201}).then((body) => {
+ expect(body).to.have.property('owner_user_id', testUser.id);
+ expect(body).to.have.property('reporter_user_id', testUser.id);
+ expect(body).to.have.property('team_id', testTeam.id);
+ expect(body).to.have.property('channel_id', channel.id);
+ expect(body).to.have.property('playbook_id', testPlaybook.id);
+ });
+ });
+ });
+
+ it('with correct team_id specified', () => {
+ // # Create a test channel without a playbook run
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel').then(({channel}) => {
+ // # Run the testPlaybook in the previously created channel
+ cy.apiRunPlaybook({
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ playbookId: testPlaybook.id,
+ teamId: testTeam.id,
+ }, {expectedStatusCode: 201}).then((body) => {
+ expect(body).to.have.property('owner_user_id', testUser.id);
+ expect(body).to.have.property('reporter_user_id', testUser.id);
+ expect(body).to.have.property('team_id', testTeam.id);
+ expect(body).to.have.property('channel_id', channel.id);
+ expect(body).to.have.property('playbook_id', testPlaybook.id);
+ });
+ });
+ });
+
+ it('with wrong team_id specified', () => {
+ // # Create a test channel without a playbook run
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel').then(({channel}) => {
+ // # Run the testPlaybook in the previously created channel
+ cy.apiRunPlaybook({
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ playbookId: testPlaybook.id,
+ teamId: 'other_team_id',
+ }, {expectedStatusCode: 400}).then((body) => {
+ expect(body).to.have.property('error', 'unable to create playbook run');
+ });
+ });
+ });
+ });
+
+ describe('in an existing, private channel', () => {
+ it('with no team_id specified', () => {
+ // # Create a test channel without a playbook run
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel', 'P').then(({channel}) => {
+ // # Run the testPlaybook in the previously created channel
+ cy.apiRunPlaybook({
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ playbookId: testPlaybook.id,
+ }, {expectedStatusCode: 201}).then((body) => {
+ expect(body).to.have.property('owner_user_id', testUser.id);
+ expect(body).to.have.property('reporter_user_id', testUser.id);
+ expect(body).to.have.property('team_id', testTeam.id);
+ expect(body).to.have.property('channel_id', channel.id);
+ expect(body).to.have.property('playbook_id', testPlaybook.id);
+ });
+ });
+ });
+
+ it('with correct team_id specified', () => {
+ // # Create a test channel without a playbook run
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel', 'P').then(({channel}) => {
+ // # Run the testPlaybook in the previously created channel
+ cy.apiRunPlaybook({
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ playbookId: testPlaybook.id,
+ teamId: testTeam.id,
+ }, {expectedStatusCode: 201}).then((body) => {
+ expect(body).to.have.property('owner_user_id', testUser.id);
+ expect(body).to.have.property('reporter_user_id', testUser.id);
+ expect(body).to.have.property('team_id', testTeam.id);
+ expect(body).to.have.property('channel_id', channel.id);
+ expect(body).to.have.property('playbook_id', testPlaybook.id);
+ });
+ });
+ });
+
+ it('with wrong team_id specified', () => {
+ // # Create a test channel without a playbook run
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel', 'P').then(({channel}) => {
+ // # Run the testPlaybook in the previously created channel
+ cy.apiRunPlaybook({
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ playbookId: testPlaybook.id,
+ teamId: 'other_team_id',
+ }, {expectedStatusCode: 400}).then((body) => {
+ expect(body).to.have.property('error', 'unable to create playbook run');
+ });
+ });
+ });
+ });
+
+ it('in an existing, private channel, of which the user is not a member', () => {
+ // # Create a test channel without a playbook run
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel', 'P').then(({channel}) => {
+ // # Leave the channel
+ cy.apiRemoveUserFromChannel(channel.id, testUser.id);
+
+ // # Run the testPlaybook in the previously created channel
+ cy.apiRunPlaybook({
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ playbookId: testPlaybook.id,
+ teamId: testTeam.id,
+ }, {expectedStatusCode: 403}).then((body) => {
+ expect(body).to.have.property('error', 'unable to create playbook run');
+ });
+ });
+ });
+
+ it('in a channel with an existing playbook run', () => {
+ // # Run the playbook, creating a channel.
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'Playbook',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # Run the testPlaybook in the previously created channel
+ cy.apiRunPlaybook({
+ owner_user_id: testUser.id,
+ channel_id: playbookRun.channel_id,
+ playbook_id: testPlaybook.id,
+ }, {expectedStatusCode: 400}).then((body) => {
+ expect(body).to.have.property('error', 'unable to create playbook run');
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/app_bar_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/app_bar_spec.js
new file mode 100644
index 00000000000..aca33a9fe8d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/app_bar_spec.js
@@ -0,0 +1,61 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > App Bar', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ cy.apiAdminLogin();
+ });
+
+ it('App Bar disabled', () => {
+ cy.apiUpdateConfig({ExperimentalSettings: {DisableAppBar: true}});
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ it('should not show the Playbook App Bar icon', () => {
+ // # Navigate directly to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ cy.findByTestId('post_textbox').should('be.visible');
+
+ // * Verify App Bar icon is not showing
+ cy.get('.app-bar').should('not.exist');
+ });
+ });
+
+ it('App Bar enabled', () => {
+ cy.apiUpdateConfig({ExperimentalSettings: {DisableAppBar: false}});
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ it('should show "Playbooks" tooltip for Playbook App Bar icon', () => {
+ // # Navigate directly to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // # Hover over the channel header icon
+ cy.getPlaybooksAppBarIcon().trigger('mouseenter');
+
+ // * Verify tooltip text
+ cy.findByRole('tooltip', {name: 'Playbooks'}).should('be.visible');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/broadcast_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/broadcast_spec.js
new file mode 100644
index 00000000000..fbf84309b43
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/broadcast_spec.js
@@ -0,0 +1,390 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > broadcast', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testAdmin;
+ let testPublicChannel1;
+ let testPublicChannel2;
+ let testPrivateChannel1;
+ let testPrivateChannel2;
+ let publicBroadcastPlaybook;
+ let privateBroadcastPlaybook;
+ let allBroadcastPlaybook;
+ let rootDeletePlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateCustomAdmin().then(({sysadmin: adminUser}) => {
+ testAdmin = adminUser;
+ cy.apiAddUserToTeam(testTeam.id, adminUser.id);
+ cy.apiSaveJoinLeaveMessagesPreference(adminUser.id, false);
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'public-channel',
+ 'Public Channel 1',
+ 'O',
+ ).then(({channel: publicChannel1}) => {
+ testPublicChannel1 = publicChannel1;
+
+ // # Create a public channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'public-channel',
+ 'Public Channel 2',
+ 'O',
+ ).then(({channel: publicChannel2}) => {
+ testPublicChannel2 = publicChannel2;
+
+ // # Create a private channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'private-channel',
+ 'Private Channel 1',
+ 'P',
+ ).then(({channel: privateChannel1}) => {
+ testPrivateChannel1 = privateChannel1;
+
+ // # Create a private channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'private-channel',
+ 'Private Channel 2',
+ 'P',
+ ).then(({channel: privateChannel2}) => {
+ testPrivateChannel2 = privateChannel2;
+
+ // # Create a playbook that will broadcast to public channel1
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook - public broadcast',
+ userId: testUser.id,
+ broadcastChannelIds: [testPublicChannel1.id],
+ broadcastEnabled: true,
+ }).then((playbook) => {
+ publicBroadcastPlaybook = playbook;
+ });
+
+ // # Create a playbook that will broadcast to private channel1
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook - private broadcast',
+ userId: testUser.id,
+ broadcastChannelIds: [testPrivateChannel1.id],
+ broadcastEnabled: true,
+ }).then((playbook) => {
+ privateBroadcastPlaybook = playbook;
+ });
+
+ // # Create a playbook that will broadcast to all 4 channels
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook - public and private broadcast',
+ userId: testUser.id,
+ broadcastChannelIds: [testPublicChannel1.id, testPublicChannel2.id, testPrivateChannel1.id, testPrivateChannel2.id],
+ broadcastEnabled: true,
+ }).then((playbook) => {
+ allBroadcastPlaybook = playbook;
+ });
+
+ // # Create a playbook for testing deleting root posts
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook - test deleting root posts',
+ userId: testUser.id,
+ broadcastChannelIds: [testPublicChannel1.id, testPrivateChannel1.id],
+ broadcastEnabled: true,
+ otherMembers: [testAdmin.id],
+ invitedUserIds: [testAdmin.id],
+ }).then((playbook) => {
+ rootDeletePlaybook = playbook;
+ });
+
+ // # invite testAdmin to the channel they will need to be in to delete the post
+ cy.apiAddUserToChannel(testPublicChannel1.id, testAdmin.id);
+ cy.apiAddUserToChannel(testPrivateChannel1.id, testAdmin.id);
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Go to Town Square
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+ });
+
+ it('to public channels', () => {
+ // # Create a new playbook run
+ const now = Date.now();
+ const playbookRunName = `Playbook Run (${now})`;
+ const playbookRunChannelName = `playbook-run-${now}`;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: publicBroadcastPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Update the playbook run's status
+ const updateMessage = 'Update - ' + now;
+ cy.updateStatus(updateMessage);
+
+ // * Verify the posts
+ const initialMessage = playbookRunName;
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPublicChannel1.name, playbookRunName, initialMessage, updateMessage);
+ });
+
+ it('does not broadcast when broadcast is disabled, even if broadcastChannelIds contain data', () => {
+ // # Create a brand new channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'public-channel-do-not-broadcast',
+ 'Public Channel 1 - do not broadcast',
+ 'O',
+ ).then(({channel}) => {
+ // # Create a playbook with broadcast disabled, but with broadcastChannelIds containing channel1
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook - disabled public broadcast',
+ userId: testUser.id,
+ broadcastChannelIds: [channel.id],
+ broadcastEnabled: false,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Playbook Run (${now})`;
+ const playbookRunChannelName = `playbook-run-${now}`;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Update the playbook run's status
+ const updateMessage = 'Update - ' + now;
+ cy.updateStatus(updateMessage);
+
+ // # Navigate to the broadcast channel
+ cy.visit(`/${testTeam.name}/channels/${channel.name}`);
+
+ // * Verify that the last post is the system post containing the join message,
+ // so no announcement nor update was posted
+ cy.getLastPostId().then((lastPostId) => {
+ cy.get(`#postMessageText_${lastPostId}`).contains('You joined the channel');
+ });
+ });
+ });
+ });
+
+ it('to private channels', () => {
+ // # Create a new playbook run
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: privateBroadcastPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Update the playbook run's status
+ const updateMessage = 'Update - ' + now;
+ cy.updateStatus(updateMessage);
+
+ // * Verify the posts
+ const initialMessage = playbookRunName;
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPrivateChannel1.name, playbookRunName, initialMessage, updateMessage);
+ });
+
+ it('to 4 public and private channels', () => {
+ // # Create a new playbook run
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: allBroadcastPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Update the playbook run's status
+ const updateMessage = 'Update - ' + now;
+ cy.updateStatus(updateMessage, 0);
+
+ // * Verify the posts
+ const initialMessage = playbookRunName;
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPublicChannel1.name, playbookRunName, initialMessage, updateMessage);
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPrivateChannel1.name, playbookRunName, initialMessage, updateMessage);
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPublicChannel2.name, playbookRunName, initialMessage, updateMessage);
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPrivateChannel2.name, playbookRunName, initialMessage, updateMessage);
+ });
+
+ it('to 2 channels, delete the root post, update again', () => {
+ // # Create a new playbook run
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: rootDeletePlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Update the playbook run's status
+ const updateMessage = 'Update - ' + now;
+ cy.updateStatus(updateMessage, 0);
+
+ // * Verify the posts
+ const initialMessage = playbookRunName;
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPublicChannel1.name, playbookRunName, initialMessage, updateMessage);
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPrivateChannel1.name, playbookRunName, initialMessage, updateMessage);
+
+ // # need to be admin to delete the bot's posts
+ cy.apiLogin(testAdmin);
+
+ // # Delete both root posts
+ deleteLatestPostRoot(testTeam, testPublicChannel1.name);
+ deleteLatestPostRoot(testTeam, testPrivateChannel1.name);
+
+ // # Log back in as testUser
+ cy.apiLogin(testUser);
+
+ // # Make two more updates
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Update the playbook run's status twice
+ const updateMessage2 = updateMessage + ' - 2';
+ cy.updateStatus(updateMessage2, 0);
+ const updateMessage3 = updateMessage + ' - 3';
+ cy.updateStatus(updateMessage3, 0);
+
+ // * Verify the posts
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPublicChannel1.name, playbookRunName, updateMessage2, updateMessage3);
+ verifyInitialAndStatusPostInBroadcast(testTeam, testPrivateChannel1.name, playbookRunName, updateMessage2, updateMessage3);
+ });
+});
+
+const verifyInitialAndStatusPostInBroadcast = (testTeam, channelName, runName, initialMessage, updateMessage) => {
+ cy.log(`Verifying initial and status post in broadcast (channel ${channelName}, run ${runName})`);
+
+ // # Navigate to the broadcast channel
+ cy.visit(`/${testTeam.name}/channels/${channelName}`);
+
+ // * Verify that the last post contains the expected header and the update message verbatim
+ cy.getLastPostId().then((lastPostId) => {
+ // # Open RHS comment menu
+ cy.clickPostCommentIcon(lastPostId);
+
+ cy.get('#rhsContainer').
+ should('exist').
+ within(() => {
+ // * Thread should have two posts
+ cy.findAllByTestId('postContent').should('have.length', 2);
+
+ // * The first should be announcement
+ cy.findAllByTestId('postContent').eq(0).contains(initialMessage);
+
+ // * Latest post should be update
+ cy.get(`#rhsPost_${lastPostId}`).contains(
+ `posted an update for ${runName}`,
+ );
+ cy.get(`#rhsPost_${lastPostId}`).contains('tasks checked');
+ cy.get(`#rhsPost_${lastPostId}`).contains('participant');
+ cy.get(`#rhsPost_${lastPostId}`).contains(updateMessage);
+ });
+ });
+};
+
+const deleteLatestPostRoot = (testTeam, channelName) => {
+ cy.log(`Deleting latest root post (channel ${channelName})`);
+
+ // # Navigate to the channel
+ cy.visit(`/${testTeam.name}/channels/${channelName}`);
+
+ cy.getLastPostId().then((lastPostId) => {
+ // # Open RHS comment menu
+ cy.clickPostCommentIcon(lastPostId);
+
+ cy.get('#rhsContainer').
+ should('exist').
+ within(() => {
+ cy.findAllByTestId('postContent').eq(0).parent().then((root) => {
+ const rootId = root.attr('id').slice(8);
+
+ // # Click root's post dot menu.
+ cy.clickPostDotMenu(rootId, 'RHS_ROOT');
+
+ // # Click delete button.
+ const id = `#delete_post_${rootId}`;
+ cy.wrap(id).as('deleteId');
+ });
+ });
+
+ // * Post extra options is visible
+ cy.findByLabelText('Post extra options').should('exist');
+
+ // # Click delete button.
+ cy.get('@deleteId').then((deleteId) => {
+ cy.get(deleteId).should('be.visible').click();
+ });
+
+ // * Check that confirmation dialog is open.
+ cy.get('#deletePostModal').should('be.visible');
+
+ // * Check that confirmation dialog contains correct text
+ cy.get('#deletePostModal').
+ should('contain', 'Are you sure you want to delete this message?');
+
+ // * Check that confirmation dialog shows that the post has one comment on it
+ cy.get('#deletePostModal').should('contain', 'This message has 1 comment on it.');
+
+ // # Confirm deletion.
+ cy.get('#deletePostModalButton').click();
+ });
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/channel_header_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/channel_header_spec.js
new file mode 100644
index 00000000000..e12f9deedda
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/channel_header_spec.js
@@ -0,0 +1,138 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > channel header', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let testPlaybookRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ // # Start a playbook run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'Playbook Run',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ testPlaybookRun = run;
+ });
+ });
+ });
+ });
+
+ describe('App Bar enabled', () => {
+ it('webapp should hide the Playbook channel header button', () => {
+ cy.apiAdminLogin();
+ cy.apiUpdateConfig({ExperimentalSettings: {DisableAppBar: false}});
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Navigate directly to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // * Verify channel header button is not showing
+ cy.get('#channel-header').within(() => {
+ cy.get('#incidentIcon').should('not.exist');
+ });
+ });
+ });
+
+ describe('App Bar disabled', () => {
+ beforeEach(() => {
+ cy.apiAdminLogin();
+ cy.apiUpdateConfig({ExperimentalSettings: {DisableAppBar: true}});
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ it('webapp should show the Playbook channel header button', () => {
+ // # Navigate directly to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // * Verify channel header button is showing
+ cy.get('#channel-header').within(() => {
+ cy.get('#incidentIcon').should('exist');
+ });
+ });
+
+ it('tooltip text should show "Playbooks" for channel header button', () => {
+ // # Navigate directly to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // # Hover over the channel header icon
+ cy.get('#channel-header').within(() => {
+ cy.get('#incidentIcon').trigger('mouseenter');
+ });
+
+ // * Verify tooltip text
+ cy.findByRole('tooltip', {name: 'Playbooks'}).should('be.visible');
+ });
+
+ it('webapp should make the Playbook channel header button active when opened', () => {
+ // # Navigate directly to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ cy.get('#channel-header').within(() => {
+ // # Click the channel header button
+ cy.get('#incidentIcon').click();
+
+ // * Verify channel header button is showing active className
+ cy.get('#incidentIcon').parent().
+ should('have.class', 'channel-header__icon--active-inverted');
+ });
+ });
+ });
+
+ describe('description text', () => {
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ it('should contain a link to the playbook', () => {
+ // # Navigate directly to a playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // * Verify link to playbook
+ cy.get('.header-description__text').findByText('Playbook').should('have.attr', 'href').then((href) => {
+ expect(href).to.equals(`/playbooks/playbooks/${testPlaybook.id}`);
+ });
+ });
+
+ it('should contain a link to the overview page', () => {
+ // # Navigate directly to a playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // * Verify link to overview page
+ cy.get('.header-description__text').findByText('the overview page').should('have.attr', 'href').then((href) => {
+ expect(href).to.equals(`/playbooks/runs/${testPlaybookRun.id}`);
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/general_actions_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/general_actions_spec.js
new file mode 100644
index 00000000000..407e77b3047
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/general_actions_spec.js
@@ -0,0 +1,442 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+import * as TIMEOUTS from '../../../fixtures/timeouts';
+
+describe('channels > general actions', {testIsolation: true}, () => {
+ let testTeam;
+ let testSysadmin;
+ let testUser;
+ let testChannel;
+
+ beforeEach(() => {
+ cy.apiAdminLogin();
+
+ cy.apiInitSetup({promoteNewUserAsAdmin: true}).then(({team, user}) => {
+ testTeam = team;
+ testSysadmin = user;
+
+ cy.apiCreateUser().then((resp) => {
+ testUser = resp.user;
+ cy.apiAddUserToTeam(team.id, resp.user.id);
+
+ cy.apiLogin(testUser);
+
+ // TODO: Make this work with CRT enabled.
+ cy.apiSaveCRTPreference(testUser.id, 'off');
+ });
+
+ cy.apiLogin(testSysadmin);
+
+ // TODO: Make this work with CRT enabled.
+ cy.apiSaveCRTPreference(testSysadmin.id, 'off');
+
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'action-channel',
+ 'Action Channel',
+ 'O',
+ ).then(({channel}) => {
+ testChannel = channel;
+ });
+ });
+ });
+
+ describe('on join trigger', () => {
+ it('channel categorization can be enabled and works', () => {
+ // # Go to the test channel
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // # Enable the categorization action and set the name
+ cy.contains('sidebar category').click();
+ cy.contains('Enter category name').click().type('example category{enter}');
+
+ cy.get('#channel-actions-modal').within(() => {
+ // # Save action
+ cy.findByRole('button', {name: /save/i}).click();
+ });
+
+ // # Switch to another user and reload
+ // # This drops them into the same channel
+ cy.apiLogin(testUser);
+ cy.reload();
+ cy.wait(TIMEOUTS.TEN_SEC);
+
+ // * Verify the channel category + channel exists
+ cy.contains('.SidebarChannelGroup', 'example category', {matchCase: false}).
+ should('exist').
+ within(() => {
+ cy.contains(testChannel.display_name).should('exist');
+ });
+ });
+
+ it('welcome message can be enabled and is shown to a joining user', () => {
+ // # Go to the test channel
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // # Toggle on and set the welcome message
+ cy.contains('temporary welcome message').click();
+ cy.findByTestId('channel-actions-modal_welcome-msg').
+ type('test ephemeral welcome message');
+
+ cy.get('#channel-actions-modal').within(() => {
+ // # Save action
+ cy.findByRole('button', {name: /save/i}).click();
+ });
+
+ // # Switch to another user and reload
+ // # This drops them into the same channel
+ cy.apiLogin(testUser);
+ cy.reload();
+ cy.wait(TIMEOUTS.FIVE_SEC);
+
+ // * Verify the welcome message is shown
+ cy.verifyEphemeralMessage('test ephemeral welcome message');
+ });
+ });
+
+ describe('keyword trigger', () => {
+ it('prompt to run playbook can be enabled and works', () => {
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ });
+
+ // # Login as the non-sysadmin user first
+ // # to do the channel & action creation.
+ // # In the 'Select a playbook' dropdown later in this test,
+ // # sysadmin users could potentially see many other playbooks
+ // # besides the one created directly above. `testUser` will not.
+ cy.apiLogin(testUser);
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'action-channel',
+ 'Action Channel',
+ 'O',
+ ).then(({channel}) => {
+ // # Go to the test channel
+ cy.visit(`/${testTeam.name}/channels/${channel.name}`);
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // # Set a keyword, enable the playbook trigger,
+ // # and select the Playbook to run
+ cy.contains('Type a keyword or phrase, then press Enter on your keyboard').click().type('red alert{enter}');
+ cy.contains('Prompt to run a playbook').click();
+ cy.contains('Select a playbook').click();
+ cy.findByText('Public Playbook').click();
+
+ cy.get('#channel-actions-modal').within(() => {
+ // # Save action
+ cy.findByRole('button', {name: /save/i}).click();
+ });
+
+ // # Post the trigger phrase
+ cy.uiPostMessageQuickly('error detected red alert!');
+
+ // * Verify that the bot posts the expected prompt
+ // # Open the playbook run modal
+ cy.getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ cy.contains('trigger for the Public Playbook').should('exist');
+ cy.contains('Yes, run playbook').should('exist').click();
+ });
+ });
+
+ // # Enter a name and start the run
+ cy.findByTestId('playbookRunNameinput').type('run from trigger');
+ cy.findByRole('button', {name: /start run/i}).click();
+
+ // * Verify the run name is displayed in the RHS
+ cy.findByTestId('rendered-run-name').should('be.visible').contains('run from trigger');
+ });
+ });
+
+ it('deletes the post and ignores the thread when clicking on No, ignore thread', () => {
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ });
+
+ // # Login as the non-sysadmin user first
+ // # to do the channel & action creation.
+ // # In the 'Select a playbook' dropdown later in this test,
+ // # sysadmin users could potentially see many other playbooks
+ // # besides the one created directly above. `testUser` will not.
+ cy.apiLogin(testUser);
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'action-channel',
+ 'Action Channel',
+ 'O',
+ ).then(({channel}) => {
+ // # Go to the test channel
+ cy.visit(`/${testTeam.name}/channels/${channel.name}`);
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // # Set a keyword, enable the playbook trigger,
+ // # and select the Playbook to run
+ cy.contains('Type a keyword or phrase, then press Enter on your keyboard').click().type('red alert{enter}');
+ cy.contains('Prompt to run a playbook').click();
+ cy.contains('Select a playbook').click();
+ cy.findByText('Public Playbook').click();
+
+ cy.get('#channel-actions-modal').within(() => {
+ // # Save action
+ cy.findByRole('button', {name: /save/i}).click();
+ });
+
+ // # Post the trigger phrase
+ cy.uiPostMessageQuickly('error detected red alert!');
+
+ // * Verify that the bot posts the expected prompt
+ // # Click on No, ignore thread
+ cy.getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ cy.contains('trigger for the Public Playbook').should('exist');
+ cy.contains('No, ignore thread').should('exist').click();
+ });
+ });
+
+ // # Reload the channel
+ cy.visit(`/${testTeam.name}/channels/${channel.name}`);
+
+ // * Verify that the prompt post is no longer there
+ cy.getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ cy.contains('No, ignore thread').should('not.exist');
+ });
+ });
+
+ // # Reply to the last thread with the trigger phrase
+ cy.getLastPostId().then((postId) => {
+ cy.clickPostCommentIcon(postId);
+ cy.postMessageReplyInRHS('error detected red alert!');
+ });
+
+ // * Verify that the bot did not post the prompt
+ cy.getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ cy.contains('trigger for the Public Playbook').should('not.exist');
+ });
+ });
+ });
+ });
+
+ it('MM-58432 - prevents users from deleting an arbitrary post by crafting a query', () => {
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ });
+
+ // # Login
+ // # send a message and get its postID
+ // # create and trigger an alert
+ // # send a random postID
+ // # try to remove this post: this should fail
+ // # just to make sure the principle of this test work, doing the same step on the bot message should delete the bot post
+ cy.apiLogin(testUser);
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'action-channel',
+ 'Action Channel',
+ 'O',
+ ).then(({channel}) => {
+ // # Go to the test channel
+ cy.visit(`/${testTeam.name}/channels/${channel.name}`);
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // # Set a keyword, enable the playbook trigger,
+ // # and select the Playbook to run
+ cy.contains('Type a keyword or phrase, then press Enter on your keyboard').click().type('red alert{enter}');
+ cy.contains('Prompt to run a playbook').click();
+ cy.contains('Select a playbook').click();
+ cy.findByText('Public Playbook').click();
+
+ cy.get('#channel-actions-modal').within(() => {
+ // # Save action
+ cy.findByRole('button', {name: /save/i}).click();
+ });
+
+ cy.uiPostMessageQuickly('this is a red alert !');
+
+ let botPostId;
+ cy.getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ cy.contains('trigger for the Public Playbook').should('exist');
+ botPostId = postId;
+ });
+ });
+
+ cy.uiPostMessageQuickly('do not delete me!');
+ cy.getLastPostId().then((postId) => {
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/signal/keywords/ignore-thread',
+ method: 'POST',
+ failOnStatusCode: false,
+ body: {
+ post_id: postId,
+ context: {postID: postId},
+ },
+ }).then(() => {
+ // * Verify that the post is still there
+ cy.get(`#post_${postId}`).should('exist');
+
+ // Now if we do that same request but on the bot message, it should be deleted
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/signal/keywords/ignore-thread',
+ method: 'POST',
+ body: {
+ post_id: botPostId,
+ context: {postID: botPostId},
+ },
+ }).then(() => {
+ // * Verify that the post is still deleted
+ cy.get(`#post_${botPostId}`).within(() => {
+ cy.contains('(message deleted)').should('exist');
+ });
+ });
+ });
+ });
+ });
+ });
+
+ it('disabled triggers do not run even with a keyword set', () => {
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ });
+
+ // # Login as the non-sysadmin user first
+ // # to do the channel & action creation.
+ // # In the 'Select a playbook' dropdown later in this test,
+ // # sysadmin users could potentially see many other playbooks
+ // # besides the one created directly above. `testUser` will not.
+ cy.apiLogin(testUser);
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'action-channel',
+ 'Action Channel',
+ 'O',
+ ).then(({channel}) => {
+ // # Go to the test channel
+ cy.visit(`/${testTeam.name}/channels/${channel.name}`);
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // # Set a keyword, enable the playbook trigger,
+ // # and select the playbook to run. Turn the
+ // # trigger back off but leave the keyword set.
+ cy.contains('Type a keyword or phrase, then press Enter on your keyboard').click().type('red alert{enter}');
+ cy.contains('Prompt to run a playbook').click();
+ cy.contains('Select a playbook').click();
+ cy.findByText('Public Playbook').click();
+ cy.contains('Prompt to run a playbook').click();
+
+ cy.get('#channel-actions-modal').within(() => {
+ // # Save action
+ cy.findByRole('button', {name: /save/i}).click();
+ });
+
+ // # Post the trigger phrase
+ cy.uiPostMessageQuickly('error detected red alert!');
+
+ // * Verify that the bot _has not_ posted the expected prompt
+ cy.getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ cy.contains('trigger for the Public Playbook').should('not.exist');
+ cy.contains('Yes, run playbook').should('not.exist');
+ });
+ });
+ });
+ });
+ });
+
+ it('action settings are reset to the default when switching to a channel with no actions configured', () => {
+ // # Create an additional channel
+ const name = 'New channel ' + Date.now();
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'new-channel',
+ name,
+ 'O',
+ ).then(({channel}) => {
+ // # Visit the first channel
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // # Enable the categorization action and set the name
+ const categoryName = 'example category ' + Date.now();
+ cy.contains('sidebar category').click();
+ cy.contains('Enter category name').click().type(categoryName + '{enter}');
+
+ cy.get('#channel-actions-modal').within(() => {
+ // # Save action
+ cy.findByRole('button', {name: /save/i}).click();
+ });
+
+ // # wait to avoid MM-45969
+ cy.wait(5000);
+
+ // # Switch to the additional channel
+ cy.get('#sidebarItem_' + channel.name).click();
+
+ // # Open Channel Header and the Channel Actions modal
+ cy.get('#channelHeaderDropdownButton').click();
+ cy.findByText('More actions').trigger('mouseover');
+ cy.findByText('Channel Actions').click();
+
+ // * Verify that the categorization action is disabled
+ cy.findByText('Add the channel to a sidebar category for the user').parent().within(() => {
+ cy.get('input').should('not.be.checked');
+ });
+
+ // * Verify that the category name is not there
+ cy.findByText(categoryName).should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/playbook_run_actions.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/playbook_run_actions.js
new file mode 100644
index 00000000000..171659ca050
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/playbook_run_actions.js
@@ -0,0 +1,644 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > actions', {testIsolation: true}, () => {
+ let testTeam;
+ let testSysadmin;
+ let testUser;
+ let testPublicChannel;
+ const testUsers = [];
+
+ before(() => {
+ cy.apiInitSetup({userPrefix: 'u'}).then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateCustomAdmin().then(({sysadmin}) => {
+ testSysadmin = sysadmin;
+ });
+
+ // # Create extra test users in this team
+ cy.apiCreateUser({prefix: 'u'}).then((payload) => {
+ cy.apiAddUserToTeam(testTeam.id, payload.user.id);
+ testUsers.push(payload.user);
+ });
+
+ cy.apiCreateUser({prefix: 'u'}).then((payload) => {
+ cy.apiAddUserToTeam(testTeam.id, payload.user.id);
+ testUsers.push(payload.user);
+ });
+
+ // # Create a public channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'public-channel',
+ 'Public Channel',
+ 'O',
+ ).then(({channel}) => {
+ testPublicChannel = channel;
+ cy.apiAddUserToChannel(channel.id, testUser.id);
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Go to Town Square
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+ });
+
+ describe(('when a playbook run starts'), () => {
+ describe('invite members setting', () => {
+ it('with no invited users and setting disabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+ let playbookId;
+
+ // # Create a playbook with the invite users disabled and no invited users
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ invitedUserIds: [],
+ inviteUsersEnabled: false,
+ }).then((playbook) => {
+ playbookId = playbook.id;
+ });
+
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that no users were invited
+ cy.getFirstPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).
+ contains('You were added to the channel by @playbooks.').
+ should('not.contain', 'joined the channel');
+ });
+ });
+
+ it('with invited users and setting enabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with a couple of invited users and the setting enabled, and a playbook run with it
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ invitedUserIds: [testUsers[0].id, testUsers[1].id],
+ inviteUsersEnabled: true,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the users were invited
+ cy.getFirstPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).within(() => {
+ cy.findByText('2 others').click();
+ });
+
+ cy.get(`#postMessageText_${id}`).contains(`@${testUsers[0].username}`);
+ cy.get(`#postMessageText_${id}`).contains(`@${testUsers[1].username}`);
+ cy.get(`#postMessageText_${id}`).contains('added to the channel by @playbooks.');
+ });
+ });
+ });
+
+ it('with invited users and setting disabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with a couple of invited users and the setting enabled, and a playbook run with it
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ invitedUserIds: [testUsers[0].id, testUsers[1].id],
+ inviteUsersEnabled: false,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that no users were invited
+ cy.getFirstPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).
+ contains('You were added to the channel by @playbooks.').
+ should('not.contain', 'joined the channel');
+ });
+ });
+ });
+
+ it('with non-existent users', () => {
+ let userToRemove;
+ let playbook;
+
+ // # Create a playbook with a user that is later removed from the team
+ cy.apiLogin(testSysadmin).then(() => {
+ cy.apiCreateUser().then((result) => {
+ userToRemove = result.user;
+ cy.apiAddUserToTeam(testTeam.id, userToRemove.id);
+
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with the user that will be removed from the team.
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id, testSysadmin.id],
+ invitedUserIds: [userToRemove.id],
+ inviteUsersEnabled: true,
+ }).then((res) => {
+ playbook = res;
+ });
+
+ // # Remove user from the team
+ cy.apiDeleteUserFromTeam(testTeam.id, userToRemove.id);
+ });
+ }).then(() => {
+ cy.apiLogin(testUser);
+
+ // # Create a new playbook run with the playbook.
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that there is an error message from the bot
+ cy.getNthPostId(1).then((id) => {
+ cy.get(`#postMessageText_${id}`).
+ contains(`Failed to invite the following users: @${userToRemove.username}`);
+ });
+ });
+ });
+ });
+
+ describe('default owner setting', () => {
+ it('defaults to the creator when no owner is specified', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+ let playbookId;
+
+ // # Create a playbook with the default owner setting set to false
+ // and no owner specified
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ defaultOwnerId: '',
+ defaultOwnerEnabled: false,
+ }).then((playbook) => {
+ playbookId = playbook.id;
+ });
+
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the RHS shows the owner being the creator
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Owner').parent().within(() => {
+ cy.findByText(`@${testUser.username}`);
+ });
+ });
+ });
+
+ it('defaults to the creator when no owner is specified, even if the setting is enabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+ let playbookId;
+
+ // # Create a playbook with the default owner setting set to false
+ // and no owner specified
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ defaultOwnerId: '',
+ defaultOwnerEnabled: true,
+ }).then((playbook) => {
+ playbookId = playbook.id;
+ });
+
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the RHS shows the owner being the creator
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Owner').parent().within(() => {
+ cy.findByText(`@${testUser.username}`);
+ });
+ });
+ });
+
+ it('assigns the owner when they are part of the invited members list', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with the owner being part of the invited users
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ invitedUserIds: [testUsers[0].id],
+ inviteUsersEnabled: true,
+ defaultOwnerId: testUsers[0].id,
+ defaultOwnerEnabled: true,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the RHS shows the owner being the invited user
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Owner').parent().within(() => {
+ cy.findByText(`@${testUsers[0].username}`);
+ });
+ });
+ });
+ });
+
+ it('assigns the owner even if they are not invited', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with the owner being part of the invited users
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ invitedUserIds: [],
+ inviteUsersEnabled: false,
+ defaultOwnerId: testUsers[0].id,
+ defaultOwnerEnabled: true,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the RHS shows the owner being the invited user
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Owner').parent().within(() => {
+ cy.findByText(`@${testUsers[0].username}`);
+ });
+ });
+ });
+ });
+
+ it('assigns the owner when they and the creator are the same', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+ let playbookId;
+
+ // # Create a playbook with the default owner setting set to false
+ // and no owner specified
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ defaultOwnerId: testUser.id,
+ defaultOwnerEnabled: true,
+ }).then((playbook) => {
+ playbookId = playbook.id;
+ });
+
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the RHS shows the owner being the creator
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Owner').parent().within(() => {
+ cy.findByText(`@${testUser.username}`);
+ });
+ });
+ });
+ });
+
+ describe('broadcast channel setting', () => {
+ it('with channel configured and setting enabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with a couple of invited users and the setting enabled, and a playbook run with it
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ broadcastChannelIds: [testPublicChannel.id],
+ broadcastEnabled: true,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel.
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the channel is created and that the first post exists.
+ cy.getFirstPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).
+ contains('You were added to the channel by @playbooks.').
+ should('not.contain', 'joined the channel');
+ });
+
+ // # Navigate to the broadcast channel
+ cy.visit(`/${testTeam.name}/channels/${testPublicChannel.name}`);
+
+ cy.getLastPostId().then((lastPostId) => {
+ cy.get(`#postMessageText_${lastPostId}`).contains(`${playbookRunName}`);
+ cy.get(`#postMessageText_${lastPostId}`).contains(`@${testUser.username} ran the ${playbookName} playbook.`);
+ });
+ });
+ });
+
+ it('with channel configured and setting disabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with a couple of invited users and the setting enabled, and a playbook run with it
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ broadcastChannelIds: [testPublicChannel.id],
+ broadcastEnabled: false,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the channel is created and that the first post exists.
+ cy.getFirstPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).
+ contains('You were added to the channel by @playbooks.').
+ should('not.contain', 'joined the channel');
+ });
+
+ // # Navigate to the broadcast channel
+ cy.visit(`/${testTeam.name}/channels/${testPublicChannel.name}`);
+
+ cy.getLastPostId().then((lastPostId) => {
+ cy.get(`#postMessageText_${lastPostId}`).should('not.contain', `New Run: ~${playbookRunName}`);
+ });
+ });
+ });
+
+ it('with non-existent channel', () => {
+ let playbookId;
+
+ // # Create a playbook with a channel that is later deleted
+ cy.apiLogin(testSysadmin).then(() => {
+ const channelDisplayName = String('Channel to delete ' + Date.now());
+ const channelName = channelDisplayName.replace(/ /g, '-').toLowerCase();
+ cy.apiCreateChannel(testTeam.id, channelName, channelDisplayName).then(({channel}) => {
+ // # Create a playbook with the channel to be deleted as the announcement channel
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id, testSysadmin.id],
+ broadcastChannelIds: [channel.id],
+ broadcastEnabled: true,
+ }).then((playbook) => {
+ playbookId = playbook.id;
+ });
+
+ // # Delete channel
+ cy.apiDeleteChannel(channel.id);
+ });
+ }).then(() => {
+ cy.apiLogin(testUser);
+
+ // # Create a new playbook run with the playbook.
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that there is an error message from the bot
+ cy.getLastPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).
+ contains('Failed to broadcast run creation to the configured channel.');
+ });
+ });
+ });
+ });
+
+ describe('creation webhook setting', () => {
+ it('with webhook correctly configured and setting enabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a playbook with a correct webhook and the setting enabled
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ webhookOnCreationURLs: ['https://httpbin.org/post'],
+ webhookOnCreationEnabled: true,
+ }).then((playbook) => {
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ description: 'Playbook run description.',
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the bot has not posted a message informing of the failure to send the webhook
+ cy.getLastPostId().then((lastPostId) => {
+ cy.get(`#postMessageText_${lastPostId}`).
+ should('not.contain', 'Playbook run creation announcement through the outgoing webhook failed. Contact your System Admin for more information.');
+ });
+ });
+ });
+ });
+ });
+
+ describe('when a playbook run is finished', () => {
+ it('retrospective is disabled', () => {
+ const playbookName = 'Playbook (' + Date.now() + ')';
+
+ // # Create a new playbook run with that playbook
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ const playbookRunChannelName = `run-${now}`;
+
+ // # Create a playbook with the disabled retrospective functionality
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookName,
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id],
+ retrospectiveEnabled: false,
+ }).then((playbook) => {
+ // # Run playbook
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+ }).then((playbookRun) => {
+ // # End the playbook run
+ cy.apiFinishRun(playbookRun.id);
+ });
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that playbook run finished message was posted
+ cy.findAllByTestId('postView').contains(`marked ${playbookName} as finished`);
+
+ // * Verify that retrospective dialog was not posted
+ cy.findAllByTestId('retrospective-reminder').should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/post_type_components_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/post_type_components_spec.js
new file mode 100644
index 00000000000..70b52e0495c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/post_type_components_spec.js
@@ -0,0 +1,130 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > post type components', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testChannel;
+ let testPlaybookRun;
+
+ beforeEach(() => {
+ cy.apiAdminLogin();
+
+ cy.apiInitSetup({loginAfter: true}).then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ }).then((playbook) => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: 'Test Run',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+ });
+ });
+
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'other-channel',
+ 'Other Channel',
+ 'O',
+ ).then(({channel}) => {
+ testChannel = channel;
+ });
+ });
+ });
+
+ describe('update post (custom_run_update)', () => {
+ it('displays in run channel', () => {
+ // # Go to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/test-run`);
+
+ // # Post a status update
+ cy.apiUpdateStatus({
+ playbookRunId: testPlaybookRun.id,
+ message: 'status update',
+ reminder: 60,
+ });
+
+ cy.getLastPost().then((element) => {
+ // * Verify the expected message text
+ cy.get(element).contains(`${testUser.username} posted an update for ${testPlaybookRun.name}`);
+ cy.get(element).contains('status update');
+ });
+ });
+
+ it('displays when permalinked in a different channel', () => {
+ // # Go to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/test-run`);
+
+ // # Post a status update
+ cy.apiUpdateStatus({
+ playbookRunId: testPlaybookRun.id,
+ message: 'status update',
+ reminder: 60,
+ });
+
+ // Grab the post id
+ cy.getLastPostId().then((postId) => {
+ // # Go to the other channel
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Post a permalink to the status update
+ cy.uiPostMessageQuickly(`${Cypress.config('baseUrl')}/${testTeam.name}/pl/${postId}`);
+
+ cy.getLastPost().then((element) => {
+ // * Verify the expected message text
+ cy.get(element).contains(`${testUser.username} posted an update for ${testPlaybookRun.name}`);
+ cy.get(element).contains('status update');
+ });
+ });
+ });
+
+ // https://mattermost.atlassian.net/browse/MM-63645
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('displays when permalinked in a different channel, even if not a member of the original channel', () => {
+ // # Go to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/test-run`);
+
+ // # Post a status update
+ cy.apiUpdateStatus({
+ playbookRunId: testPlaybookRun.id,
+ message: 'status update',
+ reminder: 60,
+ });
+
+ cy.getLastPostId().then((postId) => {
+ // # Leave the playbook run channel
+ cy.uiLeaveChannel();
+
+ // # Go to the other channel
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Post a permalink to the status update
+ cy.uiPostMessageQuickly(`${Cypress.config('baseUrl')}/${testTeam.name}/pl/${postId}`);
+
+ cy.getLastPost().then((element) => {
+ // * Verify the expected message text
+ cy.get(element).contains(`${testUser.username} posted an update for ${testPlaybookRun.name}`);
+ cy.get(element).contains('status update');
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/retrospective_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/retrospective_spec.js
new file mode 100644
index 00000000000..f8541a90610
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/retrospective_spec.js
@@ -0,0 +1,242 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > retrospective', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybookWithMetrics;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create playbook with metrics
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook with metrics',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ metrics: [
+ {
+ title: 'Time to acknowledge',
+ description: 'some description text',
+ type: 'metric_duration',
+ target: 7200000,
+ },
+ {
+ title: 'Cost',
+ description: 'Cost of some events',
+ type: 'metric_currency',
+ target: 400,
+ },
+ {
+ title: 'Number of customers',
+ description: 'Number of customers who had issues',
+ type: 'metric_integer',
+ target: 30,
+ },
+ {
+ title: 'Duration',
+ description: 'Duration of incident',
+ type: 'metric_duration',
+ },
+ ],
+ }).then((playbook) => {
+ testPlaybookWithMetrics = playbook;
+ });
+ });
+
+ describe('runs with metrics', () => {
+ let runId;
+ let runName;
+ let playbookRunChannelName;
+
+ beforeEach(() => {
+ // # Create a new playbook run
+ const now = Date.now();
+ runName = `Run (${now})`;
+ playbookRunChannelName = `run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybookWithMetrics.id,
+ playbookRunName: runName,
+ ownerUserId: testUser.id,
+ createPublicPlaybookRun: true,
+ }).then((run) => {
+ runId = run.id;
+ });
+ });
+
+ describe('publish retrospective', () => {
+ it('retrospective with 4 key metrics', () => {
+ // # Navigate directly to the retro tab
+ cy.visit(`/playbooks/runs/${runId}/retrospective`);
+
+ // * Verify metrics number
+ cy.getStyledComponent('InputContainer').should('have.length', 4);
+
+ // # Enter metrics values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('00:11:10').
+ tab().type('560').
+ tab().type('12').
+ tab().type('14:00:59');
+
+ // # Publish retrospective
+ publishRetro();
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify channel retro post content
+ cy.findAllByTestId('postView').last().contains(`Retrospective for ${runName} has been published by`);
+ cy.getStyledComponent('MetricInfo').should('have.length', 4);
+ cy.getStyledComponent('MetricInfo').eq(0).contains('11 hours, 10 minutes');
+ cy.getStyledComponent('MetricInfo').eq(1).contains('560');
+ cy.getStyledComponent('MetricInfo').eq(2).contains('12');
+ cy.getStyledComponent('MetricInfo').eq(3).contains('14 days, 59 minutes');
+ });
+
+ it('retrospective with 3 key metrics', () => {
+ // # Remove first metric, leave only 3
+ testPlaybookWithMetrics.metrics.splice(0, 1);
+ cy.apiUpdatePlaybook(testPlaybookWithMetrics).then(() => {
+ // # Navigate directly to the retro tab
+ cy.visit(`/playbooks/runs/${runId}/retrospective`);
+
+ // * Verify metrics number
+ cy.getStyledComponent('InputContainer').should('have.length', 3);
+
+ // # Enter metrics values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('43').
+ tab().type('121').
+ tab().type('11:00:02');
+
+ // # Publish retrospective
+ publishRetro();
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify channel retro post content
+ cy.findAllByTestId('postView').last().contains(`Retrospective for ${runName} has been published by`);
+ cy.getStyledComponent('MetricInfo').should('have.length', 3);
+ cy.getStyledComponent('MetricInfo').eq(0).contains('43');
+ cy.getStyledComponent('MetricInfo').eq(1).contains('121');
+ cy.getStyledComponent('MetricInfo').eq(2).contains('11 days, 2 minutes');
+ });
+ });
+
+ it('retrospective with 2 key metrics', () => {
+ // # Remove first two metrics, leave only 2
+ testPlaybookWithMetrics.metrics.splice(0, 2);
+ cy.apiUpdatePlaybook(testPlaybookWithMetrics).then(() => {
+ // # Navigate directly to the retro tab
+ cy.visit(`/playbooks/runs/${runId}/retrospective`);
+
+ // * Verify metrics number
+ cy.getStyledComponent('InputContainer').should('have.length', 2);
+
+ // # Enter metrics values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('0').
+ tab().type('00:04:02');
+
+ // # Publish retrospective
+ publishRetro();
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify channel retro post content
+ cy.findAllByTestId('postView').last().contains(`Retrospective for ${runName} has been published by`);
+ cy.getStyledComponent('MetricInfo').should('have.length', 2);
+ cy.getStyledComponent('MetricInfo').eq(0).contains('0');
+ cy.getStyledComponent('MetricInfo').eq(1).contains('4 hours, 2 minutes');
+ });
+ });
+
+ it('retrospective with 1 key metrics', () => {
+ // # Remove first 3 metrics, leave only 1
+ testPlaybookWithMetrics.metrics.splice(0, 3);
+ cy.apiUpdatePlaybook(testPlaybookWithMetrics).then(() => {
+ // # Navigate directly to the retro tab
+ cy.visit(`/playbooks/runs/${runId}/retrospective`);
+
+ // * Verify metrics number
+ cy.getStyledComponent('InputContainer').should('have.length', 1);
+
+ // # Enter metrics values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('00:00:00');
+
+ // # Publish retrospective
+ publishRetro();
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify channel retro post content
+ cy.findAllByTestId('postView').last().contains(`Retrospective for ${runName} has been published by`);
+ cy.getStyledComponent('MetricInfo').should('have.length', 1);
+ cy.getStyledComponent('MetricInfo').eq(0).contains('0 seconds');
+ });
+ });
+
+ it('retrospective with no metrics', () => {
+ // # Remove all metrics
+ testPlaybookWithMetrics.metrics.splice(0, 4);
+ cy.apiUpdatePlaybook(testPlaybookWithMetrics).then(() => {
+ // # Navigate directly to the retro tab
+ cy.visit(`/playbooks/runs/${runId}/retrospective`);
+
+ // * Verify there are no metrics inputs
+ cy.getStyledComponent('InputContainer').should('not.exist');
+
+ // # Publish retrospective
+ publishRetro();
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify channel retro post content
+ cy.findAllByTestId('postView').last().contains(`Retrospective for ${runName} has been published by`);
+ cy.getStyledComponent('MetricInfo').should('not.exist');
+ });
+ });
+ });
+ });
+});
+
+const publishRetro = () => {
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+
+ cy.get('#confirm-modal-light').within(() => {
+ // * Verify we're showing the publish retro confirmation modal
+ cy.findByText('Are you sure you want to publish?');
+
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+ });
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/about_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/about_spec.js
new file mode 100644
index 00000000000..c6855311481
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/about_spec.js
@@ -0,0 +1,146 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ONE_SEC} from '../../../../fixtures/timeouts';
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > rhs > header', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let testPlaybookRun;
+ let playbookRunChannelName;
+ let playbookRunName;
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Run the playbook
+ const now = Date.now();
+ playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ testPlaybookRun = run;
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+ });
+
+ describe('shows name', () => {
+ it('of active playbook run', () => {
+ // * Verify the title is displayed
+ cy.get('#rhsContainer').contains(playbookRunName);
+ });
+
+ it('of renamed playbook run', () => {
+ // * Verify the existing title is displayed
+ cy.get('#rhsContainer').contains(playbookRunName);
+
+ // # Rename the channel
+ cy.apiPatchChannel(testPlaybookRun.channel_id, {
+ id: testPlaybookRun.channel_id,
+ display_name: 'Updated',
+ });
+
+ // * Verify the updated title is displayed
+ cy.get('#rhsContainer').contains(playbookRunName);
+ });
+ });
+
+ describe('edit run name', () => {
+ it('by clicking on name', () => {
+ // # Click the menu button to open the dropdown
+ cy.get('#rhsContainer').findByTestId('rendered-run-name').should('be.visible');
+ cy.get('#rhsContainer').findByTestId('menuButton').should('be.visible').click();
+
+ // # Click "Rename" from the dropdown menu
+ cy.findByText('Rename').click();
+
+ // # type text in textarea
+ cy.get('#rhsContainer').findByTestId('textarea-run-name').should('be.visible').clear().type('new run name{enter}');
+
+ // * make sure the updated name is here
+ cy.get('#rhsContainer').findByTestId('rendered-run-name').should('be.visible').contains('new run name');
+
+ // * make sure the channel name remains unchanged
+ cy.get('#channel-header').contains(playbookRunName);
+ });
+ });
+
+ describe('edit summary', () => {
+ it('by clicking on placeholder', () => {
+ cy.get('#rhsContainer').findByTestId('rendered-description').should('be.visible').click();
+
+ // # type text in textarea
+ cy.get('#rhsContainer').findByTestId('textarea-description').should('be.visible').type('new summary{ctrl+enter}');
+
+ // * make sure the updated summary is here
+ cy.get('#rhsContainer').findByTestId('rendered-description').should('be.visible').contains('new summary');
+ });
+
+ // https://mattermost.atlassian.net/browse/MM-63692
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('by clicking on dot menu item', () => {
+ // # click on the field
+ cy.get('#rhsContainer').within(() => {
+ cy.findByTestId('buttons-row').invoke('show').within(() => {
+ cy.findAllByRole('button').eq(1).click();
+ });
+ });
+
+ cy.findByText('Edit run summary').click();
+
+ cy.wait(ONE_SEC);
+
+ // # type text in textarea
+ cy.focused().should('be.visible').as('textarea');
+ cy.get('@textarea').type('new summary');
+ cy.wait(ONE_SEC);
+ cy.get('@textarea').type('{ctrl+enter}');
+
+ // * make sure the updated summary is here
+ cy.get('#rhsContainer').findByTestId('rendered-description').should('be.visible').contains('new summary');
+ });
+ });
+
+ describe('participate', () => {
+ it('icon is not visible if I am a participant', () => {
+ // * assert icon is not visible if I'm participant
+ cy.get('#rhsContainer').findByTestId('rhs-participate-icon').should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/checklist_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/checklist_spec.js
new file mode 100644
index 00000000000..606350e326d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/checklist_spec.js
@@ -0,0 +1,489 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+import {HALF_SEC, ONE_SEC} from '../../../../fixtures/timeouts';
+
+describe('channels > rhs > checklist', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreatePlaybook({
+ teamId: team.id,
+ title: 'Playbook',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1', command: '/invalid'},
+ {title: 'Step 2', command: '/echo VALID'},
+ {title: 'Step 3', command: '/playbook check 0 0'},
+ {title: 'Step 4'},
+ {title: 'Step 5'},
+ {title: 'Step 6'},
+ {title: 'Step 7'},
+ {title: 'Step 8'},
+ {title: 'Step 9'},
+ {title: 'Step 10'},
+ {title: 'Step 11'},
+ {title: 'Step 12'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1', command: '/invalid'},
+ {title: 'Step 2', command: '/echo VALID'},
+ {title: 'Step 3'},
+ {title: 'Step 4'},
+ {title: 'Step 5'},
+ {title: 'Step 6'},
+ {title: 'Step 7'},
+ {title: 'Step 8'},
+ {title: 'Step 9'},
+ {title: 'Step 10'},
+ {title: 'Step 11'},
+ {title: 'Step 12'},
+ ],
+ },
+ {
+ title: 'Stage 3',
+ items: [
+ {title: 'Step 1', command: '/invalid'},
+ {title: 'Step 2', command: '/echo VALID'},
+ {title: 'Step 3'},
+ {title: 'Step 4'},
+ {title: 'Step 5'},
+ {title: 'Step 6'},
+ {title: 'Step 7'},
+ {title: 'Step 8'},
+ {title: 'Step 9'},
+ {title: 'Step 10'},
+ {title: 'Step 11'},
+ {title: 'Step 12'},
+ ],
+ },
+ {
+ title: 'Stage 3',
+ items: [
+ {title: 'Step 1', command: '/invalid'},
+ {title: 'Step 2', command: '/echo VALID'},
+ {title: 'Step 3'},
+ {title: 'Step 4'},
+ {title: 'Step 5'},
+ {title: 'Step 6'},
+ {title: 'Step 7'},
+ {title: 'Step 8'},
+ {title: 'Step 9'},
+ {title: 'Step 10'},
+ {title: 'Step 11'},
+ {title: 'Step 12'},
+ ],
+ },
+ ],
+ memberIDs: [
+ user.id,
+ ],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ // // # Switch to clean display mode
+ // cy.apiSaveMessageDisplayPreference('clean');
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to task list without scrolling issues
+ cy.viewport('macbook-13');
+ });
+
+ describe('rhs stuff', () => {
+ let playbookRunName;
+ let playbookRunChannelName;
+
+ beforeEach(() => {
+ // # Run the playbook
+ const now = Date.now();
+ playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify the playbook run RHS is open (use test ID to avoid multiple matches)
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.findByTestId('rendered-run-name').should('contain', playbookRunName);
+ });
+ });
+
+ describe('header', () => {
+ it('has title', () => {
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Tasks').should('exist');
+ });
+ });
+ });
+
+ it('shows an ephemeral error when running an invalid slash command', () => {
+ cy.get('#rhsContainer').should('exist').within(() => {
+ // * Verify the command has not yet been run.
+ cy.findAllByTestId('run').eq(0).should('have.text', 'Run');
+
+ // * Run the /invalid slash command
+ cy.findAllByTestId('run').eq(0).click();
+
+ // * Verify the command still has not yet been run.
+ cy.findAllByTestId('run').eq(0).should('have.text', 'Run');
+ });
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Failed to execute slash command /invalid');
+ });
+
+ it('successfully runs a valid slash command', () => {
+ cy.get('#rhsContainer').should('exist').within(() => {
+ // * Verify the command has not yet been run.
+ cy.findAllByTestId('run').eq(1).should('have.text', 'Run');
+
+ // * Run the /invalid slash command
+ cy.findAllByTestId('run').eq(1).click();
+
+ // * Verify the command has now been run.
+ cy.findAllByTestId('run').eq(1).should('have.text', 'Rerun');
+ });
+
+ // # Verify the expected output.
+ cy.verifyPostedMessage('VALID');
+ });
+
+ it('still shows slash commands as having been run after reload', () => {
+ cy.get('#rhsContainer').should('exist').within(() => {
+ // * Verify the command has not yet been run.
+ cy.findAllByTestId('run').eq(1).should('have.text', 'Run');
+
+ // * Run the /invalid slash command
+ cy.findAllByTestId('run').eq(1).click();
+
+ // * Verify the command has now been run.
+ cy.findAllByTestId('run').eq(1).should('have.text', 'Rerun');
+ });
+
+ // # Verify the expected output.
+ cy.verifyPostedMessage('VALID');
+
+ // # Reload the page
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ cy.get('#rhsContainer').should('exist').within(() => {
+ // * Verify the invalid command still has not yet been run.
+ cy.findAllByTestId('run').eq(0).should('have.text', 'Run');
+
+ // * Verify the valid command has been run.
+ cy.findAllByTestId('run').eq(1).should('have.text', 'Rerun');
+ });
+ });
+
+ it('runs /playbook slash commands', () => {
+ cy.get('#rhsContainer').should('exist').within(() => {
+ // * Verify the `/playbook check 0 0` command has not yet been run.
+ cy.findAllByTestId('run').eq(2).should('have.text', 'Run');
+
+ // * Run the slash command
+ cy.findAllByTestId('run').eq(2).click();
+
+ // * Verify the command has now been run.
+ cy.findAllByTestId('run').eq(2).should('have.text', 'Rerun');
+
+ // * Verify the first checklist item is checked
+ cy.findAllByTestId('checkbox-item-container').eq(0).within(() => {
+ // # Check the overdue task
+ cy.get('input').should('be.checked');
+ });
+ });
+
+ // # Reload the page
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ cy.get('#rhsContainer').should('exist').within(() => {
+ // * Verify the command has still been run.
+ cy.findAllByTestId('run').eq(2).should('have.text', 'Rerun');
+
+ // * Verify the first checklist item is still checked
+ cy.findAllByTestId('checkbox-item-container').eq(0).within(() => {
+ // # Check the overdue task
+ cy.get('input').should('be.checked');
+ });
+ });
+ });
+
+ it('can skip and restore task', () => {
+ // # Skip task and verify
+ skipTask(0);
+
+ // # Hover over the checklist item
+ cy.findAllByTestId('checkbox-item-container').eq(0).trigger('mouseover');
+
+ // # Click dot menu
+ cy.findAllByTestId('checkbox-item-container').eq(0).within(() => {
+ cy.findByTitle('More').click();
+ });
+
+ // # Click the restore button
+ cy.findByRole('button', {name: 'Restore task'}).click();
+
+ // * Verify the item has been restored
+ cy.findAllByTestId('checkbox-item-container').eq(0).within(() => {
+ cy.get('[data-cy=skipped]').should('not.exist');
+ });
+ });
+
+ it('add new task', () => {
+ const newTasktext = 'This is my new task' + Date.now();
+
+ cy.addNewTaskFromRHS(newTasktext);
+
+ // Check that it was created
+ cy.findByText(newTasktext).should('exist');
+ });
+
+ it('add new task slash command', () => {
+ const newTasktext = 'Task from slash command' + Date.now();
+
+ cy.uiPostMessageQuickly(`/playbook checkadd 0 ${newTasktext}`);
+
+ // Check that it was created
+ cy.findByText(newTasktext).should('exist');
+ });
+
+ it('creates a new checklist', () => {
+ // # Click on the button to add a checklist
+ cy.get('#rhsContainer').within(() => {
+ cy.findByTestId('add-a-checklist-button').click();
+ });
+
+ // # Type a title and click on the Add button
+ const title = 'Checklist - ' + Date.now();
+ cy.findByTestId('checklist-title-input').type(title);
+ cy.findByTestId('checklist-item-save-button').click();
+
+ // # Click on the button to add a checklist
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText(title).should('exist');
+ });
+ });
+
+ it('renames a checklist', () => {
+ const oldTitle = 'Stage 1';
+ const newTitle = 'New title - ' + Date.now();
+
+ // # Open the dot menu and click on the rename button
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText(oldTitle).trigger('mouseover');
+ cy.findAllByTestId('checklistHeader').eq(0).within(() => {
+ cy.findByTitle('More').click();
+ });
+ });
+ cy.findByTestId('dropdownmenu').findByText('Rename section').click();
+
+ // # Type the new title and click the confirm button
+ cy.findByTestId('checklist-title-input').type(newTitle, {force: true});
+ cy.findByTestId('checklist-item-save-button').click();
+
+ // * Verify that the checklist changed its name
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText(oldTitle).should('not.exist');
+ cy.findByText(oldTitle + newTitle).should('exist');
+ });
+ });
+
+ it('can set due date, from hover menu', () => {
+ // # Set due date and verify
+ setTaskDueDate(6, 'in 10 minutes');
+ });
+
+ it('can set due date, from edit mode', () => {
+ // # Hover over the checklist item
+ cy.findAllByTestId('checkbox-item-container').eq(6).trigger('mouseover');
+
+ // # Click the edit button
+ cy.findAllByTestId('hover-menu-edit-button').eq(0).click();
+
+ cy.findAllByTestId('due-date-info-button').eq(0).click();
+
+ // # Enter due date in 3 days
+ cy.get('.playbook-react-select__value-container').type('in 3 days').
+ wait(HALF_SEC).
+ trigger('keydown', {
+ key: 'Enter',
+ });
+
+ // * Verify if Due in 3 days info is added
+ cy.findAllByTestId('due-date-info-button').eq(0).should('exist').within(() => {
+ cy.findByText('in 3 days').should('exist');
+ cy.findByText('Due').should('exist');
+ });
+ });
+
+ it('filter overdue tasks', {retries: {runMode: 3}}, () => {
+ // # Set overdue date for several items
+ setTaskDueDate(2, '1 hour ago');
+
+ setTaskDueDate(3, '7 hours ago', 1);
+ setTaskDueDate(5, '3 hours ago', 2);
+ setTaskDueDate(6, '6 hours ago', 3);
+
+ // # Skip task
+ skipTask(3);
+
+ // # Mark a task as completed
+ cy.findAllByTestId('checkbox-item-container').eq(5).within(() => {
+ // # Check the overdue task
+ cy.get('input').click();
+ });
+
+ // * Verify if overdue tasks info was added. Should not include skipped / completed tasks.
+ cy.findAllByTestId('overdue-tasks-filter').eq(0).should('exist').within(() => {
+ cy.findByText('2 tasks overdue').should('exist');
+ });
+
+ // # Filter overdue tasks
+ cy.findAllByTestId('overdue-tasks-filter').eq(0).click();
+
+ // * Verify if filter works. Should not include skipped / completed tasks.
+ cy.findAllByTestId('checkbox-item-container').should('have.length', 2);
+
+ // # Cancel filter overdue tasks
+ cy.findAllByTestId('overdue-tasks-filter').eq(0).click();
+
+ // * Verify if filter was canceled
+ cy.findAllByTestId('checkbox-item-container').should('have.length', 48);
+ });
+
+ it('filter overdue automatically disappear if we check all overdue items', () => {
+ // # Set due date
+ setTaskDueDate(2, '1 minute ago');
+
+ // * Verify if overdue tasks info was added
+ cy.findAllByTestId('overdue-tasks-filter').eq(0).should('exist').within(() => {
+ cy.findByText('1 task overdue').should('exist');
+ });
+
+ // # Filter overdue tasks
+ cy.findAllByTestId('overdue-tasks-filter').eq(0).click();
+
+ // * Verify if filter works
+ cy.findAllByTestId('checkbox-item-container').should('have.length', 1);
+
+ // # Mark a task as completed
+ cy.findAllByTestId('checkbox-item-container').within(() => {
+ // # Check the overdue task
+ cy.get('input').click();
+ });
+
+ // * Verify there is no filter
+ cy.findAllByTestId('overdue-tasks-filter').should('not.exist');
+
+ // * Verify if filter was canceled
+ cy.findAllByTestId('checkbox-item-container').should('have.length', 48);
+ });
+
+ it('switching between runs with the same checklist', () => {
+ // # Create another run using the same playbook
+ const playbookRunName2 = 'RunWithSameChecklist';
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: playbookRunName2,
+ ownerUserId: testUser.id,
+ });
+
+ // # Set due date for the first channel's task
+ setTaskDueDate(2, 'in 2 hours');
+
+ // # Switch to the second run channel
+ cy.get('#sidebarItem_runwithsamechecklist').click();
+
+ // * Verify that tasks do not have due dates
+ cy.findAllByTestId('checkbox-item-container').eq(2).within(() => {
+ cy.findAllByTestId('due-date-info-button').should('not.exist');
+ });
+ });
+
+ it('scroll 2-3 pages and open due date selector- unexpected scroll issue', () => {
+ // # Hover over the checklist item that is ~3 pages down
+ cy.findAllByTestId('checkbox-item-container').eq(26).trigger('mouseover').within(() => {
+ // # Click the set due date button
+ cy.get('.icon-calendar-outline').click();
+ });
+
+ // * Verify if date selector is visible
+ cy.get('.playbook-react-select').should('be.visible');
+ });
+ });
+});
+
+const setTaskDueDate = (taskIndex, dateQuery, offset = 0) => {
+ // # Hover over the checklist item
+ cy.findAllByTestId('checkbox-item-container').eq(taskIndex).trigger('mouseover').within(() => {
+ // # Click the set due date button
+ cy.get('.icon-calendar-outline').click();
+ });
+
+ // # Wait for react select to finish rendering.
+ cy.wait(ONE_SEC);
+
+ // # Enter due date query
+ cy.get('.playbook-react-select').within(() => {
+ cy.get('input').type(dateQuery, {force: true}).
+ wait(HALF_SEC).
+ trigger('keydown', {
+ key: 'Enter',
+ });
+ });
+
+ // * Verify if Due date info is added
+ cy.findAllByTestId('due-date-info-button').eq(offset).should('exist').within(() => {
+ cy.findByText(dateQuery).should('exist');
+ cy.findByText('Due').should('exist');
+ });
+};
+
+const skipTask = (taskIndex) => {
+ // # Hover over the checklist item
+ cy.findAllByTestId('checkbox-item-container').eq(taskIndex).trigger('mouseover');
+
+ // # Click dot menu
+ cy.findAllByTestId('checkbox-item-container').eq(taskIndex).within(() => {
+ cy.findByTitle('More').click();
+ });
+
+ // # Click the skip button
+ cy.findByRole('button', {name: 'Skip task'}).click();
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/header_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/header_spec.js
new file mode 100644
index 00000000000..7e8f77af4e8
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/header_spec.js
@@ -0,0 +1,318 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+import * as TIMEOUTS from '../../../../fixtures/timeouts';
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > rhs > header', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let testViewerUser;
+ // eslint-disable-next-line no-unused-vars
+ let standaloneRun;
+ // eslint-disable-next-line no-unused-vars
+ let standaloneRunChannelName;
+ let privatePlaybook;
+ let privateRun;
+ // eslint-disable-next-line no-unused-vars
+ let privateRunChannelName;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user, channel}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ // # Create a standalone run without a playbook (channel checklist) in existing channel (MM-67648)
+ const now = Date.now();
+ const standaloneRunName = 'Standalone Run (' + now + ')';
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: '', // Empty playbook ID for standalone run
+ playbookRunName: standaloneRunName,
+ ownerUserId: testUser.id,
+ channelId: channel.id,
+ }).then((run) => {
+ standaloneRun = run;
+
+ // # Get the actual channel name from the API
+ cy.apiGetChannel(run.channel_id).then(({channel: ch}) => {
+ standaloneRunChannelName = ch.name;
+ });
+ });
+
+ // # Create a second user (viewer) and add to team
+ cy.apiCreateUser().then(({user: viewerUser}) => {
+ testViewerUser = viewerUser;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+
+ // # Create a private playbook with only testUser as member
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Private Playbook',
+ memberIDs: [testUser.id], // Only testUser is a member
+ makePublic: false,
+ }).then((privPlaybook) => {
+ privatePlaybook = privPlaybook;
+
+ // # Create a run from the private playbook
+ const privateRunName = 'Private Run (' + Date.now() + ')';
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: privatePlaybook.id,
+ playbookRunName: privateRunName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ privateRun = run;
+
+ // # Get the actual channel name from the API
+ cy.apiGetChannel(run.channel_id).then(({channel: ch}) => {
+ privateRunChannelName = ch.name;
+ });
+
+ // # Add viewerUser as participant to the run
+ cy.apiAddUsersToRun(privateRun.id, [testViewerUser.id]);
+ });
+ });
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+ });
+
+ describe('shows name', () => {
+ it('of active playbook run', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify the title is displayed
+ cy.get('#rhsContainer').contains(playbookRunName);
+ });
+
+ it('of renamed playbook run', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify the existing title is displayed
+ cy.get('#rhsContainer').contains(playbookRunName);
+
+ // # Rename the channel
+ cy.apiPatchChannel(playbookRun.channel_id, {
+ id: playbookRun.channel_id,
+ display_name: 'Updated',
+ });
+
+ // * Verify the updated title is displayed
+ cy.get('#rhsContainer').contains(playbookRunName);
+ });
+ });
+ });
+
+ describe('edit summary', () => {
+ it('by clicking on placeholder', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # click on the field
+ cy.get('#rhsContainer').findByTestId('rendered-description').should('be.visible').click();
+
+ // # type text in textarea
+ cy.get('#rhsContainer').findByTestId('textarea-description').should('be.visible').type('new summary{ctrl+enter}');
+
+ // * make sure the updated summary is here
+ cy.get('#rhsContainer').findByTestId('rendered-description').should('be.visible').contains('new summary');
+
+ // * reload the page
+ cy.reload();
+
+ // * make sure the updated summary is still there
+ cy.get('#rhsContainer').findByTestId('rendered-description').should('be.visible').contains('new summary');
+ });
+ });
+
+ describe('playbook badge', () => {
+ it('is shown for runs started from a playbook and navigates to playbook editor when clicked', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to the run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify the playbook badge is visible and shows the playbook name
+ cy.findByTestId('playbook-badge').should('be.visible').and('contain', 'Playbook');
+
+ // # Click the playbook badge
+ cy.findByTestId('playbook-badge').click();
+
+ // * Verify we navigated to the playbook editor
+ cy.url().should('include', `/playbooks/${testPlaybook.id}`);
+ });
+
+ it('is hidden for runs started from a playbook I do not have access to', () => {
+ // # Login as viewer and navigate to the private run
+ cy.apiLogin(testViewerUser);
+ cy.visit(`/${testTeam.name}/channels/${privateRunChannelName}`);
+
+ // * Verify the playbook badge does not exist
+ cy.findByTestId('playbook-badge').should('not.exist');
+ });
+
+ it('is hidden for channel checklists', () => {
+ // # Navigate to the standalone run channel (channel checklist)
+ cy.visit(`/${testTeam.name}/channels/${standaloneRunChannelName}`);
+
+ // * Verify the playbook badge does not exist
+ cy.findByTestId('playbook-badge').should('not.exist');
+ });
+ });
+
+ describe('edit summary of finished run', () => {
+ let playbookRunChannelName;
+ let finishedPlaybookRun;
+
+ beforeEach(() => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ finishedPlaybookRun = playbookRun;
+ });
+ });
+
+ it('by clicking on placeholder', () => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Wait for the RHS to open
+ cy.get('#rhsContainer').should('be.visible');
+
+ // # Mark the run as finished
+ cy.apiFinishRun(finishedPlaybookRun.id);
+
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // # click on the field
+ cy.get('#rhsContainer').findByTestId('rendered-description').should('be.visible').click();
+
+ // * Verify textarea does not appear
+ cy.get('#rhsContainer').findByTestId('textarea-description').should('not.exist');
+
+ // * Verify no prompt to join appears (timeout ensures it fails right away before toast disappears)
+ cy.findByText('Become a participant to interact with this run', {timeout: 500}).should('not.exist');
+ });
+ });
+
+ describe('rename checklist', () => {
+ it('can rename active checklist from RHS header', () => {
+ // # Visit the standalone run channel (channel checklist)
+ cy.visit(`/${testTeam.name}/channels/${standaloneRunChannelName}`);
+
+ // # Wait for the RHS to open (standalone runs may not auto-open)
+ cy.get('#rhsContainer').should('exist');
+
+ // # Click on the checklist dropdown in the RHS header
+ cy.get('#rhsContainer').findByTestId('menuButton').should('be.visible').click();
+
+ // * Verify "Rename" option exists for active checklists
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Rename').should('exist');
+ cy.findByText('Finish').should('exist');
+ });
+ });
+
+ it('cannot rename finished checklist from RHS header', () => {
+ // # Visit the standalone run channel (channel checklist)
+ cy.visit(`/${testTeam.name}/channels/${standaloneRunChannelName}`);
+
+ // # Wait for the RHS to open (standalone runs may not auto-open)
+ cy.get('#rhsContainer').should('exist');
+
+ // # Finish the checklist and wait for RHS to reflect finished state
+ cy.apiFinishRun(standaloneRun.id);
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Finished').should('be.visible');
+ cy.findByRole('button', {name: 'Done'}).should('be.visible');
+ });
+
+ // # Click on the title menu in the RHS header
+ cy.get('#rhsContainer').findByTestId('menuButton').should('be.visible').click();
+
+ // * Verify "Rename" option does not exist for finished checklists
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Save as playbook');
+ cy.findByText('Resume');
+ cy.findByText('Rename').should('not.exist');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/home_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/home_spec.js
new file mode 100644
index 00000000000..96568848739
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/home_spec.js
@@ -0,0 +1,218 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+import * as TIMEOUTS from '../../../../fixtures/timeouts';
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > rhs > home', {testIsolation: true}, () => {
+ let testSysadmin;
+ let testTeam;
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateCustomAdmin().then(({sysadmin}) => {
+ testSysadmin = sysadmin;
+ });
+ });
+ });
+
+ describe('default permission settings', () => {
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Navigate to the application, starting in a non-run channel.
+ cy.visit(`/${testTeam.name}/`);
+
+ // # Wait for page to fully load and settle
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // * Check post list content as an indicator of page stability
+ cy.get('#postListContent').should('be.visible');
+ });
+
+ describe('shows available', () => {
+ // TBD: UI changes for Checklists feature - template access workflow has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('starter templates', () => {
+ // templates are defined in webapp/src/components/templates/template_data.tsx
+ const templates = [
+ {name: 'Blank', checklists: '1 checklist', actions: '1 action'},
+ {name: 'Product Release', checklists: '4 checklists', actions: '3 actions'},
+ {name: 'Incident Resolution', checklists: '4 checklists', actions: '4 actions'},
+ {name: 'Customer Onboarding', checklists: '4 checklists', actions: '3 actions'},
+ {name: 'Employee Onboarding', checklists: '5 checklists', actions: '2 actions'},
+ {name: 'Feature Lifecycle', checklists: '5 checklists', actions: '3 actions'},
+ {name: 'Bug Bash', checklists: '5 checklists', actions: '3 actions'},
+ {name: 'Learn how to use playbooks', checklists: '2 checklists', actions: '2 actions'},
+ ];
+
+ // # Ensure any existing runs in this channel are finished so we get the empty state
+ cy.apiFinishAllRuns(testTeam.id);
+ cy.wait(500);
+
+ // # Ensure RHS is closed before opening it
+ cy.get('body').then(($body) => {
+ if ($body.find('#sidebar-right.is-open').length > 0) {
+ cy.getPlaybooksAppBarIcon().click(); // Close if already open
+ cy.wait(500);
+ }
+ });
+
+ // # Click the icon to open RHS
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # Wait for RHS to open
+ cy.get('#rhsContainer', {timeout: 10000}).should('be.visible');
+
+ // * Verify we see the new checklist UI for empty channels
+ cy.get('#rhsContainer').within(() => {
+ cy.findByText('Get started with a checklist for this channel').should('be.visible');
+
+ // # First create a blank checklist so the header with dropdown appears
+ cy.findByTestId('create-blank-checklist').click();
+ });
+ cy.wait(2000); // Wait for checklist creation and RHS update
+
+ // # Click the dropdown next to "+ New checklist" button in header
+ cy.get('[data-testid="create-blank-checklist"]').parent().find('.icon-chevron-down').click();
+
+ // # Click "Run a playbook" from the dropdown
+ cy.findByTestId('create-from-playbook').click();
+
+ // * Verify the templates are shown in the modal
+ cy.get('#root-portal.modal-open').within(() => {
+ cy.findByText('Select a playbook').should('be.visible');
+
+ // * Verify template tab and templates
+ cy.findByText('Playbook Templates').click();
+
+ cy.findAllByTestId('template-details').each(($templateElement, index) => {
+ cy.wrap($templateElement).within(() => {
+ cy.findByText(templates[index].name).should('exist');
+ cy.findByText(templates[index].checklists).should('exist');
+ cy.findByText(templates[index].actions).should('exist');
+ });
+ });
+ });
+ });
+ });
+
+ describe('show zero case if there are playbooks', () => {
+ beforeEach(() => {
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Team Playbook',
+ memberIDs: [],
+ });
+
+ // # Ensure any existing runs in this channel are finished so we get the empty state
+ cy.apiFinishAllRuns(testTeam.id);
+ cy.wait(500);
+
+ // # Ensure RHS is closed before opening it
+ cy.get('body').then(($body) => {
+ if ($body.find('#sidebar-right.is-open').length > 0) {
+ cy.getPlaybooksAppBarIcon().click(); // Close if already open
+ cy.wait(500);
+ }
+ });
+
+ // # Click the icon to open RHS
+ cy.getPlaybooksAppBarIcon().click();
+
+ // # Wait for RHS to open
+ cy.get('#sidebar-right', {timeout: 10000}).should('be.visible');
+ });
+
+ // TBD: UI changes for Checklists feature - empty state display has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('without pre-populated channel name template', () => {
+ // * Verify the templates are not shown
+ cy.findAllByTestId('template-details').should('not.exist');
+
+ // * Verify the zero case is shown
+ cy.get('#sidebar-right').findByText('There are no runs in progress linked to this channel').should('be.visible');
+ });
+ });
+ });
+
+ let restrictedTestTeam;
+ let restrictedTestUser;
+
+ describe('user is lacking permissions to create playbooks', () => {
+ before(() => {
+ cy.apiLogin(testSysadmin);
+
+ cy.apiCreateUser().then(({user}) => {
+ restrictedTestUser = user;
+ });
+
+ cy.apiCreateTeam('restricted-team', 'Restricted Team').then(({team}) => {
+ restrictedTestTeam = team;
+ cy.apiAddUserToTeam(restrictedTestTeam.id, restrictedTestUser.id);
+ });
+
+ cy.apiCreateScheme('Restricted Team Scheme', 'team').then(({scheme}) => {
+ cy.apiSetTeamScheme(restrictedTestTeam.id, scheme.id);
+ cy.apiGetRolesByNames([scheme.default_team_user_role]).then(({roles}) => {
+ const role = roles[0];
+
+ // Remove permissions to create playbooks
+ const permissions = role.permissions.filter((perm) => !(/playbook_(private|public)_create/).test(perm));
+ cy.apiPatchRole(role.id, {permissions});
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as user with restricted permissions
+ cy.apiLogin(restrictedTestUser);
+
+ // # Navigate to the application, starting in a non-run channel.
+ cy.visit(`/${restrictedTestTeam.name}/`);
+ });
+
+ // TBD: UI changes for Checklists feature - permission messaging has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('permission notice should be shown and no create button should exist', () => {
+ // # Ensure any existing runs in this channel are finished so we get the empty state
+ cy.apiFinishAllRuns(restrictedTestTeam.id);
+ cy.wait(500);
+
+ // # Ensure RHS is closed before opening it
+ cy.get('body').then(($body) => {
+ if ($body.find('#sidebar-right.is-open').length > 0) {
+ cy.getPlaybooksAppBarIcon().click(); // Close if already open
+ cy.wait(500);
+ }
+ });
+
+ // # Click the icon to open RHS
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # Wait for RHS to open
+ cy.get('#sidebar-right', {timeout: 10000}).should('be.visible');
+
+ cy.get('#sidebar-right').within(() => {
+ // * Verify notice about missing permissions exists
+ cy.findByText('You don\'t have permission to create playbooks in this workspace.').should('be.visible');
+
+ // * Verify create playbook button does not exist
+ cy.findByText('Create playbook').should('not.exist');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/list_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/list_spec.js
new file mode 100644
index 00000000000..1cbc9337c98
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/list_spec.js
@@ -0,0 +1,319 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ONE_SEC} from '../../../../fixtures/timeouts';
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > rhs > runlist', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPlaybook;
+ let testChannel;
+ // eslint-disable-next-line no-unused-vars
+ let standaloneRun;
+ let privatePlaybook;
+ let privateRun;
+ const numActiveRuns = 10;
+ const numFinishedRuns = 4;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'The playbook name',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ // # Create a test channel
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel').then(({channel}) => {
+ testChannel = channel;
+
+ // # Run the playbook a few times in the existing channel
+ for (let i = 0; i < numActiveRuns; i++) {
+ const runName = 'playbook-run-' + i;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ ownerUserId: testUser.id,
+ channelId: testChannel.id,
+ playbookRunName: runName,
+ });
+ }
+
+ // # Do it again but finished
+ for (let i = 0; i < numFinishedRuns; i++) {
+ const runName = 'playbook-run-' + i;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ ownerUserId: testUser.id,
+ channelId: testChannel.id,
+ playbookRunName: runName,
+ }).then((run) => {
+ cy.apiFinishRun(run.id);
+ });
+ }
+
+ // # Create a standalone run without a playbook (channel checklist) in the same channel
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: '', // Empty playbook ID for standalone run
+ playbookRunName: 'standalone checklist',
+ ownerUserId: testUser.id,
+ channelId: testChannel.id,
+ }).then((run) => {
+ standaloneRun = run;
+ });
+
+ // # Create a second user (viewer) and add to team
+ cy.apiCreateUser().then(({user: viewerUser}) => {
+ testViewerUser = viewerUser;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+
+ // # Create a private playbook with only testUser as member
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Private Playbook',
+ memberIDs: [testUser.id], // Only testUser is a member
+ makePublic: false,
+ }).then((privPlaybook) => {
+ privatePlaybook = privPlaybook;
+
+ // # Create a run from the private playbook in the same channel
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: privatePlaybook.id,
+ playbookRunName: 'private run',
+ ownerUserId: testUser.id,
+ channelId: testChannel.id,
+ }).then((run) => {
+ privateRun = run;
+
+ // # Add viewerUser as participant to the run
+ cy.apiAddUsersToRun(privateRun.id, [testViewerUser.id]);
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Wait the RHS to load
+ cy.findByText('In progress').should('be.visible');
+ });
+
+ it('can filter', () => {
+ // # Click the filter menu
+ cy.findByTestId('rhs-runs-filter-menu').click();
+
+ // * Verify displayed options
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText(`${numActiveRuns + 2}`).should('exist');
+ cy.findByText(`${numFinishedRuns}`).should('exist');
+ });
+
+ // # Click the filter for finished runs
+ cy.findByTestId('dropdownmenu').findByText(`${numFinishedRuns}`).click();
+
+ // # Wait for filtering to complete - API needs time to apply include_ended=true
+ cy.wait(500);
+
+ // * Verify exactly the number of finished runs are displayed
+ cy.findByTestId('rhs-runs-list').children().should('have.length', numFinishedRuns);
+ });
+
+ it('can show more (pagination)', () => {
+ // * Verify we have the first page
+ cy.findByTestId('rhs-runs-list').findAllByTestId('run-list-card').should('have.length', 8);
+
+ // # Click the show-more button
+ cy.findByTestId('rhs-runs-list').findByRole('button', {name: /show more/i}).click();
+
+ // * Verify we have loaded the second page
+ cy.findByTestId('rhs-runs-list').findAllByTestId('run-list-card').should('have.length', numActiveRuns + 2);
+ });
+
+ it('card has the basic info', () => {
+ // # Find the run card by its name
+ cy.findByTestId('rhs-runs-list').contains('[data-testid="run-list-card"]', 'playbook-run-9').within(() => {
+ cy.findByText('playbook-run-9').should('be.visible');
+ cy.findByText('The playbook name').should('be.visible');
+ cy.findByText(testUser.username).should('be.visible');
+ });
+ });
+
+ it('can click through', () => {
+ // # Click the run card with playbook-run-9
+ cy.findByTestId('rhs-runs-list').contains('[data-testid="run-list-card"]', 'playbook-run-9').click();
+
+ // * Verify we made it to the run details at Channels RHS
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.findByText('playbook-run-9').should('exist');
+ });
+ cy.findByTestId('pb-checklists-inner-container').within(() => {
+ cy.findByText('Tasks').should('be.visible');
+ });
+ });
+
+ describe('dotmenu', () => {
+ it('can navigate to RDP', () => {
+ // # Click the run's dotmenu button
+ cy.findByTestId('rhs-runs-list').contains('[data-testid="run-list-card"]', privateRun.name).findByRole('button').click();
+
+ // # Click on go to run
+ cy.findByText('Go to overview').click();
+
+ // * Assert we are in the run details page
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=channel_rhs_dotmenu');
+ });
+
+ it('can navigate to PBE', () => {
+ // # Click the run's dotmenu button
+ cy.findByTestId('rhs-runs-list').contains('[data-testid="run-list-card"]', privateRun.name).findByRole('button').click();
+
+ // # Click on go to playbook
+ cy.findByText('Go to playbook').click();
+
+ cy.wait(5000);
+
+ // * Assert we are in the PBE page
+ cy.findByTestId('playbook-editor-title').should('contain', privatePlaybook.title);
+ });
+
+ it('hides "Go to playbook" for standalone runs', () => {
+ // # Visit the channel with the standalone run
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Wait for the RHS to load
+ cy.findByText('In progress').should('be.visible');
+
+ // # Find the standalone run card by its name and click its dotmenu
+ cy.findByTestId('rhs-runs-list').contains('standalone checklist').parents('div[data-testid="run-list-card"]').findByRole('button').click();
+
+ // * Verify "Go to playbook" does not exist
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Go to playbook').should('not.exist');
+ cy.findByText('Move to a different channel').should('exist');
+ });
+ });
+
+ it('hides "Go to playbook" for private playbooks without access', () => {
+ // # Login as viewer and visit the channel
+ cy.apiLogin(testViewerUser);
+ cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
+
+ // # Wait for the RHS to load
+ cy.findByText('In progress').should('be.visible');
+
+ // # Find the private run card by its name and click its dotmenu
+ cy.findByTestId('rhs-runs-list').contains(privateRun.name).parents('div[data-testid="run-list-card"]').findByRole('button').click();
+
+ // * Verify "Go to playbook" does not exist for user without access
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Go to playbook').should('not.exist');
+ cy.findByText('Go to overview').should('exist');
+ });
+ });
+
+ // https://mattermost.atlassian.net/browse/MM-63692
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it('can change linked channel', () => {
+ // # Click on the first run's dotmenu button
+ cy.findByTestId('rhs-runs-list').findAllByTestId('run-list-card').first().findByRole('button').click();
+
+ // # Click on the move to a different channel option
+ cy.findByText('Move to a different channel').click();
+
+ // # Select town square channel
+ cy.get('#link_existing_channel_selector').click().type('Town Square{enter}');
+
+ // # Click save
+ cy.findByTestId('modal-confirm-button').click();
+
+ // # Let the listing refresh
+ cy.wait(1000);
+
+ // * Verify we have the first page (8 cards)
+ cy.findByTestId('rhs-runs-list').findAllByTestId('run-list-card').should('have.length', 8);
+
+ // # Click the show-more button
+ cy.findByTestId('rhs-runs-list').findByRole('button', {name: /show more/i}).click();
+
+ // * Verify the channel has changed, now one run less (11 total instead of 12)
+ cy.findByTestId('rhs-runs-list').findAllByTestId('run-list-card').should('have.length', 11);
+ });
+
+ describe('navigation', () => {
+ let testChannelWith2Runs;
+ before(() => {
+ cy.apiLogin(testUser);
+
+ // # Create a test channel
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel').then(({channel}) => {
+ testChannelWith2Runs = channel;
+
+ // # Run the playbook a few times in the existing channel
+ for (let i = 0; i < 2; i++) {
+ const runName = 'playbook-run-' + i;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ ownerUserId: testUser.id,
+ channelId: testChannelWith2Runs.id,
+ playbookRunName: runName,
+ });
+ }
+ });
+ });
+
+ it('stays at list even if one only linked run after moving run', () => {
+ // # Visit channel with 2 runs
+ cy.visit(`/${testTeam.name}/channels/${testChannelWith2Runs.name}`);
+
+ // # Click on the first run's dotmenu button
+ cy.findByTestId('rhs-runs-list').findAllByTestId('run-list-card').first().findByRole('button').click();
+
+ // # Click on the move to a different channel option
+ cy.findByText('Move to a different channel').click();
+
+ cy.wait(ONE_SEC);
+
+ // # Select town square channel
+ cy.get('#link_existing_channel_selector').click().type('Town Square{enter}');
+
+ // # Click save
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify the run is not there, but we are still in the list (not rhs details) - only 1 card remains
+ cy.findByTestId('rhs-runs-list').findAllByTestId('run-list-card').should('have.length', 1);
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/start_run_rhs_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/start_run_rhs_spec.js
new file mode 100644
index 00000000000..3bccd350c49
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/start_run_rhs_spec.js
@@ -0,0 +1,330 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {FIVE_SEC} from '../../../../../tests/fixtures/timeouts';
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels rhs > start a run', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testChannel;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiCreateChannel(testTeam.id, 'existing-channel', 'Existing Channel').then(({channel}) => {
+ testChannel = channel;
+ });
+ });
+
+ const createPlaybook = ({channelNameTemplate, runSummaryTemplate, channelId, channelMode, title}) => {
+ const runSummaryTemplateEnabled = Boolean(runSummaryTemplate);
+
+ // # Create a public playbook
+ return cy.apiCreatePlaybook({
+ title: title || 'Public Playbook',
+ channelNameTemplate,
+ runSummaryTemplate,
+ runSummaryTemplateEnabled,
+ channelMode,
+ channelId,
+ teamId: testTeam.id,
+ makePublic: true,
+ memberIDs: [testUser.id],
+ createPublicPlaybookRun: true,
+ }).then((playbook) => {
+ cy.wrap(playbook);
+ });
+ };
+
+ describe('From RHS run list > ', () => {
+ describe('playbook configured as create new channel', () => {
+ // TBD: UI changes for Checklists feature - RHS workflow has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('defaults', () => {
+ // # Fill default values
+ createPlaybook({
+ title: 'Playbook title' + Date.now(),
+ channelNameTemplate: 'Channel template',
+ runSummaryTemplate: 'run summary template',
+ channelMode: 'create_new_channel',
+ }).then(() => {
+ // # Visit the selected playbook
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ cy.wait(FIVE_SEC);
+
+ // # Open playbooks RHS.
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # Create a blank checklist
+ cy.get('#rhsContainer').findByTestId('create-blank-checklist').click();
+
+ // # Wait for checklist to be created and RHS to update to details view
+ cy.wait(2000);
+
+ // * Verify we're now in the RHS details view showing the new checklist
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.findByText('Untitled checklist').should('be.visible');
+ cy.findByText('Tasks').should('be.visible');
+ });
+ });
+ });
+
+ // TBD: UI changes for Checklists feature - RHS workflow has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('change title/summary', () => {
+ // # Fill default values
+ createPlaybook({
+ title: 'Playbook title' + Date.now(),
+ channelNameTemplate: 'Channel template',
+ runSummaryTemplate: 'run summary template',
+ channelMode: 'create_new_channel',
+ }).then((playbook) => {
+ // # Visit the selected playbook
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ cy.wait(FIVE_SEC);
+
+ // # Open playbooks RHS.
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # First create a blank checklist so the header with dropdown appears
+ cy.get('#rhsContainer').findByTestId('create-blank-checklist').click();
+ cy.wait(1000); // Wait for checklist to be created and RHS to update
+
+ // # Now the header with dropdown should be visible, click the dropdown
+ cy.get('#rhsContainer').find('[data-testid="create-blank-checklist"]').parent().find('.icon-chevron-down').click();
+
+ // # Click "Run a playbook" from the dropdown
+ cy.findByTestId('create-from-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert we are at playbooks tab
+ cy.findByText('Select a playbook').should('be.visible');
+
+ // # Click on the playbook
+ cy.findAllByText(playbook.title).eq(0).click();
+
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template are filled (and force wait to them)
+ cy.findByTestId('run-name-input').should('have.value', 'Channel template');
+
+ // * Assert summary template is filled
+ cy.findByTestId('run-summary-input').should('have.value', 'run summary template');
+
+ // # Fill run name
+ cy.findByTestId('run-name-input').clear().type('Test Run Name');
+
+ // # Fill run summary
+ cy.findByTestId('run-summary-input').clear().type('Test Run Summary');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on the channel just created
+ cy.url().should('include', `/${testTeam.name}/channels/test-run-name`);
+
+ // * Verify channel name
+ cy.get('#channelHeaderTitle').contains('Test Run Name');
+
+ // * Verify run RHS
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.contains('Test Run Name');
+ cy.contains('Test Run Summary');
+ });
+ });
+ });
+
+ // TBD: UI changes for Checklists feature - RHS workflow has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('change to link to existing channel defaults to current channel', () => {
+ // # Fill default values
+ createPlaybook({
+ title: 'Playbook title' + Date.now(),
+ channelNameTemplate: 'Channel template',
+ runSummaryTemplate: 'run summary template',
+ channelMode: 'create_new_channel',
+ }).then((playbook) => {
+ // # Visit the town square channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ cy.wait(FIVE_SEC);
+
+ // # Open playbooks RHS.
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # First create a blank checklist so the header with dropdown appears
+ cy.get('#rhsContainer').findByTestId('create-blank-checklist').click();
+ cy.wait(1000); // Wait for checklist to be created and RHS to update
+
+ // # Now the header with dropdown should be visible, click the dropdown
+ cy.get('#rhsContainer').find('[data-testid="create-blank-checklist"]').parent().find('.icon-chevron-down').click();
+
+ // # Click "Run a playbook" from the dropdown
+ cy.findByTestId('create-from-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert we are at playbooks tab
+ cy.findByText('Select a playbook').should('be.visible');
+
+ // # Click on the playbook
+ cy.findAllByText(playbook.title).eq(0).click();
+
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // # Change to link to existing channel
+ cy.findByTestId('link-existing-channel-radio').click();
+
+ // * Assert current channel is selected
+ cy.findByText('Town Square').should('be.visible');
+ });
+ });
+ });
+
+ // TBD: UI changes for Checklists feature - RHS workflow has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('change to link to existing channel with already selected channel', () => {
+ // # Fill default values
+ createPlaybook({
+ title: 'Playbook title' + Date.now(),
+ channelNameTemplate: 'Channel template',
+ runSummaryTemplate: 'run summary template',
+ channelMode: 'create_new_channel',
+ channelId: testChannel.id,
+ }).then((playbook) => {
+ // # Visit the town square channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ cy.wait(FIVE_SEC);
+
+ // # Open playbooks RHS.
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # First create a blank checklist so the header with dropdown appears
+ cy.get('#rhsContainer').findByTestId('create-blank-checklist').click();
+ cy.wait(1000); // Wait for checklist to be created and RHS to update
+
+ // # Now the header with dropdown should be visible, click the dropdown
+ cy.get('#rhsContainer').find('[data-testid="create-blank-checklist"]').parent().find('.icon-chevron-down').click();
+
+ // # Click "Run a playbook" from the dropdown
+ cy.findByTestId('create-from-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert we are at playbooks tab
+ cy.findByText('Select a playbook').should('be.visible');
+
+ // # Click on the playbook
+ cy.findAllByText(playbook.title).eq(0).click();
+
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // # Change to link to existing channel
+ cy.findByTestId('link-existing-channel-radio').click();
+
+ // * Assert selected channel is unchanged
+ cy.findByText(testChannel.display_name).should('be.visible');
+ });
+ });
+ });
+
+ // TBD: UI changes for Checklists feature - RHS workflow has changed
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('change to link to existing channel', () => {
+ // # Fill default values
+ createPlaybook({
+ title: 'Playbook title' + Date.now(),
+ channelNameTemplate: 'Channel template',
+ runSummaryTemplate: 'run summary template',
+ channelMode: 'create_new_channel',
+ }).then((playbook) => {
+ // # Visit the selected playbook
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ cy.wait(FIVE_SEC);
+
+ // # Open playbooks RHS.
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # First create a blank checklist so the header with dropdown appears
+ cy.get('#rhsContainer').findByTestId('create-blank-checklist').click();
+ cy.wait(1000); // Wait for checklist to be created and RHS to update
+
+ // # Now the header with dropdown should be visible, click the dropdown
+ cy.get('#rhsContainer').find('[data-testid="create-blank-checklist"]').parent().find('.icon-chevron-down').click();
+
+ // # Click "Run a playbook" from the dropdown
+ cy.findByTestId('create-from-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert we are at playbooks tab
+ cy.findByText('Select a playbook').should('be.visible');
+
+ // # Click on the playbook
+ cy.findAllByText(playbook.title).eq(0).click();
+
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // # Change to link to existing channel
+ cy.findByTestId('link-existing-channel-radio').click();
+
+ // # Fill run name
+ cy.findByTestId('run-name-input').clear().type('Test Run Name');
+
+ // # Select test channel instead of current channel
+ cy.findByText('Town Square').click().type(`${testChannel.display_name}{enter}`);
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on the existing channel
+ cy.url().should('include', `/${testTeam.name}/channels/${testChannel.name}`);
+
+ // * Verify channel name
+ cy.get('#channelHeaderTitle').contains(`${testChannel.display_name}`);
+
+ // * Verify run RHS
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.contains('Test Run Name');
+ cy.contains('run summary template');
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/status_update_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/status_update_spec.js
new file mode 100644
index 00000000000..002baaab421
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/status_update_spec.js
@@ -0,0 +1,481 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+import * as TIMEOUTS from '../../../../fixtures/timeouts';
+
+describe('channels > rhs > status update', {testIsolation: true}, () => {
+ const defaultReminderMessage = '# Default reminder message';
+ let testTeam;
+ let testChannel;
+ let testUser;
+ let testPlaybook;
+ let testRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, channel, user}) => {
+ testTeam = team;
+ testChannel = channel;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ userId: testUser,
+ broadcastChannelIds: [testChannel.id],
+ reminderTimerDefaultSeconds: 3600,
+ reminderMessageTemplate: defaultReminderMessage,
+ retrospectiveEnabled: false,
+ broadcastEnabled: true,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a new playbook run
+ const now = Date.now();
+ const name = 'Playbook Run (' + now + ')';
+ const channelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: name,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ testRun = run;
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${channelName}`);
+ });
+
+ describe('post update dialog', () => {
+ it('renders description correctly', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Check description
+ cy.findByTestId('update_run_status_description').contains(`This update for the run ${testRun.name} will be broadcasted to one channel and one direct message.`);
+ });
+ });
+
+ it('prevents posting an update message with only whitespace', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // # Type the invalid data
+ cy.findByTestId('update_run_status_textbox').clear().type(' {enter} {enter} ');
+
+ // * Verify submit is disabled.
+ cy.get('button.confirm').should('be.disabled');
+
+ // # Enter valid data
+ cy.findByTestId('update_run_status_textbox').type('valid update');
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+ });
+
+ // * Verify that the Post update dialog has gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+ });
+
+ it('lets users with no access to the playbook post an update', () => {
+ let channelName;
+ const updateMessage = 'status update ' + Date.now();
+
+ // # Login as sysadmin and create a private playbook and a run
+ cy.apiAdminLogin().then(({user: sysadmin}) => {
+ // # Create a private playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook - Private',
+ memberIDs: [sysadmin.id], // Make it accesible only to sysadmin
+ inviteUsersEnabled: true,
+ invitedUserIds: [testUser.id], // Invite the test user
+ }).then((playbook) => {
+ // # Create a new playbook run
+ const now = Date.now();
+ const name = 'Playbook Run (' + now + ')';
+ channelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: name,
+ ownerUserId: sysadmin.id,
+ }).then((run) => {
+ cy.apiAddUsersToRun(run.id, [testUser.id]);
+ });
+ });
+ }).then(() => {
+ // # Login as the test user
+ cy.apiLogin(testUser);
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${channelName}`);
+
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // # Enter valid data
+ cy.findByTestId('update_run_status_textbox').type(updateMessage);
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+ });
+
+ // * Verify that the Post update dialog has gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+
+ // * Verify that the status update was posted.
+ cy.getLastPost().within(() => {
+ cy.findByText(updateMessage).should('exist');
+ });
+ });
+ });
+
+ it('confirms finishing the run, and remembers changes and reminder when canceled', () => {
+ const updateMessage = 'This is the update text to test with.';
+ const reminderTime = '1 day';
+
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the first message is there.
+ cy.findByTestId('update_run_status_textbox').within(() => {
+ cy.findByText(defaultReminderMessage).should('exist');
+ });
+
+ // # Type text to test for later
+ cy.findByTestId('update_run_status_textbox').clear().type(updateMessage);
+
+ // # Set a new reminder to test for later
+ cy.openReminderSelector();
+ cy.selectReminderTime(reminderTime);
+
+ // # Mark the run as finished
+ cy.findByTestId('mark-run-as-finished').click({force: true});
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+ });
+
+ // * Confirmation should appear
+ cy.get('.modal-header').should('be.visible').contains('Confirm finish run');
+
+ // # Cancel
+ cy.get('#cancelModalButton').click({force: true});
+
+ // * Verify post update has the same information
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the message was remembered
+ cy.findByTestId('update_run_status_textbox').within(() => {
+ cy.findByText(updateMessage).should('exist');
+ });
+
+ // * Verify the reminder was remembered
+ cy.get('#reminder_timer_datetime').contains(reminderTime);
+
+ // * Marked run is still checked
+ cy.findByTestId('mark-run-as-finished').within(() => {
+ cy.get('[type="checkbox"]').should('be.checked');
+ });
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+ });
+
+ // * Confirmation should appear
+ cy.get('.modal-header').should('be.visible').contains('Confirm finish run');
+
+ // # Submit
+ cy.get('#confirmModalButton').click({force: true});
+
+ // * Verify the status update was posted.
+ cy.getStyledComponent('CustomPostContent').within(() => {
+ cy.findByText(updateMessage).should('exist');
+ });
+
+ // * Verify the run was finished.
+ cy.getLastPost().contains(`@${testUser.username} marked ${testRun.name} as finished.`);
+ });
+
+ describe('prevents user from losing changes', () => {
+ it('cancel, go back and save', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // # Type the invalid data
+ cy.findByTestId('update_run_status_textbox').clear().type('My valid and important changes that I don\'t want to lose');
+
+ // * Click cancel
+ cy.findByTestId('modal-cancel-button').click();
+ });
+
+ // * Go back from unsaved changes modal
+ cy.get('#confirm-modal-light').within(() => {
+ cy.findByTestId('modal-cancel-button').click();
+ });
+
+ // # Delay in between the modal switch to ensure the
+ // # animation has fully happened
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+
+ // * Verify that the Post update and unsaved changes modals have gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+ cy.get('#confirm-modal-light').should('not.exist');
+ });
+
+ it('click overview link, go back and save', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // # Type the invalid data
+ cy.findByTestId('update_run_status_textbox').clear().type('My valid and important changes that I don\'t want to lose');
+
+ // # Click overview link
+ cy.findByTestId('run-overview-link').click();
+ });
+
+ // Verify that the confirmation modal is shown
+ cy.get('#confirm-modal-light').within(() => {
+ // * Go back from unsaved changes modal
+ cy.findByTestId('modal-cancel-button').click();
+ });
+
+ // # Delay in between the modal switch to ensure the
+ // # animation has fully happened
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+
+ // * Verify that the Post update and unsaved changes modals have gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+ cy.get('#confirm-modal-light').should('not.exist');
+ });
+
+ it('cancel and discard explicitly', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // # Type the invalid data
+ cy.findByTestId('update_run_status_textbox').clear().type('My valid and important changes that I don\'t want to lose');
+
+ // * Click cancel
+ cy.findByTestId('modal-cancel-button').click();
+ });
+
+ // * Discard explicitly from unsaved changes
+ cy.get('#confirm-modal-light').within(() => {
+ cy.get('button.confirm').click();
+ });
+
+ // * Verify that the Post update and unsaved changes modals have gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+ cy.get('#confirm-modal-light').should('not.exist');
+ });
+
+ it('click overview link and discard explicitly', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // # Type the invalid data
+ cy.findByTestId('update_run_status_textbox').clear().type('My valid and important changes that I don\'t want to lose');
+
+ // # Click overview link
+ cy.findByTestId('run-overview-link').click();
+ });
+
+ // * Discard explicitly from unsaved changes
+ cy.get('#confirm-modal-light').within(() => {
+ cy.get('button.confirm').click();
+ });
+
+ // * Assert that we are at run overview page.
+ cy.url().should('include', `/playbooks/runs/${testRun.id}`);
+
+ // * Verify that the Post update and unsaved changes modals have gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+ cy.get('#confirm-modal-light').should('not.exist');
+
+ // * Verify that the run actions modal is opened.
+ cy.findByRole('dialog', {name: /Run Actions/i}).should('exist');
+ });
+ });
+
+ describe('shows the last update in update message', () => {
+ it('shows the default when we have not made an update before', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the first message is there.
+ cy.findByTestId('update_run_status_textbox').within(() => {
+ cy.findByText(defaultReminderMessage).should('exist');
+ });
+ });
+ });
+
+ it('when we have made a previous update', () => {
+ const now = Date.now();
+ const firstMessage = 'Update - ' + now;
+
+ // # Create a first status update
+ cy.updateStatus(firstMessage);
+
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the first message is there.
+ cy.findByTestId('update_run_status_textbox').within(() => {
+ cy.findByText(firstMessage).should('exist');
+ });
+ });
+ });
+ });
+ });
+
+ describe('the default reminder', () => {
+ it('shows the configured default when we have not made a previous update', () => {
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the default is as expected
+ cy.get('#reminder_timer_datetime').within(() => {
+ cy.get('[class$=singleValue]').should('have.text', '1 hour');
+ });
+ });
+ });
+
+ it('shows the last reminder we typed in: 15 minutes', () => {
+ const now = Date.now();
+ const firstMessage = 'Update - ' + now;
+
+ // # Create a first status update
+ cy.updateStatus(firstMessage, '15 minutes');
+
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the default is as expected
+ cy.get('#reminder_timer_datetime').within(() => {
+ cy.get('[class$=singleValue]').should('have.text', '15 minutes');
+ });
+ });
+ });
+
+ it('shows the last reminder we typed in: 90 minutes', () => {
+ const now = Date.now();
+ const firstMessage = 'Update - ' + now;
+
+ // # Create a first status update
+ cy.updateStatus(firstMessage, '90 minutes');
+
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the default is as expected
+ cy.get('#reminder_timer_datetime').within(() => {
+ cy.get('[class$=singleValue]').should('have.text', '1 hour, 30 minutes');
+ });
+ });
+ });
+
+ it('shows the last reminder we typed in: 7 days', () => {
+ const now = Date.now();
+ const firstMessage = 'Update - ' + now;
+
+ // # Create a first status update
+ cy.updateStatus(firstMessage, '7 days');
+
+ // # Run the `/playbook update` slash command.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // * Verify the default is as expected
+ cy.get('#reminder_timer_datetime').within(() => {
+ cy.get('[class$=singleValue]').should('have.text', '7 days');
+ });
+ });
+ });
+ });
+
+ describe('playbook with disabled status updates', () => {
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ userId: testUser,
+ broadcastChannelId: testChannel.id,
+ statusUpdateEnabled: false,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ describe('omit status update dialog when status updates are disabled', () => {
+ it('shows the default when we have not made an update before', () => {
+ // * Check if RHS section is loaded
+ cy.get('#rhs-about').should('exist');
+
+ // * Check if Post Update section is omitted
+ cy.get('#rhs-post-update').should('not.exist');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/template_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/template_spec.js
new file mode 100644
index 00000000000..63ddace9dfb
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/template_spec.js
@@ -0,0 +1,112 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {FIVE_SEC} from '../../../../fixtures/timeouts';
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > rhs > template', {testIsolation: true}, () => {
+ let team1;
+ let testUser;
+
+ beforeEach(() => {
+ cy.apiAdminLogin().then(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ team1 = team;
+ testUser = user;
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+ });
+ });
+
+ describe('create playbook', () => {
+ describe('open new playbook creation modal and navigates to playbooks', () => {
+ // TODO: This workflow has been deprecated with the new Checklists UI. May be re-enabled when template access is redesigned.
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('after clicking on Use', () => {
+ // # Switch to playbooks DM channel
+ cy.visit(`/${team1.name}/messages/@playbooks`);
+
+ cy.wait(FIVE_SEC);
+
+ // # Open playbooks RHS.
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # Create a blank checklist first to get the header with dropdown
+ cy.get('#rhsContainer').findByTestId('create-blank-checklist').click();
+ cy.wait(1000);
+
+ // # Click the dropdown to access "Run a playbook"
+ cy.get('#rhsContainer').find('[data-testid="create-blank-checklist"]').parent().find('.icon-chevron-down').click();
+ cy.findByTestId('create-from-playbook').click();
+
+ // # Click on Playbook Templates tab
+ cy.get('#root-portal.modal-open').within(() => {
+ cy.findByText('Playbook Templates').click();
+
+ // # Return first template (Blank)
+ cy.contains('Blank').click();
+ });
+
+ // * Assert playbooks creation modal is shown.
+ cy.get('#playbooks_create').should('exist');
+
+ // # Click create playbook button.
+ cy.get('button[data-testid=modal-confirm-button]').click();
+
+ // * Assert expected playbook template title in outline.
+ cy.findByTestId('playbook-editor-title').contains('Blank');
+ });
+
+ // TODO: This workflow has been deprecated with the new Checklists UI. May be re-enabled when template access is redesigned.
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('after clicking on title', () => {
+ // # Switch to playbooks DM channel
+ cy.visit(`/${team1.name}/messages/@playbooks`);
+
+ cy.wait(FIVE_SEC);
+
+ // # Open playbooks RHS.
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // # Create a blank checklist first to get the header with dropdown
+ cy.get('#rhsContainer').findByTestId('create-blank-checklist').click();
+ cy.wait(1000);
+
+ // # Click the dropdown to access "Run a playbook"
+ cy.get('#rhsContainer').find('[data-testid="create-blank-checklist"]').parent().find('.icon-chevron-down').click();
+ cy.findByTestId('create-from-playbook').click();
+
+ // # Click on Playbook Templates tab and then on the template title
+ cy.get('#root-portal.modal-open').within(() => {
+ cy.findByText('Playbook Templates').click();
+
+ // # Click on 'Blank' template title
+ cy.findByTestId('template-details').first().within(() => {
+ cy.contains('Blank').click();
+ });
+ });
+
+ // * Assert playbooks creation modal is shown.
+ cy.get('#playbooks_create').should('exist');
+
+ // # Click create playbook button.
+ cy.get('button[data-testid=modal-confirm-button]').click();
+
+ // * Assert expected playbook template title in outline.
+ cy.findByTestId('playbook-editor-title').contains('Blank');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/title_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/title_spec.js
new file mode 100644
index 00000000000..807845d314d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs/title_spec.js
@@ -0,0 +1,94 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > rhs > title', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let playbookRunChannelName;
+ let testPlaybookRun;
+
+ const getHeaderTitle = () => cy.get('#rhsContainer').find('.sidebar--right__title');
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ testPlaybookRun = run;
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+ });
+
+ it('has title', () => {
+ // * Verify the title is displayed
+ getHeaderTitle().contains('Checklist');
+ });
+
+ it('has following button', () => {
+ // * Verify the following button is displayed
+ getHeaderTitle().find('button.unfollowButton').contains('Following');
+
+ // * Verify the follow button is not displayed
+ getHeaderTitle().find('button.followButton').should('not.exist');
+ });
+
+ it('can stop following', () => {
+ // # Click the following button
+ getHeaderTitle().find('button.unfollowButton').click();
+
+ // * Verify the following button is not displayed
+ getHeaderTitle().find('button.unfollowButton').should('not.exist');
+
+ // * Verify the follow button is displayed
+ getHeaderTitle().find('button.followButton').contains('Follow');
+ });
+
+ it('can navigate to RDP', () => {
+ // # Click the title
+ getHeaderTitle().findByTestId('rhs-title').click();
+
+ // * assert url is RDP
+ cy.url().should('include', `/playbooks/runs/${testPlaybookRun.id}`);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs_spec.js
new file mode 100644
index 00000000000..b951695a0c2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/rhs_spec.js
@@ -0,0 +1,415 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+import * as TIMEOUTS from '../../../fixtures/timeouts';
+
+describe('channels > rhs', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('does not open', () => {
+ it('when navigating to a non-playbook run channel', () => {
+ // # Navigate to the application
+ cy.visit(`/${testTeam.name}/`);
+
+ // # Select a channel without a playbook run.
+ cy.get('#sidebarItem_off-topic').click({force: true});
+
+ // # Wait until the channel loads enough to show the post textbox.
+ cy.get('#post-create').should('exist');
+
+ // # Wait a bit longer to be confident.
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // * Verify the playbook run RHS is not open.
+ cy.get('#rhsContainer').should('not.exist');
+ });
+
+ it('when navigating to a playbook run channel with the RHS already open', () => {
+ // # Navigate to the application.
+ cy.visit(`/${testTeam.name}/`);
+
+ // # Select a channel without a playbook run.
+ cy.get('#sidebarItem_off-topic').click({force: true});
+
+ // # Run the playbook after loading the application
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Open the flagged posts RHS
+ cy.get('body').then(($body) => {
+ if ($body.find('#channelHeaderFlagButton').length > 0) {
+ cy.get('#channelHeaderFlagButton').click({force: true});
+ } else {
+ cy.findByRole('button', {name: 'Saved messages'}).
+ click({force: true});
+ }
+ });
+
+ // # Open the playbook run channel from the LHS.
+ cy.get(`#sidebarItem_${playbookRunChannelName}`).click({force: true});
+
+ // # Wait until the channel loads enough to show the post textbox.
+ cy.get('#post-create').should('exist');
+
+ // # Wait a bit longer to be confident.
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // * Verify the playbook run RHS is not open.
+ cy.get('#rhsContainer').should('not.exist');
+ });
+
+ it('when navigating directly to a finished playbook run channel', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # End the playbook run
+ cy.apiFinishRun(playbookRun.id);
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Wait a bit longer to be confident.
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // * Verify the playbook run RHS is not open.
+ cy.get('#rhsContainer').should('not.exist');
+ });
+
+ it('for an existing, finished playbook run channel opened from the lhs', () => {
+ // # Run the playbook before loading the application
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # End the playbook run
+ cy.apiFinishRun(playbookRun.id);
+ });
+
+ // # Navigate to a channel without a playbook run.
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // # Ensure the channel is loaded before continuing (allows redux to sync).
+ cy.findByTestId('post_textbox').should('exist');
+
+ // # Open the playbook run channel from the LHS.
+ cy.get(`#sidebarItem_${playbookRunChannelName}`).click({force: true});
+
+ // # Wait a bit longer to be confident.
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // * Verify the playbook run RHS is not open.
+ cy.get('#rhsContainer').should('not.exist');
+ });
+
+ it('for a new, finished playbook run channel opened from the lhs', () => {
+ // # Navigate to the application.
+ cy.visit(`/${testTeam.name}/`);
+
+ // # Ensure the channel is loaded before continuing (allows redux to sync).
+ cy.findByTestId('post_textbox').should('exist');
+
+ // # Select a channel without a playbook run.
+ cy.get('#sidebarItem_off-topic').click({force: true});
+
+ // * Verify the playbook run RHS is not open.
+ cy.get('#rhsContainer').should('not.exist');
+
+ // # Run the playbook after loading the application
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # Wait a bit longer to avoid websocket events potentially being out-of-order.
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // # End the playbook run
+ cy.apiFinishRun(playbookRun.id);
+ });
+
+ // # Wait because this test is flaky if we move too quickly
+ cy.wait(TIMEOUTS.FIVE_SEC);
+
+ // # Open the playbook run channel from the LHS.
+ cy.get(`#sidebarItem_${playbookRunChannelName}`).click({force: true});
+
+ // # Wait a bit longer to be confident.
+ cy.wait(TIMEOUTS.FIVE_SEC);
+
+ // * Verify the playbook run RHS is not open.
+ cy.get('#rhsContainer').should('not.exist');
+ });
+
+ // Skip: This test relies on accessing "Run a playbook" from an empty channel,
+ // which is no longer supported in the new Checklists UI. The empty channel state
+ // only provides a "New checklist" button without a dropdown.
+ it('when starting a new run of a newly-created playbook created from RHS in a newly-created channel', () => {
+ // # Create a new channel
+ const channelName = 'playbook-test-' + Date.now();
+ cy.apiCreateChannel(testTeam.id, channelName, channelName, 'O').then(({channel}) => {
+ // # Navigate to the new channel
+ cy.visit(`/${testTeam.name}/channels/${channel.name}`);
+
+ // # Open RHS
+ cy.getPlaybooksAppBarIcon().click();
+
+ // # Wait a bit
+ cy.wait(TIMEOUTS.TWO_SEC);
+
+ // # Now click dropdown next to "New checklist" button in header
+ cy.get('[data-testid="create-blank-checklist"]').first().parent().find('.icon-chevron-down').click();
+
+ // # Click "Run a playbook" from the dropdown
+ cy.findByTestId('create-from-playbook').click();
+
+ // # Create a new playbook
+ cy.findByText('Create new playbook').click();
+
+ // # confirm new playbook creation (with defaults)
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify we're in the playbook edit screen
+ cy.findByTestId('playbook-members');
+
+ // # Run the playbook
+ cy.findByTestId('run-playbook').click();
+ cy.findByTestId('run-name-input').type('Playbook Run');
+
+ // # Link to the new channel
+ cy.findByTestId('link-existing-channel-radio').click();
+ cy.get('#link-existing-channel-selector input').type(`${channel.name}{enter}`, {force: true});
+
+ cy.findByTestId('modal-confirm-button').click();
+
+ // # Wait a bit
+ cy.wait(TIMEOUTS.FIVE_SEC);
+
+ // * Verify the playbook run RHS is not open.
+ cy.get('#rhsContainer').should('not.exist');
+ });
+ });
+ });
+
+ describe('opens', () => {
+ it('when navigating directly to an ongoing playbook run channel', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify the playbook run RHS is open.
+ cy.findByTestId('menuButton').contains(playbookRunName);
+ });
+
+ it('for a new, ongoing playbook run channel opened from the lhs', () => {
+ // # Navigate to the application.
+ cy.visit(`/${testTeam.name}/`);
+
+ // # Ensure the channel is loaded before continuing (allows redux to sync).
+ cy.findByTestId('post_textbox').should('exist');
+
+ // # Select a channel without a playbook run.
+ cy.get('#sidebarItem_off-topic').click({force: true});
+
+ // # Run the playbook after loading the application
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Open the playbook run channel from the LHS.
+ cy.get(`#sidebarItem_${playbookRunChannelName}`).click({force: true});
+
+ // * Verify the playbook run RHS is open.
+ cy.findByTestId('menuButton').contains(playbookRunName);
+ });
+
+ it('for an existing, ongoing playbook run channel opened from the lhs', () => {
+ // # Run the playbook before loading the application
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Navigate to a channel without a playbook run.
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // # Ensure the channel is loaded before continuing (allows redux to sync).
+ cy.findByTestId('post_textbox').should('exist');
+
+ // # Open the playbook run channel from the LHS.
+ cy.get(`#sidebarItem_${playbookRunChannelName}`).click({force: true});
+
+ // * Verify the playbook run RHS is open.
+ cy.findByTestId('menuButton').contains(playbookRunName);
+ });
+
+ it('when starting a playbook run', () => {
+ // # Navigate to the application and a channel without a playbook run
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // # Start a playbook run with a slash command
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+
+ cy.startPlaybookRunWithSlashCommand('Playbook', playbookRunName);
+
+ // * Verify the playbook run RHS is open.
+ cy.findByTestId('menuButton').contains(playbookRunName);
+ });
+
+ it('when starting a playbook run when rhs is already open', () => {
+ // # Navigate to the application and a channel without a playbook run
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // # Wait until the channel loads enough to show the post textbox.
+ cy.get('#post-create').should('exist');
+
+ // # Open the saved posts RHS
+ cy.findByRole('button', {name: 'Saved messages'}).
+ click({force: true});
+
+ // * Verify Saved Messages is open
+ cy.get('.sidebar--right__title').should('contain.text', 'Saved messages');
+
+ // # Start a playbook run with a slash command
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ cy.startPlaybookRunWithSlashCommand('Playbook', playbookRunName);
+
+ // * Verify the playbook run RHS is open.
+ cy.findByTestId('menuButton').contains(playbookRunName);
+ });
+
+ it('when navigating directly to a finished playbook run channel and clicking on the button', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ const playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # End the playbook run
+ cy.apiFinishRun(playbookRun.id);
+ });
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // # Click the icon
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // * Verify no active runs screen shows
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.findByTestId('no-active-runs').should('exist');
+ });
+ });
+ });
+
+ describe('is toggled', () => {
+ it('by icon in channel header', () => {
+ // # Size the viewport to show plugin icons even when RHS is open
+ cy.viewport('macbook-13');
+
+ // # Navigate to the application and a channel without a playbook run
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // # Click the icon
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // * Verify RHS Home is open.
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.findByText('Playbooks').should('exist');
+ });
+
+ // # Click the icon
+ cy.getPlaybooksAppBarIcon().should('be.visible').click();
+
+ // * Verify the playbook run RHS is no longer open.
+ cy.get('#rhsContainer').should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/run_dialog_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/run_dialog_spec.js
new file mode 100644
index 00000000000..013dff5cb9e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/run_dialog_spec.js
@@ -0,0 +1,132 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > run dialog', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ });
+
+ // # Create a second playbook, so as to force dropdown.
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Second Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Navigate to the application
+ cy.visit(`${testTeam.name}`);
+
+ // # Trigger the playbook run creation dialog
+ cy.openPlaybookRunDialogFromSlashCommand();
+
+ // * Verify the playbook run creation dialog has opened
+ cy.get('#appsModal').should('exist').within(() => {
+ cy.findByText('Start run').should('exist');
+ });
+ });
+
+ it('cannot create a playbook run without filling required fields', () => {
+ cy.get('#appsModal').within(() => {
+ cy.findByText('Start run').should('exist');
+
+ // # Attempt to submit
+ cy.get('#appsModalSubmit').click();
+ });
+
+ // * Verify it didn't submit
+ cy.get('#appsModal').should('exist');
+
+ // * Verify required fields
+ cy.findByTestId('playbookID').contains('Playbook');
+ cy.findByTestId('playbookID').contains('This field is required.');
+ cy.findByTestId('playbookRunName').contains('This field is required.');
+ });
+
+ it('rejects invalid channel names', () => {
+ cy.selectPlaybookFromDropdown('Playbook');
+
+ const invalidPlaybookRunName = ' ';
+ cy.get('#appsModal').within(() => {
+ cy.findByTestId('playbookRunNameinput').type(invalidPlaybookRunName, {force: true});
+ });
+
+ cy.get('#appsModal').within(() => {
+ cy.findByText('Start run').should('exist');
+
+ // # Attempt to submit
+ cy.get('#appsModalSubmit').click();
+ });
+
+ // * Verify it didn't submit
+ cy.get('#appsModal').should('exist');
+
+ // * Verify error message
+ cy.get('#appsModal').within(() => {
+ cy.get('div.error-text').contains('unable to create playbook run');
+ });
+ });
+
+ it('shows expected metadata', () => {
+ cy.get('#appsModal').within(() => {
+ // * Shows current user as owner.
+ cy.findByText(`${testUser.first_name} ${testUser.last_name}`).should('exist');
+
+ // * Verify playbook dropdown prompt
+ cy.findByText('Playbook').should('exist');
+
+ // * Verify playbook run name prompt
+ cy.findByText('Run name').should('exist');
+ });
+ });
+
+ it('is canceled when cancel is clicked', () => {
+ // # Populate the interactive dialog
+ const playbookRunName = 'New Run' + Date.now();
+ cy.get('#appsModal').within(() => {
+ cy.findByTestId('playbookRunNameinput').type('Playbook', {force: true});
+ });
+
+ // # Cancel the interactive dialog
+ cy.get('#appsModalCancel').click();
+
+ // * Verify the modal is no longer displayed
+ cy.get('#appsModal').should('not.exist');
+
+ // * Verify the playbook run did not get created
+ cy.apiGetAllPlaybookRuns(testTeam.id).then((response) => {
+ const allPlaybookRuns = response.body;
+ const playbookRun = allPlaybookRuns.items.find((inc) => inc.name === playbookRunName);
+ expect(playbookRun).to.be.undefined;
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/run_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/run_spec.js
new file mode 100644
index 00000000000..2c4970095d2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/run_spec.js
@@ -0,0 +1,120 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > run', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPrivateChannel;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreatePlaybook({
+ teamId: team.id,
+ title: 'Playbook',
+ memberIDs: [user.id],
+ });
+
+ // # Create a private channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'private-channel',
+ 'Private Channel',
+ 'P',
+ ).then(({channel}) => {
+ testPrivateChannel = channel;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show plugin icons even when RHS is open
+ cy.viewport('macbook-13');
+ });
+
+ describe('via slash command', () => {
+ it('while viewing a public channel', () => {
+ // # Visit a public channel
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // * Verify that playbook run can be started with slash command
+ const playbookRunName = 'Public ' + Date.now();
+ cy.startPlaybookRunWithSlashCommand('Playbook', playbookRunName);
+ cy.verifyPlaybookRunActive(testTeam.id, playbookRunName);
+ });
+
+ it('while viewing a private channel', () => {
+ // # Visit a private channel
+ cy.visit(`/${testTeam.name}/channels/${testPrivateChannel.name}`);
+
+ // * Verify that playbook run can be started with slash command
+ const playbookRunName = 'Private ' + Date.now();
+ cy.startPlaybookRunWithSlashCommand('Playbook', playbookRunName);
+ cy.verifyPlaybookRunActive(testTeam.id, playbookRunName);
+ });
+ });
+
+ describe('via post menu', () => {
+ it('while viewing a public channel', () => {
+ // # Visit a public channel
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // * Verify that playbook run can be started from post menu
+ const playbookRunName = 'Public - ' + Date.now();
+ cy.startPlaybookRunFromPostMenu('Playbook', playbookRunName);
+ cy.verifyPlaybookRunActive(testTeam.id, playbookRunName);
+ });
+
+ it('while viewing a private channel', () => {
+ // # Visit a private channel
+ cy.visit(`/${testTeam.name}/channels/${testPrivateChannel.name}`);
+
+ // * Verify that playbook run can be started from post menu
+ const playbookRunName = 'Private - ' + Date.now();
+ cy.startPlaybookRunFromPostMenu('Playbook', playbookRunName);
+ cy.verifyPlaybookRunActive(testTeam.id, playbookRunName);
+ });
+ });
+
+ it('always as channel admin', () => {
+ // # Visit a public channel
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // # Start a playbook run with a slash command
+ const playbookRunName = 'Public ' + Date.now();
+ cy.startPlaybookRunWithSlashCommand('Playbook', playbookRunName);
+ cy.verifyPlaybookRunActive(testTeam.id, playbookRunName);
+
+ // # Open channel header dropdown
+ cy.get('#channelHeaderDropdownButton').click();
+
+ // # Click on Channel Settings
+ cy.findByText('Channel Settings').should('be.visible').click();
+
+ // * Verify Channel Settings modal opens
+ cy.get('.ChannelSettingsModal').should('be.visible');
+
+ // * Verify the ability to edit the channel header exists
+ cy.get('#channel_settings_header_textbox').should('be.visible').and('not.be.disabled');
+
+ // # Close the modal
+ cy.get('.GenericModal .modal-header button[aria-label="Close"]').click();
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/commands_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/commands_spec.js
new file mode 100644
index 00000000000..664c7501d37
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/commands_spec.js
@@ -0,0 +1,552 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+import {switchToChannel} from '../../../channels/mark_as_unread/helpers';
+
+describe('channels > slash command > owner', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testUser2;
+ let testPlaybook;
+ let playbookRunName;
+ let playbookRunChannelName;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+ cy.apiAddUserToTeam(testTeam.id, testUser2.id);
+ });
+
+ cy.apiLogin(testUser);
+
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ memberIDs: [testUser.id],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ const now = Date.now();
+ playbookRunName = `Playbook Run (${now})`;
+ playbookRunChannelName = `playbook-run-${now}`;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+ });
+
+ describe('single run channel', () => {
+ it('check', () => {
+ // # Type a command
+ cy.findByTestId('post_textbox').clear().type('/playbook check ');
+
+ // * Verify suggestions number: a single run with 4 tasks + 1 title
+ cy.get('.slash-command__info').should('have.length', 5);
+
+ // # Clear input
+ cy.findByTestId('post_textbox').clear();
+
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook check 1 1');
+
+ // * Verify the task is checked
+ cy.get('[data-rbd-droppable-id="1"]').find('.checkbox').eq(1).should('be.checked');
+ });
+
+ it('check add', () => {
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook checkadd 1 new-task');
+
+ // * Verify the task was added
+ cy.get('[data-rbd-droppable-id="1"]').contains('new-task');
+ });
+
+ it('check remove', () => {
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook checkremove 1 1');
+
+ // * Verify the task was added
+ cy.get('[data-rbd-droppable-id="1"]').contains('Step 2').should('not.exist');
+ });
+
+ it('owner', () => {
+ // # Run a slash command
+ cy.uiPostMessageQuickly('/playbook owner');
+
+ // * Verify the message.
+ cy.verifyEphemeralMessage(`@${testUser.username} is the current owner for this playbook run.`);
+
+ // # Run a slash command
+ cy.uiPostMessageQuickly(`/playbook owner @${testUser2.username}`);
+
+ // * Verify that the owner was set.
+ cy.uiPostMessageQuickly('/playbook owner');
+ cy.verifyEphemeralMessage(`@${testUser2.username} is the current owner for this playbook run.`);
+ });
+
+ it('timeline', () => {
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly('/playbook timeline');
+
+ // * Verify the message.
+ cy.verifyEphemeralMessage(`Timeline for ${playbookRunName}`);
+ });
+
+ it('finish', () => {
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook finish');
+
+ // * Verify confirm modal is visible and click Finish button
+ cy.findByRole('button', {name: /Finish/i}).should('be.visible').click();
+
+ // * Verify that the run finished (RHS remains open without errors)
+ cy.findByRole('button', {name: /Done/i}).should('be.visible');
+ });
+ });
+
+ describe('multiple runs in the channel', () => {
+ let playbookRuns;
+ let testPrivatePlaybook;
+ let testPublicPlaybook;
+ let testPublicChannel;
+ let channelName;
+
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create private playbook, channel mode set to link existing channel
+ cy.apiCreatePlaybook({
+ makePublic: false,
+ createPublicPlaybookRun: false,
+ teamId: testTeam.id,
+ title: 'Playbook private',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ memberIDs: [testUser.id],
+ channelMode: 'link_existing_channel',
+ }).then((playbook) => {
+ testPrivatePlaybook = playbook;
+ });
+
+ // # Create public playbook, channel mode set to link existing channel
+ cy.apiCreatePlaybook({
+ makePublic: true,
+ teamId: testTeam.id,
+ title: 'Playbook public',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ memberIDs: [testUser.id],
+ channelMode: 'link_existing_channel',
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+
+ beforeEach(() => {
+ playbookRuns = [];
+ const now = Date.now();
+ channelName = 'public-channel-' + now;
+
+ // # Create channel for runs
+ cy.apiCreateChannel(
+ testTeam.id,
+ channelName,
+ 'public channel',
+ 'O',
+ ).then(({channel: publicChannel}) => {
+ testPublicChannel = publicChannel;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPrivatePlaybook.id,
+ playbookRunName: 'run write access ' + now,
+ ownerUserId: testUser.id,
+ channelId: testPublicChannel.id,
+ }).then((playbookRun) => {
+ cy.apiAddUsersToRun(playbookRun.id, [testUser2.id]);// add test user to participants list
+ playbookRuns.push(playbookRun);
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'run view access' + now,
+ ownerUserId: testUser.id,
+ channelId: testPublicChannel.id,
+ }).then((playbookRun2) => {
+ playbookRuns.push(playbookRun2);
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPrivatePlaybook.id,
+ playbookRunName: 'run no access' + now,
+ ownerUserId: testUser.id,
+ channelId: testPublicChannel.id,
+ }).then((playbookRun3) => {
+ playbookRuns.push(playbookRun3);
+
+ // # Add testUser2 to the channel
+ cy.apiAddUserToChannel(testPublicChannel.id, testUser2.id);
+
+ // # Login as testUser2
+ cy.apiLogin(testUser2);
+
+ // # Navigate directly to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${testPublicChannel.name}`);
+ switchToChannel(testPublicChannel);
+ });
+ });
+ });
+ });
+ });
+
+ it('check', () => {
+ // # Run a slash command with not enough parameters
+ cy.uiPostMessageQuickly('/playbook check 1 1');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Command expects three arguments: the run number, the checklist number and the item number.');
+
+ // # Run a slash command wrong run number
+ cy.uiPostMessageQuickly('/playbook check 2 1 1');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Invalid run number');
+
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly('/playbook check 0 1 1');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Become a participant to interact with this run');
+
+ // # Type a command
+ cy.findByTestId('post_textbox').clear().type('/playbook check ');
+
+ // * Verify suggestions number: 2 runs * 4 tasks + 1 title
+ cy.get('.slash-command__info').should('have.length', 9);
+
+ // # Clear input
+ cy.findByTestId('post_textbox').clear();
+
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook check 1 1 1');
+ cy.get('#rhsContainer').within(() => {
+ // * Verify number of runs
+ cy.get('[data-testid="run-list-card"]').should('have.length', 2);
+
+ // # Open run details view
+ cy.findByText(playbookRuns[0].name).click({force: true});
+ });
+
+ // * Verify the task is checked
+ cy.get('[data-rbd-droppable-id="1"]').find('.checkbox').eq(1).should('be.checked');
+ });
+
+ it('check add', () => {
+ // # Run a slash command with not enough parameters
+ cy.uiPostMessageQuickly('/playbook checkadd 1');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Command expects two arguments: the run number and the checklist number.');
+
+ // # Run a slash command wrong run number
+ cy.uiPostMessageQuickly('/playbook checkadd 2 1 1');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Invalid run number');
+
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly('/playbook checkadd 0 1 new-task');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Become a participant to interact with this run');
+
+ // # Type a command
+ cy.findByTestId('post_textbox').clear().type('/playbook checkadd ');
+
+ // * Verify suggestions number: 2 runs * 2 checklists + 1 title
+ cy.get('.slash-command__info').should('have.length', 5);
+
+ // # Clear input
+ cy.findByTestId('post_textbox').clear();
+
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook checkadd 1 1 new-task');
+
+ cy.get('#rhsContainer').within(() => {
+ // * Verify number of runs
+ cy.get('[data-testid="run-list-card"]').should('have.length', 2);
+
+ // # Open run details view
+ cy.findByText(playbookRuns[0].name).click({force: true});
+ });
+
+ // * Verify the task was added
+ cy.get('[data-rbd-droppable-id="1"]').contains('new-task');
+ });
+
+ it('check remove', () => {
+ // # Run a slash command with not enough parameters
+ cy.uiPostMessageQuickly('/playbook checkremove 1 1');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Command expects three arguments: the run number, the checklist number and the item number.');
+
+ // # Run a slash command wrong run number
+ cy.uiPostMessageQuickly('/playbook checkremove 2 0 1');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Invalid run number');
+
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly('/playbook checkremove 0 1 0');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Become a participant to interact with this run');
+
+ // # Type a command
+ cy.findByTestId('post_textbox').clear().type('/playbook checkremove ');
+
+ // * Verify suggestions number: 2 runs * 4 tasks + 1 title
+ cy.get('.slash-command__info').should('have.length', 9);
+
+ // # Clear input
+ cy.findByTestId('post_textbox').clear();
+
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook checkremove 1 1 1');
+
+ cy.get('#rhsContainer').within(() => {
+ // * Verify number of runs
+ cy.get('[data-testid="run-list-card"]').should('have.length', 2);
+
+ // # Open run details view
+ cy.findByText(playbookRuns[0].name).click({force: true});
+ });
+
+ // * Verify the task was added
+ cy.get('[data-rbd-droppable-id="1"]').contains('Step 2').should('not.exist');
+ });
+
+ it('owner', () => {
+ // # Run a slash command with not enough parameters
+ cy.uiPostMessageQuickly('/playbook owner');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('/playbook owner expects at most one argument.');
+
+ // # Run a slash command wrong run number
+ cy.uiPostMessageQuickly('/playbook owner 2');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Invalid run number');
+
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly('/playbook owner 0');
+
+ // * Verify the message.
+ cy.verifyEphemeralMessage(`@${testUser.username} is the current owner for this playbook run.`);
+
+ // # Type a command
+ cy.findByTestId('post_textbox').clear().type('/playbook owner ');
+
+ // * Verify suggestions number: 2 runs + 1 title
+ cy.get('.slash-command__info').should('have.length', 3);
+
+ // # Clear input
+ cy.findByTestId('post_textbox').clear();
+
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly(`/playbook owner 0 @${testUser2.username}`);
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Become a participant to interact with this run');
+
+ // # Run a slash command on a run with write access
+ cy.uiPostMessageQuickly(`/playbook owner 1 @${testUser2.username}`);
+
+ // * Verify that the owner was set.
+ cy.uiPostMessageQuickly('/playbook owner 1');
+ cy.verifyEphemeralMessage(`@${testUser2.username} is the current owner for this playbook run.`);
+ });
+
+ it('finish', () => {
+ // # Run a slash command with not enough parameters
+ cy.uiPostMessageQuickly('/playbook finish');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Command expects one argument: the run number.');
+
+ // # Run a slash command wrong run number
+ cy.uiPostMessageQuickly('/playbook finish 2');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Invalid run number');
+
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly('/playbook finish 0');
+
+ // * Verify the message.
+ cy.verifyEphemeralMessage(`userID ${testUser2.id} is not an admin or channel member`);
+
+ // # Type a command
+ cy.findByTestId('post_textbox').clear().type('/playbook finish ');
+
+ // * Verify suggestions number: 2 runs + 1 title
+ cy.get('.slash-command__info').should('have.length', 3);
+
+ // # Clear input
+ cy.findByTestId('post_textbox').clear();
+
+ cy.get('#rhsContainer').within(() => {
+ // * Verify number of runs
+ cy.get('[data-testid="run-list-card"]').should('have.length', 2);
+
+ // # Open run details view
+ cy.findByText(playbookRuns[0].name).click({force: true});
+ });
+
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook finish 1');
+
+ // * Verify confirm modal is visible and click Finish button
+ cy.findByRole('button', {name: /Finish/i}).should('be.visible').click();
+
+ // * Verify that the run finished (RHS remains open without errors)
+ cy.findByRole('button', {name: /Done/i}).should('be.visible');
+ });
+
+ it('timeline', () => {
+ // # Run a slash command with not enough parameters
+ cy.uiPostMessageQuickly('/playbook timeline');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Command expects one argument: the run number.');
+
+ // # Run a slash command wrong run number
+ cy.uiPostMessageQuickly('/playbook timeline 2');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Invalid run number');
+
+ // # Run a slash command on a run with view access
+ cy.uiPostMessageQuickly('/playbook timeline 0');
+
+ // * Verify the message.
+ cy.verifyEphemeralMessage(`Timeline for ${playbookRuns[1].name}`);
+ });
+
+ it('update', () => {
+ // # Run a slash command with not enough parameters
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Command expects one argument: the run number.');
+
+ // # Run a slash command wrong run number
+ cy.uiPostMessageQuickly('/playbook update 2');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Invalid run number');
+
+ // # Type a command
+ cy.findByTestId('post_textbox').clear().type('/playbook update ');
+
+ // * Verify suggestions number: 2 runs + 1 title
+ cy.get('.slash-command__info').should('have.length', 3);
+
+ // # Clear input
+ cy.findByTestId('post_textbox').clear();
+
+ // # Run a slash command with correct parameters
+ cy.uiPostMessageQuickly('/playbook update 1');
+
+ // # Get dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ // # Enter valid data
+ cy.findByTestId('update_run_status_textbox').type('valid update');
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+ });
+
+ // * Verify that the Post update dialog has gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+
+ // * Verify that the status update was posted.
+ cy.getLastPost().within(() => {
+ cy.findByText('posted an update for').should('exist');
+ });
+ });
+ });
+});
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/info_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/info_spec.js
new file mode 100644
index 00000000000..fb1266d25c0
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/info_spec.js
@@ -0,0 +1,120 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > slash command > info', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testUser2;
+ let testPlaybook;
+ let testPlaybookRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+ cy.apiAddUserToTeam(testTeam.id, testUser2.id);
+ });
+
+ cy.apiLogin(testUser);
+
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ memberIDs: [testUser.id],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'Playbook Run',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Reset the owner back to testUser as necessary.
+ cy.apiChangePlaybookRunOwner(testPlaybookRun.id, testUser.id);
+ });
+
+ describe('/playbook info', () => {
+ it('should show an error when not in a playbook run channel', () => {
+ // # Navigate to a non-playbook run channel.
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // # Run a slash command to show the playbook run's info.
+ cy.uiPostMessageQuickly('/playbook info');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('This command only works when run from a playbook run channel.');
+ });
+
+ it('should open the RHS when it is not open', () => {
+ // # Navigate directly to the application and the playbook run channel.
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // # Close the RHS, which is opened by default when navigating to a playbook run channel.
+ cy.get('#searchResultsCloseButton').click();
+
+ // * Verify that the RHS is indeed closed.
+ cy.get('#rhsContainer').should('not.exist');
+
+ // # Run a slash command to show the playbook run's info.
+ cy.uiPostMessageQuickly('/playbook info');
+
+ // * Verify that the RHS is now open.
+ cy.get('#rhsContainer').should('be.visible');
+ });
+
+ it('should show an ephemeral post when the RHS is already open', () => {
+ // # Navigate directly to the application and the playbook run channel.
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // * Verify that the RHS is open.
+ cy.get('#rhsContainer').should('be.visible');
+
+ // # Run a slash command to show the playbook run's info.
+ cy.uiPostMessageQuickly('/playbook info');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Your playbook run details are already open in the right hand side of the channel.');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/owner_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/owner_spec.js
new file mode 100644
index 00000000000..212bcf237e7
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/owner_spec.js
@@ -0,0 +1,222 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > slash command > owner', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testUser2;
+ let testPlaybook;
+ let testPlaybookRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+ cy.apiAddUserToTeam(testTeam.id, testUser2.id);
+ });
+
+ cy.apiLogin(testUser);
+
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ memberIDs: [testUser.id],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'Playbook Run',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Reset the owner back to testUser as necessary.
+ cy.apiChangePlaybookRunOwner(testPlaybookRun.id, testUser.id);
+ });
+
+ describe('/playbook owner', () => {
+ it('should show an error when not in a playbook run channel', () => {
+ // # Navigate to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // # Run a slash command to show the current owner
+ cy.uiPostMessageQuickly('/playbook owner');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('This command only works when run from a playbook run channel.');
+ });
+
+ it('should show the current owner', () => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // # Run a slash command to show the current owner
+ cy.uiPostMessageQuickly('/playbook owner');
+
+ // * Verify the expected owner.
+ cy.verifyEphemeralMessage(`@${testUser.username} is the current owner for this playbook run.`);
+ });
+ });
+
+ describe('/playbook owner @username', () => {
+ it('should show an error when not in a playbook run channel', () => {
+ // # Navigate to a non-playbook run channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly(`/playbook owner ${testUser2.username}`);
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('This command only works when run from a playbook run channel.');
+ });
+
+ describe('should show an error when the user is not found', () => {
+ beforeEach(() => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+ });
+
+ it('when the username has no @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly('/playbook owner unknown');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Unable to find user @unknown');
+ });
+
+ it('when the username has an @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly('/playbook owner @unknown');
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('Unable to find user @unknown');
+ });
+ });
+
+ describe('should not show an error when the user is not in the channel', () => {
+ beforeEach(() => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // # Ensure the user3 is not part of the channel.
+ cy.uiPostMessageQuickly(`/kick ${testUser2.username}`);
+ });
+
+ it('when the username has no @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly(`/playbook owner ${testUser2.username}`);
+
+ // * Verify the owner has changed.
+ cy.findByTestId('owner-profile-selector').contains(testUser2.username);
+ });
+
+ it('when the username has an @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly(`/playbook owner @${testUser2.username}`);
+
+ // * Verify the owner has changed.
+ cy.findByTestId('owner-profile-selector').contains(testUser2.username);
+ });
+ });
+
+ describe('should show a message when the user is already the owner', () => {
+ beforeEach(() => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+ });
+
+ it('when the username has no @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly(`/playbook owner ${testUser.username}`);
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage(`User @${testUser.username} is already owner of this playbook run.`);
+ });
+
+ it('when the username has an @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly(`/playbook owner @${testUser.username}`);
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage(`User @${testUser.username} is already owner of this playbook run.`);
+ });
+ });
+
+ describe('should change the current owner', () => {
+ beforeEach(() => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // # Ensure the testUser2 is part of the channel.
+ cy.uiPostMessageQuickly(`/invite ${testUser2.username}`);
+ });
+
+ it('when the username has no @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly(`/playbook owner ${testUser2.username}`);
+
+ // # Verify the owner has changed.
+ cy.findByTestId('owner-profile-selector').contains(testUser2.username);
+ });
+
+ it('when the username has an @-prefix', () => {
+ // # Run a slash command to change the current owner
+ cy.uiPostMessageQuickly(`/playbook owner @${testUser2.username}`);
+
+ // # Verify the owner has changed.
+ cy.findByTestId('owner-profile-selector').contains(testUser2.username);
+ });
+ });
+
+ it('should show an error when specifying more than one username', () => {
+ // # Navigate directly to the application and the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/playbook-run`);
+
+ // # Run a slash command with too many parameters
+ cy.uiPostMessageQuickly(`/playbook owner ${testUser.username} ${testUser2.username}`);
+
+ // * Verify the expected error message.
+ cy.verifyEphemeralMessage('/playbook owner expects at most one argument.');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/test_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/test_spec.js
new file mode 100644
index 00000000000..f5b39a2e4a9
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/test_spec.js
@@ -0,0 +1,255 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > slash command > test', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testUser2;
+ let testPlaybook;
+ let testPlaybookRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+ cy.apiAddUserToTeam(testTeam.id, testUser2.id);
+ });
+
+ cy.apiLogin(testUser);
+
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ memberIDs: [testUser.id],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'Playbook Run',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Reset the owner back to testUser as necessary.
+ cy.apiChangePlaybookRunOwner(testPlaybookRun.id, testUser.id);
+ });
+
+ describe('as a regular user', () => {
+ before(() => {
+ // # Login as sysadmin.
+ cy.apiAdminLogin();
+
+ // # Set EnableTesting to true.
+ cy.apiUpdateConfig({
+ ServiceSettings: {
+ EnableTesting: true,
+ },
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as user-1
+ cy.apiLogin(testUser);
+
+ // # Navigate to a channel.
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+ });
+
+ it('fails to run subcommand bulk-data', () => {
+ // # Execute the bulk-data command.
+ cy.uiPostMessageQuickly('/playbook test bulk-data');
+
+ // * Verify the ephemeral message warns that the user is not admin.
+ cy.verifyEphemeralMessage('Running the test command is restricted to system administrators.');
+ });
+
+ it('fails to run subcommand create-playbook-run', () => {
+ // # Execute the create-playbook-run command.
+ cy.uiPostMessageQuickly('/playbook test create-playbook-run');
+
+ // * Verify the ephemeral message warns that the user is not admin.
+ cy.verifyEphemeralMessage('Running the test command is restricted to system administrators.');
+ });
+
+ it('fails to run subcommand self', () => {
+ // # Execute the self command.
+ cy.uiPostMessageQuickly('/playbook test self');
+
+ // * Verify the ephemeral message warns that the user is not admin.
+ cy.verifyEphemeralMessage('Running the test command is restricted to system administrators.');
+ });
+ });
+
+ describe('as an admin', () => {
+ describe('with EnableTesting set to false', () => {
+ before(() => {
+ // # Login as sysadmin.
+ cy.apiAdminLogin();
+
+ // # Set EnableTesting to false.
+ cy.apiUpdateConfig({
+ ServiceSettings: {
+ EnableTesting: false,
+ },
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as sysadmin.
+ cy.apiAdminLogin();
+
+ // # Navigate to a channel.
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+ });
+
+ it('fails to run subcommand bulk-data', () => {
+ // # Execute the bulk-data command.
+ cy.uiPostMessageQuickly('/playbook test bulk-data');
+
+ // * Verify the ephemeral message warns that the user is not admin.
+ cy.verifyEphemeralMessage('Setting EnableTesting must be set to true to run the test command.');
+ });
+
+ it('fails to run subcommand create-playbook-run', () => {
+ // # Execute the create-playbook-run command.
+ cy.uiPostMessageQuickly('/playbook test create-playbook-run');
+
+ // * Verify the ephemeral message warns that the user is not admin.
+ cy.verifyEphemeralMessage('Setting EnableTesting must be set to true to run the test command.');
+ });
+
+ it('fails to run subcommand self', () => {
+ // # Execute the self command.
+ cy.uiPostMessageQuickly('/playbook test self');
+
+ // * Verify the ephemeral message warns that the user is not admin.
+ cy.verifyEphemeralMessage('Setting EnableTesting must be set to true to run the test command.');
+ });
+ });
+
+ describe('with EnableTesting set to true', () => {
+ before(() => {
+ // # Login as sysadmin.
+ cy.apiAdminLogin();
+
+ // # Set EnableTesting to true.
+ cy.apiUpdateConfig({
+ ServiceSettings: {
+ EnableTesting: true,
+ },
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as sysadmin.
+ cy.apiAdminLogin();
+
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Navigate to a channel.
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+ });
+
+ describe('with subcommand self', () => {
+ it('asks for confirmation', () => {
+ // # Execute the self command.
+ cy.uiPostMessageQuickly('/playbook test self');
+
+ // * Verify the ephemeral message asks for the confirmation keywords.
+ cy.verifyEphemeralMessage('Are you sure you want to self-test (which will nuke the database and delete all data -- instances, configuration)? All data will be lost. To self-test, type /playbook test self CONFIRM TEST SELF');
+ });
+ });
+
+ describe('with subcommand create', () => {
+ it('fails to run with no arguments', () => {
+ // # Execute the create-playbook-run command with no arguments.
+ cy.uiPostMessageQuickly('/playbook test create-playbook-run');
+
+ // * Verify the ephemeral message warns about the parameters.
+ cy.verifyEphemeralMessage('The command expects three parameters: ');
+ });
+
+ it('fails to run with one argument', () => {
+ // # Execute the create-playbook-run command with one argument.
+ cy.uiPostMessageQuickly(`/playbook test create-playbook-run ${testPlaybook.id}`);
+
+ // * Verify the ephemeral message warns about the parameters.
+ cy.verifyEphemeralMessage('The command expects three parameters: ');
+ });
+
+ it('fails to run with two arguments', () => {
+ // # Execute the create-playbook-run command with two arguments.
+ cy.uiPostMessageQuickly(`/playbook test create-playbook-run ${testPlaybook.id} 2020-01-01`);
+
+ // * Verify the ephemeral message warns about the parameters.
+ cy.verifyEphemeralMessage('The command expects three parameters: ');
+ });
+
+ it('fails to run with a malformed playbook ID', () => {
+ // # Execute the create-playbook-run command with all arguments, but a malformed plabook ID.
+ cy.uiPostMessageQuickly('/playbook test create-playbook-run unknownID 2020-01-01 The playbook run name');
+
+ // * Verify the ephemeral message warns about the ID.
+ cy.verifyEphemeralMessage('The first parameter, , must be a valid ID.');
+ });
+
+ it('fails to run with a valid, but unknown playbook ID', () => {
+ // # Execute the create-playbook-run command with all arguments, but an unknown plabook ID.
+ cy.uiPostMessageQuickly('/playbook test create-playbook-run abcdefghijklmnopqrstuvwxyz 2020-01-01 The playbook run name');
+
+ // * Verify the ephemeral message warns about the parameter.
+ cy.verifyEphemeralMessage('The playbook with ID \'abcdefghijklmnopqrstuvwxyz\' does not exist.');
+ });
+
+ it('fails to run with a malformed date', () => {
+ // # Execute the create-playbook-run command with all arguments, but a malformed creation timestamp.
+ cy.uiPostMessageQuickly(`/playbook test create-playbook-run ${testPlaybook.id} 2020-1-1 The playbook run name`);
+
+ // * Verify the ephemeral message warns about the parameter.
+ cy.verifyEphemeralMessage('Timestamp \'2020-1-1\' could not be parsed as a date. If you want the playbook run to start on January 2, 2006, the timestamp should be \'2006-01-02\'.');
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/todo_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/todo_spec.js
new file mode 100644
index 00000000000..5b612ce8db8
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/slash_command/todo_spec.js
@@ -0,0 +1,322 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > slash command > todo', {testIsolation: true}, () => {
+ let team1;
+ let team2;
+ let testUser;
+ let testOtherUser;
+ let run1;
+ let run2;
+ let run3;
+ let run4;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ team1 = team;
+ testUser = user;
+
+ cy.apiCreateUser().then(({user: otherUser}) => {
+ testOtherUser = otherUser;
+
+ // # Add this new user to the team
+ cy.apiAddUserToTeam(team1.id, testOtherUser.id);
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: team1.id,
+ title: 'Playbook One',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ checklists: [
+ {
+ title: 'Playbook One - Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Playbook One - Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ }).then(({id: playbookId}) => {
+ // # Create two runs in team 1.
+ const now = Date.now();
+ cy.apiRunPlaybook({
+ teamId: team1.id,
+ playbookId,
+ playbookRunName: 'Playbook Run (' + now + ')',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ run1 = run;
+ });
+
+ const now2 = Date.now() + 100;
+ cy.apiRunPlaybook({
+ teamId: team1.id,
+ playbookId,
+ playbookRunName: 'Playbook Run (' + now2 + ')',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ run2 = run;
+ });
+ });
+
+ // # Create a second team to test cross-team notifications
+ cy.apiCreateTeam('team2', 'Team 2').then(({team: secondTeam}) => {
+ team2 = secondTeam;
+
+ cy.apiAdminLogin();
+ cy.apiAddUserToTeam(team2.id, testUser.id);
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: team2.id,
+ title: 'Playbook Two',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ checklists: [
+ {
+ title: 'Playbook Two - Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Playbook Two - Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ }).then(({id: playbookId}) => {
+ // # Create one run in team 2.
+ const now = Date.now() + 200;
+ cy.apiRunPlaybook({
+ teamId: team2.id,
+ playbookId,
+ playbookRunName: 'Playbook Run (' + now + ')',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ run3 = run;
+ });
+ });
+ });
+
+ // # Create another playbook with runs owned by another user
+ cy.apiCreatePlaybook({
+ teamId: team1.id,
+ title: 'Playbook Other',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ checklists: [
+ {
+ title: 'Playbook Other - Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Playbook Other - Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ }).then(({id: playbookId}) => {
+ // # Login as testOtherUser
+ cy.apiLogin(testOtherUser);
+
+ // # Create a run in team 1, with testOtherUser as owner and inviting testUser
+ const now = Date.now();
+ cy.apiRunPlaybook({
+ teamId: team1.id,
+ playbookId,
+ playbookRunName: 'Other Playbook Run (' + now + ')',
+ ownerUserId: testOtherUser.id,
+ }).then((run) => {
+ run4 = run;
+
+ // # Invite testUser to the channel
+ // cy.apiAddUserToChannel(run.channel_id, testUser.id);
+ cy.apiAddUsersToRun(run.id, [testUser.id]);
+
+ // # Force this run to be overdue
+ cy.apiUpdateStatus({
+ playbookRunId: run4.id,
+ message: 'no message 4',
+ reminder: 1,
+ });
+ });
+
+ // # Create a run in team 1, with testOtherUser as owner but not inviting testUser
+ const now2 = Date.now() + 100;
+ cy.apiRunPlaybook({
+ teamId: team1.id,
+ playbookId,
+ playbookRunName: 'Other Playbook Run (' + now2 + ')',
+ ownerUserId: testOtherUser.id,
+ }).then((run) => {
+ // # Force this run to be overdue
+ cy.apiUpdateStatus({
+ playbookRunId: run.id,
+ message: 'no message 5',
+ reminder: 1,
+ });
+ });
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('/playbook todo should show', () => {
+ it('three runs', () => {
+ // # Navigate to a non-playbook run channel.
+ cy.visit(`/${team2.name}/channels/town-square`);
+
+ // # Run a slash command to show the to-do list.
+ cy.uiPostMessageQuickly('/playbook todo');
+
+ cy.getLastPost().within((post) => {
+ // * Should show titles
+ cy.wrap(post).contains('You have 0 runs overdue.');
+ cy.wrap(post).contains('You have 0 assigned tasks.');
+ cy.wrap(post).contains('You have 4 runs currently in progress:');
+
+ // * Should show four active runs
+ cy.get('li').then((liItems) => {
+ expect(liItems[0]).to.contain.text(run4.name);
+ expect(liItems[1]).to.contain.text(run1.name);
+ expect(liItems[2]).to.contain.text(run2.name);
+ expect(liItems[3]).to.contain.text(run3.name);
+ });
+ });
+ });
+
+ it('four assigned tasks', () => {
+ // # assign self four tasks
+ cy.apiChangeChecklistItemAssignee(run1.id, 0, 0, testUser.id);
+ cy.apiChangeChecklistItemAssignee(run1.id, 1, 1, testUser.id);
+ cy.apiChangeChecklistItemAssignee(run2.id, 0, 1, testUser.id);
+ cy.apiChangeChecklistItemAssignee(run3.id, 1, 0, testUser.id);
+
+ // # Navigate to a non-playbook run channel.
+ cy.visit(`/${team2.name}/channels/town-square`);
+
+ // # Run a slash command to show the to-do list.
+ cy.uiPostMessageQuickly('/playbook todo');
+
+ cy.getLastPost().within((post) => {
+ // * Should show titles
+ cy.wrap(post).contains('You have 0 runs overdue.');
+ cy.wrap(post).contains('You have 4 total assigned tasks:');
+
+ // * Should show 3 runs w/ tasks
+ cy.get('.post__body a').then((links) => {
+ expect(links[0]).to.contain.text(run1.name);
+ expect(links[1]).to.contain.text(run2.name);
+ expect(links[2]).to.contain.text(run3.name);
+ });
+
+ cy.get('.post__body li').then((items) => {
+ // * first run
+ expect(items[0]).to.contain.text('Playbook One - Stage 1: Step 1');
+ expect(items[1]).to.contain.text('Playbook One - Stage 2: Step 2');
+
+ // * second run
+ expect(items[2]).to.contain.text('Playbook One - Stage 1: Step 2');
+
+ // * third run
+ expect(items[3]).to.contain.text('Playbook Two - Stage 2: Step 1');
+ });
+ });
+
+ // # check two of the items via API
+ cy.apiSetChecklistItemState(run1.id, 0, 0, 'closed');
+ cy.apiSetChecklistItemState(run3.id, 1, 0, 'closed');
+
+ // # Show the to-do list.
+ cy.uiPostMessageQuickly('/playbook todo');
+
+ // * Should show 2 tasks
+ cy.getLastPost().within((post) => {
+ // * Should show titles
+ cy.wrap(post).contains('You have 0 runs overdue.');
+ cy.wrap(post).contains('You have 2 total assigned tasks:');
+
+ // * Should show 2 runs w/ tasks
+ cy.get('.post__body a').then((links) => {
+ expect(links[0]).to.contain.text(run1.name);
+ expect(links[1]).to.contain.text(run2.name);
+ });
+
+ cy.get('.post__body li').then((items) => {
+ // * first run
+ expect(items[0]).to.contain.text('Playbook One - Stage 2: Step 2');
+
+ // * second run
+ expect(items[1]).to.contain.text('Playbook One - Stage 1: Step 2');
+ });
+ });
+ });
+
+ it('two overdue status updates', () => {
+ // # set two updates with short timers
+ cy.apiUpdateStatus({
+ playbookRunId: run1.id,
+ message: 'no message 1',
+ reminder: 1,
+ });
+ cy.apiUpdateStatus({
+ playbookRunId: run3.id,
+ message: 'no message 3',
+ reminder: 1,
+ });
+
+ cy.wait(1100);
+
+ // # Switch to playbooks DM channel
+ cy.visit(`/${team2.name}/messages/@playbooks`);
+
+ // # Run a slash command to show the to-do list.
+ cy.uiPostMessageQuickly('/playbook todo');
+
+ // # Should show two runs overdue -- ignoring the rest
+ cy.getLastPost().within(() => {
+ cy.get('.post__body li').then((liItems) => {
+ expect(liItems[0]).to.contain.text(run1.name);
+ expect(liItems[1]).to.contain.text(run3.name);
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/update_post_dm_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/update_post_dm_spec.js
new file mode 100644
index 00000000000..47ad0730243
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/update_post_dm_spec.js
@@ -0,0 +1,75 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('channels > status update posts in DMs', {testIsolation: true}, () => {
+ let testTeam;
+ let userA;
+ let userB;
+ let testPlaybookRun;
+
+ beforeEach(() => {
+ cy.apiAdminLogin();
+
+ cy.apiInitSetup({loginAfter: false}).then(({team, user}) => {
+ testTeam = team;
+ userA = user;
+
+ // # Create second user
+ cy.apiCreateUser().then(({user: secondUser}) => {
+ userB = secondUser;
+ cy.apiAddUserToTeam(testTeam.id, userB.id);
+
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Test Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ }).then((playbook) => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: 'Test Run',
+ ownerUserId: userA.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+
+ // # Add both users as participants to the run
+ cy.apiAddUsersToRun(playbookRun.id, [userA.id, userB.id]);
+ });
+ });
+ });
+ });
+ });
+
+ it('status update posts render correctly in DMs from playbooks bot', () => {
+ const updateMessage = 'Test status update with **markdown**';
+
+ // # User A posts a status update
+ cy.apiLogin(userA);
+ cy.apiUpdateStatus({
+ playbookRunId: testPlaybookRun.id,
+ message: updateMessage,
+ });
+ cy.apiLogout();
+
+ // # Switch to User B
+ cy.apiLogin(userB);
+
+ // # User B visits the DM channel with playbooks bot
+ cy.visit(`/${testTeam.name}/messages/@playbooks`);
+
+ // * Verify the status update message is visible
+ cy.get('[data-testid="postView"]').first().within(() => {
+ cy.contains('Test status update with markdown');
+ cy.contains(`@${userA.username} posted an update for ${testPlaybookRun.name}`);
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/update_request_post_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/update_request_post_spec.js
new file mode 100644
index 00000000000..6a180c8151c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/channels/update_request_post_spec.js
@@ -0,0 +1,199 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+import * as TIMEOUTS from '../../../fixtures/timeouts';
+
+describe('channels > update request post', {testIsolation: true}, () => {
+ let testTeam;
+ let testParticipant;
+ let testChannelMemberOnly;
+ let testPlaybookRun;
+ let testPlaybookRun2;
+
+ before(() => {
+ cy.apiUpdateConfig({
+ ServiceSettings: {
+ ThreadAutoFollow: true,
+ CollapsedThreads: 'default_on',
+ },
+ });
+
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testParticipant = user;
+
+ cy.apiCreateUser().then(({user: channelMemberOnly}) => {
+ testChannelMemberOnly = channelMemberOnly;
+
+ // # Add testChannelMemberOnly to the testTeam
+ cy.apiAddUserToTeam(testTeam.id, testChannelMemberOnly.id);
+
+ // # Login as testChannelMemberOnly
+ cy.apiLogin(testChannelMemberOnly);
+
+ // # Enable threads view
+ cy.apiSaveCRTPreference(testChannelMemberOnly.id, 'on');
+ });
+
+ // # Login as testParticipant
+ cy.apiLogin(testParticipant);
+
+ // # Enable threads view
+ cy.apiSaveCRTPreference(testParticipant.id, 'on');
+
+ // # Create a public playbook with 2 runs in the same channel
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ }).then((playbook) => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: 'Test Run',
+ ownerUserId: testParticipant.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+
+ // # Add testChannelMemberOnly to the channel, but not the run.
+ cy.apiAddUserToChannel(playbookRun.channel_id, testChannelMemberOnly.id);
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: 'Test Run 2',
+ ownerUserId: testParticipant.id,
+ channelId: testPlaybookRun.channel_id,
+ }).then((playbookRun2) => {
+ testPlaybookRun2 = playbookRun2;
+ });
+ });
+ });
+ });
+ });
+
+ describe('displays interactive post', () => {
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testParticipant);
+
+ // # Post a status update, with a reminder in 1 second.
+ cy.apiUpdateStatus({
+ playbookRunId: testPlaybookRun2.id,
+ message: 'status update 2',
+ reminder: 1,
+ });
+
+ // # Post a status update, with a reminder in 2 second.
+ cy.apiUpdateStatus({
+ playbookRunId: testPlaybookRun.id,
+ message: 'status update',
+ reminder: 2,
+ });
+
+ // Ensure the status update reminder gets posted
+ cy.wait(TIMEOUTS.TWO_SEC);
+ });
+
+ describe('as a participant', () => {
+ beforeEach(() => {
+ // # Navigate to the application
+ cy.visit(`${testTeam.name}/channels/test-run`);
+ });
+
+ it('in the run channel', () => {
+ cy.getLastPost().then((element) => {
+ // # Verify the expected message text
+ cy.get(element).contains(`@${testParticipant.username}, please provide a status update for ${testPlaybookRun.name}.`);
+
+ // # Verify interactive message button to post an update
+ cy.get(element).find('button').contains('Post update');
+ });
+ });
+
+ it('reset reminder', () => {
+ cy.getLastPost().within(() => {
+ // * Snooze reminder
+ cy.getStyledComponent('StyledSelect').click().type('{downArrow}{downArrow}{enter}');
+
+ // # Verify interactive message button to post an update has dissapeared
+ cy.findByText('(message deleted)').should('be.visible');
+ });
+ });
+
+ it('in threads view', () => {
+ // # Find the update request post and post a reply to make it show up in threads view
+ cy.getLastPostId().then((lastPostId) => {
+ // Open RHS
+ cy.clickPostCommentIcon(lastPostId);
+
+ // Post a reply message
+ cy.postMessageReplyInRHS('test reply');
+
+ // # Navigate to the threads view
+ cy.get('#sidebarItem_threads').click();
+
+ // # Verify the expected text in the list view
+ cy.get('.ThreadItem').first().contains(`@${testParticipant.username}, please provide a status update for ${testPlaybookRun.name}.`);
+
+ // # Click to open details
+ cy.get('.ThreadItem').first().click();
+
+ // # Verify post still rendered
+ cy.get(`#rhsPost_${lastPostId}`).contains(`@${testParticipant.username}, please provide a status update for ${testPlaybookRun.name}.`);
+
+ // # Verify interactive message button to post an update
+ cy.get(`#rhsPost_${lastPostId}`).find('button').contains('Post update');
+ });
+ });
+ });
+
+ describe('as a channel member only', () => {
+ beforeEach(() => {
+ // # Login as testChannelMemberOnly
+ cy.apiLogin(testChannelMemberOnly);
+
+ // # Navigate to the application
+ cy.visit(`${testTeam.name}/channels/test-run`);
+ });
+
+ it('in the run channel', () => {
+ cy.getLastPost().then((element) => {
+ // # Verify the expected message text
+ cy.get(element).contains(`@${testParticipant.username}, please provide a status update for ${testPlaybookRun.name}.`);
+ });
+ });
+
+ it('in threads view', () => {
+ // # Find the update request post and post a reply to make it show up in threads view
+ cy.getLastPostId().then((lastPostId) => {
+ // Open RHS
+ cy.clickPostCommentIcon(lastPostId);
+
+ // Post a reply message
+ cy.postMessageReplyInRHS('test reply');
+
+ // # Navigate to the threads view
+ cy.get('#sidebarItem_threads').click();
+
+ // # Verify the expected text in the list view
+ cy.get('.ThreadItem').first().contains(`@${testParticipant.username}, please provide a status update for ${testPlaybookRun.name}.`);
+
+ // # Click to open details
+ cy.get('.ThreadItem').first().click();
+
+ // # Verify post still rendered
+ cy.get(`#rhsPost_${lastPostId}`).contains(`@${testParticipant.username}, please provide a status update for ${testPlaybookRun.name}.`);
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/digest_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/digest_spec.js
new file mode 100644
index 00000000000..8579b6e5319
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/digest_spec.js
@@ -0,0 +1,146 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {FIVE_SEC} from '../../fixtures/timeouts';
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+// https://mattermost.atlassian.net/browse/MM-63692
+// eslint-disable-next-line no-only-tests/no-only-tests
+describe.skip('digest messages', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1', command: '/invalid'},
+ {title: 'Step 2', command: '/echo VALID'},
+ {title: 'Step 3', command: '/playbook check 0 0'},
+ {title: 'Step 4'},
+ ],
+ },
+ ],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('digest message >', () => {
+ let testRun;
+ before(() => {
+ const runName = 'Playbook Run (' + Date.now() + ')';
+
+ // # Start a run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: runName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ testRun = run;
+
+ // # Set a timer that will expire.
+ cy.apiUpdateStatus({
+ playbookRunId: run.id,
+ message: 'no message 1',
+ reminder: 1,
+ });
+ cy.apiChangeChecklistItemAssignee(run.id, 0, 0, testUser.id);
+ });
+ });
+
+ it('has one run overdue and links to RDP', () => {
+ // # Switch to playbooks DM channel
+ cy.visit(`/${testTeam.name}/messages/@playbooks`);
+
+ // # Wait until the channel loads enough to show the post textbox.
+ cy.get('#post-create').should('exist');
+
+ // # Run a slash command to show the to-do list.
+ cy.uiPostMessageQuickly('/playbook todo');
+
+ cy.getLastPost().within(() => {
+ // # assert two blocks: inprogress+overdue
+ cy.get('ul').should('have.length', 3);
+
+ // * Click the first link - overdue status
+ cy.get('ul a').eq(0).click().wait(FIVE_SEC);
+ });
+
+ // # assert url is RDP
+ cy.url().should('contain', '/playbooks/runs/' + testRun.id + '?from=digest_overduestatus');
+ });
+
+ it('has one run in progress and links to RDP', () => {
+ // # Switch to playbooks DM channel
+ cy.visit(`/${testTeam.name}/messages/@playbooks`);
+
+ // # Wait until the channel loads enough to show the post textbox.
+ cy.get('#post-create').should('exist');
+
+ // # Run a slash command to show the to-do list.
+ cy.uiPostMessageQuickly('/playbook todo');
+
+ cy.getLastPost().within(() => {
+ // # assert two blocks: inprogress+overdue
+ cy.get('ul').should('have.length', 3);
+
+ // * Click the second link - inprogress
+ cy.get('ul a').eq(1).click().wait(FIVE_SEC);
+ });
+
+ // # assert url is RDP
+ cy.url().should('contain', '/playbooks/runs/' + testRun.id + '?from=digest_runsinprogress');
+ });
+
+ it('has one run with one assigned task and links to RDP', () => {
+ // # Switch to playbooks DM channel
+ cy.visit(`/${testTeam.name}/messages/@playbooks`);
+
+ // # Wait until the channel loads enough to show the post textbox.
+ cy.get('#post-create').should('exist');
+
+ // # Run a slash command to show the to-do list.
+ cy.uiPostMessageQuickly('/playbook todo');
+
+ cy.getLastPost().within(() => {
+ // # assert two blocks: inprogress+overdue
+ cy.get('ul').should('have.length', 3);
+
+ // * Click link - assigned task
+ cy.get('p a').click().wait(FIVE_SEC);
+ });
+
+ // # assert url is RDP
+ cy.url().should('contain', '/playbooks/runs/' + testRun.id + '?from=digest_assignedtask');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/lhs_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/lhs_spec.js
new file mode 100644
index 00000000000..41096adaeef
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/lhs_spec.js
@@ -0,0 +1,349 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+import {HALF_SEC} from '../../fixtures/timeouts';
+import {stubClipboard} from '../../utils';
+
+describe('lhs', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPublicPlaybook;
+ let testPrivatePlaybook;
+ let playbookRun;
+ let testViewerUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+
+ // # Create a private playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Private Playbook',
+ memberIDs: [],
+ public: false,
+ }).then((playbook) => {
+ testPrivatePlaybook = playbook;
+ });
+ });
+ });
+
+ const getRunDropdownItemByText = (groupName, runName, itemName) => {
+ // # Click on run at LHS
+ cy.findByTestId(groupName).findByTestId(runName).click();
+
+ // # Click dot menu
+ cy.findByTestId(groupName).
+ findByTestId(runName).
+ findByTestId('menuButton').
+ click({force: true});
+
+ cy.findByTestId('dropdownmenu').should('be.visible');
+
+ return cy.findByTestId('dropdownmenu').findByText(itemName).should('be.visible');
+ };
+
+ describe('navigate', () => {
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name(' + Date.now() + ')',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ playbookRun = run;
+
+ // # Visit the playbook run
+ cy.visit('/playbooks/runs');
+ cy.findByTestId('lhs-navigation').findByText(playbookRun.name).should('be.visible');
+ });
+
+ cy.wait;
+ });
+
+ it('click run', () => {
+ // # Click on run at LHS
+ cy.findByTestId('Runs').findByTestId(playbookRun.name).click();
+ });
+ });
+
+ describe('run dot menu', () => {
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name(' + Date.now() + ')',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ playbookRun = run;
+ });
+ });
+
+ it('shows on click', () => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+
+ // # Click dot menu
+ cy.findByTestId('Runs').
+ findByTestId(playbookRun.name).
+ findByTestId('menuButton').
+ click({force: true});
+
+ // * Assert context menu is opened
+ cy.findByTestId('dropdownmenu').should('be.visible');
+ });
+
+ it('can copy link', () => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ stubClipboard().as('clipboard');
+
+ // # Click on Copy link menu item
+ getRunDropdownItemByText('Runs', playbookRun.name, 'Copy link').click();
+
+ // * Verify clipboard content
+ cy.get('@clipboard').its('contents').should('contain', `/playbooks/runs/${playbookRun.id}`);
+ });
+
+ it('can favorite / unfavorite', () => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+
+ // # Click on favorite menu item
+ getRunDropdownItemByText('Runs', playbookRun.name, 'Favorite').click();
+
+ // * Verify the run is added to favorites
+ cy.findByTestId('Favorite').findByTestId(playbookRun.name).should('exist');
+
+ // # Click on unfavorite menu item
+ getRunDropdownItemByText('Favorite', playbookRun.name, 'Unfavorite').click();
+
+ // * Verify the run is removed from favorites
+ cy.findByTestId('Favorite').should('not.exist');
+ });
+
+ it('lhs refresh on follow/unfollow', () => {
+ cy.apiLogin(testViewerUser);
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+
+ // # The assertions here guard against the click() on 194
+ // # happening on a detached element.
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ cy.findByTestId('runinfo-following').should('be.visible').within(() => {
+ // # Verify follower icon
+ cy.findAllByTestId('profile-option', {exact: false}).should('have.length', 1);
+ cy.findByText('Follow').should('be.visible').click();
+
+ // # Verify icons update
+ cy.findAllByTestId('profile-option', {exact: false}).should('have.length', 2);
+ });
+
+ // * Verify that the run was added to the lhs
+ cy.findByTestId('lhs-navigation').findByText(playbookRun.name).should('exist');
+
+ // # Click on unfollow menu item
+ getRunDropdownItemByText('Runs', playbookRun.name, 'Unfollow').click();
+
+ // * Verify that the run is removed lhs
+ cy.findByTestId('Runs').findByTestId(playbookRun.name).should('not.exist');
+ });
+
+ it('leave run', () => {
+ // # Add viewer user to the channel
+ cy.apiAddUsersToRun(playbookRun.id, [testViewerUser.id]);
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+
+ // # Click on leave menu item
+ getRunDropdownItemByText('Runs', playbookRun.name, 'Leave and unfollow').click();
+
+ // * Verify that owner can't leave.
+ cy.get('#confirmModal').should('not.exist');
+
+ // # Change the owner to testViewerUser
+ cy.findByTestId('runinfo-owner').findByTestId('assignee-profile-selector').click();
+ cy.get('.playbook-react-select').findByText('@' + testViewerUser.username).click();
+
+ // # Wait for owner to change
+ cy.wait(HALF_SEC);
+
+ // # Click on leave menu item
+ getRunDropdownItemByText('Runs', playbookRun.name, 'Leave and unfollow').click();
+
+ // * Click leave confirmation
+ cy.get('#confirmModalButton').click();
+ });
+ });
+
+ describe('leave run - no permanent access', () => {
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPrivatePlaybook.id,
+ playbookRunName: 'the run name(' + Date.now() + ')',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ playbookRun = run;
+
+ cy.apiAddUsersToRun(playbookRun.id, [testViewerUser.id]);
+
+ cy.apiLogin(testViewerUser).then(() => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+ });
+
+ it('leave run, when on rdp of the same run', () => {
+ // # Click on leave menu item
+ getRunDropdownItemByText('Runs', playbookRun.name, 'Leave and unfollow').click();
+
+ // # confirm modal
+ cy.get('#confirmModal').should('be.visible').within(() => {
+ cy.get('#confirmModalButton').click();
+ });
+
+ // * Verify that user was redirected to the run list page
+ cy.url().should('include', 'playbooks/runs?sort=');
+ });
+
+ it('leave run, when not on rdp of the same run', () => {
+ // # Visit playbooks list page
+ cy.visit('/playbooks/playbooks');
+
+ // # Open dot menu without clicking the run item (which would navigate to RDP)
+ cy.findByTestId('Runs').findByTestId(playbookRun.name).trigger('mouseover');
+ cy.findByTestId('Runs').findByTestId(playbookRun.name).findByTestId('menuButton').click({force: true});
+ cy.findByTestId('dropdownmenu').should('be.visible');
+ cy.findByTestId('dropdownmenu').findByText('Leave and unfollow').should('be.visible').click();
+
+ // # confirm modal
+ cy.get('#confirmModal').should('be.visible').within(() => {
+ cy.get('#confirmModalButton').click();
+ });
+
+ // * Verify leave completed and user stayed on the playbooks list page
+ cy.get('#confirmModal').should('not.exist');
+ cy.url({timeout: 5000}).should('include', '/playbooks/playbooks');
+ });
+ });
+
+ describe('playbook dot menu', () => {
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'the run name(' + Date.now() + ')',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+
+ // # Visit the playbooks page
+ cy.visit('/playbooks/playbooks');
+ });
+ });
+
+ it('shows on click', () => {
+ // # Click dot menu
+ cy.findByTestId('Playbooks').
+ findByTestId(testPublicPlaybook.title).
+ findByTestId('menuButton').
+ click({force: true});
+
+ // * Assert context menu is opened
+ cy.findByTestId('dropdownmenu').should('be.visible');
+ });
+
+ it('can copy link', () => {
+ stubClipboard().as('clipboard');
+
+ // # Click on Copy link menu item
+ getRunDropdownItemByText('Playbooks', testPublicPlaybook.title, 'Copy link').click();
+
+ // * Verify clipboard content
+ cy.get('@clipboard').
+ its('contents').
+ should('contain', `/playbooks/playbooks/${testPublicPlaybook.id}`);
+ });
+
+ it('can favorite / unfavorite', () => {
+ // # Click on favorite menu item
+ getRunDropdownItemByText('Playbooks', testPublicPlaybook.title, 'Favorite').click();
+
+ // * Verify the playbook is added to favorites
+ cy.findByTestId('Favorite').findByTestId(testPublicPlaybook.title).should('exist');
+
+ // # Click on unfavorite menu item
+ getRunDropdownItemByText('Favorite', testPublicPlaybook.title, 'Unfavorite').click();
+
+ // * Verify the playbook is removed from favorites
+ cy.findByTestId('Playbooks').findByTestId(testPublicPlaybook.title).should('exist');
+ });
+
+ it('can leave', () => {
+ stubClipboard().as('clipboard');
+
+ // # Click on Leave menu item
+ getRunDropdownItemByText('Playbooks', testPublicPlaybook.title, 'Leave').click();
+
+ // * Verify the playbook is removed from the list
+ cy.findByTestId('Playbooks').findByTestId(testPublicPlaybook.title).should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/navigation_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/navigation_spec.js
new file mode 100644
index 00000000000..53eb35f5c0e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/navigation_spec.js
@@ -0,0 +1,72 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('navigation', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as user-1
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ cy.apiRunPlaybook({
+ teamId: team.id,
+ playbookId: playbook.id,
+ playbookRunName: 'Playbook Run',
+ ownerUserId: user.id,
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Navigate to the application
+ cy.visit(`/${testTeam.name}/`);
+ });
+
+ it('switches to playbooks list view via sidebar view all button', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // * Verify that playbooks are shown
+ cy.findByTestId('titlePlaybook').should('exist').contains('Playbooks');
+ });
+
+ it('switches to playbook runs list view via sidebar view all button', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Switch to playbook runs
+ cy.findByTestId('playbookRunsLHSButton').click();
+
+ // * Verify that playbook runs page is shown (header was removed, check for run list)
+ cy.get('#playbookRunList').should('exist');
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/access_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/access_spec.js
new file mode 100644
index 00000000000..958c0118fcb
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/access_spec.js
@@ -0,0 +1,114 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > edit', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testUser2;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Create a second test user in this team
+ cy.apiCreateUser().then((payload) => {
+ testUser2 = payload.user;
+ cy.apiAddUserToTeam(testTeam.id, payload.user.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('rdp information refresh', () => {
+ let testPlaybook;
+
+ beforeEach(() => {
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ public: true,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ // Navigate to the playbook page
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+ });
+ });
+
+ it('add / remove a member', () => {
+ // # Open playbook access modal
+ cy.findByTestId('playbook-members').click();
+
+ // # Add a new member
+ cy.findByTestId('add-people-input').type(testUser2.username);
+ cy.wait(500);
+ cy.findByTestId('profile-option-' + testUser2.username).click({force: true});
+
+ // * Verify that user was added
+ cy.findByTestId('members-list').findByText(testUser2.username).should('exist');
+
+ // # Close playbook access modal
+ cy.get('.close > [aria-hidden="true"]').click();
+
+ // * Verify members number
+ cy.findByTestId('playbook-members').findByText('2').should('exist');
+
+ // # Open playbook access modal
+ cy.findByTestId('playbook-members').click();
+
+ // # Open dropdown and remove user
+ cy.findByText('Playbook Member').click();
+ cy.findByTestId('dropdownmenu').findByText('Remove').click();
+
+ // * Verify that user was removed
+ cy.findByTestId('members-list').findByText(testUser2.username).should('not.exist');
+
+ // # Close playbook access modal
+ cy.get('.close > [aria-hidden="true"]').click();
+
+ // * Verify members number
+ cy.findByTestId('playbook-members').findByText('1').should('exist');
+ });
+
+ it('change to private', () => {
+ // # Open playbook access modal
+ cy.findByTestId('playbook-members').click();
+
+ // # Click on convert to private
+ cy.findByText('Convert to private playbook').click();
+
+ // * Check that confirm modal is open
+ cy.get('#confirmModal').should('be.visible');
+
+ // # Confirm convert to private
+ cy.get('#confirmModal').get('#confirmModalButton').click();
+
+ // * Verify that playbook is private
+ cy.findByText('Convert to private playbook').should('not.exist');
+
+ // # Close playbook access modal
+ cy.get('.close > [aria-hidden="true"]').click();
+
+ // * Verify lock icon is visible
+ cy.findByTestId('playbook-editor-header').get('.icon-lock-outline').should('be.visible');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/creation_button_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/creation_button_spec.js
new file mode 100644
index 00000000000..9ba500c5b32
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/creation_button_spec.js
@@ -0,0 +1,201 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > creation button', {testIsolation: true}, () => {
+ let testSysadmin;
+ let testTeam;
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateCustomAdmin().then(({sysadmin}) => {
+ testSysadmin = sysadmin;
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ // # Creating this playbook ensures the list view
+ // # specifically is shown in the backstage content section.
+ // # Without it there is a brief flicker from the list view
+ // # to the no content view, which causes some flake
+ // # on clicking the 'Create playbook' button
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as user-1
+ cy.apiLogin(testUser);
+
+ // # Size the viewport to show playbooks without weird scrolling issues
+ cy.viewport('macbook-13');
+ });
+
+ it('opens playbook creation page with New Playbook button', () => {
+ const playbookName = 'Untitled Playbook';
+
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Click 'New Playbook' button
+ cy.findByTestId('titlePlaybook').findByText('Create playbook').click();
+ cy.get('#playbooks_create').findByText('Create playbook').click();
+
+ // * Verify playbook outline page opened
+ verifyPlaybookOutlineOpened(playbookName);
+
+ // * Verify playbook was added to the LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookName).should('exist');
+ });
+
+ it('auto creates a playbook with "Blank" template option', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Click 'Blank'
+ cy.findByText('Blank').click();
+
+ const playbookName = `@${testUser.username}'s Blank`;
+
+ // * Verify playbook outline opened
+ verifyPlaybookOutlineOpened(playbookName);
+
+ // * Verify playbook was added to the LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookName).should('exist');
+ });
+
+ it('opens Service Outage Incident page from its template option (multiple teams)', () => {
+ cy.apiCreateTeam('second-team', 'Second Team').then(() => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Click 'Incident Resolution'
+ cy.findByText('Incident Resolution').click();
+
+ const playbookName = `@${testUser.username}'s Incident Resolution`;
+
+ // * Verify playbook outline opened
+ verifyPlaybookOutlineOpened(playbookName);
+
+ // * Verify the playbook was added to the lhs of current team
+ cy.findByTestId('lhs-navigation').findByText(playbookName).should('exist');
+ });
+ });
+
+ let restrictedTestTeam;
+ let restrictedTestUser;
+
+ describe('user is lacking permissions to create playbooks', () => {
+ before(() => {
+ cy.apiLogin(testSysadmin);
+
+ cy.apiCreateUser().then(({user: createdUser}) => {
+ restrictedTestUser = createdUser;
+ });
+
+ cy.apiCreateTeam('restricted-team', 'Restricted Team').then(({team: createdTeam}) => {
+ restrictedTestTeam = createdTeam;
+ cy.apiAddUserToTeam(restrictedTestTeam.id, restrictedTestUser.id);
+ });
+
+ cy.apiCreateScheme('Restricted Team Scheme', 'team').then(({scheme}) => {
+ cy.apiSetTeamScheme(restrictedTestTeam.id, scheme.id);
+ cy.apiGetRolesByNames([scheme.default_team_user_role]).then(({roles}) => {
+ const role = roles[0];
+
+ // Remove permissions to create playbooks
+ const permissions = role.permissions.filter((perm) => !(/playbook_(private|public)_create/).test(perm));
+ cy.apiPatchRole(role.id, {permissions});
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as user with restricted permissions
+ cy.apiLogin(restrictedTestUser);
+ });
+
+ it('create playbook entry in LHS dropdown should not exist', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Open menu dropdown
+ cy.findByTestId('create-playbook-dropdown-toggle').click();
+
+ cy.get('#CreatePlaybookDropdown').within(() => {
+ // * Verify create playbook entry is missing
+ cy.findByText('Create New Playbook').should('not.exist');
+ });
+ });
+
+ it('permission notice should be shown if no playbooks exist', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // * Verify notice about missing permissions and no playbooks is shown
+ cy.findByText('There are no playbooks to view. You don\'t have permission to create playbooks in this workspace.').should('exist');
+ });
+
+ it('create playbook button should not exist if playbooks exist', () => {
+ // # Create a playbook for the team
+ cy.apiLogin(testSysadmin).then(() => {
+ cy.apiCreatePlaybook({
+ teamId: restrictedTestTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ });
+ });
+
+ // # Login as user with restricted permissions
+ cy.apiLogin(restrictedTestUser);
+
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // * Verify create playbook button is missing
+ cy.findByTestId('titlePlaybook').findByText('Create playbook').should('not.exist');
+ });
+ });
+});
+
+function verifyPlaybookOutlineOpened(playbookName) {
+ // * Verify the page url contains 'playbooks/playbooks/new'
+ cy.url().should('contain', '/outline');
+
+ // * Verify the playbook name matches the one provided
+ cy.findByTestId('playbook-editor-title').within(() => {
+ cy.findByText(playbookName).should('be.visible');
+ });
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/actions_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/actions_spec.js
new file mode 100644
index 00000000000..19c9376830e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/actions_spec.js
@@ -0,0 +1,1062 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+import * as TIMEOUTS from '../../../../fixtures/timeouts';
+
+// assumes that E20 license is uploaded
+describe('playbooks > edit', {testIsolation: true}, () => {
+ let testTeam;
+ let testSysadmin;
+ let testUser;
+ let testUser2;
+ let testUser3;
+
+ const openCategorySelector = () => {
+ cy.get('.channel-selector__control input').click({force: true});
+ };
+ const selectCategory = (name) => {
+ cy.get('.channel-selector__menu').findByText(name).click({force: true});
+ };
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateCustomAdmin().then(({sysadmin}) => {
+ testSysadmin = sysadmin;
+ });
+
+ // # Create a second test user in this team
+ cy.apiCreateUser().then((payload) => {
+ testUser2 = payload.user;
+ cy.apiAddUserToTeam(testTeam.id, payload.user.id);
+ });
+
+ // # Create a third test user in this team
+ cy.apiCreateUser().then((payload) => {
+ testUser3 = payload.user;
+ cy.apiAddUserToTeam(testTeam.id, payload.user.id);
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ const commonActionTests = () => {
+ describe('when a playbook run starts', () => {
+ let testPlaybook;
+ beforeEach(() => {
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ describe('create channel setting', () => {
+ it('is enabled by default in a new playbook', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section.
+ cy.get('#actions').within(() => {
+ // * Verify that the toggle is checked
+ cy.get('#create-new-channel label input').should('be.checked');
+ });
+ });
+ });
+
+ describe('invite members setting', () => {
+ it('is disabled in a new playbook', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('#invite-users label input').should('not.be.checked');
+ });
+ });
+
+ it('can be enabled', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('be.checked');
+ });
+ });
+ });
+
+ it('does not let add users when disabled', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('#invite-users label input').should('not.be.checked');
+
+ // * Verify that the menu is disabled
+ cy.get('#invite-users').within(() => {
+ cy.getStyledComponent('StyledReactSelect').should(
+ 'have.class',
+ 'invite-users-selector--is-disabled',
+ );
+ });
+ });
+ });
+
+ it('allows adding users when enabled', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Open the invited users selector
+ cy.openSelector();
+
+ // # Add one user
+ cy.addInvitedUser(testUser2.username);
+ cy.wait(TIMEOUTS.ONE_SEC);
+
+ // * Verify that the badge in the selector shows the correct number of members
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '1 SELECTED');
+
+ // * Verify that the user shows in the group of invited members
+ cy.findByText('SELECTED').
+ parent().
+ within(() => {
+ cy.findByText(testUser2.username);
+ });
+ });
+ });
+ });
+
+ it('allows adding new users to an already populated list', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Open the invited users selector
+ cy.openSelector();
+
+ // # Add one user
+ cy.addInvitedUser(testUser2.username);
+
+ // * Verify that the user shows in the group of invited members
+ cy.findByText('SELECTED').
+ parent().
+ within(() => {
+ cy.findByText(testUser2.username);
+ });
+
+ // # Add a new user
+ cy.addInvitedUser(testUser3.username);
+ cy.wait(TIMEOUTS.ONE_SEC);
+
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '2 SELECTED');
+
+ // * Verify that the user shows in the group of invited members
+ cy.findByText('SELECTED').
+ parent().
+ within(() => {
+ cy.findByText(testUser2.username);
+ cy.findByText(testUser3.username);
+ });
+ });
+ });
+ });
+
+ it('allows removing users', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Open the invited users selector
+ cy.openSelector();
+
+ // # Add a couple of users
+ cy.addInvitedUser(testUser2.username);
+ cy.wait(TIMEOUTS.ONE_SEC);
+ cy.addInvitedUser(testUser3.username);
+ cy.wait(TIMEOUTS.ONE_SEC);
+
+ // * Verify that the badge in the selector shows the correct number of members
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '2 SELECTED');
+
+ // # Remove the first users added
+ cy.get('.invite-users-selector__option').
+ eq(0).
+ within(() => {
+ cy.findByText('Remove').click();
+ });
+ cy.wait(TIMEOUTS.ONE_SEC);
+
+ // * Verify that there is only one user, the one not removed
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '1 SELECTED');
+
+ cy.findByText('SELECTED').
+ parent().
+ within(() => {
+ cy.get('.invite-users-selector__option').
+ should('have.length', 1).
+ contains(testUser3.username);
+ });
+ });
+ });
+ });
+
+ it('persists the list of users even if the toggle is off', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Open the invited users selector
+ cy.openSelector();
+
+ // # Add a couple of users
+ cy.addInvitedUser(testUser2.username);
+ cy.wait(TIMEOUTS.ONE_SEC);
+ cy.addInvitedUser(testUser3.username);
+ cy.wait(TIMEOUTS.ONE_SEC);
+
+ // * Verify that the badge in the selector shows the correct number of members
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '2 SELECTED');
+
+ // # Click on the toggle to disable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+ });
+ });
+
+ cy.reload();
+
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // * Verify that the badge in the selector shows the correct number of members
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '2 SELECTED');
+
+ // # Open the invited users selector
+ cy.openSelector();
+
+ // * Verify that the user shows in the group of invited members
+ cy.findByText('SELECTED').
+ parent().
+ within(() => {
+ cy.findByText(testUser2.username);
+ cy.findByText(testUser2.username);
+ });
+ });
+ });
+ });
+
+ describe('allow removing pre-assigned users with confirmation', () => {
+ beforeEach(() => {
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ checklists: [{
+ title: 'Example',
+ items: [
+ {
+ title: 'Untitled task',
+ assignee_id: testUser.id,
+ },
+ ],
+ }],
+ invitedUserIds: [testUser.id],
+ inviteUsersEnabled: true,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ it('when removing an invited user', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ cy.get('#checklists').within(() => {
+ // * Verify user is pre-assigned
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByText(`@${testUser.username}`).should('exist');
+ });
+
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify invitations enabled and user is invited
+ cy.get('label input').should('be.checked');
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '1 SELECTED');
+
+ cy.openSelector();
+
+ cy.get('.invite-users-selector__menu').within(() => {
+ // # Trigger remove for pre-assigned user
+ cy.findByText('Remove').click({force: true});
+ });
+ });
+ });
+
+ // * Verify that confirmation dialog is open
+ cy.get('#confirmModal').should('be.visible');
+
+ // * Verify that confirmation dialog contains correct text
+ cy.get('#confirmModal').should('contain', 'Are you sure you want to stop inviting this user as a member of the run?');
+
+ // * Verify that the confirmation button is focused and click
+ cy.focused().
+ should('have.id', 'confirmModalButton').
+ click({force: true});
+
+ // * Verify that the confirmation dialog is closed
+ cy.get('#confirmModal').should('not.exist');
+
+ cy.reload();
+
+ cy.get('#checklists').within(() => {
+ // * Verify that user is not pre-assigned anymore
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByTestId('assignee-profile-selector').should('exist');
+ cy.get('.icon-account-plus-outline').should('exist'); // Icon shows when no assignee
+ });
+
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that user is not invited anymore
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '');
+ });
+ });
+ });
+
+ it('when disabling invitations', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ cy.get('#checklists').within(() => {
+ // * Verify user is pre-assigned
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByText(`@${testUser.username}`).should('exist');
+ });
+
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify invitations are enabled and user is invited
+ cy.get('label input').should('be.checked');
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '1 SELECTED');
+
+ // # Disable invitations
+ cy.get('label input').click({force: true});
+ });
+ });
+
+ // * Verify that confirmation dialog is open
+ cy.get('#confirmModal').should('be.visible');
+
+ // * Verify that confirmation dialog contains correct text
+ cy.get('#confirmModal').should('contain', 'Are you sure you want to disable invitations?');
+
+ // * Verify that the confirmation button is focused and click
+ cy.focused().
+ should('have.id', 'confirmModalButton').
+ click({force: true});
+
+ // * Verify that confirmation dialog is closed
+ cy.get('#confirmModal').should('not.exist');
+
+ cy.reload();
+
+ cy.get('#checklists').within(() => {
+ // * Verify that user is not pre-assigned
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByTestId('assignee-profile-selector').should('exist');
+ cy.get('.icon-account-plus-outline').should('exist'); // Icon shows when no assignee
+ });
+
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify that invitations are disabled and no user is invited
+ cy.get('label input').should('not.be.checked');
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '');
+ });
+ });
+ });
+ });
+ });
+
+ describe('assign owner setting', () => {
+ it('is disabled in a new playbook', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('#assign-owner label input').should(
+ 'not.be.checked',
+ );
+ });
+ });
+
+ it('can be enabled', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#assign-owner').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+ });
+ });
+ });
+
+ it('does not allow adding an owner when disabled', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#assign-owner').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('input').should(
+ 'not.be.checked',
+ );
+
+ // * Verify that the menu is disabled
+ cy.getStyledComponent('StyledReactSelect').should(
+ 'have.class',
+ 'assign-owner-selector--is-disabled',
+ );
+ });
+ });
+ });
+
+ it('allows adding users when enabled', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#assign-owner').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Open the owner selector
+ cy.openSelector();
+
+ // # Select a owner
+ cy.selectOwner(testUser2.username);
+
+ // * Verify that the control shows the selected owner
+ cy.get('.assign-owner-selector__control').contains(
+ testUser2.username,
+ );
+ });
+ });
+ });
+
+ it('allows changing the owner', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # select the actions section
+ cy.get('#actions').within(() => {
+ cy.get('#assign-owner').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Open the owner selector
+ cy.openSelector();
+
+ // # Select a owner
+ cy.selectOwner(testUser2.username);
+
+ // * Verify that the control shows the selected owner
+ cy.get('.assign-owner-selector__control').contains(
+ testUser2.username,
+ );
+
+ // # Open the owner selector
+ cy.get('.assign-owner-selector__control').click({
+ force: true,
+ });
+
+ // # Select a new owner
+ cy.selectOwner(testUser3.username);
+
+ // * Verify that the control shows the selected owner
+ cy.get('.assign-owner-selector__control').contains(
+ testUser3.username,
+ );
+ });
+ });
+ });
+ });
+ });
+ };
+
+ describe('actions toggled', () => {
+ let testPlaybook;
+
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ commonActionTests();
+
+ describe('link to an existing channel setting', () => {
+ beforeEach(() => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+ });
+
+ it('can be checked', () => {
+ // # select the action section.
+ cy.get('#actions #link-existing-channel').within(() => {
+ // * Verify that the toggle is unchecked and input is disabled
+ cy.get('input[type=radio]').should('not.be.checked');
+ cy.get('input[type=text]').should('be.disabled');
+
+ // # click radio
+ cy.get('input[type=radio]').click();
+
+ // * Verify that the toggle is checked and input is enabled
+ cy.get('input[type=radio]').should('be.checked');
+ cy.get('input[type=text]').should('not.be.disabled');
+ });
+ });
+
+ it('create channel choices are disabled when is checked', () => {
+ // # select the action section.
+ cy.get('#actions #link-existing-channel').within(() => {
+ // # click radio
+ cy.get('input[type=radio]').click();
+ });
+
+ // # select the action section.
+ cy.get('#actions #create-new-channel').within(() => {
+ // * Verify that the toggle is unchecked and inputs are disabled
+ cy.get('input[type=radio]').eq(0).should('not.be.checked');
+ cy.get('label input[type=radio]').should('be.disabled');
+ cy.get('button').should('be.disabled');
+ });
+ });
+ });
+ });
+
+ describe('actions', () => {
+ let testPrivateChannel;
+ let testPlaybook;
+
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'public-channel',
+ 'Public Channel',
+ 'O',
+ );
+
+ // # Create a private channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'private-channel',
+ 'Private Channel',
+ 'P',
+ ).then(({channel}) => {
+ testPrivateChannel = channel;
+ });
+ });
+
+ beforeEach(() => {
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ describe('when an update is posted', () => {
+ describe('broadcast channel setting', () => {
+ it('none configured in a new playbook', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ cy.get('#status-updates').within(() => {
+ cy.findByText('no channels').should('be.visible');
+ });
+ });
+
+ it('can change channel and edit is saved immediately', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ cy.get('#status-updates').within(() => {
+ cy.findByText('no channels').click();
+ });
+ cy.findByText(/off-topic/i).click();
+
+ cy.reload();
+
+ cy.get('#status-updates').within(() => {
+ cy.findByText('1 channel').should('be.visible');
+ });
+ });
+
+ it('persists selected channels when status update toggle is off', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Add a channel and turn off the
+ // # status updates toggle
+ cy.get('#status-updates').within(() => {
+ cy.findByText('no channels').click();
+ });
+ cy.findByText(/off-topic/i).click();
+
+ // # Close the channel selector
+ cy.findByText(/search for a channel/i).type('{esc}');
+
+ cy.get('#status-updates').trigger('mouseenter').within(() => {
+ // # Click on the toggle to disable the setting
+ cy.get('label').click();
+
+ // * Verify that the toggle off
+ cy.get('label input').should('not.be.checked');
+ });
+
+ // * Verify disabled status updates text
+ cy.findByText(/status updates are not expected/i).should('exist');
+ cy.reload();
+
+ // # Turn the status update toggle back on
+ // * Verify there's still 1 channel selected
+ cy.get('#status-updates').trigger('mouseenter').within(() => {
+ cy.get('label').click();
+ cy.findByText('1 channel').should('be.visible');
+ });
+ });
+
+ it('removes the channel and disables the setting if the channel no longer exists', () => {
+ // # Create a playbook with a user that is later removed from the team
+ cy.apiLogin(testSysadmin).
+ then(() => {
+ const channelDisplayName = String(
+ 'Channel to delete ' + Date.now(),
+ );
+ const channelName = channelDisplayName.
+ replace(/ /g, '-').
+ toLowerCase();
+ cy.apiCreateChannel(
+ testTeam.id,
+ channelName,
+ channelDisplayName,
+ ).then(({channel}) => {
+ // # Create a playbook with the channel to be deleted as the announcement channel
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ createPublicPlaybookRun: true,
+ memberIDs: [testUser.id, testSysadmin.id],
+ announcementChannelId: channel.id,
+ announcementChannelEnabled: true,
+ });
+
+ // # Delete channel
+ cy.apiDeleteChannel(channel.id);
+ });
+ }).
+ then(() => {
+ cy.apiLogin(testUser);
+
+ // # Navigate again to the playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ cy.get('#status-updates').within(() => {
+ cy.findByText('no channels').should('be.visible');
+ });
+ });
+ });
+
+ it('shows channel name when private broadcast channel configured and user is a member', () => {
+ // # Visit the selected playbook
+ cy.visit('/playbooks/playbooks/' + testPlaybook.id + '/outline');
+
+ // * Verify no channel is selected
+ cy.findByTestId('status-update-broadcast-channels').should(
+ 'have.text',
+ 'no channels',
+ );
+
+ // # Open the broadcast channel widget
+ cy.findByTestId('status-update-broadcast-channels').click();
+
+ // # select a private channel
+ cy.get('#playbook-automation-broadcast').within(() => {
+ cy.get('input').type(`${testPrivateChannel.display_name}{enter}{esc}`);
+ });
+
+ // * Verify placeholder text is present
+ cy.findByTestId('status-update-broadcast-channels').should(
+ 'have.text',
+ '1 channel',
+ );
+
+ // # Visit the selected playbook
+ cy.visit('/playbooks/playbooks/' + testPlaybook.id + '/outline');
+
+ // * Verify placeholder text is present
+ cy.findByTestId('status-update-broadcast-channels').should(
+ 'have.text',
+ '1 channel',
+ );
+
+ // # Open the broadcast channel widget
+ cy.findByTestId('status-update-broadcast-channels').click();
+
+ // * Verify channel name displayed
+ cy.get('#playbook-automation-broadcast').within(() => {
+ cy.findByText(testPrivateChannel.display_name).should('be.visible');
+ });
+ });
+ });
+ });
+
+ describe('when a new member joins the channel', () => {
+ beforeEach(() => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+ cy.findByTestId('playbook-channel-actions-button').click();
+ });
+
+ describe('add the channel to a sidebar category', () => {
+ it('is disabled in a new playbook', () => {
+ cy.findByTestId('user-joins-channel-categorize').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+ });
+ });
+
+ it('can be enabled', () => {
+ cy.findByTestId('user-joins-channel-categorize').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label').eq(1).click();
+
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('be.checked');
+ });
+ });
+
+ it('prevents category selection when disabled', () => {
+ // * Verify that the toggle is unchecked
+ cy.findByTestId('user-joins-channel-categorize').within(() => {
+ cy.get('label input').should('not.be.checked');
+ cy.getStyledComponent('StyledCreatable').should('not.exist');
+ });
+ });
+
+ it('persists the category even if the toggle is off', () => {
+ cy.findByTestId('user-joins-channel-categorize').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.getStyledComponent('Container').click();
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Open the channel selector
+ openCategorySelector();
+
+ // # Select a channel
+ selectCategory('Favorites');
+
+ // * Verify that the control shows the selected category
+ cy.get('.channel-selector__control').contains('Favorites');
+
+ // # Click on the toggle to disable the setting
+ cy.getStyledComponent('Container').click();
+
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+ });
+ cy.findByTestId('modal-confirm-button').click();
+ cy.reload();
+
+ cy.findByTestId('playbook-channel-actions-button').click();
+
+ cy.findByTestId('user-joins-channel-categorize').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.getStyledComponent('Container').click();
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // * Verify that the control still shows the selected category
+ cy.get('.channel-selector__control').contains('Favorites');
+ });
+ });
+
+ it('shows new category name when category was created', () => {
+ cy.findByTestId('user-joins-channel-categorize').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label').eq(1).click();
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+ });
+
+ // # Type name to use new custom category
+ cy.get('.channel-selector__control').click().type('Custom category{enter}', {delay: 200});
+
+ // # click save modal
+ cy.findByTestId('modal-confirm-button').click();
+
+ // # reload to check that changes aren't local
+ cy.reload();
+
+ // # Open the channel modal
+ cy.findByTestId('playbook-channel-actions-button').click();
+
+ cy.findByTestId('user-joins-channel-categorize').within(() => {
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // * Verify that the control still shows the new category
+ cy.get('.channel-selector__control').should(
+ 'have.text',
+ 'Custom category',
+ );
+ });
+ });
+ });
+ });
+
+ describe('status updates enable / disabled', () => {
+ beforeEach(() => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+ });
+
+ it('is enabled in a new playbook', () => {
+ // * Verify that the toggle is checked
+ cy.get('#status-updates label input').should('be.checked');
+ });
+
+ it('can be disabled', () => {
+ // * Verify that toggle can be disabled
+ cy.get('#status-updates').within(() => {
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+ });
+
+ // * Verify disabled status updates text
+ cy.findByText(/status updates are not expected/i).should('be.visible');
+ cy.reload();
+ cy.findByText(/status updates are not expected/i).should('be.visible');
+ });
+ });
+
+ describe('retrospective enable / disable', () => {
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+ });
+
+ it('is enabled in a new playbook', () => {
+ cy.get('#retrospective').within(() => {
+ // * Verify that the toggle is checked
+ cy.get('input[type=checkbox]').should('be.checked');
+ });
+ });
+
+ it('can be disabled', () => {
+ cy.get('#retrospective').within(() => {
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+
+ // # Click on the toggle to disable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ cy.findByText(/a retrospective is not expected/i).should('exist');
+ });
+ });
+
+ it('saves on toggle', () => {
+ cy.get('#retrospective').within(() => {
+ // # Uncheck toggle
+ cy.get('label input').click({force: true});
+ });
+
+ cy.reload();
+
+ cy.get('#retrospective').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/checklists_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/checklists_spec.js
new file mode 100644
index 00000000000..056206df9ae
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/checklists_spec.js
@@ -0,0 +1,189 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+describe('playbooks > edit', {testIsolation: true}, () => {
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({user}) => {
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('checklists', () => {
+ describe('pre-assignee', () => {
+ it('user gets pre-assigned, added to invite user list, and invitations become enabled', () => {
+ // # Open Playbooks
+ cy.visit('/playbooks/playbooks');
+
+ // # Start a blank playbook
+ cy.findByText('Blank').click();
+ cy.findByText('Outline').click();
+
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify invitations are disabled and no invited user exists
+ cy.get('label input').should('not.be.checked');
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '');
+ });
+ });
+
+ // # Pre-assign the user
+ cy.get('#checklists').within(() => {
+ // # Trigger assignee select menu
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByTestId('assignee-profile-selector').click();
+
+ // * Verify that the assignee input is focused now
+ cy.focused().
+ should('have.attr', 'type', 'text').
+ should('have.attr', 'id');
+
+ // * Verify that the root of the assignee select menu exists
+ cy.focused().parents('.playbook-react-select').
+ should('exist').
+ within(() => {
+ // # Select the test user
+ cy.findByText('@' + testUser.username).click();
+ });
+ });
+
+ cy.reload();
+
+ cy.get('#checklists').within(() => {
+ // # Trigger assignee select menu
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByText('@' + testUser.username).click();
+
+ // * Verify that the assignee input is focused now
+ cy.focused().
+ should('have.attr', 'type', 'text').
+ should('have.attr', 'id');
+
+ // * Verify that the root of the assignee select menu exists
+ cy.focused().
+ parents('.playbook-react-select').
+ should('exist');
+ });
+
+ cy.get('#actions').within(() => {
+ cy.get('#invite-users').within(() => {
+ // * Verify invitations are enabled and a single user is invited
+ cy.get('label input').should('be.checked');
+ cy.get('.invite-users-selector__control').
+ after('content').
+ should('eq', '1 SELECTED');
+ });
+ });
+ });
+ });
+
+ /*describe('slash command', () => {
+ it('autocompletes after clicking Command...', () => {
+ // # Open Playbooks
+ cy.visit('/playbooks/playbooks');
+
+ // # Start a blank playbook
+ cy.findByText('Blank').click();
+ cy.findByText('Outline').click();
+
+ cy.get('#checklists').within(() => {
+ // # Open the slash command input on a step
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByTestId('command-button').click();
+
+ // * Verify the slash command input field now has focus
+ // * and starts with a slash prefix.
+ cy.focused().
+ should('have.attr', 'placeholder', 'Slash Command').
+ should('have.value', '/');
+ });
+
+ // * Verify the autocomplete prompt is open
+ cy.get('#suggestionList').should('exist');
+ });
+
+ it('resets when saving with an empty slash command', () => {
+ // # Open Playbooks
+ cy.visit('/playbooks/playbooks');
+
+ // # Start a blank playbook
+ cy.findByText('Blank').click();
+ cy.findByText('Outline').click();
+
+ cy.get('#checklists').within(() => {
+ // # Open the slash command input on a step
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByTestId('command-button').click();
+ });
+
+ cy.get('#floating-ui-root').within(() => {
+ // * Verify the slash command input field now has focus
+ // * and starts with a slash prefix.
+ cy.findByPlaceholderText('Slash Command').should('have.focus');
+ cy.findByPlaceholderText('Slash Command').should('have.value', '/');
+
+ cy.findByPlaceholderText('Slash Command').type('{backspace}');
+
+ // # Click the save button
+ cy.findByText('Save').click();
+ });
+
+ // * Verify no slash command was saved (icon shows when no command)
+ cy.findByTestId('command-button').should('be.visible');
+ cy.get('.icon-slash-forward').should('exist');
+ });
+
+ it('removes the input prompt when blurring with an invalid slash command', () => {
+ // # Open Playbooks
+ cy.visit('/playbooks/playbooks');
+
+ // # Start a blank playbook
+ cy.findByText('Blank').click();
+ cy.findByText('Outline').click();
+
+ cy.get('#checklists').within(() => {
+ // # Open the slash command input on a step
+ cy.findByText('Untitled task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ cy.findByTestId('command-button').click();
+ });
+
+ cy.get('#floating-ui-root').within(() => {
+ // * Verify the slash command input field now has focus
+ // * and starts with a slash prefix.
+ cy.findByPlaceholderText('Slash Command').should('have.focus');
+ cy.findByPlaceholderText('Slash Command').should('have.value', '/');
+
+ // # Click the save button
+ cy.findByText('Save').click();
+ });
+
+ // * Verify no slash command was saved (icon shows when no command)
+ cy.findByTestId('command-button').should('be.visible');
+ cy.get('.icon-slash-forward').should('exist');
+ });
+ });*/
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/condition_admin_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/condition_admin_spec.js
new file mode 100644
index 00000000000..08eeb0f7f7f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/condition_admin_spec.js
@@ -0,0 +1,322 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > edit > conditions > admin', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let priorityField;
+ let statusField;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ cy.apiLogin(testUser);
+
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Condition Test Playbook ' + Date.now(),
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+
+ cy.then(() => {
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: 'Priority',
+ type: 'select',
+ attrs: {
+ visibility: 'always',
+ sortOrder: 1,
+ options: [
+ {name: 'High'},
+ {name: 'Medium'},
+ {name: 'Low'},
+ ],
+ },
+ });
+
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: 'Status',
+ type: 'select',
+ attrs: {
+ visibility: 'always',
+ sortOrder: 2,
+ options: [
+ {name: 'Active'},
+ {name: 'Inactive'},
+ ],
+ },
+ });
+
+ cy.apiGetPropertyFields(testPlaybook.id).then((fields) => {
+ priorityField = fields.find((f) => f.name === 'Priority');
+ statusField = fields.find((f) => f.name === 'Status');
+ });
+ });
+
+ cy.viewport('macbook-16');
+ });
+
+ describe('create condition', () => {
+ it('can create a condition from task menu', () => {
+ navigateToPlaybook(testPlaybook.id);
+
+ cy.findAllByTestId('checkbox-item-container').eq(0).trigger('mouseover');
+
+ cy.findAllByTestId('checkbox-item-container').eq(0).within(() => {
+ cy.findByTitle('More').click();
+ });
+
+ cy.findByTestId('task-menu-add-condition').click();
+
+ cy.wait(500);
+
+ cy.findByTestId('condition-header').should('be.visible');
+
+ cy.reload();
+
+ cy.findByTestId('condition-header').should('be.visible');
+ });
+ });
+
+ describe('edit condition', () => {
+ it('can edit condition expression', () => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: priorityField.id,
+ value: [highOptionId],
+ },
+ }).then((condition) => {
+ cy.apiAttachConditionToTask(testPlaybook.id, 0, 0, condition.id);
+
+ navigateToPlaybook(testPlaybook.id);
+
+ cy.findByTestId('condition-header').should('be.visible');
+
+ cy.findByTestId('condition-header').within(() => {
+ cy.findByText('Priority').should('be.visible');
+ cy.findByText('High').should('be.visible');
+ });
+
+ cy.findByTestId('condition-header-edit-button').click();
+
+ cy.wait(500);
+
+ cy.contains('.condition-select__single-value', 'is').click();
+ cy.get('.condition-select__menu').contains('is not').click();
+
+ cy.wait(500);
+
+ cy.contains('.condition-select__single-value', 'High').click();
+ cy.get('.condition-select__menu').contains('Medium').click();
+
+ cy.wait(500);
+
+ cy.reload();
+
+ cy.findByTestId('condition-header').within(() => {
+ cy.findByText('Priority').should('be.visible');
+ cy.findByText('is not').should('be.visible');
+ cy.findByText('Medium').should('be.visible');
+ });
+ });
+ });
+
+ it('can add second condition with OR operator', () => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: priorityField.id,
+ value: [highOptionId],
+ },
+ }).then((condition) => {
+ cy.apiAttachConditionToTask(testPlaybook.id, 0, 0, condition.id);
+
+ navigateToPlaybook(testPlaybook.id);
+
+ cy.findByTestId('condition-header-edit-button').click();
+
+ cy.wait(500);
+
+ cy.findByTestId('condition-add-button').click();
+
+ cy.wait(500);
+
+ cy.findAllByTestId('condition-remove-button').should('have.length', 2);
+
+ cy.contains('.condition-select__single-value', 'Priority').last().click();
+ cy.get('.condition-select__menu').contains('Status').click();
+
+ cy.wait(500);
+
+ cy.contains('.condition-select__single-value', 'OR').should('be.visible');
+
+ cy.reload();
+
+ cy.findByTestId('condition-header').within(() => {
+ cy.findByText('Priority').should('be.visible');
+ cy.findByText('Status').should('be.visible');
+ });
+ });
+ });
+
+ it('can change logical operator from AND to OR', () => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+ const activeOptionId = statusField.attrs.options.find((o) => o.name === 'Active').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ and: [
+ {is: {field_id: priorityField.id, value: [highOptionId]}},
+ {is: {field_id: statusField.id, value: [activeOptionId]}},
+ ],
+ }).then((condition) => {
+ cy.apiAttachConditionToTask(testPlaybook.id, 0, 0, condition.id);
+
+ navigateToPlaybook(testPlaybook.id);
+
+ cy.findByTestId('condition-header-edit-button').click();
+
+ cy.wait(500);
+
+ cy.contains('.condition-select__single-value', 'AND').click();
+ cy.get('.condition-select__menu').contains('OR').click();
+
+ cy.wait(500);
+
+ cy.reload();
+
+ cy.findByTestId('condition-header').within(() => {
+ cy.findByText(/\bor\b/i).should('be.visible');
+ });
+ });
+ });
+ });
+
+ describe('delete condition', () => {
+ it('can delete condition', () => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: priorityField.id,
+ value: [highOptionId],
+ },
+ }).then((condition) => {
+ cy.apiAttachConditionToTask(testPlaybook.id, 0, 0, condition.id);
+
+ navigateToPlaybook(testPlaybook.id);
+
+ cy.findByTestId('condition-header').should('be.visible');
+
+ cy.findByTestId('condition-header-delete-button').click();
+
+ cy.findByRole('button', {name: /remove/i}).click();
+
+ cy.wait(500);
+
+ cy.findByTestId('condition-header').should('not.exist');
+
+ cy.findByText('Step 1').should('be.visible');
+
+ cy.reload();
+
+ cy.findByTestId('condition-header').should('not.exist');
+ });
+ });
+ });
+
+ describe('assign and remove tasks', () => {
+ it('can assign task to existing condition', () => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: priorityField.id,
+ value: [highOptionId],
+ },
+ }).then((condition) => {
+ cy.apiAttachConditionToTask(testPlaybook.id, 0, 0, condition.id);
+
+ navigateToPlaybook(testPlaybook.id);
+
+ cy.findByText('Step 1').should('be.visible');
+ cy.findByText('Step 2').should('be.visible');
+
+ cy.findAllByTestId('checkbox-item-container').eq(1).trigger('mouseover');
+
+ cy.findAllByTestId('checkbox-item-container').eq(1).within(() => {
+ cy.findByTitle('More').click();
+ });
+
+ cy.wait(500);
+
+ cy.get('[data-testid^="task-menu-assign-condition-"]').first().click();
+
+ cy.wait(500);
+
+ cy.findAllByTestId('condition-header').should('have.length', 1);
+
+ cy.reload();
+
+ cy.findAllByTestId('condition-header').should('have.length', 1);
+ });
+ });
+
+ it('can remove task from condition group', () => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: priorityField.id,
+ value: [highOptionId],
+ },
+ }).then((condition) => {
+ cy.apiAttachConditionToTask(testPlaybook.id, 0, 0, condition.id);
+ cy.apiAttachConditionToTask(testPlaybook.id, 0, 1, condition.id);
+
+ navigateToPlaybook(testPlaybook.id);
+
+ cy.findByTestId('condition-header').should('be.visible');
+
+ cy.findAllByTestId('checkbox-item-container').eq(0).trigger('mouseover');
+
+ cy.findAllByTestId('checkbox-item-container').eq(0).within(() => {
+ cy.findByTitle('More').click();
+ });
+
+ cy.wait(500);
+
+ cy.findByTestId('task-menu-remove-condition').click();
+
+ cy.wait(500);
+
+ cy.findByTestId('condition-header').should('be.visible');
+
+ cy.reload();
+
+ cy.findByTestId('condition-header').should('be.visible');
+ });
+ });
+ });
+
+ function navigateToPlaybook(playbookId) {
+ cy.visit(`/playbooks/playbooks/${playbookId}/outline`);
+ }
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/condition_user_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/condition_user_spec.js
new file mode 100644
index 00000000000..dc89d9b9734
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/condition_user_spec.js
@@ -0,0 +1,471 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > edit > conditions > user', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let testRun;
+ let priorityField;
+ let statusField;
+ let testCondition;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ cy.apiLogin(testUser);
+ cy.viewport('macbook-13');
+ });
+
+ describe('task visibility with simple condition', () => {
+ it('hides task when condition not met', () => {
+ createPlaybookWithConditionalTask('High');
+
+ startRun();
+
+ navigateToRun();
+
+ verifyTaskHidden('Conditional Task');
+
+ setPropertyValue('Priority', 'Low');
+
+ verifyTaskHidden('Conditional Task');
+
+ setPropertyValue('Priority', 'High');
+
+ verifyTaskVisible('Conditional Task');
+ });
+ });
+
+ describe('task visibility with AND logic', () => {
+ it('evaluates AND condition correctly', () => {
+ createPlaybookWithAttributes();
+
+ cy.then(() => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+ const activeOptionId = statusField.attrs.options.find((o) => o.name === 'Active').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ and: [
+ {is: {field_id: priorityField.id, value: [highOptionId]}},
+ {is: {field_id: statusField.id, value: [activeOptionId]}},
+ ],
+ }).then((condition) => {
+ testCondition = condition;
+
+ return cy.apiGetPlaybook(testPlaybook.id);
+ }).then((playbook) => {
+ playbook.checklists[0].items[0].title = 'AND Conditional Task';
+ playbook.checklists[0].items[0].condition_id = testCondition.id;
+ return cy.apiUpdatePlaybook(playbook);
+ }).then(() => {
+ startRun();
+ navigateToRun();
+
+ verifyTaskHidden('AND Conditional Task');
+
+ setPropertyValue('Priority', 'High');
+
+ verifyTaskHidden('AND Conditional Task');
+
+ setPropertyValue('Status', 'Active');
+
+ verifyTaskVisible('AND Conditional Task');
+
+ setPropertyValue('Priority', 'Low');
+
+ verifyTaskHidden('AND Conditional Task');
+ });
+ });
+ });
+ });
+
+ describe('task visibility with OR logic', () => {
+ it('evaluates OR condition correctly', () => {
+ createPlaybookWithAttributes();
+
+ cy.then(() => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+ const mediumOptionId = priorityField.attrs.options.find((o) => o.name === 'Medium').id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ or: [
+ {is: {field_id: priorityField.id, value: [highOptionId]}},
+ {is: {field_id: priorityField.id, value: [mediumOptionId]}},
+ ],
+ }).then((condition) => {
+ testCondition = condition;
+
+ return cy.apiGetPlaybook(testPlaybook.id);
+ }).then((playbook) => {
+ playbook.checklists[0].items[0].title = 'OR Conditional Task';
+ playbook.checklists[0].items[0].condition_id = testCondition.id;
+ return cy.apiUpdatePlaybook(playbook);
+ }).then(() => {
+ startRun();
+ navigateToRun();
+
+ verifyTaskHidden('OR Conditional Task');
+
+ setPropertyValue('Priority', 'Low');
+
+ verifyTaskHidden('OR Conditional Task');
+
+ setPropertyValue('Priority', 'Medium');
+
+ verifyTaskVisible('OR Conditional Task');
+
+ setPropertyValue('Priority', 'High');
+
+ verifyTaskVisible('OR Conditional Task');
+ });
+ });
+ });
+ });
+
+ describe('modified task behavior', () => {
+ it('shows warning indicator for modified task when condition no longer met', () => {
+ createPlaybookWithConditionalTask('High');
+
+ startRun();
+
+ navigateToRun();
+
+ setPropertyValue('Priority', 'High');
+
+ verifyTaskVisible('Conditional Task');
+
+ cy.findByText('Conditional Task').closest('[data-testid="checkbox-item-container"]').within(() => {
+ cy.get('input[type="checkbox"]').check();
+ });
+
+ cy.wait(500);
+
+ setPropertyValue('Priority', 'Low');
+
+ cy.wait(500);
+
+ verifyTaskVisible('Conditional Task');
+
+ cy.findByTestId('condition-indicator-error').should('exist');
+ });
+ });
+
+ describe('real-time updates', () => {
+ it('updates task visibility without page reload', () => {
+ createPlaybookWithConditionalTask('High');
+
+ startRun();
+
+ navigateToRun();
+
+ verifyTaskHidden('Conditional Task');
+
+ setPropertyValue('Priority', 'High');
+
+ verifyTaskVisible('Conditional Task');
+
+ setPropertyValue('Priority', 'Medium');
+
+ verifyTaskHidden('Conditional Task');
+
+ setPropertyValue('Priority', 'High');
+
+ verifyTaskVisible('Conditional Task');
+ });
+ });
+
+ describe('channel messages for conditional tasks', () => {
+ it('posts channel message when property change adds new tasks', () => {
+ createPlaybookWithConditionalTask('High');
+
+ startRun();
+
+ navigateToRun();
+
+ // # Change property to trigger task addition
+ setPropertyValue('Priority', 'High');
+
+ // # Navigate to the run's channel
+ cy.then(() => {
+ cy.visit(`/${testTeam.name}/channels/${testRun.channel_id}`);
+ });
+
+ // * Verify message posted about new tasks
+ cy.get('#postListContent').within(() => {
+ cy.contains('updated Priority to High, resulting in the addition of 1 new task to Stage 1 checklist').should('exist');
+ });
+ });
+
+ it('posts message when multiple tasks are added', () => {
+ createPlaybookWithAttributes();
+
+ cy.then(() => {
+ const highOptionId = priorityField.attrs.options.find((o) => o.name === 'High').id;
+
+ // Create condition and add multiple conditional tasks
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: priorityField.id,
+ value: [highOptionId],
+ },
+ }).then((condition) => {
+ testCondition = condition;
+
+ return cy.apiGetPlaybook(testPlaybook.id);
+ }).then((playbook) => {
+ // Add multiple conditional tasks
+ playbook.checklists[0].items = [
+ {
+ title: 'High Priority Task 1',
+ condition_id: testCondition.id,
+ },
+ {
+ title: 'High Priority Task 2',
+ condition_id: testCondition.id,
+ },
+ {
+ title: 'High Priority Task 3',
+ condition_id: testCondition.id,
+ },
+ ];
+ return cy.apiUpdatePlaybook(playbook);
+ }).then(() => {
+ startRun();
+
+ navigateToRun();
+
+ // # Change property to trigger task additions
+ setPropertyValue('Priority', 'High');
+
+ // # Navigate to the run's channel
+ cy.then(() => {
+ cy.visit(`/${testTeam.name}/channels/${testRun.channel_id}`);
+ });
+
+ // * Verify message posted about multiple tasks
+ cy.get('#postListContent').within(() => {
+ cy.contains('updated Priority to High, resulting in the addition of 3 new tasks to Stage 1 checklist').should('exist');
+ });
+ });
+ });
+ });
+ });
+
+ describe('text property conditions', () => {
+ it('evaluates is and is_not conditions for text fields', () => {
+ let textField;
+ let isCondition;
+ let isNotCondition;
+
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Text Condition Test ' + Date.now(),
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+
+ cy.then(() => {
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: 'Code',
+ type: 'text',
+ attrs: {
+ visibility: 'always',
+ sortOrder: 1,
+ },
+ });
+
+ cy.apiGetPropertyFields(testPlaybook.id).then((fields) => {
+ textField = fields.find((f) => f.name === 'Code');
+ });
+ });
+
+ cy.then(() => {
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: textField.id,
+ value: 'abc',
+ },
+ }).then((condition) => {
+ isCondition = condition;
+ });
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ isNot: {
+ field_id: textField.id,
+ value: 'abc',
+ },
+ }).then((condition) => {
+ isNotCondition = condition;
+ });
+ });
+
+ cy.then(() => {
+ return cy.apiGetPlaybook(testPlaybook.id);
+ }).then((playbook) => {
+ playbook.checklists[0].items[0].title = 'Task when IS abc';
+ playbook.checklists[0].items[0].condition_id = isCondition.id;
+
+ playbook.checklists[0].items.push({
+ title: 'Task when NOT abc',
+ condition_id: isNotCondition.id,
+ });
+
+ return cy.apiUpdatePlaybook(playbook);
+ }).then(() => {
+ startRun();
+ navigateToRun();
+
+ verifyTaskHidden('Task when IS abc');
+ verifyTaskVisible('Task when NOT abc');
+
+ setTextPropertyValue('Code', 'abc');
+
+ verifyTaskVisible('Task when IS abc');
+ verifyTaskHidden('Task when NOT abc');
+
+ setTextPropertyValue('Code', 'xyz');
+
+ verifyTaskHidden('Task when IS abc');
+ verifyTaskVisible('Task when NOT abc');
+ });
+ });
+ });
+
+ function createPlaybookWithAttributes() {
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Condition User Test ' + Date.now(),
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+
+ cy.then(() => {
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: 'Priority',
+ type: 'select',
+ attrs: {
+ visibility: 'always',
+ sortOrder: 1,
+ options: [
+ {name: 'High'},
+ {name: 'Medium'},
+ {name: 'Low'},
+ ],
+ },
+ });
+
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: 'Status',
+ type: 'select',
+ attrs: {
+ visibility: 'always',
+ sortOrder: 2,
+ options: [
+ {name: 'Active'},
+ {name: 'Inactive'},
+ ],
+ },
+ });
+
+ cy.apiGetPropertyFields(testPlaybook.id).then((fields) => {
+ priorityField = fields.find((f) => f.name === 'Priority');
+ statusField = fields.find((f) => f.name === 'Status');
+ });
+ });
+ }
+
+ function createPlaybookWithConditionalTask(priorityValue) {
+ createPlaybookWithAttributes();
+
+ cy.then(() => {
+ const optionId = priorityField.attrs.options.find((o) => o.name === priorityValue).id;
+
+ cy.apiCreatePlaybookCondition(testPlaybook.id, {
+ is: {
+ field_id: priorityField.id,
+ value: [optionId],
+ },
+ }).then((condition) => {
+ testCondition = condition;
+
+ return cy.apiGetPlaybook(testPlaybook.id);
+ }).then((playbook) => {
+ playbook.checklists[0].items[0].title = 'Conditional Task';
+ playbook.checklists[0].items[0].condition_id = testCondition.id;
+ return cy.apiUpdatePlaybook(playbook);
+ });
+ });
+ }
+
+ function startRun() {
+ cy.then(() => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'Condition Test Run',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ testRun = run;
+ });
+ });
+ }
+
+ function navigateToRun() {
+ cy.then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ }
+
+ function setPropertyValue(propertyName, value) {
+ const testId = `run-property-${propertyName.toLowerCase().replace(/\s+/g, '-')}`;
+
+ cy.findByRole('complementary').within(() => {
+ cy.findByTestId(testId).within(() => {
+ cy.findByTestId('property-value').realClick();
+ });
+ });
+
+ cy.findByText(value).click();
+
+ cy.wait(500);
+ }
+
+ function setTextPropertyValue(propertyName, value) {
+ const testId = `run-property-${propertyName.toLowerCase().replace(/\s+/g, '-')}`;
+
+ cy.findByRole('complementary').within(() => {
+ cy.findByTestId(testId).within(() => {
+ cy.findByTestId('property-value').realClick();
+ });
+ });
+
+ cy.focused().clear().realType(value);
+ cy.realPress('Tab');
+
+ cy.wait(500);
+ }
+
+ function verifyTaskVisible(taskTitle) {
+ cy.findByText(taskTitle).should('exist').should('be.visible');
+ }
+
+ function verifyTaskHidden(taskTitle) {
+ cy.findByText(taskTitle).should('not.exist');
+ }
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/header_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/header_spec.js
new file mode 100644
index 00000000000..7487a9b5b15
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/header_spec.js
@@ -0,0 +1,103 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+describe('playbooks > edit', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('Edit playbook name', () => {
+ it('can be updated', () => {
+ // # Open Playbooks
+ cy.visit('/playbooks/playbooks');
+
+ // # Start a blank playbook
+ cy.findByText('Blank').click();
+
+ // # Open the title dropdown and Rename
+ cy.findByTestId('playbook-editor-title').click();
+ cy.findByText('Rename').click();
+
+ // # Change the name and save
+ cy.findByTestId('rendered-editable-text').type('{selectAll}{del}renamed playbook');
+ cy.findByRole('button', {name: /save/i}).click();
+
+ cy.reload();
+
+ // * Verify the modified name persists
+ cy.findByTestId('playbook-editor-header').within(() => {
+ cy.findByRole('button', {name: /renamed playbook/i}).should('exist');
+ });
+ });
+ });
+
+ describe('Edit playbook description', () => {
+ it('can be updated', () => {
+ // # Open Playbooks
+ cy.visit('/playbooks/playbooks');
+
+ // # Start a blank playbook
+ cy.findByText('Blank').click();
+ cy.findByText(/customize this playbook's description/i).dblclick();
+ cy.focused().type('{selectAll}{del}some new description');
+ cy.findByRole('button', {name: /save/i}).click();
+
+ cy.reload();
+
+ cy.findByText('some new description').should('exist');
+ });
+ });
+
+ describe('Duplicate', () => {
+ let testPlaybook;
+ beforeEach(() => {
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ it('can be duplicated', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Open the title dropdown and Duplicate
+ cy.findByTestId('playbook-editor-title').click();
+ cy.findByText('Duplicate').click();
+
+ // * Verify that playbook got duplicated
+ cy.findByTestId('playbook-editor-header').within(() => {
+ cy.findByText('Copy of ' + testPlaybook.title).should('exist');
+ });
+
+ // * Verify that the duplicated playbook is shown in the LHS
+ cy.findByTestId('Playbooks').within(() => {
+ cy.findByText('Copy of ' + testPlaybook.title).should('be.visible');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/task_actions_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/task_actions_spec.js
new file mode 100644
index 00000000000..3d966d30381
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit/task_actions_spec.js
@@ -0,0 +1,460 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+//
+import * as TIMEOUTS from '../../../../fixtures/timeouts';
+
+describe('playbooks > edit > task actions', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testUser2;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+
+ // # Add this new user to the team
+ cy.apiAddUserToTeam(team.id, testUser2.id);
+ });
+ });
+ });
+
+ let testPlaybook;
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ checklists: [{
+ title: 'Test Checklist',
+ items: [
+ {title: 'Test Task'},
+ ],
+ }],
+ memberIDs: [
+ testUser.id,
+ ],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+ });
+ });
+
+ const editTask = () => {
+ cy.findByTestId('checkbox-item-container').within(() => {
+ cy.findByText('Test Task').trigger('mouseover');
+ cy.findByTestId('hover-menu-edit-button').click();
+ });
+
+ // Wait for edit mode to render - assignee button appears in edit mode
+ cy.findByTestId('checkbox-item-container').within(() => {
+ cy.findByTestId('assignee-profile-selector', {timeout: 10000}).should('be.visible');
+ });
+ };
+
+ const getTaskActionsButton = () => {
+ return cy.findByTestId('checkbox-item-container').findByTestId('task-actions-button', {timeout: 10000});
+ };
+
+ it('disallows no keywords', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify no actions are configured
+ getTaskActionsButton().should('exist');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, []);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isFalse(actions.enabled);
+ });
+ });
+
+ it('allows a single keyword', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+ });
+
+ it('allows multiple keywords', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add multiple keywords
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ cy.get('input').eq(0).type('keyword2{enter}', {force: true});
+ });
+
+ // Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1', 'keyword2']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+ });
+
+ it('allows multi-word phrases', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add a phrase
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('a phrase with multiple words{enter}', {force: true});
+ });
+
+ // Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['a phrase with multiple words']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+ });
+
+ it('allows removing previously configured keywords', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add multiple keywords
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ cy.get('input').eq(0).type('keyword2{enter}', {force: true});
+ });
+
+ // Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Re-open the dialog
+ cy.findByText('1 action').click();
+
+ // Remove one trigger keyword
+ cy.get('.modal-body').within(() => {
+ cy.findByText('keyword1').next().click();
+ });
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword2']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+ });
+
+ it('disables when all keywords removed', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add multiple keywords
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ cy.get('input').eq(0).type('keyword2{enter}', {force: true});
+ });
+
+ // Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Re-open the dialog
+ cy.findByText('1 action').click();
+
+ // Remove all trigger keywords
+ cy.get('.modal-body').within(() => {
+ cy.findByText('keyword1').next().click();
+ cy.findByText('keyword2').next().click();
+ });
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions
+ getTaskActionsButton().should('exist');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, []);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isFalse(actions.enabled);
+ });
+ });
+
+ it('disallows a user without keywords', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add a user
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify no actions are configured
+ getTaskActionsButton().should('exist');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, []);
+ assert.deepEqual(trigger.user_ids, [testUser.id]);
+ assert.isFalse(actions.enabled);
+ });
+ });
+
+ it('allows a single user', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // Add a user
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions and user
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, [testUser.id]);
+ assert.isTrue(actions.enabled);
+ });
+ });
+
+ it('allows configuring multiple users', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // Add two users
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ cy.get('input').eq(1).
+ type('@' + testUser2.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions and user
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, [testUser.id, testUser2.id]);
+ assert.isTrue(actions.enabled);
+ });
+ });
+
+ it('rejects unknown user', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // Type an unknown user
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@unknown', {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // Click away
+ cy.get('.modal-body').click();
+
+ // Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions and user
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+ });
+
+ it('allows removing previously configured users', () => {
+ // Open the task actions modal
+ editTask();
+ getTaskActionsButton().click();
+
+ // Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // Add two users
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ cy.get('input').eq(1).
+ type('@' + testUser2.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Re-open the dialog
+ cy.findByText('1 action').click();
+
+ // Remove one user keyword
+ cy.get('.modal-body').within(() => {
+ cy.findByText(testUser.username).parent().parent().next().click();
+ });
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ const trigger = JSON.parse(playbook.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbook.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, [testUser2.id]);
+ assert.isTrue(actions.enabled);
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit_metrics_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit_metrics_spec.js
new file mode 100644
index 00000000000..0180c42a73f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/edit_metrics_spec.js
@@ -0,0 +1,570 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+describe('playbooks > edit_metrics', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ describe('actions', () => {
+ let testPlaybook;
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+
+ // # Set a bigger viewport so the action don't scroll out of view
+ cy.viewport('macbook-16');
+ cy.intercept('PUT', '/plugins/playbooks/api/v0/playbooks/**').as('addMetric');
+ });
+
+ describe('adding and editing metrics', () => {
+ it('can add 4, but not 5 metrics; can save and re-edit with metrics saved', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}`);
+
+ // # Switch to Outline tab and focus retro section
+ cy.findByText('Outline').click();
+ cy.get('#retrospective').scrollIntoView();
+
+ // # Add and verify metric
+ addMetric('Duration', 'test duration', '0:0:1', 'test description');
+ verifyViewMetric(0, 'test duration', '1 minute per run', 'test description');
+
+ // # Add and verify metric
+ addMetric('Cost', 'test dollars', '2', 'test description 2');
+ verifyViewMetric(1, 'test dollars', '2 per run', 'test description 2');
+
+ // # Add and verify metric
+ addMetric('Integer', 'test integer', '4', 'test descr 3');
+ verifyViewMetric(2, 'test integer', '4 per run', 'test descr 3');
+
+ // # Add and verify metric
+ addMetric('Duration', 'test duration 2', '0:0:2', 'test description 4');
+ verifyViewMetric(3, 'test duration 2', '2 minutes per run', 'test description 4');
+
+ // * Verify Add Metric button is inactive
+ cy.findByRole('button', {name: 'Add Metric'}).should('be.disabled');
+
+ // * Verify we have four valid metrics and are editing none.
+ verifyViewsAndEdits(4, 0);
+
+ // Refresh the page
+ cy.reload();
+
+ // * Verify we saved the metrics
+ verifyViewMetric(0, 'test duration', '1 minute per run', 'test description');
+ verifyViewMetric(1, 'test dollars', '2 per run', 'test description 2');
+ verifyViewMetric(2, 'test integer', '4 per run', 'test descr 3');
+ verifyViewMetric(3, 'test duration 2', '2 minutes per run', 'test description 4');
+
+ // # Edit all 4 metrics and repeat the test
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.get('input[type=text]').eq(2).clear().type('12:8:97');
+ saveMetric();
+ cy.findAllByTestId('edit-metric').eq(1).click();
+ cy.get('textarea').eq(0).clear().type('a new description');
+ saveMetric();
+ cy.findAllByTestId('edit-metric').eq(2).click();
+ cy.get('input[type=text]').eq(2).clear().type('7777777');
+ saveMetric();
+ cy.findAllByTestId('edit-metric').eq(3).click();
+ cy.get('input[type=text]').eq(1).clear().type('test duration 2!!!');
+ saveMetric();
+
+ // # Refresh the page
+ cy.reload();
+
+ // * Verify we saved the metrics
+ verifyViewMetric(0, 'test duration', '12 days, 9 hours, 37 minutes per run', 'test description');
+ verifyViewMetric(1, 'test dollars', '2 per run', 'a new description');
+ verifyViewMetric(2, 'test integer', '7777777 per run', 'test descr 3');
+ verifyViewMetric(3, 'test duration 2!!!', '2 minutes per run', 'test description 4');
+
+ // # Now test: verifies when clicking "Add", for duration type
+ // # (using the previous state)
+
+ // # Edit the first metric
+ cy.findAllByTestId('edit-metric').eq(0).click();
+
+ // * Metrics need a title
+ cy.get('input[type=text]').eq(1).clear();
+ saveMetric();
+ cy.getStyledComponent('ErrorText').contains('Please add a title for your metric.');
+
+ // * Metrics need a unique title
+ cy.get('input[type=text]').eq(1).type('test dollars');
+ saveMetric();
+ cy.getStyledComponent('ErrorText').
+ contains('A metric with the same name already exists. Please add a unique name for each metric.');
+
+ // * A duration target needs to be in the correct format (no letters)
+ cy.get('input[type=text]').eq(1).clear().wait(100).type('test duration again');
+ cy.get('input[type=text]').eq(2).clear().type('a');
+ saveMetric();
+ cy.getStyledComponent('ErrorText').
+ contains('Please enter a duration in the format: dd:hh:mm (e.g., 12:00:00), or leave the target blank.');
+
+ // * A duration target needs to be in the correct format (mm:dd:ss)
+ cy.get('input[type=text]').eq(2).clear().type('0:123:0');
+ saveMetric();
+ cy.getStyledComponent('ErrorText').
+ contains('Please enter a duration in the format: dd:hh:mm (e.g., 12:00:00), or leave the target blank.');
+
+ // # A duration can have 1 or 2 numbers in each position
+ cy.get('input[type=text]').eq(2).clear().type('2:12:1');
+ saveMetric();
+ verifyViewMetric(0, 'test duration again', '2 days, 12 hours, 1 minute per run', 'test description');
+
+ // * Verify we have four valid metrics and are editing none.
+ verifyViewsAndEdits(4, 0);
+
+ // # Now test: on clicking edit, closes & saves current editing metric, and switches
+ // # (using the previous state)
+
+ // # Edit the second metric
+ cy.findAllByTestId('edit-metric').eq(1).click();
+
+ // * Verify editing correct metric, and only this metric
+ cy.getStyledComponent('EditContainer').should('have.length', 1).within(() => {
+ cy.get('input[type=text]').eq(0).should('have.value', 'test dollars');
+ });
+ cy.getStyledComponent('ViewContainer').should('have.length', 3);
+
+ // # Switch to editing third metric (second is in edit mode, so this is the third:)
+ cy.findAllByTestId('edit-metric').eq(1).click();
+
+ // * Verify editing correct metric, and only this metric
+ cy.getStyledComponent('EditContainer').should('have.length', 1).within(() => {
+ cy.get('input[type=text]').eq(0).should('have.value', 'test integer');
+ });
+ cy.getStyledComponent('ViewContainer').should('have.length', 3);
+
+ // # Edit third metric's title, switch to another metric
+ cy.getStyledComponent('EditContainer').should('have.length', 1).within(() => {
+ cy.get('input[type=text]').eq(0).clear().type('test integer222');
+ });
+ cy.findAllByTestId('edit-metric').eq(0).click();
+
+ // * Verify the title on the third metric (the second in view mode) was saved on switching
+ verifyViewMetric(1, 'test integer222', '7777777 per run', 'test descr 3');
+
+ // * Verify we have three valid metrics and are editing one.
+ verifyViewsAndEdits(3, 1);
+ });
+ });
+
+ describe('adding and editing metrics (new playbook)', () => {
+ it('verifies when clicking "Add Metric", for Currency type, and switches to new edit', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}`);
+
+ // # Switch to Outline tab and focus retro section
+ cy.findByText('Outline').click();
+ cy.get('#retrospective').scrollIntoView();
+
+ // # Add and verify 1st metric
+ addMetric('Integer', 'test integer!', '12314123', 'test description');
+ verifyViewMetric(0, 'test integer!', '12314123 per run', 'test description');
+
+ // # Add metric
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Cost').click();
+ });
+
+ // # Don't fill in the metric's details
+ cy.get('input[type=text]').eq(1).clear();
+
+ // * Metrics need a title
+ cy.get('input[type=text]').eq(1).clear();
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Integer').click();
+ });
+ cy.getStyledComponent('ErrorText').contains('Please add a title for your metric.');
+
+ // * Metrics need a unique title
+ cy.get('input[type=text]').eq(1).type('test integer!');
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Integer').click();
+ });
+ cy.getStyledComponent('ErrorText').
+ contains('A metric with the same name already exists. Please add a unique name for each metric.');
+
+ // # Fill in title
+ cy.get('input[type=text]').eq(1).clear().type('test currency!');
+
+ // * A Currency target cannot be text
+ cy.get('input[type=text]').eq(2).clear().type('z');
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Integer').click();
+ });
+ cy.getStyledComponent('ErrorText').contains('Please enter a number, or leave the target blank.');
+
+ // * A Currency target /can/ be blank, so can the description, and Add next Integer metric
+ cy.get('input[type=text]').eq(2).clear();
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Integer').click();
+ });
+ cy.getStyledComponent('EditContainer').should('be.visible');
+
+ // * Verify metric was added without target or description.
+ verifyViewMetric(1, 'test currency!', '', '');
+
+ // * Verify we have two valid metrics and are editing next one.
+ verifyViewsAndEdits(2, 1);
+
+ // # Now test: verifies when clicking edit button, for Currency type, and switches to next edit
+ // # (using the previous state)
+
+ // # Don't fill in the metric's details
+ cy.get('input[type=text]').eq(1).clear();
+
+ // * Metrics need a title
+ cy.get('input[type=text]').eq(1).clear();
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.getStyledComponent('ErrorText').contains('Please add a title for your metric.');
+
+ // * Metrics need a unique title
+ cy.get('input[type=text]').eq(1).type('test currency!');
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.getStyledComponent('ErrorText').
+ contains('A metric with the same name already exists. Please add a unique name for each metric.');
+
+ // # Fill in title
+ cy.get('input[type=text]').eq(1).clear().type('test integer #2!!');
+
+ // * An Integer target cannot be text
+ cy.get('input[type=text]').eq(2).clear().type('arsoton');
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.getStyledComponent('ErrorText').contains('Please enter a number, or leave the target blank.');
+
+ // * An Integer target /can/ be blank, so can the description, and edit first metric
+ cy.get('input[type=text]').eq(2).clear();
+ cy.findAllByTestId('edit-metric').eq(0).click();
+
+ // * Verify we're editing the first metric, and only this metric
+ cy.getStyledComponent('EditContainer').should('have.length', 1).within(() => {
+ cy.get('input[type=text]').eq(0).should('have.value', 'test integer!');
+ });
+ cy.getStyledComponent('ViewContainer').should('have.length', 2);
+
+ // # Stop editing
+ saveMetric();
+
+ // * Verify metric was added without target or description.
+ verifyViewMetric(2, 'test integer #2!!', '', '');
+
+ // * Verify we have three valid metrics and are editing none.
+ verifyViewsAndEdits(3, 0);
+ });
+ });
+
+ describe('delete metric', () => {
+ it('verifies when clicking delete button; saved metrics have different confirmation text; deleted metrics are deleted', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}`);
+
+ // # Switch to Outline tab and focus retro section
+ cy.findByText('Outline').click();
+ cy.get('#retrospective').scrollIntoView();
+
+ // # Add and verify 1st metric
+ addMetric('Integer', 'test integer!', '12314123', 'test description');
+ verifyViewMetric(0, 'test integer!', '12314123 per run', 'test description');
+
+ // # Add metric
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Cost').click();
+ });
+
+ // # Don't fill in the metric's details
+ cy.get('input[type=text]').eq(1).clear();
+
+ // * Metrics need a title
+ cy.get('input[type=text]').eq(1).clear();
+ cy.findAllByTestId('delete-metric').eq(0).click();
+ cy.getStyledComponent('ErrorText').contains('Please add a title for your metric.');
+
+ // * Metrics need a unique title
+ cy.get('input[type=text]').eq(1).type('test integer!');
+ cy.findAllByTestId('delete-metric').eq(0).click();
+ cy.getStyledComponent('ErrorText').
+ contains('A metric with the same name already exists. Please add a unique name for each metric.');
+
+ // # Fill in title
+ cy.get('input[type=text]').eq(1).clear().type('test currency!');
+
+ // * A Currency target cannot be text
+ cy.get('input[type=text]').eq(2).clear().type('z');
+ cy.findAllByTestId('delete-metric').eq(0).click();
+ cy.getStyledComponent('ErrorText').contains('Please enter a number, or leave the target blank.');
+
+ // # Remove error text and type another invalid entry
+ cy.get('input[type=text]').eq(2).clear().type('invalid');
+
+ // * Verify that we're allowed to delete a metric we are currently editing (even if it's invalid)
+ cy.findAllByTestId('delete-metric').eq(1).click();
+ cy.get('#confirm-modal-light').should('be.visible').contains('Are you sure you want to delete?');
+
+ // # Should see the confirmation /without/ extra text because we haven't saved this metric yet
+ cy.get('#confirm-modal-light').
+ should('not.contain.text', 'You will still be able to access historical data for this metric.');
+
+ // # Dismiss
+ cy.findByRole('button', {name: 'Cancel'}).click();
+
+ // * A Currency target /can/ be blank, so can the description, try to delete first metric
+ cy.get('input[type=text]').eq(2).clear();
+ cy.findAllByTestId('delete-metric').eq(0).click();
+
+ cy.get('#confirm-modal-light').
+ should('contain.text', 'If you delete this metric, the values for it will not be collected for any future runs.');
+
+ // # Delete first metric
+ cy.findByRole('button', {name: 'Delete metric'}).click();
+
+ // * Verify metric
+ verifyViewsAndEdits(1, 0);
+ verifyViewMetric(0, 'test currency!', '', '');
+
+ // # Make sure we can still edit and add a metric after deleting one (testing that the metrics
+ // component's state isn't broken)
+ addMetric('Integer', 'test integer 2!', '123', 'test description');
+ verifyViewMetric(1, 'test integer 2!', '123 per run', 'test description');
+ cy.findAllByTestId('delete-metric').eq(1).click();
+ cy.findByRole('button', {name: 'Delete metric'}).click();
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.get('input[type=text]').eq(1).clear().type('test currency 2!');
+ saveMetric();
+ verifyViewsAndEdits(1, 0);
+ verifyViewMetric(0, 'test currency 2!', '', '');
+
+ // # Make sure we can add a metric and then delete it, then can keep editing
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Cost').click();
+ });
+ cy.findAllByTestId('delete-metric').eq(1).click();
+ cy.findByRole('button', {name: 'Delete metric'}).click();
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.get('input[type=text]').eq(1).clear().type('test currency 3!');
+ saveMetric();
+ verifyViewsAndEdits(1, 0);
+ verifyViewMetric(0, 'test currency 3!', '', '');
+
+ // # Make sure we can add a metric and then delete it, then can keep adding
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Cost').click();
+ });
+ cy.findAllByTestId('delete-metric').eq(1).click();
+ cy.findByRole('button', {name: 'Delete metric'}).click();
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText('Cost').click();
+ });
+ cy.findAllByTestId('delete-metric').eq(1).click();
+ cy.findByRole('button', {name: 'Delete metric'}).click();
+ verifyViewsAndEdits(1, 0);
+ verifyViewMetric(0, 'test currency 3!', '', '');
+
+ // # Refresh and verify one is saved
+ cy.reload();
+ verifyViewsAndEdits(1, 0);
+ verifyViewMetric(0, 'test currency 3!', '', '');
+
+ // # Delete metric
+ cy.findAllByTestId('delete-metric').eq(0).click();
+
+ // # Should see the confirmation /with/ extra text
+ cy.get('#confirm-modal-light').
+ should('contain.text', 'If you delete this metric, the values for it will not be collected for any future runs. You will still be able to access historical data for this metric.');
+
+ // # Delete first metric
+ cy.findByRole('button', {name: 'Delete metric'}).click();
+
+ // * Verify
+ verifyViewsAndEdits(0, 0);
+
+ // # Refresh and verify deleted
+ cy.reload();
+ verifyViewsAndEdits(0, 0);
+ });
+ });
+
+ describe('nullable and 0-able targets', () => {
+ it('can add 0 targets and no (null) targets', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}`);
+
+ // # Switch to Outline tab and focus retro section
+ cy.findByText('Outline').click();
+ cy.get('#retrospective').scrollIntoView();
+
+ // # Add and verify duration
+ addMetric('Duration', 'test duration', '0:0:0', 'test description');
+ verifyViewMetric(0, 'test duration', '0 seconds per run', 'test description');
+
+ // # Verify it shows 0:0:0, then turn it into null.
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.get('input[type=text]').eq(2).should('have.value', '00:00:00').
+ clear();
+ saveMetric();
+
+ // # Verify that the 'Target' section is gone
+ cy.getStyledComponent('ViewContainer').
+ getStyledComponent('DetailDiv').
+ should('have.length', 1);
+
+ verifyViewMetric(0, 'test duration', '', 'test description');
+
+ // * Verify it has null value when editing again.
+ cy.findAllByTestId('edit-metric').eq(0).click();
+ cy.get('input[type=text]').eq(2).should('have.value', '');
+ saveMetric();
+
+ // # Add and verify currency
+ addMetric('Cost', 'test money', '0', 'test description 2');
+ cy.wait('@addMetric');
+ verifyViewMetric(1, 'test money', '0', 'test description 2');
+
+ // # Verify it shows 0, then turn it into null.
+ cy.findAllByTestId('edit-metric').eq(1).click();
+ cy.get('input[type=text]').eq(2).should('have.value', '0').
+ clear();
+ saveMetric();
+ cy.getStyledComponent('ViewContainer').should('have.length', 2).eq(1).within(() => {
+ // # Verify that the 'Target' section is gone
+ cy.getStyledComponent('DetailDiv').
+ should('have.length', 1);
+ });
+
+ verifyViewMetric(1, 'test money', '', 'test description 2');
+
+ // * Verify it has null value when editing again.
+ cy.findAllByTestId('edit-metric').eq(1).click();
+ cy.get('input[type=text]').eq(2).should('have.value', '');
+ saveMetric();
+
+ // # Add and verify Integer
+ addMetric('Integer', 'test number', '0', 'test description 3');
+ cy.wait('@addMetric');
+ verifyViewMetric(2, 'test number', '0', 'test description 3');
+
+ // # Verify it shows 0, then turn it into null.
+ cy.findAllByTestId('edit-metric').eq(2).click();
+ cy.get('input[type=text]').eq(2).should('have.value', '0').
+ clear();
+ saveMetric();
+ cy.getStyledComponent('ViewContainer').should('have.length', 3).eq(2).within(() => {
+ // # Verify that the 'Target' section is gone
+ cy.getStyledComponent('DetailDiv').
+ should('have.length', 1);
+ });
+
+ verifyViewMetric(2, 'test number', '', 'test description 3');
+
+ // * Verify it has null value when editing again.
+ cy.findAllByTestId('edit-metric').eq(2).click();
+ cy.get('input[type=text]').eq(2).should('have.value', '');
+ saveMetric();
+
+ // * Verify we have three valid metrics and are editing none.
+ verifyViewsAndEdits(3, 0);
+
+ // # Refresh
+ cy.reload();
+
+ // * Verify we saved the metrics
+ verifyViewMetric(0, 'test duration', '', 'test description');
+ verifyViewMetric(1, 'test money', '', 'test description 2');
+ verifyViewMetric(2, 'test number', '', 'test description 3');
+ });
+ });
+ });
+});
+
+const addMetric = (type, title, target, description) => {
+ const fullType = type === 'Duration' ? 'Duration (in dd:hh:mm)' : type;
+
+ // # Add the requested metric
+ cy.findByRole('button', {name: 'Add Metric'}).click();
+ cy.findByTestId('dropdownmenu').within(() => {
+ cy.findByText(fullType).click();
+ });
+
+ // # Fill in the metric's details
+ cy.get('input[type=text]').eq(1).type(title).
+ tab().type(target).
+ tab().type(description);
+
+ // # Add the metric
+ saveMetric();
+ cy.wait('@addMetric');
+};
+
+const verifyViewMetric = (index, title, target, description) => {
+ cy.getStyledComponent('ViewContainer').should('have.length.of.at.least', index + 1).eq(index).within(() => {
+ cy.getStyledComponent('Title').should('have.text', title);
+
+ if (target) {
+ cy.getStyledComponent('DetailDiv').eq(0).contains(target);
+ }
+
+ if (description) {
+ const idx = target ? 1 : 0;
+ cy.getStyledComponent('DetailDiv').eq(idx).contains(description);
+ }
+ });
+};
+
+const verifyViewsAndEdits = (numViews, numEdits) => {
+ if (numViews === 0) {
+ cy.getStyledComponent('ViewContainer').should('not.exist');
+ } else {
+ cy.getStyledComponent('ViewContainer').should('have.length', numViews);
+ }
+
+ if (numEdits === 0) {
+ cy.getStyledComponent('EditContainer').should('not.exist');
+ } else {
+ cy.getStyledComponent('EditContainer').should('have.length', numEdits);
+ }
+};
+
+function saveMetric() {
+ cy.get('#retrospective-metrics').within(() => {
+ cy.findByRole('button', {name: 'Save'}).click();
+ });
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/list_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/list_spec.js
new file mode 100644
index 00000000000..f488158945b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/list_spec.js
@@ -0,0 +1,216 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > list', {testIsolation: true}, () => {
+ const playbookTitle = 'The Playbook Name';
+ let testTeam;
+ let testUser;
+ let testUser2;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+ cy.apiAddUserToTeam(testTeam.id, testUser2.id);
+ });
+
+ // # Login as user-1
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: playbookTitle,
+ memberIDs: [],
+ });
+
+ // # Create an archived public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook archived',
+ memberIDs: [],
+ }).then(({id}) => cy.apiArchivePlaybook(id));
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ it('has "Playbooks" in heading', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // * Assert contents of heading.
+ cy.findByTestId('titlePlaybook').should('exist').contains('Playbooks');
+ });
+
+ it('join/leave playbook', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Click on the dot menu
+ cy.findByTestId('menuButtonActions').click();
+
+ // # Click on leave
+ cy.findByText('Leave').click();
+
+ // * Verify it has disappeared from the LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookTitle).should('not.exist');
+
+ // # Join a playbook
+ cy.findByTestId('join-playbook').click();
+
+ // * Verify it has appeared in LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookTitle).should('exist');
+ });
+
+ it('can duplicate playbook', () => {
+ // # Login as testUser2
+ cy.apiLogin(testUser2);
+
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Click on the dot menu
+ cy.findByTestId('menuButtonActions').click();
+
+ // # Click on duplicate
+ cy.findByText('Duplicate').click();
+
+ // * Verify that playbook got duplicated
+ cy.findByText('Copy of ' + playbookTitle).should('exist');
+
+ // * Verify that the current user is a member and can run the playbook.
+ cy.findByText('Copy of ' + playbookTitle).closest('[data-testid="playbook-item"]').within(() => {
+ cy.findByTestId('run-playbook').should('exist');
+ cy.findByTestId('join-playbook').should('not.exist');
+ });
+
+ // * Verify that the duplicated playbook is shown in the LHS
+ cy.findByTestId('Playbooks').within(() => {
+ cy.findByText('Copy of ' + playbookTitle).should('be.visible');
+ });
+ });
+
+ context('archived playbooks', () => {
+ it('does not show them by default', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // * Assert the archived playbook is not there.
+ cy.findAllByTestId('playbook-title').should((titles) => {
+ expect(titles).to.have.length(2);
+ });
+ });
+ it('shows them upon click on the filter', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Click the With Archived button
+ cy.findByTestId('with-archived').click();
+
+ // * Assert the archived playbook is there.
+ cy.findAllByTestId('playbook-title').should((titles) => {
+ expect(titles).to.have.length(3);
+ });
+ });
+ });
+
+ describe('can import playbook', () => {
+ let validPlaybookExport;
+ let invalidTypePlaybookExport;
+
+ const bufferToCypressFile = (fileName, fileData, fileType) => ({
+ fileName,
+ contents: fileData,
+ mimeType: fileType,
+ });
+
+ before(() => {
+ // # Load fixtures and convert to File
+ cy.fixture('playbook-export.json', null).then((buffer) => {
+ validPlaybookExport = bufferToCypressFile('export.json', buffer, 'application/json');
+ });
+ cy.fixture('mp3-audio-file.mp3', null).then((buffer) => {
+ invalidTypePlaybookExport = bufferToCypressFile('audio.mp3', buffer, 'audio/mpeg');
+ });
+ });
+
+ it('triggered by drag and drop', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Drop loaded fixture onto playbook list
+ cy.findByTestId('playbook-list-scroll-container').selectFile(validPlaybookExport, {
+ action: 'drag-drop',
+ });
+
+ // * Verify that a new playbook was created.
+ cy.findByTestId('playbook-editor-title').should('contain', 'Example Playbook');
+ });
+
+ it('triggered by using button/input', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ cy.findByTestId('titlePlaybook').within(() => {
+ // # Select loaded fixture for upload
+ cy.findByTestId('playbook-import-input').selectFile(validPlaybookExport, {force: true});
+ });
+
+ // * Verify that a new playbook was created.
+ cy.findByTestId('playbook-editor-title').should('contain', 'Example Playbook');
+ });
+
+ it('fails to import invalid file type', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ cy.findByTestId('titlePlaybook').within(() => {
+ // # Select loaded fixture for upload
+ cy.findByTestId('playbook-import-input').selectFile(invalidTypePlaybookExport, {force: true});
+ });
+
+ // * Verify that an error message is displayed.
+ cy.findByText('The file must be a valid JSON playbook template.').should('be.visible');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/overview_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/overview_spec.js
new file mode 100644
index 00000000000..f6944e21377
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/overview_spec.js
@@ -0,0 +1,476 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+import {stubClipboard} from '../../../utils';
+
+describe('playbooks > overview', {testIsolation: true}, () => {
+ let testTeam;
+ let testOtherTeam;
+ let testUser;
+ let testUser2;
+ let testPublicPlaybook;
+ let testPlaybookOnTeamForSwitching;
+ let testPlaybookOnOtherTeamForSwitching;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+ cy.apiAddUserToTeam(testTeam.id, testUser2.id);
+ });
+
+ // # Create another team
+ cy.apiCreateTeam('second-team', 'Second Team').then(({team: createdTeam}) => {
+ testOtherTeam = createdTeam;
+ cy.apiAddUserToTeam(testOtherTeam.id, testUser.id);
+
+ // # Create a dedicated run follower
+ cy.apiCreateUser().then(({user: createdUser}) => {
+ cy.apiAddUserToTeam(testTeam.id, createdUser.id);
+ cy.apiAddUserToTeam(testOtherTeam.id, createdUser.id);
+ });
+
+ // # Create another user
+ cy.apiCreateUser().then(({user: anotherUser}) => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ retrospectiveTemplate: 'Retro template text',
+ retrospectiveReminderIntervalSeconds: 60 * 60 * 24 * 7, // 7 days
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+
+ // # Create a private playbook with only the current user
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Private Only Mine Playbook',
+ memberIDs: [testUser.id],
+ });
+
+ // # Create a private playbook with multiple users
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Private Shared Playbook',
+ memberIDs: [testUser.id, anotherUser.id],
+ });
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Switch A',
+ memberIDs: [],
+ retrospectiveTemplate: 'Retro template text',
+ retrospectiveReminderIntervalSeconds: 60 * 60 * 24 * 7, // 7 days
+ }).then((playbook) => {
+ testPlaybookOnTeamForSwitching = playbook;
+ });
+
+ // # Create a public playbook on another team
+ cy.apiCreatePlaybook({
+ teamId: testOtherTeam.id,
+ title: 'Switch B',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPlaybookOnOtherTeamForSwitching = playbook;
+ });
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ it('redirects to not found error if the playbook is unknown', () => {
+ // # Visit the URL of a non-existing playbook
+ cy.visit('/playbooks/playbooks/abcdefghijklmnopqrstuvwxyz');
+
+ // * Verify that the user has been redirected to the playbooks not found error page
+ cy.url().should('include', '/playbooks/error?type=playbooks');
+ });
+
+ it('redirect to not found if the url is incorrect', () => {
+ // # visit the playbook url with an incorrect id
+ cy.visit('/playbooks/playbooks/..%252F..%252f..%252F..%252F..%252fapi%252Fv4%252Ffiles%252Fo47cow5h6fgjzp8abfqqxw5jwc');
+
+ // * Verify that the user has been redirected to the not found error page
+ cy.url().should('include', '/playbooks/error?type=default');
+ });
+
+ describe('should switch to channels and prompt to run when clicking run', () => {
+ const openAndRunPlaybook = (team) => {
+ // # Navigate directly to town square on the team
+ cy.visit(`${team.name}/channels/town-square`);
+
+ // # Open Playbooks
+ cy.get('[aria-label="Product switch menu"]').click({force: true});
+ cy.get('a[href="/playbooks"]').click({force: true});
+
+ // Click through to open the playbook
+ cy.findByTestId('playbooksLHSButton').click({force: true});
+ cy.get('[placeholder="Search for a playbook"]').type(testPlaybookOnTeamForSwitching.title);
+ cy.findByTestId('playbook-title').click({force: true});
+
+ // # Click Run Playbook
+ cy.findByTestId('run-playbook').click({force: true});
+
+ // * Verify the playbook run creation dialog has opened
+ cy.get('#playbooks_run_playbook_dialog').should('exist').within(() => {
+ cy.findByText('Create checklist').should('exist');
+ });
+ };
+
+ it('for testPlaybookOnTeamForSwitching from its own team', () => {
+ openAndRunPlaybook(testTeam, testPlaybookOnTeamForSwitching);
+ });
+
+ it('for testPlaybookOnTeamForSwitching from another team', () => {
+ openAndRunPlaybook(testOtherTeam, testPlaybookOnTeamForSwitching);
+ });
+
+ it('for testPlaybookOnOtherTeamForSwitching from its own team', () => {
+ openAndRunPlaybook(testTeam, testPlaybookOnOtherTeamForSwitching);
+ });
+
+ it('for testPlaybookOnOtherTeamForSwitchingOnOtherTeam from another team', () => {
+ openAndRunPlaybook(testOtherTeam, testPlaybookOnOtherTeamForSwitching);
+ });
+
+ it('on direct navigation to a playbook', () => {
+ // # Navigate directly to the playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybookOnTeamForSwitching.id}`);
+
+ // # Click Run Playbook
+ cy.findByTestId('run-playbook').click();
+
+ // * Verify the playbook run creation dialog has opened
+ cy.get('#playbooks_run_playbook_dialog').should('exist').within(() => {
+ cy.findByText('Create checklist').should('exist');
+ });
+ });
+ });
+
+ it('should copy playbook link', () => {
+ // # Navigate directly to the playbook
+ cy.visit(`/playbooks/playbooks/${testPublicPlaybook.id}`);
+
+ // # trigger the tooltip
+ cy.get('.icon-link-variant').trigger('mouseover', {force: true});
+
+ // * Verify tooltip text
+ cy.get('#copy-playbook-link-tooltip').should('contain', 'Copy link to');
+
+ stubClipboard().as('clipboard');
+
+ // # click on copy button
+ cy.get('.icon-link-variant').click({force: true}).then(() => {
+ // * Verify that tooltip text changed
+ cy.get('#copy-playbook-link-tooltip').should('contain', 'Copied!');
+
+ // * Verify clipboard content
+ cy.get('@clipboard').its('contents').should('contain', `/playbooks/playbooks/${testPublicPlaybook.id}`);
+ });
+ });
+
+ it('should duplicate playbook', () => {
+ // # Login as testUser2
+ cy.apiLogin(testUser2);
+
+ // # Navigate directly to the playbook
+ cy.visit(`/playbooks/playbooks/${testPublicPlaybook.id}`);
+
+ // # Click on playbook title
+ cy.findByTestId('playbook-editor-title').click();
+
+ // # Click on duplicate
+ cy.findByText('Duplicate').click();
+
+ // * Verify that playbook got duplicated
+ cy.findByTestId('playbook-editor-title').should('contain', `Copy of ${testPublicPlaybook.title}`);
+
+ // * Verify that the current user is a member and can run the playbook.
+ cy.findByTestId('run-playbook').should('exist');
+ cy.findByTestId('join-playbook').should('not.exist');
+
+ // * Verify that the current user is the only member.
+ cy.findByTestId('playbook-members').should('contain', '1');
+ });
+
+ describe('checklists', () => {
+ describe('header', () => {
+ beforeEach(() => {
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ description: 'Cypress Playbook',
+ memberIDs: [],
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ retrospectiveTemplate: 'Cypress test template',
+ }).then((playbook) => {
+ cy.visit(`/playbooks/playbooks/${playbook.id}/outline`);
+ });
+ });
+
+ it('has title', () => {
+ cy.get('#checklists').within(() => {
+ cy.findByText('Tasks').should('exist');
+ });
+ });
+ });
+
+ it('shows checklists', () => {
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ description: 'Cypress Playbook',
+ memberIDs: [],
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ retrospectiveTemplate: 'Cypress test template',
+ }).then((playbook) => {
+ cy.visit(`/playbooks/playbooks/${playbook.id}`);
+ });
+
+ // # Switch to Outline section
+ cy.findByText('Outline').click();
+
+ // * Verify checklist and associated steps
+ cy.get('#checklists').within(() => {
+ cy.findByText('Stage 1').should('exist');
+ cy.findByText('Step 1').should('exist');
+ cy.findByText('Step 2').should('exist');
+ });
+ });
+ });
+
+ it('shows correct retrospective timer and template text', () => {
+ cy.visit(`/playbooks/playbooks/${testPublicPlaybook.id}`);
+ cy.findByText('Outline').click();
+
+ cy.get('#retrospective').within(() => {
+ cy.findByText('7 days').should('exist');
+ cy.findByText('Retro template text').should('exist');
+ });
+ });
+
+ it('shows statistics in usage tab', () => {
+ // # Start playbook run.
+ const now = Date.now();
+ const playbookRunName = `Run (${now})`;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # Go to usage view
+ cy.visit(`/playbooks/playbooks/${testPublicPlaybook.id}`);
+
+ // * Verify basic information.
+ cy.findByText('Runs currently in progress').next().should('contain', '1');
+ cy.findByText('Participants currently active').next().should('contain', '1');
+ cy.findByText('Runs finished in the last 30 days').next().should('contain', '0');
+
+ // # End the run so those metrics change.
+ cy.apiFinishRun(playbookRun.id).then(() => {
+ cy.reload();
+
+ // * Verify changes.
+ cy.findByText('Runs currently in progress').next().should('contain', '0');
+ cy.findByText('Participants currently active').next().should('contain', '0');
+ cy.findByText('Runs finished in the last 30 days').next().should('contain', '1');
+ });
+ });
+ });
+
+ it('start a run', () => {
+ // # Visit playbook page
+ cy.visit(`/playbooks/playbooks/${testPublicPlaybook.id}`);
+
+ // # Click Run Playbook
+ cy.findByTestId('run-playbook').click();
+
+ // # Enter the run name
+ cy.findByTestId('run-name-input').clear().type('run1234567');
+
+ // # Click start run button
+ cy.get('button[data-testid=modal-confirm-button]').click();
+
+ // * Verify the run is added to lhs
+ cy.findByTestId('Runs').findByTestId('run1234567').should('exist');
+ });
+
+ describe('archiving', () => {
+ const playbookTitle = 'Playbook (' + Date.now() + ')';
+ let testPlaybook;
+
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: playbookTitle,
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ it('shows intended UI and disallows further updates', () => {
+ // # Programmatically archive it
+ cy.apiArchivePlaybook(testPlaybook.id);
+
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}`);
+
+ // * Verify we're on the right playbook
+ cy.get('[class^="Title-"]').contains(playbookTitle);
+
+ // * Verify we can see the archived badge
+ cy.get('.icon-archive-outline').should('be.visible');
+
+ // * Verify the run button is disabled
+ cy.findByTestId('run-playbook').should('be.disabled');
+
+ // # Attempt to edit the playbook
+ cy.apiGetPlaybook(testPlaybook.id).then((playbook) => {
+ // # New title
+ playbook.title = 'new Title!!!';
+
+ // * Verify update fails
+ cy.apiUpdatePlaybook(playbook, 400);
+ });
+ });
+ });
+
+ describe('start a run', () => {
+ let testPlaybook;
+
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ after(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ beforeEach(() => {
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ it('start a run, create a new channel', () => {
+ // # Visit playbook page
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}`);
+
+ // # Click Run Playbook
+ cy.findByTestId('run-playbook').click();
+
+ // * Verify that channel configuration matches playbook config
+ cy.findByTestId('link-existing-channel-radio').should('not.be.checked');
+ cy.get('#link-existing-channel-selector').should('not.exist');
+ cy.findByTestId('create-channel-radio').should('be.checked');
+ cy.findByTestId('create-private-channel-radio').should('be.checked');
+
+ // # Enter the run name
+ const runName = 'run' + Date.now();
+ cy.findByTestId('run-name-input').clear().type(runName);
+
+ // # Click start run button
+ cy.get('button[data-testid=modal-confirm-button]').click();
+
+ // * Verify the run is added to lhs
+ cy.findByTestId('Runs').findByTestId(runName).should('exist');
+
+ // * Verify the channel is created
+ cy.findByTestId('runinfo-channel-link').contains(runName);
+ });
+
+ it('start a run in existing channel', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Select the action section.
+ cy.get('#actions #link-existing-channel').within(() => {
+ // # Enable link to existing channel
+ cy.get('input[type=radio]').click();
+
+ // * Verify that the toggle is checked and input is enabled
+ cy.get('input[type=radio]').should('be.checked');
+ cy.get('input[type=text]').should('not.be.disabled');
+
+ // # Select channel
+ cy.findByText('Select a channel').click().type('Town{enter}');
+ });
+
+ // # Click Run Playbook
+ cy.findByTestId('run-playbook').click({force: true});
+
+ // # Enter the run name
+ const runName = 'run' + Date.now();
+ cy.findByTestId('run-name-input').clear().type(runName);
+
+ // * Verify that channel configuration matches playbook config
+ cy.findByTestId('link-existing-channel-radio').should('be.checked');
+ cy.get('#link-existing-channel-selector').get('input[type=text]').should('be.enabled');
+ cy.findByTestId('create-channel-radio').should('not.be.checked');
+ cy.findByTestId('create-private-channel-radio').should('not.exist');
+
+ // # Click start run button
+ cy.get('button[data-testid=modal-confirm-button]').click();
+
+ // * Verify the run is added to lhs
+ cy.findByTestId('Runs').findByTestId(runName).should('exist');
+
+ // * Verify the channel is created
+ cy.findByTestId('runinfo-channel-link').contains('Town');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/pagination_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/pagination_spec.js
new file mode 100644
index 00000000000..4a2d06232ea
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/pagination_spec.js
@@ -0,0 +1,68 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > list pagination', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ const ExtraPlaybooks = 20;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as user-1
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ });
+
+ // # Populate the DB with more elements to force the pagination
+ for (let i = 0; i < ExtraPlaybooks; i++) {
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Elements before',
+ memberIDs: [],
+ });
+ }
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ it('reset page to 0 after search for an name with one value', () => {
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to Playbooks
+ cy.findByTestId('playbooksLHSButton').click();
+
+ // # Click on next page
+ cy.findByText('Next').click();
+
+ // # Click on Search input
+ cy.get('input[placeholder="Search for a playbook"]').type('Playbook');
+
+ // * Verify the page display the first page
+ cy.findByText('1–1 of 1 total');
+
+ // * Verify that previous isn't exist
+ cy.findByText('Previous').should('not.exist');
+ });
+});
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/playbook_attributes_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/playbook_attributes_spec.js
new file mode 100644
index 00000000000..8e42ce53f45
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/playbook_attributes_spec.js
@@ -0,0 +1,649 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > playbook_attributes', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a fresh playbook for each test
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Attributes Test Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+
+ // # Set viewport for consistent testing
+ cy.viewport('macbook-16');
+ });
+
+ describe('empty state', () => {
+ it('shows empty state when no attributes exist', () => {
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // * Verify empty state is displayed
+ cy.findByText(/no attributes yet/i).should('be.visible');
+ cy.findByText(/add custom attributes/i).should('be.visible');
+ cy.findByRole('button', {name: /add.*first attribute/i}).should('be.visible');
+ });
+
+ it('can add first attribute from empty state', () => {
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // # Click add button in empty state
+ cy.findByRole('button', {name: /add.*first attribute/i}).click();
+
+ // # Wait for attribute to be created
+ cy.wait(500);
+
+ // * Verify empty state is gone
+ cy.findByText(/no attributes yet/i).should('not.exist');
+
+ // * Verify attribute row appears in table
+ cy.findAllByTestId('property-field-row').should('have.length', 1);
+
+ // # Edit the default name
+ cy.findAllByTestId('property-field-row').first().within(() => {
+ cy.findByLabelText('Attribute name').clear().type('Priority');
+ });
+
+ // # Save by clicking outside
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify attribute is displayed with correct name
+ verifyAttribute(0, 'Priority');
+ });
+ });
+
+ describe('create attribute', () => {
+ it('can create a text attribute', () => {
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // # Add a text attribute
+ addAttribute('Customer Name', 'text');
+
+ // * Verify attribute was created
+ verifyAttribute(0, 'Customer Name');
+
+ // # Reload page
+ cy.reload();
+
+ // * Verify attribute persists
+ verifyAttribute(0, 'Customer Name');
+ });
+
+ it('can create a select attribute with options', () => {
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // # Add a select attribute with options
+ addAttribute('Severity', 'select', ['Critical', 'High', 'Medium', 'Low']);
+
+ // * Verify attribute was created
+ verifyAttribute(0, 'Severity');
+
+ // * Verify options are present
+ cy.get('table tbody tr').eq(0).within(() => {
+ cy.findByText('Critical').should('exist');
+ cy.findByText('High').should('exist');
+ cy.findByText('Medium').should('exist');
+ cy.findByText('Low').should('exist');
+ });
+ });
+
+ it('can create a multi-select attribute', () => {
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // # Add a multiselect attribute
+ addAttribute('Tags', 'multi-select', ['Security', 'Performance', 'Bug']);
+
+ // * Verify attribute was created
+ verifyAttribute(0, 'Tags');
+
+ // * Verify options are present
+ cy.get('table tbody tr').eq(0).within(() => {
+ cy.findByText('Security').should('exist');
+ cy.findByText('Performance').should('exist');
+ cy.findByText('Bug').should('exist');
+ });
+ });
+
+ it('can create a URL attribute', () => {
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // # Add a URL attribute
+ addAttribute('Documentation Link', 'url');
+
+ // * Verify attribute was created
+ verifyAttribute(0, 'Documentation Link');
+
+ // # Reload page
+ cy.reload();
+
+ // * Verify attribute persists
+ verifyAttribute(0, 'Documentation Link');
+ });
+ });
+
+ describe('update attribute', () => {
+ it('can rename an attribute', () => {
+ // # Navigate and create an attribute
+ navigateToAttributes();
+ addAttribute('Old Name', 'text');
+
+ // # Edit the attribute name
+ editAttributeName(0, 'New Name');
+
+ // * Verify name was updated
+ verifyAttribute(0, 'New Name');
+
+ // # Reload page
+ cy.reload();
+
+ // * Verify change persists
+ verifyAttribute(0, 'New Name');
+ });
+
+ it('can change attribute type', () => {
+ // # Navigate and create a text attribute
+ navigateToAttributes();
+ addAttribute('Flexible Field', 'text');
+
+ // # Click on type button to change type
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByRole('button', {name: 'Change attribute type'}).click();
+ });
+
+ // # Select new type
+ cy.findByText(/^select$/i).click();
+
+ // # Wait for GraphQL mutation
+ cy.wait(500);
+
+ // * Verify type changed (should now have property values input)
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByTestId('property-values-input').should('exist');
+ });
+ });
+
+ it('can add options to existing select attribute', () => {
+ // # Navigate and create a select attribute with initial options
+ navigateToAttributes();
+ addAttribute('Status', 'select', ['Open']);
+
+ // # Add another option
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ addNewOption('Closed');
+ });
+
+ // # Click outside to save
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify both options exist
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByText('Open').should('exist');
+ cy.findByText('Closed').should('exist');
+ });
+ });
+
+ it('can update an existing option value', () => {
+ // # Navigate and create a select attribute with options
+ navigateToAttributes();
+ addAttribute('Priority', 'select', ['Low', 'High']);
+
+ // # Click on an existing option to edit it
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ getOptionEditor('Low').within(() => {
+ cy.findByPlaceholderText('Enter value name').clear().type('Medium{enter}');
+ });
+ });
+
+ cy.waitForGraphQLQueries();
+
+ // # Click outside to save
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify the option was updated
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByText('Medium').should('exist');
+ cy.findByText('Low').should('not.exist');
+ cy.findByText('High').should('exist');
+ });
+ });
+
+ it('can delete an option value', () => {
+ // # Navigate and create a select attribute with multiple options
+ navigateToAttributes();
+ addAttribute('Status', 'select', ['Open', 'In Progress', 'Closed']);
+
+ // # Click on an option to open the dropdown and delete it
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ getOptionEditor('In Progress').within(() => {
+ cy.findByText('Delete').click();
+ });
+ });
+
+ cy.waitForGraphQLQueries();
+
+ // # Click outside to save
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify the option was deleted
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByText('Open').should('exist');
+ cy.findByText('In Progress').should('not.exist');
+ cy.findByText('Closed').should('exist');
+ });
+ });
+
+ it('cannot delete the last option', () => {
+ // # Navigate and create a select attribute with one option
+ navigateToAttributes();
+ addAttribute('Category', 'select', ['Single']);
+
+ // # Click on the only option to open the dropdown
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ // * Verify the Delete option is not available
+ getOptionEditor('Single').within(() => {
+ cy.findByText('Delete').should('not.exist');
+ });
+ });
+ });
+ });
+
+ describe('delete attribute', () => {
+ it('can delete an attribute', () => {
+ // # Navigate and create two attributes
+ navigateToAttributes();
+ addAttribute('Attribute 1', 'text');
+ addAttribute('Attribute 2', 'text');
+
+ // * Verify both exist
+ cy.findAllByTestId('property-field-row').should('have.length', 2);
+
+ // # Delete the first attribute
+ deleteAttribute(0);
+
+ // * Verify only one attribute remains
+ cy.findAllByTestId('property-field-row').should('have.length', 1);
+ verifyAttribute(0, 'Attribute 2');
+ });
+
+ it('shows confirmation modal when deleting', () => {
+ // # Navigate and create an attribute
+ navigateToAttributes();
+ addAttribute('Important Field', 'text');
+
+ // # Click the dot menu for the attribute
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByTestId('menuButton').click();
+ });
+
+ // # Click delete
+ cy.findByText(/delete/i).click();
+
+ // * Verify confirmation modal appears
+ cy.get('#confirm-property-delete-modal').should('be.visible');
+ cy.findByText(/are you sure/i).should('be.visible');
+
+ // # Cancel the deletion
+ cy.findByRole('button', {name: /cancel/i}).click();
+
+ // * Verify attribute still exists
+ cy.findAllByTestId('property-field-row').should('have.length', 1);
+ });
+
+ it('returns to empty state after deleting last attribute', () => {
+ // # Navigate and create one attribute
+ navigateToAttributes();
+ addAttribute('Last Attribute', 'text');
+
+ // # Delete the attribute
+ deleteAttribute(0);
+
+ // * Verify empty state is displayed
+ cy.findByText(/no attributes yet/i).should('be.visible');
+ cy.findByRole('button', {name: /add.*first attribute/i}).should('be.visible');
+ });
+ });
+
+ describe('attribute limits', () => {
+ it('can add attributes up to MAX_PROPERTIES_LIMIT', () => {
+ // # Get the max limit
+ const maxLimit = 20;
+
+ // # Add 19 attributes via API for speed
+ for (let i = 0; i < maxLimit - 1; i++) {
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: `Attribute ${i + 1}`,
+ type: 'text',
+ attrs: {
+ visibility: 'when_set',
+ sortOrder: i,
+ },
+ });
+ }
+
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // * Verify 19 attributes exist
+ cy.findAllByTestId('property-field-row').should('have.length', maxLimit - 1);
+
+ // # Add the last attribute via UI to test button state change
+ addAttribute();
+
+ // * Verify all attributes were created
+ cy.findAllByTestId('property-field-row').should('have.length', maxLimit);
+
+ // * Verify add button is disabled with appropriate message
+ cy.findByRole('button', {name: /maximum attributes reached/i}).
+ should('be.disabled');
+ });
+
+ it('can add new attribute after deleting when at limit', () => {
+ const maxLimit = 20;
+
+ // # Add 19 attributes via API for speed
+ for (let i = 0; i < maxLimit - 1; i++) {
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: `Attribute ${i + 1}`,
+ type: 'text',
+ attrs: {
+ visibility: 'when_set',
+ sortOrder: i,
+ },
+ });
+ }
+
+ // # Navigate to attributes section
+ navigateToAttributes();
+
+ // * Verify 19 attributes exist
+ cy.findAllByTestId('property-field-row').should('have.length', maxLimit - 1);
+
+ // # Add the last attribute via UI to reach the limit
+ addAttribute();
+
+ // * Verify add button is disabled with appropriate message
+ cy.findByRole('button', {name: /maximum attributes reached/i}).
+ should('be.disabled');
+
+ // # Delete one attribute
+ deleteAttribute(0);
+
+ // * Verify add button is now enabled
+ cy.findByRole('button', {name: /add.*attribute/i}).
+ should('not.be.disabled');
+
+ // # Add a new attribute
+ addAttribute();
+
+ // * Verify we're back at the limit
+ cy.findAllByTestId('property-field-row').should('have.length', maxLimit);
+ });
+ });
+
+ describe('duplicate attribute', () => {
+ it('can duplicate a text attribute', () => {
+ // # Navigate and create an attribute
+ navigateToAttributes();
+ addAttribute('Original Field', 'text');
+
+ // # Duplicate the attribute
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByTestId('menuButton').click();
+ });
+ cy.findByText(/duplicate/i).click();
+
+ // # Wait for duplication
+ cy.wait(500);
+
+ // * Verify duplicate was created with "Copy" suffix
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByLabelText('Attribute name').should('have.value', 'Original Field');
+ });
+ cy.findAllByTestId('property-field-row').eq(1).within(() => {
+ cy.findByLabelText('Attribute name').should('have.value', 'Original Field Copy');
+ });
+
+ // * Verify we now have 2 attributes
+ cy.findAllByTestId('property-field-row').should('have.length', 2);
+ });
+
+ it('can duplicate a select attribute with all its options', () => {
+ // # Navigate and create a select attribute
+ navigateToAttributes();
+ addAttribute('Priority', 'select', ['High', 'Medium', 'Low']);
+
+ // # Duplicate the attribute
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByTestId('menuButton').click();
+ });
+ cy.findByText(/duplicate/i).click();
+
+ // # Wait for duplication
+ cy.wait(500);
+
+ // * Verify duplicate has the same options
+ cy.findAllByTestId('property-field-row').eq(1).within(() => {
+ cy.findByLabelText('Attribute name').should('have.value', 'Priority Copy');
+ cy.findByText('High').should('exist');
+ cy.findByText('Medium').should('exist');
+ cy.findByText('Low').should('exist');
+ });
+ });
+
+ it('duplicated attribute can be edited independently', () => {
+ // # Navigate and create an attribute
+ navigateToAttributes();
+ addAttribute('Original', 'text');
+
+ // # Duplicate it
+ cy.findAllByTestId('property-field-row').eq(0).within(() => {
+ cy.findByTestId('menuButton').click();
+ });
+ cy.findByText(/duplicate/i).click();
+
+ // # Wait for duplication
+ cy.wait(500);
+
+ // # Edit the duplicate's name
+ editAttributeName(1, 'Modified Copy');
+
+ // * Verify original is unchanged
+ verifyAttribute(0, 'Original');
+ verifyAttribute(1, 'Modified Copy');
+ });
+ });
+
+ // Helper Functions
+
+ /**
+ * Navigate to the playbook attributes section
+ */
+ function navigateToAttributes() {
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/attributes`);
+ }
+
+ /**
+ * Open the option editor for a specific option and return the floating UI element
+ * @param {string} optionText - The text of the option to edit
+ * @returns {Cypress.Chainable} The floating UI element for chaining
+ */
+ function getOptionEditor(optionText) {
+ cy.findByText(optionText).parent().as('targetOption');
+ cy.get('@targetOption').click();
+
+ cy.waitUntil(() =>
+ cy.get('@targetOption').then(($el) => $el.attr('aria-controls') !== undefined)
+ , {
+ errorMsg: 'aria-controls attribute not found on option element',
+ timeout: 2000,
+ interval: 100,
+ });
+
+ return cy.get('@targetOption').invoke('attr', 'aria-controls').then((ariaControls) => {
+ const escapedId = ariaControls.replace(/:/g, '\\:');
+ return cy.document().its('body').find(`#${escapedId}`);
+ });
+ }
+
+ /**
+ * Add a new option to a select/multi-select attribute
+ * @param {string} optionText - The text of the option to add
+ */
+ function addNewOption(optionText, isFirstOption = false) {
+ if (!isFirstOption) {
+ cy.findByRole('button', {name: 'Add value'}).click();
+ cy.waitForGraphQLQueries();
+ }
+
+ cy.findAllByText(/^Option \d+$/).last().parent().as('optionElement');
+ cy.get('@optionElement').click();
+
+ cy.waitUntil(() =>
+ cy.get('@optionElement').then(($el) => $el.attr('aria-controls') !== undefined)
+ , {
+ errorMsg: 'aria-controls attribute not found on option element',
+ timeout: 2000,
+ interval: 100,
+ });
+
+ cy.get('@optionElement').invoke('attr', 'aria-controls').then((ariaControls) => {
+ const escapedId = ariaControls.replace(/:/g, '\\:');
+ cy.document().its('body').find(`#${escapedId}`).within(() => {
+ cy.findByPlaceholderText('Enter value name').clear().type(`${optionText}{enter}`);
+ });
+ });
+ cy.waitForGraphQLQueries();
+ }
+
+ /**
+ * Add a new attribute with specified parameters
+ * @param {string} name - The attribute name (optional, uses default "Attribute X" if not provided)
+ * @param {string} type - The attribute type (text, select, multiselect, etc.)
+ * @param {Array} options - Array of option strings for select types
+ */
+ function addAttribute(name = null, type = 'text', options = []) {
+ // # Click add attribute button
+ cy.findByRole('button', {name: /add.*attribute/i}).click();
+
+ // # Wait for GraphQL mutation
+ cy.wait(500);
+
+ // # Fill in the name only if provided
+ if (name) {
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ cy.findByLabelText('Attribute name').clear().type(name);
+ });
+ cy.get('body').click(0, 0);
+
+ // # Wait for GraphQL mutation
+ cy.wait(500);
+ }
+
+ // # Change type if not text
+ if (type !== 'text') {
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ cy.findByRole('button', {name: 'Change attribute type'}).trigger('click');
+ });
+
+ // # Select the type from dropdown
+ cy.findByText(new RegExp(`^${type}$`, 'i')).click();
+ cy.wait(500);
+ }
+
+ // # Add options for select types
+ if (options.length > 0 && (type === 'select' || type === 'multi-select')) {
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ // # Rename the first option (Option 1)
+ addNewOption(options[0], true);
+
+ // # Add remaining options
+ for (let i = 1; i < options.length; i++) {
+ addNewOption(options[i]);
+ }
+ });
+ }
+
+ // # Click outside to save (trigger blur)
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+ }
+
+ /**
+ * Verify an attribute exists with specific properties
+ * @param {number} index - The index of the attribute in the list
+ * @param {string} name - Expected attribute name
+ */
+ function verifyAttribute(index, name) {
+ cy.findAllByTestId('property-field-row').eq(index).within(() => {
+ cy.findByLabelText('Attribute name').should('have.value', name);
+ });
+ }
+
+ /**
+ * Delete an attribute by index
+ * @param {number} index - The index of the attribute to delete
+ */
+ function deleteAttribute(index) {
+ // # Click the dot menu for the attribute
+ cy.findAllByTestId('property-field-row').eq(index).within(() => {
+ cy.findByTestId('menuButton').click();
+ });
+
+ // # Click delete
+ cy.findByText(/delete/i).click();
+
+ // # Confirm deletion in modal
+ cy.get('#confirm-property-delete-modal').should('be.visible');
+ cy.findByRole('button', {name: /delete/i}).click();
+ cy.wait(500);
+ }
+
+ /**
+ * Edit attribute name
+ * @param {number} index - The index of the attribute to edit
+ * @param {string} newName - The new name for the attribute
+ */
+ function editAttributeName(index, newName) {
+ cy.findAllByTestId('property-field-row').eq(index).within(() => {
+ cy.findByLabelText('Attribute name').clear().type(newName);
+ });
+
+ // # Click outside to trigger save
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+ }
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/start_run_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/start_run_spec.js
new file mode 100644
index 00000000000..3d4ea14c3f7
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/start_run_spec.js
@@ -0,0 +1,442 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+const RUN_NAME_MAX_LENGTH = 64;
+
+describe('playbooks > start a run', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ makePublic: true,
+ memberIDs: [testUser.id],
+ createPublicPlaybookRun: true,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+
+ // This data is intentionally changed here instead of via api
+ const fillPBE = ({name, summary, channelMode, channelNameToLink, defaultOwnerEnabled}) => {
+ // # fill channel name temaplte
+ if (name) {
+ cy.get('#create-new-channel input[type="text"]').clear().type('Channel template');
+ }
+
+ // # fill summary template
+ if (summary) {
+ cy.contains('run summary template').click();
+ cy.focused().type('run summary template');
+ cy.findByRole('button', {name: /save/i}).click();
+ }
+ if (channelMode === 'create_new_channel') {
+ cy.get('#create-new-channel input[type="radio"]').eq(0).click();
+ } else if (channelMode === 'link_to_existing_channel') {
+ cy.get('#link-existing-channel input[type="radio"]').click();
+ }
+
+ if (channelNameToLink) {
+ cy.get('#link-existing-channel').within(() => {
+ cy.findByText('Select a channel').click().type(`${channelNameToLink}{enter}`);
+ });
+ }
+
+ if (defaultOwnerEnabled) {
+ cy.get('#assign-owner').within(() => {
+ // * Verify that the toggle is unchecked
+ cy.get('label input').should('not.be.checked');
+
+ // # Click on the toggle to enable the setting
+ cy.get('label input').click({force: true});
+
+ // * Verify that the toggle is checked
+ cy.get('label input').should('be.checked');
+ });
+ }
+ };
+ describe('from playbook list', () => {
+ it('defaults', () => {
+ // # Visit playbook list
+ cy.visit('/playbooks/playbooks');
+
+ // # Click "Run" button on the first playbook
+ cy.findAllByTestId('playbook-item').first().within(() => {
+ cy.findByText('Run').click();
+ });
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template name is filled
+ cy.findByTestId('run-name-input').clear().type('Run name');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on RDP
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=run_modal');
+
+ // * Verify run name
+ cy.get('h1').contains('Run name');
+ });
+ });
+
+ describe('from playbook editor', () => {
+ describe('pbe configured as create new channel', () => {
+ it('defaults', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // Fill default values
+ fillPBE({name: 'Channel template', summary: 'run summary template', channelMode: 'create_new_channel', defaultOwnerEnabled: true});
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template name is filled
+ cy.findByTestId('run-name-input').should('have.value', 'Channel template');
+
+ // * Assert template summary is filled
+ cy.findByTestId('run-summary-input').should('have.value', 'run summary template');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on RDP
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=run_modal');
+
+ // * Verify run name
+ cy.get('h1').contains('Channel template');
+
+ // * Verify run summary
+ cy.findByTestId('run-summary-section').contains('run summary template');
+ });
+
+ it('change title/summary', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Fill default values
+ fillPBE({name: 'Channel template', summary: 'run summary template', channelMode: 'create_new_channel'});
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template are filled (and force wait to them)
+ cy.findByTestId('run-name-input').should('have.value', 'Channel template');
+ cy.findByTestId('run-summary-input').should('have.value', 'run summary template');
+
+ // # Fill run name
+ cy.findByTestId('run-name-input').clear().type('Test Run Name');
+
+ // # Fill run summary
+ cy.findByTestId('run-summary-input').clear().type('Test Run Summary');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on RDP
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=run_modal');
+
+ // * Verify run name
+ cy.get('h1').contains('Test Run Name');
+
+ // * Verify run summary
+ cy.findByTestId('run-summary-section').contains('Test Run Summary');
+ });
+
+ it('change to link to existing channel does not default to current channel', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Fill default values
+ fillPBE({name: 'Channel template', summary: 'run summary template', channelMode: 'create_new_channel', defaultOwnerEnabled: true});
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // # Change to link to existing channel
+ cy.findByTestId('link-existing-channel-radio').click();
+
+ // * Assert selected channel is unchanged
+ cy.findByText('Select a channel').should('be.visible');
+ });
+ });
+
+ it('change to link to existing channel', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Fill default values
+ fillPBE({name: 'Channel template', summary: 'run summary template', channelMode: 'create_new_channel', defaultOwnerEnabled: true});
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // # Change to link to existing channel
+ cy.findByTestId('link-existing-channel-radio').click();
+
+ // # Fill run name
+ cy.findByTestId('run-name-input').clear().type('Test Run Name');
+
+ // * Assert cta is disabled
+ cy.findByTestId('modal-confirm-button').should('be.disabled');
+
+ // # Fill Town square as the channel to be linked
+ cy.findByText('Select a channel').click().type('Town{enter}');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on RDP
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=run_modal');
+
+ // * Verify run name
+ cy.get('h1').contains('Test Run Name');
+
+ // # Click channel link
+ cy.findByTestId('runinfo-channel-link').click();
+
+ // * Verify we are on town square
+ cy.url().should('include', `/${testTeam.name}/channels/town-square`);
+ });
+ });
+
+ describe('pbe configured as linked to existing channel', () => {
+ it('defaults', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Fill default values
+ fillPBE({summary: 'run summary template', channelMode: 'link_to_existing_channel', channelNameToLink: 'Town'});
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template name is empty
+ cy.findByTestId('run-name-input').should('be.empty');
+
+ // * Assert template summary is filled
+ cy.findByTestId('run-summary-input').should('have.value', 'run summary template');
+
+ // * Assert button is still disabled
+ cy.findByTestId('modal-confirm-button').should('be.disabled');
+
+ // # Fill run name
+ cy.findByTestId('run-name-input').clear().type('Test Run Name');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on RDP
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=run_modal');
+
+ // * Verify run name
+ cy.get('h1').contains('Test Run Name');
+
+ // * Verify run summary
+ cy.findByTestId('run-summary-section').contains('run summary template');
+
+ // # Click channel link
+ cy.findByTestId('runinfo-channel-link').click();
+
+ // * Verify we are on town square
+ cy.url().should('include', `/${testTeam.name}/channels/town-square`);
+ });
+
+ it('fill initially empty channel', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Fill default values
+ fillPBE({summary: 'run summary template', channelMode: 'link_to_existing_channel'});
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template name is empty
+ cy.findByTestId('run-name-input').should('be.empty');
+
+ // * Assert template summary is filled
+ cy.findByTestId('run-summary-input').should('have.value', 'run summary template');
+
+ // # Fill run name
+ cy.findByTestId('run-name-input').clear().type('Test Run Name');
+
+ // * Assert button is still disabled
+ cy.findByTestId('modal-confirm-button').should('be.disabled');
+
+ // # Fill Town square as the channel to be linked
+ cy.findByText('Select a channel').click().type('Town{enter}');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on RDP
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=run_modal');
+
+ // * Verify run name
+ cy.get('h1').contains('Test Run Name');
+
+ // * Verify run summary
+ cy.findByTestId('run-summary-section').contains('run summary template');
+
+ // # Click channel link
+ cy.findByTestId('runinfo-channel-link').click();
+
+ // * Verify we are on town square
+ cy.url().should('include', `/${testTeam.name}/channels/town-square`);
+ });
+
+ it('change to create new channel', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // Fill default values
+ fillPBE({name: 'Channel template', summary: 'run summary template', channelMode: 'link_to_existing_channel', channelNameToLink: 'Town'});
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Change to create new channel
+ cy.findByTestId('create-channel-radio').click();
+
+ // # Fill run name
+ cy.findByTestId('run-name-input').clear().type('Test Run Name');
+
+ // # Click start button
+ cy.findByTestId('modal-confirm-button').click();
+ });
+
+ // * Verify we are on RDP
+ cy.url().should('include', '/playbooks/runs/');
+ cy.url().should('include', '?from=run_modal');
+
+ // * Verify run name
+ cy.get('h1').contains('Test Run Name');
+
+ // # Click channel link
+ cy.findByTestId('runinfo-channel-link').click();
+
+ // * Verify we are on channel Test Run Name
+ cy.url().should('include', `/${testTeam.name}/channels/test-run-name`);
+ });
+ });
+ });
+
+ describe('start run modal > invalid user input', () => {
+ it('submit button is disabled when run name is empty', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template name is empty
+ cy.findByTestId('run-name-input').should('have.value', '');
+
+ // * Assert start button is disabled
+ cy.findByTestId('modal-confirm-button').should('have.attr', 'disabled');
+ });
+ });
+
+ it('error is shown when maximum length of run name is exceeded', () => {
+ // # Visit the selected playbook
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Click start a run button
+ cy.findByTestId('run-playbook').click();
+
+ cy.get('#root-portal.modal-open').within(() => {
+ // # Wait the modal to render
+ cy.wait(500);
+
+ // * Assert template name is empty
+ cy.findByTestId('run-name-input').should('have.value', '');
+
+ // # Type run name that exceeds maximum length
+ cy.findByTestId('run-name-input').type('a'.repeat(RUN_NAME_MAX_LENGTH + 1));
+
+ // * Assert error shown and contains maximum length in message
+ cy.findByTestId('run-name-error').should('contain', RUN_NAME_MAX_LENGTH);
+
+ // * Assert start button is disabled
+ cy.findByTestId('modal-confirm-button').should('have.attr', 'disabled');
+
+ // # Delete last character via backspace
+ cy.findByTestId('run-name-input').type('{backspace}');
+
+ // * Assert that error is not shown anymore
+ cy.findByTestId('run-name-error').should('not.exist');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/status_update_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/status_update_spec.js
new file mode 100644
index 00000000000..a546eb5025a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/playbooks/status_update_spec.js
@@ -0,0 +1,214 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {FIVE_SEC, TWO_SEC, TEN_SEC, ONE_SEC} from '../../../../tests/fixtures/timeouts';
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbooks > edit status update', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let testChannel;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public channel
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'public-channel',
+ 'Public Channel',
+ 'O',
+ ).then(({channel}) => {
+ testChannel = channel;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a playbook
+ cy.apiCreateTestPlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ userId: testUser.id,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+
+ // # Set a bigger viewport so the action don't scroll out of view
+ cy.viewport('macbook-16');
+ });
+
+ describe('status update enable/disable', () => {
+ it('can enable/disable status update', () => {
+ // # Visit the selected playbook outline tab
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // * Verify status update message
+ cy.findAllByTestId('status-update-section').within(() => {
+ cy.contains('A status update is expected every');
+ cy.contains('1 day');
+ cy.contains('no channels');
+ cy.contains('no outgoing webhooks');
+ });
+
+ // # Disable status update
+ cy.findAllByTestId('status-update-toggle').eq(0).click();
+
+ // * Verify status update message
+ cy.get('#status-updates').within(() => {
+ cy.contains('Status updates are not expected.');
+ cy.contains('A status update is expected every').should('not.exist');
+ });
+ });
+ });
+
+ describe('edit channels and webhooks', () => {
+ it('can enable/disable status update', () => {
+ // # Visit the selected playbook outline tab
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Select a channel
+ cy.findAllByTestId('status-update-broadcast-channels').click();
+ cy.get('#playbook-automation-broadcast').contains('Town Square').click({force: true});
+ cy.findAllByTestId('status-update-broadcast-channels').click();
+
+ // # Refresh the page
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // # Add webhooks
+ cy.findAllByTestId('status-update-webhooks').click();
+ cy.findAllByTestId('webhooks-input').should('be.visible');
+ cy.findAllByTestId('webhooks-input').clear();
+ cy.findAllByTestId('webhooks-input').type('http://hook1.com');
+ cy.findAllByTestId('webhooks-input').should('have.value', 'http://hook1.com');
+ cy.findAllByTestId('checklist-item-save-button').should('be.visible');
+ cy.findAllByTestId('checklist-item-save-button').click();
+
+ cy.wait(TEN_SEC);
+
+ // # Refresh the page
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // * Verify status update message
+ cy.findAllByTestId('status-update-section').within(() => {
+ cy.contains('1 channel');
+ cy.contains('1 outgoing webhook');
+ });
+
+ // # Disable status update
+ cy.findAllByTestId('status-update-toggle').eq(0).click();
+
+ // * Verify status update message
+ cy.get('#status-updates').within(() => {
+ cy.findByText('Status updates are not expected.');
+ });
+
+ // # Re-enable status update
+ cy.findAllByTestId('status-update-toggle').eq(0).click();
+
+ cy.wait(TWO_SEC);
+
+ // # Refresh the page
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/outline`);
+
+ // * Verify that channels and webhooks persist
+ cy.get('#status-updates').within(() => {
+ cy.contains('1 channel');
+ cy.contains('1 outgoing webhook');
+ });
+ });
+ });
+
+ describe('status enabled, broadcasts disabled, but channels and webhooks specified', () => {
+ it('can enable/disable status update', () => {
+ const broadcastChannelIds = [testChannel.id];
+ const webhookOnStatusUpdateURLs = ['https://one.com', 'https://two.com'];
+
+ // # Create a playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook #### (' + Date.now() + ')',
+ userId: testUser.id,
+ broadcastChannelIds,
+ webhookOnStatusUpdateURLs,
+ }).then((playbook) => {
+ // # Visit the selected playbook outline tab
+ cy.visit(`/playbooks/playbooks/${playbook.id}/outline`);
+
+ // * Verify status update message. Status update should be enabled, but message should say `updates will be posted to no channels and no outgoing webhooks`
+ cy.findAllByTestId('status-update-section').within(() => {
+ cy.contains('A status update is expected every');
+ cy.contains('no channels');
+ cy.contains('no outgoing webhooks');
+ });
+
+ // * Verify selected channels style
+ cy.findAllByTestId('status-update-broadcast-channels').click();
+ cy.get('.playbook-react-select__option').contains('Public Channel').
+ invoke('css', 'text-decoration').
+ should('equal', 'line-through solid rgba(63, 67, 80, 0.48)');
+
+ // # Close select options
+ cy.findAllByTestId('status-update-broadcast-channels').click();
+
+ // # Open webhooks text area
+ cy.findAllByTestId('status-update-webhooks').click();
+
+ // * Verify webhooks text style
+ cy.findAllByTestId('webhooks-input').
+ invoke('css', 'text-decoration').
+ should('equal', 'line-through solid rgba(63, 67, 80, 0.48)');
+
+ cy.wait(ONE_SEC);
+
+ // # Edit webhooks
+ cy.findAllByTestId('webhooks-input').
+ should('be.visible').
+ type('http://hook1.com{enter}http://hook2.com{enter}http://hook3.com{enter}', {delay: 100});
+ cy.findAllByTestId('checklist-item-save-button').click();
+
+ cy.wait(FIVE_SEC);
+
+ // # Select a channel
+ cy.findAllByTestId('status-update-broadcast-channels').click();
+ cy.get('#playbook-automation-broadcast').contains('Town Square').click({force: true});
+ cy.findAllByTestId('status-update-broadcast-channels').click();
+
+ cy.wait(TWO_SEC);
+
+ // * Verify status update message.
+ cy.findAllByTestId('status-update-section').within(() => {
+ cy.contains('A status update is expected every');
+ cy.contains('2 channels');
+ cy.contains('4 outgoing webhooks');
+ });
+
+ // # Refresh the page
+ cy.visit(`/playbooks/playbooks/${playbook.id}/outline`);
+
+ // * Verify status update message.
+ cy.findAllByTestId('status-update-section').within(() => {
+ cy.contains('A status update is expected every');
+ cy.contains('2 channels');
+ cy.contains('4 outgoing webhooks');
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/list_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/list_spec.js
new file mode 100644
index 00000000000..29fb5b772c4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/list_spec.js
@@ -0,0 +1,262 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > list', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testAnotherUser;
+ let testPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ // # Create another user
+ cy.apiCreateUser().then(({user: anotherUser}) => {
+ testTeam = team;
+ testUser = user;
+ testAnotherUser = anotherUser;
+ cy.apiAddUserToTeam(testTeam.id, anotherUser.id);
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ makePublic: true,
+ memberIDs: [testUser.id, testAnotherUser.id],
+ createPublicPlaybookRun: true,
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show all
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ it('has "Runs" and team name in heading', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to playbook runs
+ cy.findByTestId('playbookRunsLHSButton').click();
+
+ // * Assert playbook runs page is shown (header was removed, check for run list)
+ cy.get('#playbookRunList').should('exist');
+ });
+
+ it('loads playbook run details page when clicking on a playbook run', () => {
+ // # Run the playbook
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ });
+
+ // # Open the product
+ cy.visit('/playbooks');
+
+ // # Switch to runs
+ cy.findByTestId('playbookRunsLHSButton').click();
+
+ // # Find the playbook run and click to open details view
+ cy.get('#playbookRunList').within(() => {
+ cy.findByText(playbookRunName).click();
+ });
+
+ // * Verify that the header contains the playbook run name
+ cy.findByTestId('run-header-section').get('h1').contains(playbookRunName);
+ });
+
+ describe('filters my runs only', () => {
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Run a playbook with testUser as a participant
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'testUsers Run',
+ ownerUserId: testUser.id,
+ });
+
+ // # Login as testAnotherUser
+ cy.apiLogin(testAnotherUser);
+
+ // # Run a playbook with testAnotherUser as a participant
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'testAnotherUsers Run',
+
+ // ownerUserId: testUser.id,
+ ownerUserId: testAnotherUser.id,
+ });
+ });
+
+ it('for testUser', () => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Open the product
+ cy.visit('/playbooks/runs');
+
+ cy.get('#playbookRunList').within(() => {
+ // # Make sure both runs are visible by default
+ cy.findByText('testUsers Run').should('be.visible');
+ cy.findByText('testAnotherUsers Run').should('be.visible');
+
+ // # Filter to only my runs
+ cy.findByTestId('my-runs-only').click();
+
+ // # Verify runs by testAnotherUser are not visible
+ cy.findByText('testAnotherUsers Run').should('not.exist');
+
+ // # Verify runs by testUser remain visible
+ cy.findByText('testUsers Run').should('be.visible');
+ });
+ });
+
+ it('for testAnotherUser', () => {
+ // # Login as testAnotherUser
+ cy.apiLogin(testAnotherUser);
+
+ // # Open the product
+ cy.visit('/playbooks');
+ cy.get('#playbookRunList').within(() => {
+ // Make sure both runs are visible by default
+ cy.findByText('testUsers Run').should('be.visible');
+ cy.findByText('testAnotherUsers Run').should('be.visible');
+
+ // # Filter to only my runs
+ cy.findByTestId('my-runs-only').click();
+
+ // # Verify runs by testUser are not visible
+ cy.findByText('testUsers Run').should('not.exist');
+
+ // # Verify runs by testAnotherUser remain visible
+ cy.findByText('testAnotherUsers Run').should('be.visible');
+ });
+ });
+ });
+
+ describe('filters Finished runs correctly', () => {
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Run a playbook with testUser as a participant
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'testUsers Run to be finished',
+ ownerUserId: testUser.id,
+ }).then((playbook) => {
+ cy.apiFinishRun(playbook.id);
+ });
+ });
+
+ it('shows finished runs', () => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Open the product
+ cy.visit('/playbooks');
+
+ cy.get('#playbookRunList').within(() => {
+ // # Make sure runs are visible by default, and finished is not
+ cy.findByText('testUsers Run').should('be.visible');
+ cy.findByText('testAnotherUsers Run').should('be.visible');
+ cy.findByText('testUsers Run to be finished').should('not.exist');
+
+ // # Filter to finished runs as well
+ cy.findByTestId('finished-runs').click();
+
+ // # Verify runs remain visible
+ cy.findByText('testUsers Run').should('be.visible');
+ cy.findByText('testAnotherUsers Run').should('be.visible');
+
+ // # Verify finished run is visible
+ cy.findByText('testUsers Run to be finished').should('be.visible');
+ });
+ });
+ });
+
+ describe('LHS run list', () => {
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ const runs = [
+ {
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'run-sort-check 0',
+ ownerUserId: testUser.id,
+ },
+ {
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'run-sort-check 1',
+ ownerUserId: testUser.id,
+ },
+ {
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'run-sort-check 2',
+ ownerUserId: testUser.id,
+ },
+ {
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'run-sort-check 3',
+ ownerUserId: testUser.id,
+ },
+ ];
+
+ Promise.all(runs.map((run) => {
+ return new Promise((resolve) => cy.apiRunPlaybook(run).then(resolve));
+ })).then(() => {
+ cy.visit('/playbooks');
+ });
+ });
+
+ it('lhs run list sorted by name', () => {
+ cy.findByTestId('lhs-navigation').within(() => {
+ cy.get('li:contains(run-sort-check)').each((item, index) => {
+ // * Verify run list order
+ cy.wrap(item).should('have.text', 'run-sort-check ' + index);
+ });
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/permissions_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/permissions_spec.js
new file mode 100644
index 00000000000..594487de284
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/permissions_spec.js
@@ -0,0 +1,500 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+import {getRandomId} from '../../../utils';
+
+describe('runs > permissions', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testOtherTeam;
+
+ let playbookMember;
+ let runParticipant;
+ let runFollower;
+ let teamMember;
+ let nonTeamMember;
+ let sysadminInTeam;
+ let sysadminNotInTeam;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Create a dedicated playbook member
+ cy.apiCreateUser().then(({user: createdUser}) => {
+ playbookMember = createdUser;
+
+ cy.apiAddUserToTeam(testTeam.id, createdUser.id);
+ });
+
+ // # Create a dedicated run participant
+ cy.apiCreateUser().then(({user: createdUser}) => {
+ runParticipant = createdUser;
+
+ cy.apiAddUserToTeam(testTeam.id, createdUser.id);
+ });
+
+ // # Create a dedicated run follower
+ cy.apiCreateUser().then(({user: createdUser}) => {
+ runFollower = createdUser;
+
+ cy.apiAddUserToTeam(testTeam.id, createdUser.id);
+ });
+
+ // # Create a dedicated member in team 1
+ cy.apiCreateUser().then(({user: createdUser}) => {
+ teamMember = createdUser;
+
+ cy.apiAddUserToTeam(testTeam.id, createdUser.id);
+ });
+
+ // # Create a dedicated sysadmin in team 1
+ cy.apiCreateCustomAdmin().then(({sysadmin: createdUser}) => {
+ sysadminInTeam = createdUser;
+
+ cy.apiAddUserToTeam(testTeam.id, createdUser.id);
+ });
+
+ // # Create a public playbook and corresponding run with a public channel in
+ // team 1. This is to ensure the list isn't empty for users who can't access the
+ // run under test.
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (Team 1)',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ }).then((createdPlaybook) => {
+ // Create a run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: createdPlaybook.id,
+ playbookRunName: getRandomId(),
+ ownerUserId: testUser.id,
+ });
+ });
+
+ // # Create another team
+ cy.apiCreateTeam('second-team', 'Second Team').then(({team: createdTeam}) => {
+ testOtherTeam = createdTeam;
+
+ // # Create a dedicated member not in team 1
+ cy.apiCreateUser().then(({user: createdUser}) => {
+ nonTeamMember = createdUser;
+
+ cy.apiAddUserToTeam(testOtherTeam.id, createdUser.id);
+ });
+
+ // # Create a dedicated sysadmin not in team 1
+ cy.apiCreateCustomAdmin().then(({sysadmin: createdUser}) => {
+ sysadminNotInTeam = createdUser;
+
+ cy.apiAddUserToTeam(testOtherTeam.id, createdUser.id);
+ });
+
+ // # Create a public playbook and corresponding run with a public channel in
+ // team 2. This is to ensure the list isn't empty for users who can't access the
+ // run under test.
+ cy.apiCreatePlaybook({
+ teamId: testOtherTeam.id,
+ title: 'Playbook (Team 2)',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ }).then((createdPlaybook) => {
+ // Create a run
+ cy.apiRunPlaybook({
+ teamId: testOtherTeam.id,
+ playbookId: createdPlaybook.id,
+ playbookRunName: getRandomId(),
+ ownerUserId: nonTeamMember.id,
+ });
+ });
+ });
+ });
+ });
+
+ describe('run with private channel from a public playbook', () => {
+ let playbook;
+ let run;
+
+ before(() => {
+ // # Login as the user setup during initialization.
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook, configured to create private channels for runs
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: false,
+ }).then((createdPlaybook) => {
+ playbook = createdPlaybook;
+
+ // Create a run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: getRandomId(),
+ ownerUserId: runParticipant.id,
+ }).then((createdRun) => {
+ run = createdRun;
+
+ // Have the dedicated participant join the run
+ cy.apiAddUsersToRun(run.id, [runParticipant.id]);
+
+ // # Have the dedicated follower follow this playbook run
+ cy.apiLogin(runFollower);
+ cy.apiFollowPlaybookRun(run.id);
+ });
+ });
+ });
+
+ describe('should be visible', () => {
+ it('to playbook members', () => {
+ assertRunIsVisible(run, playbookMember);
+ });
+
+ it('to run participants', () => {
+ assertRunIsVisible(run, runParticipant);
+ });
+
+ it('to run followers', () => {
+ assertRunIsVisible(run, runFollower);
+ });
+
+ it('to team members', () => {
+ assertRunIsVisible(run, teamMember);
+ });
+
+ it('to admins in the team', () => {
+ assertRunIsVisible(run, sysadminInTeam);
+ });
+
+ // XXX: The following asserts that while sysadmins don't see runs from other teams in
+ // the list, they still have access to view the overview directly. Once we support
+ // sudo-admins, we should change this behaviour to be consistent with normal users.
+ it('to admins not in the team (overview only)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunOverviewIsVisible(run);
+ });
+ });
+
+ describe('should not be visible', () => {
+ it('to non-team members', () => {
+ assertRunIsNotVisible(run, nonTeamMember);
+ });
+
+ it('to admins not in the team (list only)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunIsNotVisibleInList(run);
+ });
+ });
+ });
+
+ describe('run with public channel from a public playbook', () => {
+ let playbook;
+ let run;
+
+ before(() => {
+ // # Login as the user setup during initialization.
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook, configured to create public channels for runs
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ }).then((createdPlaybook) => {
+ playbook = createdPlaybook;
+
+ // Create a run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: getRandomId(),
+ ownerUserId: runParticipant.id,
+ }).then((createdRun) => {
+ run = createdRun;
+
+ // Have the dedicated participant join the run
+ cy.apiAddUsersToRun(run.id, [runParticipant.id]);
+
+ // # Have the dedicated follower follow this playbook run
+ cy.apiLogin(runFollower);
+ cy.apiFollowPlaybookRun(run.id);
+ });
+ });
+ });
+
+ describe('should be visible', () => {
+ it('to playbook members', () => {
+ assertRunIsVisible(run, playbookMember);
+ });
+
+ it('to run participants', () => {
+ assertRunIsVisible(run, runParticipant);
+ });
+
+ it('to run followers', () => {
+ assertRunIsVisible(run, runFollower);
+ });
+
+ it('to team members', () => {
+ assertRunIsVisible(run, teamMember);
+ });
+
+ it('to admins in the team', () => {
+ assertRunIsVisible(run, sysadminInTeam);
+ });
+
+ // XXX: The following asserts that while sysadmins don't see runs from other teams in
+ // the list, they still have access to view the overview directly. Once we support
+ // sudo-admins, we should change this behaviour to be consistent with normal users.
+ it('to admins not in the team (overview only)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunOverviewIsVisible(run);
+ });
+ });
+
+ describe('should not be visible', () => {
+ it('to non-team members', () => {
+ assertRunIsNotVisible(run, nonTeamMember);
+ });
+
+ it('to admins not in the team (list only)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunIsNotVisibleInList(run);
+ });
+ });
+ });
+
+ describe('run with private channel from a private playbook', () => {
+ let playbook;
+ let run;
+
+ before(() => {
+ // # Login as the user setup during initialization.
+ cy.apiLogin(testUser);
+
+ // # Create private playbook, configured to create private channels for runs
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ makePublic: false,
+ memberIDs: [testUser.id, playbookMember.id],
+ createPublicPlaybookRun: false,
+ }).then((createdPlaybook) => {
+ playbook = createdPlaybook;
+
+ // Login as the playbook member authorized to start a run
+ cy.apiLogin(playbookMember);
+
+ // Create a run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: getRandomId(),
+ ownerUserId: runParticipant.id,
+ }).then((createdRun) => {
+ run = createdRun;
+
+ // Have the dedicated participant join the run
+ cy.apiAddUsersToRun(run.id, [runParticipant.id]);
+ });
+ });
+ });
+
+ describe('should be visible', () => {
+ it('to playbook members', () => {
+ assertRunIsVisible(run, playbookMember);
+ });
+
+ it('to run participants', () => {
+ assertRunIsVisible(run, runParticipant);
+ });
+
+ // Followers cannot follow a run with a private channel from a private playbook
+ it('to run followers', () => {
+ assertRunIsNotVisible(run, runFollower);
+ });
+
+ it('to admins in the team', () => {
+ assertRunIsVisible(run, sysadminInTeam);
+ });
+
+ // XXX: The following asserts that while sysadmins don't see runs from other teams in
+ // the list, they still have access to view the run directly. Once we support
+ // sudo-admins, we should change this behaviour to be consistent with normal users.
+ it('to admins not in the team (run directly)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunOverviewIsVisible(run);
+ });
+ });
+
+ describe('should not be visible', () => {
+ it('to team members', () => {
+ assertRunIsNotVisible(run, teamMember);
+ });
+
+ it('to non-team members', () => {
+ assertRunIsNotVisible(run, nonTeamMember);
+ });
+
+ it('to admins not in the team (list only)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunIsNotVisibleInList(run);
+ });
+ });
+ });
+
+ describe('run with public channel from a private playbook', () => {
+ let playbook;
+ let run;
+
+ before(() => {
+ // # Login as the user setup during initialization.
+ cy.apiLogin(testUser);
+
+ // # Create private playbook, configured to create private channels for runs
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook',
+ memberIDs: [testUser.id, playbookMember.id],
+ makePublic: false,
+ createPublicPlaybookRun: true,
+ }).then((createdPlaybook) => {
+ playbook = createdPlaybook;
+
+ // Login as the playbook member authorized to start a run
+ cy.apiLogin(playbookMember);
+
+ // Create a run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: getRandomId(),
+ ownerUserId: runParticipant.id,
+ }).then((createdRun) => {
+ run = createdRun;
+
+ // Have the dedicated participant join the run
+ cy.apiAddUsersToRun(run.id, [runParticipant.id]);
+ });
+ });
+ });
+
+ describe('should be visible', () => {
+ it('to playbook members', () => {
+ assertRunIsVisible(run, playbookMember);
+ });
+
+ it('to run participants', () => {
+ assertRunIsVisible(run, runParticipant);
+ });
+
+ // Followers cannot follow a run with a private channel from a private playbook
+ it('to run followers', () => {
+ assertRunIsNotVisible(run, runFollower);
+ });
+
+ it('to admins in the team', () => {
+ assertRunIsVisible(run, sysadminInTeam);
+ });
+
+ // XXX: The following asserts that while sysadmins don't see runs from other teams in
+ // the list, they still have access to view the run directly. Once we support
+ // sudo-admins, we should change this behaviour to be consistent with normal users.
+ it('to admins not in the team (run directly)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunOverviewIsVisible(run);
+ });
+ });
+
+ describe('should not be visible', () => {
+ it('to team members', () => {
+ assertRunIsNotVisible(run, teamMember);
+ });
+
+ it('to non-team members', () => {
+ assertRunIsNotVisible(run, nonTeamMember);
+ });
+
+ it('to admins not in the team (list only)', () => {
+ cy.apiLogin(sysadminNotInTeam);
+
+ assertRunIsNotVisibleInList(run);
+ });
+ });
+ });
+});
+
+const assertRunIsVisible = (run, user) => {
+ // # Login as the user in question
+ cy.apiLogin(user);
+
+ // # Open Runs
+ cy.visit('/playbooks/runs');
+
+ // # Find the playbook run and click to open details view
+ cy.get('#playbookRunList').within(() => {
+ cy.findByText(run.name).click();
+ });
+
+ // * Verify that the details loaded
+ cy.findByTestId('run-header-section').get('h1').contains(run.name);
+};
+
+const assertRunOverviewIsVisible = (run) => {
+ // # Opening the playbook run directly
+ cy.visit(`/playbooks/runs/${run.id}`);
+
+ // * Verify that the details loaded
+ cy.findByTestId('run-header-section').get('h1').contains(run.name);
+};
+
+const assertRunIsNotVisible = (run, user) => {
+ // # Login as the user in question
+ cy.apiLogin(user);
+
+ assertRunIsNotVisibleInList(run, user);
+ assertRunOverviewIsNotVisible(run, user);
+};
+
+const assertRunIsNotVisibleInList = (run) => {
+ // # Open Runs
+ cy.visit('/playbooks/runs');
+
+ // * Verify the playbook run is not visible
+ cy.get('#playbookRunList').within(() => {
+ cy.findByText(run.name).should('not.exist');
+ });
+};
+
+const assertRunOverviewIsNotVisible = (run) => {
+ // # Opening the playbook run directly
+ cy.visit(`/playbooks/runs/${run.id}`);
+
+ // * Verify the not found error screen
+ cy.get('.error__container').within(() => {
+ cy.findByText('Run not found').should('be.visible');
+ cy.findByText('The run you\'re requesting is private or does not exist.').should('be.visible');
+ });
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_general_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_general_spec.js
new file mode 100644
index 00000000000..dfb38749688
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_general_spec.js
@@ -0,0 +1,66 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPublicPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ });
+ });
+
+ it('redirects to not found error if the playbook run is unknown', () => {
+ // # Visit the URL of a non-existing playbook run
+ cy.visit('/playbooks/runs/abcdefghijklmnopqrstuvwxyz');
+
+ // * Verify that the user has been redirected to the playbook runs not found error page
+ cy.url().should('include', '/playbooks/error?type=playbook_runs');
+ });
+
+ it('redirect to not found if the url is incorrect', () => {
+ // # visit the run url with an incorrect id
+ cy.visit('/playbooks/runs/..%252F..%252f..%252F..%252F..%252fapi%252Fv4%252Ffiles%252Fo47cow5h6fgjzp8abfqqxw5jwc');
+
+ // * Verify that the user has been redirected to the not found error page
+ cy.url().should('include', '/playbooks/error?type=default');
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_checklist_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_checklist_spec.js
new file mode 100644
index 00000000000..7e7e6f62189
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_checklist_spec.js
@@ -0,0 +1,150 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+// Note that this test checks the basic behavior in Run details page as participant / viewer
+// It relies on the Channel RHS Checklist test to cover the full behavior of the checklists
+
+describe('runs > run details page > checklist', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+ const taskIndex = 0;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ const getChecklist = () => cy.findByTestId('run-checklist-section');
+ const getChecklistTasks = () => getChecklist().findAllByTestId('checkbox-item-container');
+
+ const commonTests = () => {
+ it('is visible', () => {
+ // * Verify the tasks section is present
+ getChecklist().should('be.visible');
+ });
+
+ it('has title', () => {
+ // * Verify the task section has a title
+ getChecklist().find('h3').contains('Tasks');
+ });
+
+ it('can see the tasks', () => {
+ // * Verify tasks are shown
+ getChecklistTasks().should('have.length', 4);
+ });
+ };
+
+ describe('as participant', () => {
+ commonTests();
+
+ it('click marks task as done', () => {
+ // # Click first task
+ getChecklistTasks().eq(taskIndex).find('.checkbox').check({force: true});
+
+ // * Assert checkbox is checked
+ getChecklistTasks().eq(taskIndex).find('.checkbox').should('be.checked');
+ });
+
+ it('has hover menu', () => {
+ // # Hover over the checklist item
+ getChecklistTasks().eq(taskIndex).trigger('mouseover');
+
+ // # Click dot menu
+ getChecklistTasks().eq(taskIndex).findByTitle('More').click({force: true});
+
+ // * Assert actions are available
+ cy.findByRole('button', {name: 'Skip task'}).should('be.visible');
+ cy.findByRole('button', {name: 'Duplicate task'}).should('be.visible');
+ });
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ commonTests();
+
+ it('click does not work', () => {
+ // # Click first task
+ getChecklistTasks().eq(taskIndex).find('.checkbox').should('have.attr', 'readonly');
+ });
+
+ it('has not hover menu', () => {
+ // # Hover over the checklist item
+ getChecklistTasks().eq(taskIndex).trigger('mouseover');
+
+ // * Check that the hover menu is not rendered
+ getChecklistTasks().eq(taskIndex).findByTitle('More').should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_finish_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_finish_spec.js
new file mode 100644
index 00000000000..daa85e71e7a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_finish_spec.js
@@ -0,0 +1,134 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page > finish', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPlaybookRun;
+ let testPublicPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name(' + Date.now() + ')',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ it('is hidden as viewer', () => {
+ cy.apiLogin(testViewerUser).then(() => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${testPlaybookRun.id}`);
+ });
+
+ // * Assert that finish section does not exist
+ cy.findByTestId('run-finish-section').should('not.exist');
+ });
+
+ it('is visible', () => {
+ // * Verify the finish section is present
+ cy.findByTestId('run-finish-section').should('be.visible');
+ });
+
+ it('has a placeholder visible', () => {
+ // * Verify the placeholder is present
+ cy.findByTestId('run-finish-section').contains('Time to wrap up?');
+ });
+
+ describe('finish run', () => {
+ it('can be confirmed', () => {
+ // # Click finish run button
+ cy.findByTestId('run-finish-section').find('button').click();
+
+ // * Check that status badge is in-progress
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+
+ // * Check that finish run modal is open and has the right title
+ cy.get('#confirmModal').should('be.visible');
+
+ // Note: Title can be either "Confirm finish run" or "Confirm finish" depending on context
+ cy.get('#confirmModal').find('h1').should('contain', 'Confirm finish');
+
+ // # Click on confirm
+ cy.get('#confirmModal').get('#confirmModalButton').click();
+
+ // * Assert finish section is not visible anymore
+ cy.findByTestId('run-finish-section').should('not.exist');
+
+ // * Assert status badge is finished
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('Finished');
+
+ // * Verify run has been removed from LHS
+ cy.findByTestId('lhs-navigation').findByText(testPlaybookRun.name).should('not.exist');
+ });
+
+ it('can be canceled', () => {
+ // # Click on finish run
+ cy.findByTestId('run-finish-section').find('button').click();
+
+ // * Check that status badge is in-progress
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+
+ // * Check that finish run modal is open
+ cy.get('#confirmModal').should('be.visible');
+
+ // Note: Title can be either "Confirm finish run" or "Confirm finish" depending on context
+ cy.get('#confirmModal').find('h1').should('contain', 'Confirm finish');
+
+ // # Click on cancel
+ cy.get('#confirmModal').get('#cancelModalButton').click();
+
+ // * Check that status badge is still in-progress
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+
+ // * Check that section is still visible
+ cy.findByTestId('run-finish-section').should('be.visible');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_header_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_header_spec.js
new file mode 100644
index 00000000000..15eb3668f13
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_header_spec.js
@@ -0,0 +1,837 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+import {stubClipboard} from '../../../utils';
+
+describe('runs > run details page > header', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testPublicPlaybookAndChannel;
+ let playbookRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // # Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ createPublicPlaybookRun: true,
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybookAndChannel = playbook;
+ });
+ });
+ });
+
+ const openRunActionsModal = () => {
+ // # Click on the run actions modal button
+ cy.findByRole('button', {name: /Run Actions/i}).click({force: true});
+
+ // * Verify that the modal is shown
+ cy.findByRole('dialog', {name: /Run Actions/i}).should('exist');
+ };
+
+ const saveRunActionsModal = () => {
+ // # Click on the Save button without changing anything
+ cy.findByRole('button', {name: /Save/i}).click();
+
+ // * Verify that the modal is dismissed (removed from DOM after save)
+ cy.get('#run-actions-modal').should('not.exist');
+ };
+
+ const getHeader = () => {
+ return cy.findByTestId('run-header-section');
+ };
+
+ const getHeaderIcon = (selector) => {
+ return getHeader().find(selector);
+ };
+
+ const getDropdownItemByText = (text) => {
+ cy.findByTestId('run-header-section').find('h1').click();
+ return cy.findByTestId('dropdownmenu').findByText(text);
+ };
+
+ const commonHeaderTests = () => {
+ it('shows the title', () => {
+ // * Assert title is shown in h1 inside header
+ cy.findByTestId('run-header-section').find('h1').contains(playbookRun.name);
+ });
+
+ it('shows the in-progress status badge', () => {
+ // * Assert in progress status badge
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+ });
+
+ it('has a copy-link icon', () => {
+ // # Mouseover on the icon
+ getHeaderIcon('.icon-link-variant').trigger('mouseover');
+
+ // * Assert tooltip is shown
+ cy.get('#copy-run-link-tooltip').should('contain', 'Copy link to run');
+
+ stubClipboard().as('clipboard');
+ getHeaderIcon('.icon-link-variant').click().then(() => {
+ // * Verify that tooltip text changed
+ cy.get('#copy-run-link-tooltip').should('contain', 'Copied!');
+
+ // * Verify clipboard content
+ cy.get('@clipboard').its('contents').should('contain', `/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+ };
+
+ const commonContextDropdownTests = () => {
+ it('shows on click', () => {
+ // # Click title
+ cy.findByTestId('run-header-section').find('h1').click();
+
+ // * Assert context menu is opened
+ cy.findByTestId('dropdownmenu').should('be.visible');
+ });
+
+ it('can copy link', () => {
+ stubClipboard().as('clipboard');
+
+ getDropdownItemByText('Copy link').click().then(() => {
+ // * Verify clipboard content
+ cy.get('@clipboard').its('contents').should('contain', `/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+ };
+
+ describe('as participant', () => {
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name(' + Date.now() + ')',
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ playbookRun = run;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ });
+ });
+
+ describe('title, icons and buttons', () => {
+ commonHeaderTests();
+
+ it('has not participate button', () => {
+ // * Assert button is not showed
+ getHeader().findByText('Participate').should('not.exist');
+ });
+
+ describe('run actions', () => {
+ describe('modal behaviour', () => {
+ it('shows and hides as expected', () => {
+ // * Verify that the run actions modal is shown when clicking on the button
+ openRunActionsModal();
+
+ // # Click on the Cancel button
+ cy.findByRole('button', {name: /Cancel/i}).click();
+
+ // * Verify that the modal is dismissed (removed from DOM after fade-out)
+ cy.get('#run-actions-modal').should('not.exist');
+
+ // # Open the run actions modal
+ openRunActionsModal();
+
+ // * Verify that saving the modal hides it
+ saveRunActionsModal();
+ });
+
+ it('can not save an invalid form', () => {
+ // * Verify that the run actions modal is shown when clicking on the button
+ openRunActionsModal();
+
+ cy.findByRole('dialog', {name: /Run Actions/i}).within(() => {
+ // # click on webhooks toggle
+ cy.findByText('Send outgoing webhook').click();
+
+ // # Type an invalid webhook URL
+ cy.getStyledComponent('TextArea').clear().type('invalidurl');
+
+ // # Click outside textarea
+ cy.findByText('Run Actions').click();
+
+ // * Assert the error message is displayed
+ cy.findByText('Invalid webhook URLs').should('be.visible');
+
+ // # Click save
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Assert that modal is still open
+ cy.findByText('Run Actions').should('be.visible');
+ });
+ });
+
+ it('honours the settings from the playbook', () => {
+ cy.apiCreateChannel(
+ testTeam.id,
+ 'action-channel',
+ 'Action Channel',
+ 'O',
+ ).then(({channel}) => {
+ // # Create a different playbook with both settings enabled and populated with data,
+ // # and then start a run from it
+ const broadcastChannelIds = [channel.id];
+ const webhookOnStatusUpdateURLs = ['https://one.com', 'https://two.com'];
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook' + Date.now(),
+ broadcastEnabled: true,
+ broadcastChannelIds,
+ webhookOnStatusUpdateEnabled: true,
+ webhookOnStatusUpdateURLs,
+ }).then((playbook) => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: 'Run with actions preconfigured',
+ ownerUserId: testUser.id,
+ });
+ });
+
+ // # Navigate to the run page
+ cy.visit(`/${testTeam.name}/channels/run-with-actions-preconfigured`);
+ cy.findByRole('button', {name: /Checklist/i}).click({force: true});
+
+ // # Open the run actions modal
+ openRunActionsModal();
+
+ // * Verify that the broadcast-to-channels toggle is checked
+ cy.findByText('Broadcast update to selected channels').parent().within(() => {
+ cy.get('input').should('be.checked');
+ });
+
+ // * Verify that the channel is in the selector
+ cy.findByText(channel.display_name);
+
+ // * Verify that the send-webhooks toggle is checked
+ cy.findByText('Send outgoing webhook').parent().within(() => {
+ cy.get('input').should('be.checked');
+ });
+ });
+ });
+ });
+ });
+
+ describe('trigger: when a status update is posted', () => {
+ describe('action: Broadcast update to selected channels', () => {
+ it('shows channel information on first load', () => {
+ // # Open the run actions modal
+ openRunActionsModal();
+
+ // # Enable broadcast to channels
+ cy.findByText('Broadcast update to selected channels').click();
+
+ // # Select a couple of channels
+ cy.findByText('Select channels').click().type('town square{enter}off-topic{enter}');
+
+ // # Save the changes
+ saveRunActionsModal();
+
+ // # Reload the page, so that the store is not pre-populated when visiting Channels
+ cy.visit(`/playbooks/runs/${playbookRun.id}/overview`);
+
+ // # Open the run actions modal
+ openRunActionsModal();
+
+ // * Check that the channels previously added are shown with their full name,
+ // * verifying that the store has been populated by the modal component.
+ cy.findByText('Town Square').should('exist');
+ cy.findByText('Off-Topic').should('exist');
+ });
+
+ it('broadcasts to two channels configured when it is enabled', () => {
+ // # Open the run actions modal
+ openRunActionsModal();
+
+ // # Enable broadcast to channels
+ cy.findByText('Broadcast update to selected channels').click();
+
+ // # Select a couple of channels
+ cy.findByText('Select channels').click().type('town square{enter}off-topic{enter}', {delay: 100});
+
+ // # Save the changes
+ saveRunActionsModal();
+
+ // # Post a status update, with a reminder in 1 second.
+ const message = 'Status update - ' + Date.now();
+ cy.apiUpdateStatus({
+ playbookRunId: playbookRun.id,
+ message,
+ });
+
+ // # Navigate to the town square channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // * Verify that the last post contains the status update
+ cy.getLastPost().then((post) => {
+ cy.get(post).contains(message);
+ });
+
+ // # Navigate to the off-topic channel
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // * Verify that the last post contains the status update
+ cy.getLastPost().then((post) => {
+ cy.get(post).contains(message);
+ });
+ });
+
+ it('does not broadcast if it is disabled, even if there are channels configured', () => {
+ // # Open the run actions modal
+ openRunActionsModal();
+
+ // # Enable broadcast to channels
+ cy.findByText('Broadcast update to selected channels').click();
+
+ // # Select a couple of channels
+ cy.findByText('Select channels').click().type('town square{enter}off-topic{enter}', {delay: 100});
+
+ // # Disable broadcast to channels
+ cy.findByText('Broadcast update to selected channels').click();
+
+ // # Save the changes
+ saveRunActionsModal();
+
+ // # Post a status update, with a reminder in 1 second.
+ const message = 'Status update - ' + Date.now();
+ cy.apiUpdateStatus({
+ playbookRunId: playbookRun.id,
+ message,
+ });
+
+ // # Navigate to the town square channel
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+
+ // * Verify that the last post does not contain the status update
+ cy.getLastPost().then((post) => {
+ cy.get(post).contains(message).should('not.exist');
+ });
+
+ // # Navigate to the off-topic channel
+ cy.visit(`/${testTeam.name}/channels/off-topic`);
+
+ // * Verify that the last post does not contain the status update
+ cy.getLastPost().then((post) => {
+ cy.get(post).contains(message).should('not.exist');
+ });
+ });
+ });
+ });
+ });
+
+ describe('context menu', () => {
+ commonContextDropdownTests();
+
+ it('can rename run', () => {
+ // # Click on rename run
+ getDropdownItemByText('Rename').click();
+
+ cy.findByTestId('run-header-section').within(() => {
+ // # Type a new name
+ cy.findByTestId('rendered-editable-text').clear().type('The new fancy name');
+
+ // # Save
+ cy.findByTestId('checklist-item-save-button').click();
+
+ // * Assert name is updated
+ cy.get('h1').contains('The new fancy name');
+ });
+
+ cy.reload();
+
+ cy.findByTestId('run-header-section').within(() => {
+ // * Assert name is persisted
+ cy.get('h1').contains('The new fancy name');
+ });
+ });
+
+ describe('finish run', () => {
+ it('can be confirmed', () => {
+ // * Check that status badge is in-progress
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+
+ // # Click on finish
+ getDropdownItemByText('Finish').click();
+
+ // # Check that finish modal is open
+ cy.get('#confirmModal').should('be.visible');
+
+ // Note: Title can be either "Confirm finish run" or "Confirm finish" depending on context
+ cy.get('#confirmModal').find('h1').should('contain', 'Confirm finish');
+
+ // # Click on confirm
+ cy.get('#confirmModal').get('#confirmModalButton').click();
+
+ // * Assert option is not anymore in context dropdown
+ getDropdownItemByText('Finish').should('not.exist');
+
+ // * Assert status badge is finished
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('Finished');
+ });
+
+ it('can be canceled', () => {
+ // * Check that status badge is in-progress
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+
+ // # Click on finish
+ getDropdownItemByText('Finish').click();
+
+ // * Check that finish run modal is open
+ cy.get('#confirmModal').should('be.visible');
+
+ // Note: Title can be either "Confirm finish run" or "Confirm finish" depending on context
+ cy.get('#confirmModal').find('h1').should('contain', 'Confirm finish');
+
+ // # Click on cancel
+ cy.get('#confirmModal').get('#cancelModalButton').click();
+
+ // * Assert option is not anymore in context dropdown
+ getDropdownItemByText('Finish').should('be.visible');
+
+ // * Assert status badge is still in progress
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+ });
+ });
+
+ describe('run actions', () => {
+ it('modal can be opened', () => {
+ // # Click on finish run
+ getDropdownItemByText('Actions').click();
+
+ // * Assert modal pop up
+ cy.findByRole('dialog', {name: /Run Actions/i}).should('exist');
+
+ // # Click on cancel
+ cy.findByRole('dialog', {name: /Run Actions/i}).findByTestId('modal-cancel-button').click();
+
+ // * Assert modal dismissed (removed from DOM after fade-out)
+ cy.get('#run-actions-modal').should('not.exist');
+ });
+ });
+
+ describe('leave run', () => {
+ it('can leave run', () => {
+ // # Add viewer user to the channel
+ cy.apiAddUsersToRun(playbookRun.id, [testViewerUser.id]);
+ cy.findAllByTestId('timeline-item', {exact: false}).should('have.length', 3);
+
+ // # Change the owner to testViewerUser
+ cy.apiChangePlaybookRunOwner(playbookRun.id, testViewerUser.id);
+ cy.findByTestId('assignee-profile-selector').should('contain', testViewerUser.username);
+
+ // # Click on leave run
+ getDropdownItemByText('Leave and unfollow').click();
+
+ // # confirm modal
+ cy.get('#confirmModal').get('#confirmModalButton').click();
+
+ // NOTE: this check fails because the front doesn't receive updated run object. Will deal in separate PR.
+ // * Assert that the Participate button is shown
+ getHeader().findByText('Participate').should('be.visible');
+
+ // * Verify run has been removed from LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookRun.name).should('not.exist');
+ });
+ });
+ });
+ });
+
+ describe('as viewer', () => {
+ let playbookRunChannelName;
+ let playbookRunName;
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ const now = Date.now();
+ playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ playbookRun = run;
+
+ cy.apiLogin(testViewerUser).then(() => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ });
+ });
+
+ describe('title, icons and buttons', () => {
+ commonHeaderTests();
+
+ describe('Favorite', () => {
+ it('add and remove from LHS', () => {
+ // # Click fav icon
+ getHeader().getStyledComponent('StarButton').click();
+
+ // * Assert run appears in LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookRunName).should('exist');
+
+ // # Click fav icon again (unfav)
+ getHeader().getStyledComponent('StarButton').click();
+
+ // * Assert run disappeared from LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookRunName).should('not.exist');
+ });
+ });
+
+ describe('Participate', () => {
+ it('shows button', () => {
+ // * Assert that the button is shown
+ getHeader().findByText('Participate').should('be.visible');
+ });
+
+ describe('Join action enabled', () => {
+ it('click button to show modal and cancel', () => {
+ // * Assert that component is rendered
+ getHeader().findByText('Participate').should('be.visible');
+
+ // # Click Participate button
+ getHeader().findByText('Participate').click();
+
+ // * Verify modal message is correct
+ cy.findByText('You’ll also be added to the channel linked to this run.').should('exist');
+
+ // # cancel modal
+ cy.findByTestId('modal-cancel-button').click();
+
+ // * Assert modal is not shown
+ cy.get('#become-participant-modal').should('not.exist');
+
+ // # Login as testUser
+ cy.apiLogin(testUser).then(() => {
+ // # Visit the channel run
+ cy.visit(`${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Assert user has not been added to the channel
+ cy.getLastPost().should('not.contain', 'Someone');
+ cy.getLastPost().should('not.contain', testViewerUser.username);
+ });
+ });
+
+ it('click button to show modal and confirm when private channel', () => {
+ // * Assert component is rendered
+ getHeader().findByText('Participate').should('be.visible');
+
+ // # Click start-participating button
+ getHeader().findByText('Participate').click();
+
+ // * Verify modal message is correct
+ cy.findByText('You’ll also be added to the channel linked to this run.').should('exist');
+
+ // # confirm modal
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Assert that modal is not shown
+ cy.get('#become-participant-modal').should('not.exist');
+
+ // * Verify run has been added to LHS
+ verifyRunHasBeenAddedToLHS(playbookRunName);
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // We'll simply check if the user can see anything in the channel
+ cy.get('body').then(($body) => {
+ // If we can see the channel header, the user has channel access
+ if ($body.find('#channelHeaderTitle').length > 0) {
+ cy.get('#channelHeaderTitle').should('be.visible').should('contain', playbookRunName);
+ }
+
+ // Otherwise, we don't assert anything - it's OK for the user not to have channel access
+ // as long as they're a participant in the run
+ });
+ });
+
+ it('click button and confirm to when public channel', () => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ const now = Date.now();
+ playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+
+ // # Create a run with public chanel
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybookAndChannel.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ cy.apiLogin(testViewerUser);
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${run.id}`);
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+
+ // * Assert that component is rendered
+ getHeader().findByText('Participate').should('be.visible');
+
+ // # Click start-participating button
+ getHeader().findByText('Participate').click();
+
+ // * Verify modal message is correct
+ cy.findByText('You’ll also be added to the channel linked to this run.').should('exist');
+
+ // # confirm modal
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Assert that modal is not shown
+ cy.get('#become-participant-modal').should('not.exist');
+
+ // * Verify run has been added to LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookRunName).should('exist');
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // We'll simply check if the user can see anything in the channel
+ cy.get('body').then(($body) => {
+ // If we can see the channel header, the user has channel access
+ if ($body.find('#channelHeaderTitle').length > 0) {
+ cy.get('#channelHeaderTitle').should('be.visible').should('contain', playbookRunName);
+ }
+
+ // Otherwise, we don't assert anything - it's OK for the user not to have channel access
+ // as long as they're a participant in the run
+ });
+ });
+ });
+ });
+
+ describe('Join action disabled', () => {
+ beforeEach(() => {
+ cy.apiLogin(testUser);
+
+ // # Disable join action
+ cy.apiUpdateRun(playbookRun.id, {createChannelMemberOnNewParticipant: false});
+
+ cy.apiLogin(testViewerUser).then(() => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ });
+
+ it('join the run with private channel, request to join the channel', () => {
+ // # Click start-participating button
+ getHeader().findByText('Participate').click();
+
+ // * Verify modal message is correct
+ cy.findByText('Request access to the channel linked to this run').should('exist');
+
+ // # Select checkbox
+ cy.findByTestId('also-add-to-channel').click({force: true});
+
+ // # confirm modal
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Assert that modal is not shown
+ cy.get('#become-participant-modal').should('not.exist');
+
+ // * Verify run has been added to LHS
+ verifyRunHasBeenAddedToLHS(playbookRunName);
+
+ // # Login as testUser to check if join request was posted in the channel
+ cy.apiLogin(testUser);
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the request was sent to the channel
+ cy.getLastPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).within(() => {
+ cy.contains(`@${testViewerUser.username} is a run participant and wants join this channel. Any member of the channel can invite them.`);
+ });
+ });
+ });
+
+ it('join the run with private channel, no request to join the channel', () => {
+ // # Click start-participating button
+ getHeader().findByText('Participate').click();
+
+ // * Verify modal message is correct
+ cy.findByText('Request access to the channel linked to this run').should('exist');
+
+ // # confirm modal
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Assert that modal is not shown
+ cy.get('#become-participant-modal').should('not.exist');
+
+ // * Verify run has been added to LHS
+ verifyRunHasBeenAddedToLHS(playbookRunName);
+
+ // # Login as testUser to check if join request was posted in the channel
+ cy.apiLogin(testUser);
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Verify that the request was sent to the channel
+ cy.getLastPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).within(() => {
+ cy.contains(`@${testViewerUser.username} is a run participant and wants join this channel. Any member of the channel can invite them.`).should('not.exist');
+ });
+ });
+ });
+
+ it('join run with public channel, join the channel', () => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ const now = Date.now();
+ playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+
+ // Create a run with public chanel
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybookAndChannel.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ cy.apiLogin(testViewerUser);
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${run.id}`);
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+
+ // * Assert that component is rendered
+ getHeader().findByText('Participate').should('be.visible');
+
+ // # Click start-participating button
+ getHeader().findByText('Participate').click();
+
+ // * Verify modal message is correct
+ cy.findByText('You’ll also be added to the channel linked to this run.').should('exist');
+
+ // # confirm modal
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Assert that modal is not shown
+ cy.get('#become-participant-modal').should('not.exist');
+
+ // * Verify run has been added to LHS
+ cy.findByTestId('lhs-navigation').findByText(playbookRunName).should('exist');
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // We'll simply check if the user can see anything in the channel
+ cy.get('body').then(($body) => {
+ // If we can see the channel header, the user has channel access
+ if ($body.find('#channelHeaderTitle').length > 0) {
+ cy.get('#channelHeaderTitle').should('be.visible').should('contain', playbookRunName);
+ }
+
+ // Otherwise, we don't assert anything - it's OK for the user not to have channel access
+ // as long as they're a participant in the run
+ });
+ });
+ });
+ });
+ });
+
+ describe('run actions', () => {
+ describe('modal behaviour', () => {
+ /* modal cannot be opened read-only from dropdown */
+ // eslint-disable-next-line no-only-tests/no-only-tests
+ it.skip('modal can be opened read-only', () => {
+ // # Click on run actions
+ getDropdownItemByText('Actions').click();
+
+ // * Assert modal pop up
+ cy.findByRole('dialog', {name: /Run Actions/i}).should('exist');
+
+ // * Assert there are no buttons
+ cy.findByRole('dialog', {name: /Run Actions/i}).findByTestId('modal-cancel-button').should('not.exist');
+ cy.findByRole('button', {name: /Save/i}).should('not.exist');
+
+ // # Close modal
+ cy.findByRole('dialog', {name: /Run Actions/i}).find('.close').click();
+ });
+ });
+ });
+ });
+
+ describe('context menu', () => {
+ commonContextDropdownTests();
+
+ it('can not rename run', () => {
+ // # There's no rename option
+ getDropdownItemByText('Rename').should('not.exist');
+ });
+
+ it('can not finish run', () => {
+ // * There's no finish run item
+ getDropdownItemByText('Finish').should('not.exist');
+ });
+ });
+ });
+});
+
+const verifyRunHasBeenAddedToLHS = (playbookRunName) => {
+ // * Verify run has been added to LHS
+ cy.findByTestId('lhs-navigation').
+ should('be.visible').
+ findByText(playbookRunName).
+ should('be.visible');
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_restore_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_restore_spec.js
new file mode 100644
index 00000000000..0b4d1b2c478
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_restore_spec.js
@@ -0,0 +1,107 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page > restart run', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+
+ // const taskIndex = 0;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ {
+ title: 'Stage 2',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ },
+ ],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ describe('restart run', () => {
+ it('can be confirmed', () => {
+ cy.intercept('PUT', `/plugins/playbooks/api/v0/runs/${testRun.id}/finish`).as('routeFinish');
+ cy.intercept('PUT', `/plugins/playbooks/api/v0/runs/${testRun.id}/restore`).as('routeRestore');
+
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+
+ // # Click finish run button
+ cy.findByTestId('run-finish-section').find('button').click();
+ cy.get('#confirmModal').get('#confirmModalButton').click();
+
+ cy.wait('@routeFinish');
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('Finished');
+
+ cy.findByTestId('runDropdown').click();
+ cy.get('.restartRun').find('span').contains('Restart');
+
+ cy.get('.restartRun').click();
+ cy.get('#confirmModal').get('#confirmModalButton').click();
+ cy.wait('@routeRestore');
+ cy.findByTestId('run-header-section').findByTestId('badge').contains('In Progress');
+ cy.findByTestId('lhs-navigation').findByText(testRun.name).should('exist');
+ },
+ );
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_retrospective_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_retrospective_spec.js
new file mode 100644
index 00000000000..25841c28c5a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_retrospective_spec.js
@@ -0,0 +1,500 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+const editAndPublishRetro = () => {
+ getRetro().within(() => {
+ // # Start editing
+ cy.findByTestId('retro-report-text').click();
+
+ // * Verify the provided template text is pre-filled
+ cy.focused().should('include.text', 'This is a retrospective template.');
+
+ // # Change the retro text
+ cy.focused().clear().type('Edited retrospective.');
+
+ // # Save it by clicking outside the text area
+ cy.findByText('Report').click();
+
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+ });
+
+ cy.get('#confirm-modal-light').within(() => {
+ // * Verify we're showing the publish retro confirmation modal
+ cy.findByText('Are you sure you want to publish?');
+
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+ });
+
+ // * Verify that retro got published
+ getRetro().get('.icon-check-all').should('be.visible');
+};
+
+const getMetricInput = (index) => getRetro().getStyledComponent('InputContainer').eq(index);
+
+const verifyMetricInput = (index, title, target, description, placeholder) => {
+ getMetricInput(index).within(() => {
+ cy.getStyledComponent('Title').contains(title);
+
+ if (target) {
+ cy.getStyledComponent('TargetTitle').contains(target);
+ } else {
+ cy.getStyledComponent('TargetTitle').should('not.exist');
+ }
+
+ if (description) {
+ cy.getStyledComponent('HelpText').contains(description);
+ }
+ if (placeholder) {
+ cy.get('input').should('have.attr', 'placeholder', placeholder);
+ }
+ });
+};
+
+const getRetro = () => cy.findByTestId('run-retrospective-section');
+
+describe('runs > run details page', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testPublicPlaybookWithMetrics;
+ let testRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ retrospectiveTemplate: 'This is a retrospective template.',
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ metrics: [
+ {
+ title: 'title1',
+ description: 'description1',
+ type: 'metric_duration',
+ target: 720000,
+ },
+ {
+ title: 'title2',
+ description: 'description2',
+ type: 'metric_currency',
+ target: 40,
+ },
+ {
+ title: 'title3',
+ description: 'description3',
+ type: 'metric_integer',
+ target: 30,
+ },
+ ],
+ }).then((playbook) => {
+ testPublicPlaybookWithMetrics = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('retrospective', () => {
+ const commonTests = () => {
+ it('is visible', () => {
+ // * Verify the retrospective section is present
+ getRetro().should('be.visible');
+ });
+
+ it('has title', () => {
+ // * Verify the retrospective section has a title
+ getRetro().find('h3').contains('Retrospective');
+ });
+
+ it('has template text', () => {
+ // * Verify the retrospective text is rendered
+ getRetro().findByTestId('retro-report-text').contains('This is a retrospective template.');
+ });
+
+ it('has no metrics', () => {
+ // * Verify there are no metric for this playbook
+ getRetro().getStyledComponent('InputContainer').should('not.exist');
+ });
+ };
+
+ describe('as participant', () => {
+ beforeEach(() => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ commonTests();
+
+ it('publishing posts to run channel', () => {
+ editAndPublishRetro();
+
+ // # Switch to the run channel
+ cy.findByTestId('runinfo-channel-link').click();
+
+ // * Verify the modified retro text is posted
+ cy.getStyledComponent('CustomPostContent').should('exist').contains('Edited retrospective.');
+ });
+
+ it('can be published once', () => {
+ editAndPublishRetro();
+
+ // * Verify the button is disabled
+ getRetro().findByText('Publish').should('not.be.enabled');
+ });
+ });
+
+ describe('as viewer', () => {
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create test playbook run
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+ });
+ });
+
+ beforeEach(() => {
+ // Login as the test viewer
+ cy.apiLogin(testViewerUser);
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+
+ commonTests();
+
+ it('text is not clickable', () => {
+ getRetro().findByTestId('retro-report-text').click();
+ getRetro().find('textarea').should('not.exist');
+ });
+
+ it('there is no publish button', () => {
+ getRetro().findByText('Publish').should('not.exist');
+ });
+ });
+ });
+
+ describe('metrics', () => {
+ const commonTests = () => {
+ it('inputs info(title, target, description) and order', () => {
+ // * Verify the created metrics
+ verifyMetricInput(0, 'title1', '12 minutes', 'description1', 'Add value (in dd:hh:mm)');
+ verifyMetricInput(1, 'title2', '40', 'description2', 'Add value');
+ verifyMetricInput(2, 'title3', '30', 'description3', 'Add value');
+ });
+ };
+
+ describe('as participant', () => {
+ beforeEach(() => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybookWithMetrics.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+ });
+ });
+
+ beforeEach(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+
+ commonTests();
+
+ it('inputs, null and zero values', () => {
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ createPublicPlaybookRun: true,
+ metrics: [
+ {
+ title: 'title1',
+ description: 'description1',
+ type: 'metric_duration',
+ target: null,
+ },
+ {
+ title: 'title2',
+ description: 'description2',
+ type: 'metric_currency',
+ target: 0,
+ },
+ {
+ title: 'title3',
+ description: 'description3',
+ type: 'metric_integer',
+ target: 30,
+ },
+ ],
+ }).then((playbook) => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ // # Navigate directly to the retro tab
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+
+ // * Verify changes are reflected
+ verifyMetricInput(0, 'title1', null, 'description1', 'Add value (in dd:hh:mm)');
+ verifyMetricInput(1, 'title2', '0', 'description2', 'Add value');
+ verifyMetricInput(2, 'title3', '30', 'description3', 'Add value');
+ });
+ });
+ });
+
+ it('auto save', () => {
+ getRetro().within(() => {
+ // # Enter metric values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('12:11:10').
+ tab().type('56').
+ tab().type('123');
+
+ // # Click outside
+ cy.findByText('Retrospective').click({force: true});
+ cy.wait(2000);
+
+ // * Validate if values persist
+ cy.get('input[type=text]').eq(0).should('have.value', '12:11:10');
+ cy.get('input[type=text]').eq(1).should('have.value', '56');
+ cy.get('input[type=text]').eq(2).should('have.value', '123');
+
+ // # Enter new values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).clear().type('12:00:10').
+ tab().clear().type('20').
+ tab().clear().type('21');
+ });
+
+ // # Wait 2 sec to auto save
+ cy.wait(2000);
+
+ // # Reload page
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+
+ getRetro().within(() => {
+ // * Validate if values are saved
+ cy.get('input[type=text]').eq(0).should('have.value', '12:00:10');
+ cy.get('input[type=text]').eq(1).should('have.value', '20');
+ cy.get('input[type=text]').eq(2).should('have.value', '21');
+ });
+ });
+
+ it('save empty and zero values', () => {
+ getRetro().within(() => {
+ // # Enter metric values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).clear().type('00:00:00').
+ tab().type('7').
+ tab().type('0');
+
+ // # Click outside
+ cy.findByText('Retrospective').click({force: true});
+
+ // * Validate if values persist
+ cy.get('input[type=text]').eq(0).should('have.value', '00:00:00');
+ cy.get('input[type=text]').eq(1).should('have.value', '7');
+ cy.get('input[type=text]').eq(2).should('have.value', '0');
+
+ // # Clear first two metrics values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).clear().
+ tab().clear();
+
+ // # Click outside
+ cy.findByText('Retrospective').click({force: true});
+
+ // * Validate if values persist
+ cy.get('input[type=text]').eq(0).should('have.value', '');
+ cy.get('input[type=text]').eq(1).should('have.value', '');
+ cy.get('input[type=text]').eq(2).should('have.value', '0');
+ });
+ });
+
+ it('only valid values are saved. check error messages', () => {
+ getRetro().within(() => {
+ // # Enter invalid metric values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('5').
+ tab().type('56d').
+ tab().type('125');
+
+ // * Validate error messages
+ cy.getStyledComponent('ErrorText').eq(0).contains('Please enter a duration in the format: dd:hh:mm (e.g., 12:00:00).');
+ cy.getStyledComponent('ErrorText').eq(1).contains('Please enter a number.');
+
+ // # Click outside
+ cy.findByText('Retrospective').click({force: true});
+ });
+
+ // # Reload page and navigate to the retro tab
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+
+ getRetro().within(() => {
+ // * Validate that values are not saved
+ cy.get('input[type=text]').eq(0).should('have.value', '');
+ cy.get('input[type=text]').eq(1).should('have.value', '');
+ cy.get('input[type=text]').eq(2).should('have.value', '125');
+
+ // # Enter new metric values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('s').
+ tab().type('d').
+ tab().type('k');
+ });
+ });
+
+ it('publish retro', () => {
+ getRetro().within(() => {
+ // # Enter metric invalid values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('20:00:12d').
+ tab().type('56').
+ tab().type('125v');
+
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+ });
+
+ // * Verify we're not showing the publish retro confirmation modal
+ cy.get('#confirm-modal-light').should('not.exist');
+
+ getRetro().within(() => {
+ //# Enter empty metric values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).clear().
+ tab().clear().
+ tab().clear().type(24);
+
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+
+ // * Validate error messages
+ cy.getStyledComponent('ErrorText').eq(0).contains('Please fill in the metric value.');
+ cy.getStyledComponent('ErrorText').eq(1).contains('Please fill in the metric value.');
+ cy.getStyledComponent('ErrorText').should('have.length', 2);
+ });
+
+ // * Verify we're not showing the publish retro confirmation modal
+ cy.get('#confirm-modal-light').should('not.exist');
+
+ getRetro().within(() => {
+ //# Enter valid metric values
+ cy.get('input[type=text]').eq(0).click();
+ cy.get('input[type=text]').eq(0).type('09:87:12').
+ tab().type(123);
+
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+ });
+
+ cy.get('#confirm-modal-light').within(() => {
+ // * Verify we're showing the publish retro confirmation modal
+ cy.findByText('Are you sure you want to publish?');
+
+ // # Publish
+ cy.findByRole('button', {name: 'Publish'}).click();
+ });
+
+ getRetro().within(() => {
+ // * Verify that retro got published
+ cy.get('.icon-check-all').should('be.visible');
+
+ // * Verify that metrics inputs are disabled
+ cy.get('input[type=text]').each(($el) => {
+ cy.wrap($el).should('not.be.enabled');
+ });
+ });
+ });
+ });
+
+ describe('as viewer', () => {
+ before(() => {
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybookWithMetrics.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+ });
+ });
+
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ commonTests();
+
+ it('are not editable', () => {
+ // * Verify that inputs are disabled
+ getMetricInput(0).find('input').should('be.disabled');
+ getMetricInput(1).find('input').should('be.disabled');
+ getMetricInput(2).find('input').should('be.disabled');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_statusupdate_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_statusupdate_spec.js
new file mode 100644
index 00000000000..72c28cba796
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_statusupdate_spec.js
@@ -0,0 +1,292 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+/* eslint-disable no-only-tests/no-only-tests */
+
+describe('runs > run details page > status update', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+ let playbookRunChannelName;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ const now = Date.now();
+ const playbookRunName = 'Playbook Run (' + now + ')';
+ playbookRunChannelName = 'playbook-run-' + now;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ });
+ });
+
+ describe('as participant', () => {
+ it('is visible', () => {
+ // * Verify the status update section is present
+ cy.findByTestId('run-statusupdate-section').should('be.visible');
+ });
+
+ it('has no title', () => {
+ // * Verify the title
+ cy.findByTestId('run-statusupdate-section').find('h3').should('not.exist');
+ });
+
+ describe('post update', () => {
+ it('button disappears if we finish the run', () => {
+ // * Check that post update button is visible
+ cy.findByTestId('run-statusupdate-section').findByTestId('post-update-button').should('be.visible');
+
+ // # Click finish button and confirm modal
+ cy.findByTestId('run-finish-section').find('button').click();
+ cy.get('#confirmModal').get('#confirmModalButton').click();
+
+ // * Check that post update button does not exist anymore
+ cy.findByTestId('run-statusupdate-section').findByTestId('post-update-button').should('not.exist');
+ });
+
+ it('button triggers post update modal', () => {
+ // * Check due date
+ cy.findByTestId('update-due-date-text').contains('Update due');
+ cy.findByTestId('update-due-date-time').contains('in 24 hours');
+
+ // # Click post update
+ cy.findByTestId('run-statusupdate-section').findByTestId('post-update-button').click();
+
+ // * Assert modal is opened
+ cy.getStatusUpdateDialog().should('be.visible');
+
+ // # Write message
+ cy.findByTestId('update_run_status_textbox').clear().type('my nice update');
+ cy.get('#reminder_timer_datetime').within(() => {
+ cy.get('input').type('15 minutes', {delay: 200, force: true}).type('{enter}', {force: true});
+ });
+
+ // # Post update
+ cy.getStatusUpdateDialog().findByTestId('modal-confirm-button').click();
+
+ // * Check new due date
+ cy.findByTestId('update-due-date-text').contains('Update due');
+ cy.findByTestId('update-due-date-time').contains('in 15 minutes');
+
+ // # go to channel
+ cy.visit(`/${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * check that post has been added
+ cy.getLastPost().contains('my nice update');
+ });
+ });
+
+ describe('request an update', () => {
+ it('is disabled if the run is finished', () => {
+ cy.apiFinishRun(testRun.id).then(() => {
+ // # reload url
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+
+ // # Click on kebab menu
+ cy.findByTestId('run-statusupdate-section').getStyledComponent('Kebab').click();
+
+ // # click on request update option (force because is disabled)
+ cy.findByText('Request update...').click({force: true});
+
+ // * assert modal is not opened
+ cy.get('#confirmModalButton').should('not.exist');
+ });
+ });
+
+ it('requests and confirm', () => {
+ // # Click on kebab menu
+ cy.findByTestId('run-statusupdate-section').getStyledComponent('Kebab').click();
+
+ cy.findByTestId('dropdownmenu').within(($dropdown) => {
+ cy.wrap($dropdown).children().should('have.length', 2);
+
+ // # Click on request update
+ cy.findByText('Request update...').click();
+ });
+
+ // # Click on modal confirmation
+ cy.get('#confirmModalButton').click();
+
+ // # Go to channel
+ cy.visit(`${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Assert that message has been sent
+ cy.getLastPost().contains(`${testUser.username} requested a status update for ${testRun.name}.`);
+ });
+
+ it('requests and cancel', () => {
+ // # Click on kebab menu
+ cy.findByTestId('run-statusupdate-section').getStyledComponent('Kebab').click();
+ cy.findByTestId('dropdownmenu').within(($dropdown) => {
+ cy.wrap($dropdown).children().should('have.length', 2);
+
+ // # Click on request update
+ cy.findByText('Request update...').click();
+ });
+
+ // # Click on modal confirmation
+ cy.get('#cancelModalButton').click();
+
+ // # Go to channel
+ cy.visit(`${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Assert that message has not been sent
+ cy.getLastPost().should('not.contain', `${testUser.username} requested a status update for ${testRun.name}.`);
+ });
+ });
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ });
+ });
+
+ it('is visible', () => {
+ // * Verify the status update section is present
+ cy.findByTestId('run-statusupdate-section').should('be.visible');
+ });
+
+ it('has a title', () => {
+ // * Verify the title
+ cy.findByTestId('run-statusupdate-section').find('h3').contains('Recent status update');
+ });
+
+ it('has placeholder', () => {
+ // * Verify the placeholder
+ cy.findByTestId('run-statusupdate-section').find('i').contains('No updates have been posted yet');
+ });
+
+ it('has a due date', () => {
+ // * Verify the due date
+ cy.findByTestId('update-due-date-text').contains('Update due');
+ cy.findByTestId('update-due-date-time').contains('in 24 hours');
+ });
+
+ it('shows the most recent update', () => {
+ // # Login as participant
+ cy.apiLogin(testUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ });
+
+ // # Click post update
+ cy.findByTestId('run-statusupdate-section').
+ should('be.visible').
+ findByTestId('post-update-button').click();
+
+ // * Assert modal is opened
+ cy.getStatusUpdateDialog().should('be.visible');
+
+ // # Write message
+ cy.findByTestId('update_run_status_textbox').clear().type('my nice update');
+ cy.get('#reminder_timer_datetime').within(() => {
+ cy.get('input').type('15 minutes', {delay: 200, force: true}).type('{enter}', {force: true});
+ });
+
+ // # Post update
+ cy.getStatusUpdateDialog().findByTestId('modal-confirm-button').click();
+
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+
+ // * Check new due date
+ cy.findByTestId('update-due-date-text').contains('Update due');
+ cy.findByTestId('update-due-date-time').contains('in 15 minutes');
+
+ // * Assert the recent updated text
+ cy.findByTestId('status-update-card').contains('my nice update');
+ });
+ });
+
+ it('requests an update and confirm', () => {
+ // # Click on request update
+ cy.findByTestId('run-statusupdate-section').
+ should('be.visible').
+ findByText('Request update...').click();
+
+ // # Click on modal confirmation
+ cy.get('#confirmModalButton').click();
+
+ cy.apiLogin(testUser).then(() => {
+ // # Go to channel
+ cy.visit(`${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Assert that message has been sent
+ cy.getLastPost().contains(`${testViewerUser.username} requested a status update for ${testRun.name}.`);
+ });
+ });
+
+ it('requests an update and cancel', () => {
+ // # Click request update
+ cy.findByTestId('run-statusupdate-section').
+ should('be.visible').
+ findByText('Request update...').click();
+
+ // # Click on modal confirmation
+ cy.get('#cancelModalButton').click();
+
+ cy.apiLogin(testUser).then(() => {
+ // # Go to channel
+ cy.visit(`${testTeam.name}/channels/${playbookRunChannelName}`);
+
+ // * Assert that message has been sent
+ cy.getLastPost().should('not.contain', `${testUser.username} requested a status update for ${testPublicPlaybook.name}].`);
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_summary_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_summary_spec.js
new file mode 100644
index 00000000000..7b5ecc4384a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_summary_spec.js
@@ -0,0 +1,162 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page > summary', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testRun;
+ let testViewerUser;
+ let testPublicPlaybook;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ const commonTests = () => {
+ it('is visible', () => {
+ // * Verify the summary section is present
+ cy.findByTestId('run-summary-section').should('be.visible');
+ });
+
+ it('has title', () => {
+ // * Verify the summary section is present
+ cy.findByTestId('run-summary-section').find('h3').contains('Summary');
+ });
+ };
+
+ describe('as participant', () => {
+ commonTests();
+
+ it('has a placeholder', () => {
+ // * Assert the placeholder content
+ cy.findByTestId('run-summary-section').findByTestId('rendered-text').contains('Add a run summary');
+ });
+
+ it('can be edited', () => {
+ // # Mouseover the summary
+ cy.findByTestId('run-summary-section').trigger('mouseover');
+
+ cy.findByTestId('run-summary-section').within(() => {
+ // # Click the edit icon
+ cy.findByTestId('hover-menu-edit-button').click();
+
+ // # Write a summary
+ cy.findByTestId('editabletext-markdown-textbox2').clear().type('This is my new summary');
+
+ // # Save changes
+ cy.findByTestId('checklist-item-save-button').click();
+
+ // * Assert that data has changed
+ cy.findByTestId('rendered-text').contains('This is my new summary');
+ });
+
+ // * Assert last edition date is visible
+ cy.findByTestId('run-summary-section').contains('Last edited');
+ });
+
+ it('can be canceled', () => {
+ // # Mouseover the summary
+ cy.findByTestId('run-summary-section').trigger('mouseover');
+
+ cy.findByTestId('run-summary-section').within(() => {
+ // # Click the edit icon
+ cy.findByTestId('hover-menu-edit-button').click();
+
+ // # Write a summary
+ cy.findByTestId('editabletext-markdown-textbox2').clear().type('This is my new summary');
+
+ // # Cancel changes
+ cy.findByText('Cancel').click();
+
+ // * Assert that data has not changed
+ cy.findByTestId('rendered-text').contains('Add a run summary');
+ });
+
+ // * Assert last edition date is not visible
+ cy.findByTestId('run-summary-section').should('not.contain', 'Last edited');
+ });
+
+ it('can not be edited once run is finished', () => {
+ // # Finish the run
+ cy.apiFinishRun(testRun.id);
+
+ // # Mouseover the summary
+ cy.findByTestId('run-summary-section').trigger('mouseover');
+
+ // * Verify that the edit button is not rendered
+ cy.findByTestId('run-summary-section').findByTestId('hover-menu-edit-button').should('not.exist');
+ });
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ commonTests();
+
+ it('has a placeholder', () => {
+ // * Assert the placeholder content
+ cy.findByTestId('run-summary-section').findByTestId('rendered-text').contains('There\'s no summary');
+ });
+
+ it('can not be edited', () => {
+ // # Mouseover the summary
+ cy.findByTestId('run-summary-section').trigger('mouseover');
+
+ // * Verify that the edit button is not rendered
+ cy.findByTestId('run-summary-section').findByTestId('hover-menu-edit-button').should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_taskactions_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_taskactions_spec.js
new file mode 100644
index 00000000000..658aa12612f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_main_taskactions_spec.js
@@ -0,0 +1,750 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+import * as TIMEOUTS from '../../../fixtures/timeouts';
+
+describe('runs > task actions', {testIsolation: true}, () => {
+ let testPlaybook;
+ let testTeam;
+ let testUser;
+ let testUser2;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateUser().then(({user: user2}) => {
+ testUser2 = user2;
+
+ // # Add this new user to the team
+ cy.apiAddUserToTeam(team.id, testUser2.id);
+ });
+
+ // # Create a playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook (' + Date.now() + ')',
+ checklists: [{
+ title: 'Test Checklist',
+ items: [
+ {title: 'Test Task'},
+ ],
+ }],
+ memberIDs: [
+ testUser.id,
+ ],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+ });
+
+ describe('keywords trigger - mark task as done', () => {
+ let testPlaybookRun;
+
+ const getChecklist = () => cy.findByTestId('run-checklist-section');
+ const getChecklistTasks = () => getChecklist().findAllByTestId('checkbox-item-container');
+
+ beforeEach(() => {
+ // # Run a playbook
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: `the run name ${Date.now()}`,
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testPlaybookRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ cy.wait(2000); // Wait for page to load
+ });
+ });
+
+ it('disallows no keywords', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify no actions are configured
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').should('exist');
+
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, []);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isFalse(actions.enabled);
+ });
+ });
+
+ it('allows a single keyword', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // # Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser2.username} checked off checklist item "Test Task"`);
+ });
+
+ it('allows multiple keywords', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add multiple keywords
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ cy.get('input').eq(0).type('keyword2{enter}', {force: true});
+ });
+
+ // # Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1', 'keyword2']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword2 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser2.username} checked off checklist item "Test Task"`);
+ });
+
+ it('allows multi-word phrases', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a phrase
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('a phrase with multiple words{enter}', {force: true});
+ });
+
+ // # Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['a phrase with multiple words']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and a phrase with multiple words happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser2.username} checked off checklist item "Test Task"`);
+ });
+
+ it('allows removing previously configured keywords', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add multiple keywords
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ cy.get('input').eq(0).type('keyword2{enter}', {force: true});
+ });
+
+ // # Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // # Re-open the dialog
+ cy.findByText('1 action').click();
+
+ // # Remove one trigger keyword
+ cy.get('.modal-body').within(() => {
+ cy.findByText('keyword1').next().click();
+ });
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword2']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Post without activating trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action not activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('not.be.checked');
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword2 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser2.username} checked off checklist item "Test Task"`);
+ });
+
+ it('disables when all keywords removed', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add multiple keywords
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ cy.get('input').eq(0).type('keyword2{enter}', {force: true});
+ });
+
+ // # Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // # Re-open the dialog
+ cy.findByText('1 action').click();
+
+ // # Remove all trigger keywords
+ cy.get('.modal-body').within(() => {
+ cy.findByText('keyword1').next().click();
+ cy.findByText('keyword2').next().click();
+ });
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify task actions button still exists
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').should('exist');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, []);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isFalse(actions.enabled);
+ });
+
+ // # Post without activating trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh keyword1 keyword2 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action not activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('not.be.checked');
+ });
+
+ it('disallows a user without keywords', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a user
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // # Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify no actions are configured (icon still exists)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').should('exist');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, []);
+ assert.deepEqual(trigger.user_ids, [testUser.id]);
+ assert.isFalse(actions.enabled);
+ });
+ });
+
+ it('allows a single user', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // # Add a user
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // # Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions and user
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, [testUser.id]);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Post without activating trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action not activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('not.be.checked');
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser.id);
+ cy.postMessageAs({
+ sender: testUser,
+ message: `hello from ${testUser.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser.username} checked off checklist item "Test Task"`);
+ });
+
+ it('allows configuring multiple users', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // # Add two users
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ cy.get('input').eq(1).
+ type('@' + testUser2.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // # Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions and user
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, [testUser.id, testUser2.id]);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser.id);
+ cy.postMessageAs({
+ sender: testUser,
+ message: `hello from ${testUser.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser.username} checked off checklist item "Test Task"`);
+
+ // # Reset-uncheck task
+ cy.apiSetChecklistItemState(testPlaybookRun.id, 0, 0, '');
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('not.be.checked');
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser2.username} checked off checklist item "Test Task"`);
+ });
+
+ it('rejects unknown user', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // # Type an unknown user
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@unknown', {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // # Click away
+ cy.get('.modal-body').click();
+
+ // # Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions and user
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser.id);
+ cy.postMessageAs({
+ sender: testUser,
+ message: `hello from ${testUser.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser.username} checked off checklist item "Test Task"`);
+ });
+
+ it('allows removing previously configured users', () => {
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // # Add two users
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(1).
+ type('@' + testUser.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ cy.get('input').eq(1).
+ type('@' + testUser2.username, {force: true}).
+ wait(TIMEOUTS.ONE_SEC).
+ type('{enter}', {force: true});
+ });
+
+ // # Attempt to enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // # Re-open the dialog
+ cy.findByText('1 action').click();
+
+ // # Remove one user keyword
+ cy.get('.modal-body').within(() => {
+ cy.findByText(testUser.username).parent().parent().next().click();
+ });
+
+ // Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(testPlaybookRun.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, [testUser2.id]);
+ assert.isTrue(actions.enabled);
+ });
+
+ // # Post without activating trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser.id);
+ cy.postMessageAs({
+ sender: testUser,
+ message: `hello from ${testUser.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action NOT activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('not.be.checked');
+
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testPlaybookRun.channel_id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testPlaybookRun.channel_id,
+ });
+
+ // * Verify action activated
+ getChecklistTasks().eq(0).find('input[type="checkbox"]').should('be.checked');
+ cy.findAllByTestId('timeline-item task_state_modified').findByText(`${testUser2.username} checked off checklist item "Test Task"`);
+ });
+ });
+
+ describe('keywords trigger - mark task as done, multiple runs in a channel', () => {
+ let testChannel;
+ let testPlaybookRun1;
+ let testPlaybookRun2;
+
+ const getChecklist = () => cy.findByTestId('run-checklist-section');
+ const getChecklistTasks = () => getChecklist().findAllByTestId('checkbox-item-container');
+
+ const configureTaskAction = (run) => {
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${run.id}`);
+
+ // # Enter editing mode on the task first
+ getChecklistTasks().eq(0).findByTestId('hover-menu-edit-button').click();
+ cy.wait(1000); // Wait for edit mode UI to render
+
+ // # Open the task actions modal (lightning bolt icon, no text label)
+ getChecklistTasks().eq(0).find('.icon-lightning-bolt-outline').click();
+
+ // # Add a keyword
+ cy.get('.modal-body').within(() => {
+ cy.get('input').eq(0).type('keyword1{enter}', {force: true});
+ });
+
+ // # Enable the trigger
+ cy.findByText('Mark the task as done').click();
+
+ // # Save the dialog
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify configured actions
+ cy.findByText('1 action');
+ cy.apiGetPlaybookRun(run.id).then(({body: playbookRun}) => {
+ const trigger = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].trigger.payload);
+ const actions = JSON.parse(playbookRun.checklists[0].items[0].task_actions[0].actions[0].payload);
+
+ assert.deepEqual(trigger.keywords, ['keyword1']);
+ assert.deepEqual(trigger.user_ids, []);
+ assert.isTrue(actions.enabled);
+ });
+ };
+
+ beforeEach(() => {
+ cy.apiCreateChannel(testTeam.id, 'channel', 'Channel').then(({channel}) => {
+ testChannel = channel;
+
+ // # Run #1
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: `the run name ${Date.now()}`,
+ ownerUserId: testUser.id,
+ channelId: testChannel.id,
+ }).then((playbookRun) => {
+ testPlaybookRun1 = playbookRun;
+ configureTaskAction(testPlaybookRun1);
+ });
+
+ // # Run #2
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: `the run name ${Date.now()}`,
+ ownerUserId: testUser.id,
+ channelId: testChannel.id,
+ }).then((playbookRun) => {
+ testPlaybookRun2 = playbookRun;
+ configureTaskAction(testPlaybookRun2);
+ });
+ });
+ });
+
+ it('triggers', () => {
+ // # Attempt to activate trigger
+ cy.apiAddUserToChannel(testChannel.id, testUser2.id);
+ cy.postMessageAs({
+ sender: testUser2,
+ message: `hello from ${testUser2.username}: ${Date.now()}, oh and keyword1 happened`,
+ channelId: testChannel.id,
+ });
+
+ // Give the system a chance to effect the task actions.
+ cy.wait(TIMEOUTS.HALF_SEC);
+
+ // * Verify action activated ion testPlaybookRun1
+ cy.apiGetPlaybookRun(testPlaybookRun1.id).then(({body: playbookRun}) => {
+ assert.equal(playbookRun.checklists[0].items[0].state, 'closed');
+ });
+
+ // * Verify action activated in testPlaybookRun2
+ cy.apiGetPlaybookRun(testPlaybookRun2.id).then(({body: playbookRun}) => {
+ assert.equal(playbookRun.checklists[0].items[0].state, 'closed');
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_participants_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_participants_spec.js
new file mode 100644
index 00000000000..c9141fb3ba5
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_participants_spec.js
@@ -0,0 +1,250 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page > rhs > participants', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testUser2;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testUser2 = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testUser2.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the-run-name' + Date.now(),
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Add viewer user to the channel
+ cy.apiAddUsersToRun(testRun.id, [testUser2.id]);
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ describe('as participant', () => {
+ it('switching between manage modes', () => {
+ navigateToParticipantsList();
+
+ // # Switch to manage mode
+ cy.findByRole('button', {name: 'Manage'}).click();
+
+ // * Verify that we are in manage mode
+ cy.findByRole('button', {name: 'Manage'}).should('not.exist');
+
+ // # Switch to normal mode
+ cy.findByRole('button', {name: 'Done'}).click();
+
+ // * Verify that we are in normal mode
+ cy.findByRole('button', {name: 'Manage'}).should('exist');
+ });
+
+ it('change owner', () => {
+ navigateToParticipantsList();
+
+ // * Verify run owner
+ cy.findByTestId('run-owner').contains(testUser.username);
+
+ // # Switch to manage mode
+ cy.findByRole('button', {name: 'Manage'}).click();
+
+ // # Change owner
+ cy.findByTestId(testUser2.id).findByTestId('menuButton').click();
+ cy.findByTestId('dropdownmenu').findByText('Make run owner').click();
+
+ // # Wait for changes to apply
+ cy.wait(2000);
+
+ // * Verify the owner has changed
+ cy.findByTestId('run-owner').contains(testUser2.username);
+ });
+
+ it('remove participant', () => {
+ navigateToParticipantsList();
+
+ // * Verify run owner
+ cy.findByTestId('run-owner').contains(testUser.username);
+
+ // # Switch to manage mode
+ cy.findByRole('button', {name: 'Manage'}).click();
+
+ // # remove participant
+ cy.findByTestId(testUser2.id).findByTestId('menuButton').click();
+ cy.findByTestId('dropdownmenu').findByText('Remove from run').click();
+
+ // * Verify the user has been removed
+ cy.findByTestId(testUser2.id).should('not.exist');
+ });
+
+ describe('add participant', () => {
+ it('join action enabled', () => {
+ navigateToParticipantsList();
+
+ // * Verify run owner
+ cy.findByTestId('run-owner').contains(testUser.username);
+
+ // # show add participant modal
+ cy.findByRole('button', {name: 'Add'}).click();
+
+ // # Select two new participants
+ cy.get('#profile-autocomplete').click().type(testUser2.username + '{enter}', {delay: 400});
+ cy.get('#profile-autocomplete').click().type(testViewerUser.username + '{enter}', {delay: 400});
+
+ // * Verify modal message is correct
+ cy.findByText('Participants will also be added to the channel linked to this run').should('exist');
+
+ // # Add selected participant
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify the users have been added
+ cy.findByTestId(testUser2.id).should('exist');
+ cy.findByTestId(testViewerUser.id).should('exist');
+ });
+
+ it('join action disabled', () => {
+ cy.apiUpdateRun(testRun.id, {createChannelMemberOnNewParticipant: false});
+ navigateToParticipantsList();
+
+ // * Verify run owner
+ cy.findByTestId('run-owner').contains(testUser.username);
+
+ // # show add participant modal
+ cy.findByRole('button', {name: 'Add'}).click();
+
+ // # Select two new participants
+ cy.get('#profile-autocomplete').click().type(testViewerUser.username + '{enter}', {delay: 400});
+
+ // * Verify modal message is correct
+ cy.findByText('Also add people to the channel linked to this run').should('exist');
+
+ // # Add selected participant
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify the user has been added to the run
+ cy.findByTestId(testViewerUser.id).should('exist');
+
+ // # Intercept fetching channel members
+ cy.intercept('channels/members/me/view').as('members');
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${testRun.name}`);
+
+ // * Verify that no users were invited
+ cy.getFirstPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).within(() => {
+ // just to wait until the username is fetched
+ cy.contains('Someone').should('not.exist');
+ cy.contains('You were added to the channel by @playbooks.');
+ cy.contains(`@${testViewerUser.username}`).should('not.exist');
+ });
+ });
+ });
+
+ it('join action disabled, checkbox selected', () => {
+ cy.apiUpdateRun(testRun.id, {createChannelMemberOnNewParticipant: false});
+ navigateToParticipantsList();
+
+ // * Verify run owner
+ cy.findByTestId('run-owner').contains(testUser.username);
+
+ // # show add participant modal
+ cy.findByRole('button', {name: 'Add'}).click();
+
+ // # Select two new participants
+ cy.get('#profile-autocomplete').click().type(testViewerUser.username + '{enter}', {delay: 400});
+
+ // * Verify modal message is correct
+ cy.findByText('Also add people to the channel linked to this run').should('exist');
+
+ // # Select checkbox
+ cy.findByTestId('also-add-to-channel').click({force: true});
+
+ // # Add selected participant
+ cy.findByTestId('modal-confirm-button').click();
+
+ // * Verify the user has been added to the run
+ cy.findByTestId(testViewerUser.id).should('exist');
+
+ // # Navigate to the playbook run channel
+ cy.visit(`/${testTeam.name}/channels/${testRun.name}`);
+
+ // * Verify that the user was added to the channel
+ cy.getFirstPostId().then((id) => {
+ cy.get(`#postMessageText_${id}`).within(() => {
+ cy.contains('Someone').should('not.exist');
+ cy.contains(`@${testViewerUser.username}`);
+ });
+ });
+ });
+ });
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ it('no manage button', () => {
+ navigateToParticipantsList();
+
+ // * Verify that there is no manage button
+ cy.findByRole('button', {name: 'Manage'}).should('not.exist');
+ });
+ });
+});
+
+const navigateToParticipantsList = () => {
+ // # Click on participants row
+ cy.findByTestId('runinfo-participants').click();
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_runinfo_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_runinfo_spec.js
new file mode 100644
index 00000000000..9ed78bcdb94
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_runinfo_spec.js
@@ -0,0 +1,607 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page > run info', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testChannel;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+
+ const getHeader = () => {
+ return cy.findByTestId('run-header-section');
+ };
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user, channel}) => {
+ testTeam = team;
+ testUser = user;
+ testChannel = channel;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ const getRHSSection = (title) => cy.findByRole('complementary').contains('section', title);
+
+ describe('> overview', () => {
+ const getOverviewEntry = (entryName) => (
+ cy.findByRole('complementary').findByTestId(`runinfo-${entryName}`)
+ );
+
+ const commonTests = () => {
+ it('Playbook entry is visible and links to the playbook', () => {
+ // * Verify that the playbook entry exists
+ getOverviewEntry('playbook').should('exist');
+
+ // # Click on the Playbook entry
+ getOverviewEntry('playbook').within(() => cy.getStyledComponent('ItemLink').click());
+
+ // * Verify the we're in the right playbook page
+ cy.url().should('include', '/playbooks/playbooks');
+ cy.findByTestId('playbook-editor-title').contains(testPublicPlaybook.title);
+ });
+
+ it('Owner entry shows the owner', () => {
+ // * Verify that the owner is shown
+ getOverviewEntry('owner').contains(testUser.username);
+ });
+
+ it('Participants entry shows the participants', () => {
+ // * Verify that the participants are rendered
+ getOverviewEntry('participants').within(() => {
+ cy.getStyledComponent('Participants').within(() => {
+ cy.getStyledComponent('UserPic').should('exist');
+ });
+ });
+ });
+
+ it('clicking on Participants show the full list of participants', () => {
+ // * Click on the Participants entry
+ getOverviewEntry('participants').click();
+
+ cy.findByRole('complementary').within(() => {
+ // * Verify that the Participants RHS is shown
+ cy.findByTestId('rhs-title').contains('Participants');
+
+ // * Verify that the back button is shown
+ cy.findByTestId('rhs-back-button').should('exist');
+
+ // * Verify that the participants list shows the number of participants
+ cy.findByText('1 Participant');
+
+ // * Verify that the participants list contains the test user
+ cy.findByText(`@${testUser.username}`);
+
+ // # Click on the back button
+ cy.findByTestId('rhs-back-button').click();
+
+ // * Verify that the RHS is back to Info
+ cy.findByTestId('rhs-title').contains('Info');
+ });
+ });
+ };
+
+ describe('as participant', () => {
+ commonTests();
+
+ it('Following button can be toggled', () => {
+ getOverviewEntry('following').within(() => {
+ // * Verify that the user shows in the following list
+ cy.getStyledComponent('UserRow').within(() => {
+ cy.getStyledComponent('UserPic').should('have.length', 1);
+ });
+
+ // # Click the Following button
+ cy.findByRole('button', {name: /Following/}).click({force: true});
+
+ // * Verify that it now says (exactly) Follow
+ cy.findByRole('button', {name: /^Follow$/}).should('exist');
+
+ // * Verify that the user no longer shows in the following list
+ cy.getStyledComponent('UserRow').should('not.exist');
+
+ // # Click the Follow button
+ cy.findByRole('button', {name: /^Follow$/}).click({force: true});
+
+ // * Verify that it now says Following
+ cy.findByRole('button', {name: /Following/}).should('exist');
+ });
+ });
+
+ it('click channel link navigates to run\'s channel', () => {
+ // * Assert channel name
+ getOverviewEntry('channel').contains('the run name');
+
+ // # Click on channel item
+ getOverviewEntry('channel').within(() => cy.getStyledComponent('ItemLink').click());
+
+ // * Assert we navigated correctly
+ cy.url().should('include', `${testTeam.name}/channels/the-run-name`);
+ });
+
+ it('channel is still there when the run is finished', () => {
+ cy.apiFinishRun(testRun.id).then(() => {
+ // # Reload page
+ cy.reload();
+
+ // * Assert channel name
+ getOverviewEntry('channel').contains('the run name');
+
+ // # Click on channel item
+ getOverviewEntry('channel').within(() => cy.getStyledComponent('ItemLink').click());
+
+ // * Assert we navigated correctly
+ cy.url().should('include', `${testTeam.name}/channels/the-run-name`);
+ });
+ });
+
+ it('indicates when the channel has been deleted', () => {
+ cy.apiDeleteChannel(testRun.channel_id).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+
+ // * Assert channel status shows deleted
+ getOverviewEntry('channel').contains('Channel deleted');
+ });
+ });
+
+ it('Playbook entry is hidden for standalone run without playbook', () => {
+ // # Create a standalone run without a playbook (channel checklist) in existing channel (MM-67648)
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: '', // Empty playbook ID for standalone run
+ playbookRunName: 'standalone run',
+ ownerUserId: testUser.id,
+ channelId: testChannel.id,
+ }).then((standaloneRun) => {
+ // # Visit the standalone run
+ cy.visit(`/playbooks/runs/${standaloneRun.id}`);
+
+ // * Verify that the playbook entry does not exist
+ getOverviewEntry('playbook').should('not.exist');
+
+ // * Verify other overview entries are still visible
+ getOverviewEntry('owner').should('exist');
+ getOverviewEntry('participants').should('exist');
+ getOverviewEntry('channel').should('exist');
+ });
+ });
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ commonTests();
+
+ it('Playbook entry is hidden when playbook is private', () => {
+ // # Create a private playbook with only testUser as member
+ cy.apiLogin(testUser);
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Private Playbook',
+ memberIDs: [testUser.id],
+ makePublic: false,
+ }).then((privatePlaybook) => {
+ // # Create a run from the private playbook
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: privatePlaybook.id,
+ playbookRunName: 'private run',
+ ownerUserId: testUser.id,
+ }).then((privateRun) => {
+ // # Add testViewerUser as participant
+ cy.apiAddUsersToRun(privateRun.id, [testViewerUser.id]);
+
+ // # Login as viewer and visit the run
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${privateRun.id}`);
+
+ // * Verify that the playbook entry does not exist
+ getOverviewEntry('playbook').should('not.exist');
+
+ // * Verify other overview entries are still visible
+ getOverviewEntry('owner').should('exist');
+ getOverviewEntry('participants').should('exist');
+ });
+ });
+ });
+ });
+
+ it('Following button can be toggled', () => {
+ getOverviewEntry('following').within(() => {
+ // * Verify that the user is not in the following list
+ cy.getStyledComponent('UserRow').within(() => {
+ cy.getStyledComponent('UserPic').should('have.length', 1);
+ });
+
+ // # Click the Follow button
+ cy.findByRole('button', {name: /^Follow$/}).click({force: true});
+
+ // * Verify that it now says Following
+ cy.findByRole('button', {name: /Following/}).should('exist');
+
+ // * Verify that the user is now in the following list
+ cy.getStyledComponent('UserRow').within(() => {
+ cy.getStyledComponent('UserPic').should('have.length', 2);
+ });
+
+ // # Click the Follow button
+ cy.findByRole('button', {name: /Following/}).click({force: true});
+
+ // * Verify that it now says (exactly) Follow
+ cy.findByRole('button', {name: /^Follow$/}).should('exist');
+ });
+ });
+
+ it('there is no channel link but can request to join', () => {
+ // * Assert that the section exists with label Private
+ getOverviewEntry('channel').contains('Private');
+
+ // * Assert that link does not exist
+ getOverviewEntry('channel').within(() => {
+ cy.get('a').should('not.exist');
+ });
+
+ // * Assert that request-join button does not exist
+ getOverviewEntry('channel').within(() => {
+ cy.get('button').should('not.exist');
+ });
+
+ cy.wait(500);
+
+ // # Click Participate button
+ getHeader().findByText('Participate').click();
+
+ // * Assert that modal is shown
+ cy.get('#become-participant-modal').should('exist');
+
+ // # Confirm modal
+ cy.findByTestId('modal-confirm-button').click();
+
+ // # Click request-join button
+ getOverviewEntry('channel').within(() => {
+ cy.get('button').click();
+ });
+
+ // # Click send request button
+ cy.findByText('Send request').click();
+
+ // * Assert that the request was sent
+ cy.findByText('Your request was sent to the run channel.');
+ });
+ });
+ });
+
+ describe('> key metrics', () => {
+ describe('playbook without metrics', () => {
+ describe('it should not render', () => {
+ it('as participant', () => {
+ // * assert metrics does not exist
+ getRHSSection('Key Metrics').should('not.exist');
+ });
+
+ it('as viewer', () => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+
+ // * assert metrics does not exist
+ getRHSSection('Key Metrics').should('not.exist');
+ });
+ });
+ });
+
+ describe('playbook with metrics (enabled retro)', () => {
+ let playbookWithMetrics;
+ let runWithMetrics;
+
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook with metrics
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook with metrics',
+ memberIDs: [],
+ metrics: [
+ {
+ title: 'Duration',
+ description: 'duration',
+ type: 'metric_duration',
+ target: 6000,
+ },
+ {
+ title: 'Currency',
+ description: 'currency',
+ type: 'metric_currency',
+ target: 100,
+ },
+ {
+ title: 'Integer',
+ description: 'integer',
+ type: 'metric_integer',
+ target: 1,
+ },
+ ],
+ }).then((playbook) => {
+ playbookWithMetrics = playbook;
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbookWithMetrics.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ runWithMetrics = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ const commonTests = () => {
+ it('key metrics is present', () => {
+ getRHSSection('Key Metrics').should('exist');
+ });
+
+ it('link scrolls to retrospective', () => {
+ // # click in view retro link
+ cy.findByRole('link', {name: /View Retrospective/}).click({force: true});
+
+ // * verify that URL has been changed
+ cy.url().should('contain', '#playbook-run-retrospective');
+ });
+
+ it('metric items scroll to corresponding metric', () => {
+ getRHSSection('Key Metrics').within(() => {
+ playbookWithMetrics.metrics.forEach((metric) => {
+ // # Click on metric
+ cy.findByText(metric.title).click({force: true});
+
+ // * Verify that url changed (and therefore we scrolled)
+ cy.url().should('contain', `#playbook-run-retrospective${metric.id}`);
+ });
+ });
+ });
+ };
+
+ describe('as participant', () => {
+ commonTests();
+
+ it('metric items show Add value if empty', () => {
+ getRHSSection('Key Metrics').within(() => {
+ playbookWithMetrics.metrics.forEach((metric) => {
+ // * Verify that we show a placeholder when empty
+ cy.findByText(metric.title).parent().contains('Add value...');
+ });
+ });
+ });
+
+ it('click on metric items, type and see the result in the RHS', () => {
+ const testData = {
+ metric_duration: {
+ input: '12:06:03',
+ expected: '12d, 6h, 3m',
+ },
+ metric_currency: {
+ input: '5000',
+ expected: '5000',
+ },
+ metric_integer: {
+ input: '42',
+ expected: '42',
+ },
+ };
+
+ // # Type the values for the metrics
+ getRHSSection('Key Metrics').within(() => {
+ playbookWithMetrics.metrics.forEach((metric) => {
+ // # Click on the metric row
+ cy.findByText(metric.title).click();
+
+ // # Seems there's a re-render between clicking the title and
+ // # typing that occasionally leads to dropped keystrokes in
+ // # .type(). Wait for it to avoid.
+ cy.wait(1000);
+
+ // # Type a value for the metric
+ cy.focused().type(testData[metric.type].input);
+ });
+ });
+
+ // * Verify that the RHS is updated with those values
+ getRHSSection('Key Metrics').within(() => {
+ playbookWithMetrics.metrics.forEach((metric) => {
+ // * Verify that the metric was updated in the RHS
+ cy.findByText(metric.title).parent().contains(testData[metric.type].expected);
+ });
+ });
+ });
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${runWithMetrics.id}`);
+ });
+ });
+
+ commonTests();
+
+ it('metric items show - if empty', () => {
+ getRHSSection('Key Metrics').within(() => {
+ playbookWithMetrics.metrics.forEach((metric) => {
+ // * verify that values are shown as - when empty
+ cy.findByText(metric.title).parent().contains('-');
+ });
+ });
+ });
+ });
+ });
+
+ describe('playbook with metrics (disabled retro)', () => {
+ let playbookWithMetrics;
+ let runWithMetrics;
+
+ before(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook with metrics
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook with metrics',
+ memberIDs: [],
+ metrics: [
+ {
+ title: 'Integer',
+ description: 'integer',
+ type: 'metric_integer',
+ target: 1,
+ },
+ ],
+ retrospectiveEnabled: false,
+ }).then((playbook) => {
+ playbookWithMetrics = playbook;
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: playbookWithMetrics.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ runWithMetrics = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ const commonTests = () => {
+ it('key metrics is hidden', () => {
+ getRHSSection('Key Metrics').should('not.exist');
+ });
+ };
+
+ describe('as participant', () => {
+ commonTests();
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${runWithMetrics.id}`);
+ });
+ });
+
+ commonTests();
+ });
+ });
+ });
+
+ describe('> recent activity', () => {
+ const commonTests = () => {
+ it('recent activity is present and it contains a timeline', () => {
+ getRHSSection('Recent Activity').within(() => {
+ // * assert that section is shown
+ cy.findByTestId('rhs-timeline').should('exist');
+ });
+ });
+
+ it('link switches the RHS to Timeline', () => {
+ getRHSSection('Recent Activity').within(() => {
+ // * click link to see all timeline
+ cy.findByText('View all').click({force: true});
+ });
+
+ cy.findByRole('complementary').within(() => {
+ // * verify we changed to RHS-timeline
+ cy.findByTestId('rhs-title').contains('Timeline');
+ cy.findByTestId('rhs-back-button').should('exist');
+ });
+ });
+ };
+
+ describe('as participant', () => {
+ commonTests();
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ commonTests();
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_spec.js
new file mode 100644
index 00000000000..1519b681e58
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_spec.js
@@ -0,0 +1,129 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page > RHS', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ const getRHS = () => cy.findByRole('complementary');
+
+ const getHeaderButton = (name) => cy.findByTestId(`rhs-header-button-${name}`);
+
+ const checkRHSTitle = (expectedTitle) => {
+ getRHS().within(() => {
+ cy.findByTestId('rhs-title').contains(expectedTitle);
+ });
+ };
+
+ const commonTests = () => {
+ it('timeline button toggles timeline in the RHS', () => {
+ // * Verify that the run info RHS is open
+ checkRHSTitle('Info');
+
+ // # Click on the header timeline button
+ getHeaderButton('timeline').click();
+
+ // * Verify that the run info RHS changed to Timeline
+ checkRHSTitle('Timeline');
+
+ // # Wait so we don't double-click
+ cy.wait(500);
+
+ // # Click again on the header timeline button
+ getHeaderButton('timeline').click();
+
+ // * Verify that the RHS is closed
+ getRHS().should('not.exist');
+ });
+
+ it('info button toggles info in the RHS', () => {
+ // * Verify that the run info RHS is open
+ checkRHSTitle('Info');
+
+ // # Click on the header info button
+ getHeaderButton('info').click();
+
+ // * Verify that the RHS is now closed
+ getRHS().should('not.exist');
+
+ // # Wait so we don't double-click
+ cy.wait(500);
+
+ // # Click again on the header info button
+ getHeaderButton('info').click();
+
+ // * Verify that the run info RHS is open again
+ checkRHSTitle('Info');
+ });
+ };
+
+ describe('as participant', () => {
+ commonTests();
+ });
+
+ describe('as viewer', () => {
+ beforeEach(() => {
+ cy.apiLogin(testViewerUser).then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ commonTests();
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_statusupdates_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_statusupdates_spec.js
new file mode 100644
index 00000000000..36510edd0ae
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/rdp_rhs_statusupdates_spec.js
@@ -0,0 +1,150 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run details page > status update', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+
+ const getRHS = () => cy.findByRole('complementary');
+ const getStatusUpdates = () => getRHS().findAllByTestId('status-update-card');
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+
+ // # Visit the playbook run
+ cy.visit(`/playbooks/runs/${playbookRun.id}`);
+ });
+ });
+
+ describe('as participant', () => {
+ it('rhs can not be open when there is no updates', () => {
+ // * Assert that the link is not present
+ cy.findByTestId('run-statusupdate-section').findByText('View all updates').should('not.exist');
+ });
+
+ it('link opens the RHS when there are updates', () => {
+ cy.apiUpdateStatus({
+ playbookRunId: testRun.id,
+ message: 'message 1',
+ reminder: 300,
+ });
+ cy.apiUpdateStatus({
+ playbookRunId: testRun.id,
+ message: 'message 2',
+ reminder: 300,
+ });
+
+ // # Click View all updates link
+ cy.findByTestId('run-statusupdate-section').findByText('View all updates').click();
+
+ // * Assert RHS is open and have the correct title/subtitle
+ getRHS().should('be.visible');
+ getRHS().findByTestId('rhs-title').contains('Status updates');
+ getRHS().findByTestId('rhs-subtitle').contains(testRun.name);
+
+ // * Assert that we have both updates in reverse order
+ getStatusUpdates().should('have.length', 2);
+ getStatusUpdates().eq(0).contains('message 2');
+ getStatusUpdates().eq(0).contains(testUser.username);
+ getStatusUpdates().eq(1).contains('message 1');
+ getStatusUpdates().eq(1).contains(testUser.username);
+ });
+ });
+
+ describe('as viewer', () => {
+ it('rhs can not be open when there is no updates', () => {
+ // * Log in as viewer user
+ cy.apiLogin(testViewerUser);
+
+ // * Browse to test run
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+
+ // * Assert that the link is not present
+ cy.findByTestId('run-statusupdate-section').findByText('View all updates').should('not.exist');
+ });
+
+ it('link opens the RHS when there are updates', () => {
+ cy.apiLogin(testUser).then(() => {
+ cy.apiUpdateStatus({
+ playbookRunId: testRun.id,
+ message: 'message 1',
+ reminder: 300,
+ });
+ cy.apiUpdateStatus({
+ playbookRunId: testRun.id,
+ message: 'message 2',
+ reminder: 300,
+ });
+ });
+
+ // * Log in as viewer user
+ cy.apiLogin(testViewerUser);
+
+ // * Browse to test run
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+
+ // # Click View all updates link
+ cy.findByTestId('run-statusupdate-section').findByText('View all updates').click();
+
+ // * Assert RHS is open and have the correct title/subtitle
+ getRHS().should('be.visible');
+ getRHS().findByTestId('rhs-title').contains('Status updates');
+ getRHS().findByTestId('rhs-subtitle').contains(testRun.name);
+
+ // * Assert that we have both updates in reverse order
+ getStatusUpdates().should('have.length', 2);
+ getStatusUpdates().eq(0).contains('message 2');
+ getStatusUpdates().eq(0).contains(testUser.username);
+ getStatusUpdates().eq(1).contains('message 1');
+ getStatusUpdates().eq(1).contains(testUser.username);
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/run_attributes_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/run_attributes_spec.js
new file mode 100644
index 00000000000..4b6dbb0df3a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/run_attributes_spec.js
@@ -0,0 +1,714 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('runs > run_attributes', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testPlaybook;
+ let testRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+ });
+ });
+
+ beforeEach(() => {
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Set viewport to show RHS
+ cy.viewport('macbook-13');
+ });
+
+ describe('empty state', () => {
+ beforeEach(() => {
+ // # Create playbook without attributes
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook Without Attributes',
+ memberIDs: [testUser.id],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+
+ // # Start a run
+ return cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: 'Test Run',
+ ownerUserId: testUser.id,
+ });
+ }).then((run) => {
+ testRun = run;
+
+ // # Navigate to run
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ });
+
+ it('does not show attributes section when playbook has no attributes', () => {
+ // * Verify Attributes section does NOT exist
+ cy.findByRole('complementary').within(() => {
+ cy.findByText('Attributes').should('not.exist');
+ });
+ });
+ });
+
+ describe('attribute inheritance', () => {
+ it('copies text attribute from playbook to run', () => {
+ // # Create playbook with text attribute
+ createPlaybookWithAttributes([
+ {name: 'Project Name', type: 'text'},
+ ]);
+
+ // # Start a run
+ startRun('Test Run With Text Attr');
+
+ // # Navigate to run
+ navigateToRun();
+
+ // * Verify attribute appears in RHS
+ verifyAttributeExists('Project Name');
+ verifyAttributeValue('Project Name', 'Empty');
+ });
+
+ it('copies all attribute types from playbook to run', () => {
+ // # Create playbook with all attribute types
+ createPlaybookWithAttributes([
+ {name: 'Description', type: 'text'},
+ {name: 'Status', type: 'select', options: ['Not Started', 'In Progress', 'Complete']},
+ {name: 'Teams', type: 'multiselect', options: ['Engineering', 'Design', 'Product']},
+ ]);
+
+ // # Start a run
+ startRun('Test Run With All Attrs');
+
+ // # Navigate to run
+ navigateToRun();
+
+ // * Verify all attributes appear
+ verifyAttributeExists('Description');
+ verifyAttributeValue('Description', 'Empty');
+
+ verifyAttributeExists('Status');
+ verifyAttributeValue('Status', 'Empty');
+
+ verifyAttributeExists('Teams');
+ verifyAttributeValue('Teams', 'Empty');
+ });
+ });
+
+ describe('edit attribute values', () => {
+ beforeEach(() => {
+ // # Create playbook with attributes
+ createPlaybookWithAttributes([
+ {name: 'Notes', type: 'text'},
+ {name: 'Priority', type: 'select', options: ['Low', 'Medium', 'High']},
+ {name: 'Labels', type: 'multiselect', options: ['Bug', 'Feature', 'Enhancement']},
+ {name: 'Documentation', type: 'text', valueType: 'url'},
+ ]);
+
+ // # Start a run
+ startRun('Test Run For Editing');
+ });
+
+ describe('from run details page', () => {
+ beforeEach(() => {
+ // # Navigate to run details page
+ navigateToRun();
+ });
+
+ it('can edit text attribute value', () => {
+ // # Edit text attribute
+ editTextAttribute('Notes', 'Initial implementation notes');
+ cy.wait(500);
+
+ // * Verify value is displayed
+ verifyAttributeValue('Notes', 'Initial implementation notes');
+
+ // # Reload page
+ cy.reload();
+
+ // * Verify value persists
+ verifyAttributeValue('Notes', 'Initial implementation notes');
+ });
+
+ it('can edit URL attribute and displays as clickable link', () => {
+ // # Edit URL attribute with a real URL
+ const testUrl = 'https://docs.mattermost.com';
+ editTextAttribute('Documentation', testUrl);
+ cy.wait(500);
+
+ // * Verify URL is displayed as a clickable link
+ getAttributeRow('Documentation').within(() => {
+ cy.get('a').
+ should('exist').
+ should('have.attr', 'href', testUrl).
+ should('have.attr', 'target', '_blank').
+ should('have.attr', 'rel', 'noopener noreferrer').
+ should('contain', testUrl);
+ });
+
+ // # Capture current URL before navigating away
+ cy.url().as('currentUrl');
+
+ // * Verify the link is clickable and navigates correctly
+ getAttributeRow('Documentation').within(() => {
+ // # Remove target attribute to navigate in same window
+ cy.get('a').invoke('removeAttr', 'target').click();
+ });
+
+ // * Verify navigation occurred (wait for new page to load)
+ cy.url().should('include', 'docs.mattermost.com');
+
+ // # Go back to the run page
+ cy.go('back');
+
+ // * Verify we're back on the run page
+ cy.get('@currentUrl').then((currentUrl) => {
+ cy.url().should('include', currentUrl);
+ });
+
+ // # Click on the wrapper (not on the link) to start editing
+ getAttributeRow('Documentation').within(() => {
+ cy.findByTestId('property-value').then(($el) => {
+ const rect = $el[0].getBoundingClientRect();
+ cy.wrap($el).click(rect.width - 10, rect.height - 10);
+ });
+ });
+
+ // * Verify input field appears (in edit mode)
+ getAttributeRow('Documentation').within(() => {
+ cy.get('input').should('exist').should('have.value', testUrl);
+ });
+
+ // # Update the URL
+ const newUrl = 'https://github.com/mattermost';
+ cy.focused().clear().type(newUrl);
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify new URL is displayed as a link
+ getAttributeRow('Documentation').within(() => {
+ cy.get('a').
+ should('have.attr', 'href', newUrl).
+ should('contain', newUrl);
+ });
+
+ // # Reload page
+ cy.reload();
+
+ // * Verify URL persists and is still a clickable link
+ getAttributeRow('Documentation').within(() => {
+ cy.get('a').
+ should('have.attr', 'href', newUrl).
+ should('contain', newUrl);
+ });
+ });
+
+ it('can edit select attribute value', () => {
+ // # Edit select attribute
+ editSelectAttribute('Priority', 'High');
+ cy.wait(500);
+
+ // * Verify selected value is displayed
+ verifyAttributeValue('Priority', 'High');
+
+ // # Change selection
+ editSelectAttribute('Priority', 'Low');
+ cy.wait(500);
+
+ // * Verify updated value
+ verifyAttributeValue('Priority', 'Low');
+ });
+
+ it('can edit multiselect attribute value', () => {
+ // # Click on multiselect attribute
+ clickAttributeToEdit('Labels');
+
+ // # Select multiple options
+ cy.findByText('Bug').click();
+ cy.findByText('Enhancement').click();
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify both values are displayed
+ getAttributeRow('Labels').within(() => {
+ cy.contains('Bug').should('exist');
+ cy.contains('Enhancement').should('exist');
+ });
+
+ // # Add another selection
+ clickAttributeToEdit('Labels');
+ cy.findByText('Feature').click();
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify all three values displayed
+ getAttributeRow('Labels').within(() => {
+ cy.contains('Bug').should('exist');
+ cy.contains('Feature').should('exist');
+ cy.contains('Enhancement').should('exist');
+ });
+ });
+
+ it('can clear text attribute value', () => {
+ // # Set a value first
+ editTextAttribute('Notes', 'Test summary');
+ cy.wait(500);
+
+ // * Verify value is set
+ verifyAttributeValue('Notes', 'Test summary');
+
+ // # Click to edit and clear
+ clickAttributeToEdit('Notes');
+ cy.focused().clear();
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify empty state returns
+ verifyAttributeValue('Notes', 'Empty');
+ });
+
+ it('can clear select attribute value', () => {
+ // # Set a value first
+ editSelectAttribute('Priority', 'High');
+ cy.wait(500);
+
+ // * Verify value is set
+ verifyAttributeValue('Priority', 'High');
+
+ // # Click to edit
+ clickAttributeToEdit('Priority');
+
+ // # Click clear indicator
+ getAttributeRow('Priority').within(() => {
+ cy.get('div.property-select__clear-indicator').click();
+ });
+ cy.wait(500);
+
+ // * Verify empty state returns
+ verifyAttributeValue('Priority', 'Empty');
+ });
+
+ it('can clear multiselect attribute value', () => {
+ // # Set values first
+ clickAttributeToEdit('Labels');
+ cy.findByText('Bug').click();
+ cy.findByText('Feature').click();
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify values are set
+ getAttributeRow('Labels').within(() => {
+ cy.contains('Bug').should('exist');
+ cy.contains('Feature').should('exist');
+ });
+
+ // # Click to edit
+ clickAttributeToEdit('Labels');
+
+ cy.wait(500);
+
+ // # Click clear indicator
+ getAttributeRow('Labels').within(() => {
+ cy.get('div.property-select__clear-indicator').realClick();
+ });
+ cy.wait(500);
+
+ // * Verify empty state returns
+ verifyAttributeValue('Labels', 'Empty');
+ });
+ });
+
+ describe('from channel RHS', () => {
+ beforeEach(() => {
+ // # Navigate to the run's channel
+ cy.then(() => {
+ cy.visit(`/${testTeam.name}/channels/${testRun.channel_id}`);
+ });
+ });
+
+ it('can edit text attribute value', () => {
+ // # Edit text attribute
+ editTextAttribute('Notes', 'Channel edit notes');
+ cy.wait(500);
+
+ // * Verify value is displayed
+ verifyAttributeValue('Notes', 'Channel edit notes');
+ });
+
+ it('can edit URL attribute and displays as clickable link', () => {
+ // # Edit URL attribute with a real URL
+ const testUrl = 'https://docs.mattermost.com';
+ editTextAttribute('Documentation', testUrl);
+ cy.wait(500);
+
+ // * Verify URL is displayed as a clickable link
+ getAttributeRow('Documentation').within(() => {
+ cy.get('a').
+ should('exist').
+ should('have.attr', 'href', testUrl).
+ should('have.attr', 'target', '_blank').
+ should('have.attr', 'rel', 'noopener noreferrer').
+ should('contain', testUrl);
+ });
+
+ // # Capture current URL before navigating away
+ cy.url().as('currentUrl');
+
+ // * Verify the link is clickable and navigates correctly
+ getAttributeRow('Documentation').within(() => {
+ // # Remove target attribute to navigate in same window
+ cy.get('a').invoke('removeAttr', 'target').click();
+ });
+
+ // * Verify navigation occurred (wait for new page to load)
+ cy.url().should('include', 'docs.mattermost.com');
+
+ // # Go back to the channel
+ cy.go('back');
+
+ // * Verify we're back on the channel page
+ cy.get('@currentUrl').then((currentUrl) => {
+ cy.url().should('include', currentUrl);
+ });
+
+ // # Click on the wrapper (not on the link) to start editing
+ getAttributeRow('Documentation').within(() => {
+ cy.findByTestId('property-value').then(($el) => {
+ const rect = $el[0].getBoundingClientRect();
+ cy.wrap($el).click(rect.width - 10, rect.height - 10);
+ });
+ });
+
+ // * Verify input field appears (in edit mode)
+ getAttributeRow('Documentation').within(() => {
+ cy.get('input').should('exist').should('have.value', testUrl);
+ });
+
+ // # Update the URL
+ const newUrl = 'https://github.com/mattermost';
+ cy.focused().clear().type(newUrl);
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify new URL is displayed as a link
+ getAttributeRow('Documentation').within(() => {
+ cy.get('a').
+ should('have.attr', 'href', newUrl).
+ should('contain', newUrl);
+ });
+ });
+
+ it('can edit select attribute value', () => {
+ // # Edit select attribute
+ editSelectAttribute('Priority', 'Medium');
+ cy.wait(500);
+
+ // * Verify selected value is displayed
+ verifyAttributeValue('Priority', 'Medium');
+ });
+
+ it('can edit multiselect attribute value', () => {
+ // # Click on multiselect attribute
+ clickAttributeToEdit('Labels');
+
+ // # Select multiple options
+ cy.findByText('Feature').click();
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify value is displayed
+ getAttributeRow('Labels').within(() => {
+ cy.contains('Feature').should('exist');
+ });
+ });
+ });
+ });
+
+ describe('timeline entries for property changes', () => {
+ beforeEach(() => {
+ // # Create playbook with attributes
+ createPlaybookWithAttributes([
+ {name: 'Environment', type: 'text'},
+ {name: 'Severity', type: 'select', options: ['Low', 'Medium', 'High']},
+ ]);
+
+ // # Start a run
+ startRun('Timeline Test Run');
+ });
+
+ it('creates timeline entry when setting text property', () => {
+ // # Navigate to run
+ navigateToRun();
+
+ // # Set text property value
+ editTextAttribute('Environment', 'Production');
+ cy.wait(500);
+
+ // * Verify timeline entry exists with correct format
+ cy.get('[data-testid="timeline-item property_changed"]').should('exist');
+ cy.contains('set Environment to Production').should('exist');
+ });
+
+ it('creates timeline entry when clearing property', () => {
+ // # Navigate to run
+ navigateToRun();
+
+ // # Set and then clear property
+ editTextAttribute('Environment', 'Staging');
+ cy.wait(500);
+
+ clickAttributeToEdit('Environment');
+ cy.focused().clear();
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // * Verify timeline entries exist
+ cy.get('[data-testid="timeline-item property_changed"]').should('have.length.at.least', 2);
+ cy.contains('cleared Environment').should('exist');
+ });
+
+ it('creates timeline entry when updating select property', () => {
+ // # Navigate to run
+ navigateToRun();
+
+ // # Set initial value
+ editSelectAttribute('Severity', 'Low');
+ cy.wait(500);
+
+ // # Update to different value
+ editSelectAttribute('Severity', 'High');
+ cy.wait(500);
+
+ // * Verify timeline entries exist
+ cy.get('[data-testid="timeline-item property_changed"]').should('have.length.at.least', 2);
+ cy.contains('updated Severity from Low to High').should('exist');
+ });
+ });
+
+ describe('attribute independence', () => {
+ it('run attributes remain independent when playbook attributes change', () => {
+ // # Create playbook with attributes
+ createPlaybookWithAttributes([
+ {name: 'Instance ID', type: 'text'},
+ {name: 'Region', type: 'select', options: ['US-East', 'US-West', 'EU']},
+ ]);
+
+ // # Start a run
+ startRun('Test Run');
+
+ // # Navigate to run and set values
+ navigateToRun();
+ editTextAttribute('Instance ID', 'inst-001');
+ editSelectAttribute('Region', 'US-East');
+
+ // * Verify values are set
+ verifyAttributeValue('Instance ID', 'inst-001');
+ verifyAttributeValue('Region', 'US-East');
+
+ // # Navigate to playbook attributes tab
+ cy.then(() => {
+ cy.visit(`/playbooks/playbooks/${testPlaybook.id}/attributes`);
+ });
+
+ // # Remove Region attribute (should be at index 1)
+ cy.findAllByTestId('property-field-row').eq(1).within(() => {
+ cy.findByTestId('menuButton').click();
+ });
+ cy.findByText(/delete/i).click();
+ cy.get('#confirm-property-delete-modal').should('be.visible');
+ cy.findByRole('button', {name: /delete/i}).click();
+ cy.wait(500);
+
+ // # Add new attribute
+ cy.findByRole('button', {name: /add.*attribute/i}).click();
+ cy.wait(500);
+
+ // # Set attribute name
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ cy.findByLabelText('Attribute name').clear().type('Environment');
+ });
+ cy.get('body').click(0, 0);
+ cy.wait(500);
+
+ // # Change type to select
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ cy.findByRole('button', {name: 'Change attribute type'}).trigger('click');
+ });
+ cy.findByText(/^select$/i).click();
+ cy.wait(500);
+
+ // # Add options - rename Option 1 to Dev
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ cy.findByText('Option 1').click();
+ cy.wait(100);
+ });
+ cy.findByPlaceholderText('Enter value name').clear().type('Dev{enter}');
+ cy.wait(100);
+
+ // # Add Staging option
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ cy.findByRole('button', {name: 'Add value'}).click();
+ cy.wait(100);
+ });
+ cy.findAllByText(/^Option \d+$/).last().click();
+ cy.findByPlaceholderText('Enter value name').clear().type('Staging{enter}');
+ cy.wait(100);
+
+ // # Add Prod option
+ cy.findAllByTestId('property-field-row').last().within(() => {
+ cy.findByRole('button', {name: 'Add value'}).click();
+ cy.wait(100);
+ });
+ cy.findAllByText(/^Option \d+$/).last().click();
+ cy.findByPlaceholderText('Enter value name').clear().type('Prod{enter}');
+ cy.wait(100);
+
+ // # Navigate back to run
+ navigateToRun();
+
+ // * Verify run still has original attributes and values
+ verifyAttributeValue('Instance ID', 'inst-001');
+ verifyAttributeValue('Region', 'US-East');
+
+ // * Verify new playbook attribute does NOT appear on run
+ cy.findByRole('complementary').within(() => {
+ cy.findByText('Environment').should('not.exist');
+ });
+ });
+ });
+
+ /**
+ * Helper Functions
+ */
+
+ /**
+ * Navigate to the current test run
+ */
+ function navigateToRun() {
+ cy.then(() => {
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ });
+ }
+
+ /**
+ * Create a playbook with specified attributes
+ * @param {Array} attributes - Array of attribute objects {name, type, options, valueType}
+ */
+ function createPlaybookWithAttributes(attributes) {
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Playbook For Testing',
+ memberIDs: [testUser.id],
+ }).then((playbook) => {
+ testPlaybook = playbook;
+ });
+
+ // Add each attribute sequentially
+ attributes.forEach((attr, index) => {
+ cy.then(() => {
+ cy.apiAddPropertyField(testPlaybook.id, {
+ name: attr.name,
+ type: attr.type,
+ attrs: {
+ visibility: 'always',
+ sortOrder: index + 1,
+ options: attr.options ? attr.options.map((opt) => ({name: opt})) : undefined,
+ valueType: attr.valueType,
+ },
+ });
+ });
+ });
+ }
+
+ /**
+ * Start a run from the current test playbook
+ * @param {string} runName - Name for the run
+ */
+ function startRun(runName) {
+ cy.then(() => {
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPlaybook.id,
+ playbookRunName: runName,
+ ownerUserId: testUser.id,
+ }).then((run) => {
+ testRun = run;
+ });
+ });
+ }
+
+ /**
+ * Get the attribute row for a given attribute name
+ * @param {string} attributeName - Name of the attribute
+ */
+ function getAttributeRow(attributeName) {
+ const testId = `run-property-${attributeName.toLowerCase().replace(/\s+/g, '-')}`;
+ return cy.findByTestId(testId);
+ }
+
+ /**
+ * Verify an attribute exists in the RHS
+ * @param {string} attributeName - Name of the attribute
+ */
+ function verifyAttributeExists(attributeName) {
+ const testId = `run-property-${attributeName.toLowerCase().replace(/\s+/g, '-')}`;
+ cy.findByRole('complementary').within(() => {
+ cy.findByTestId(testId).should('exist');
+ });
+ }
+
+ /**
+ * Verify an attribute has a specific value
+ * @param {string} attributeName - Name of the attribute
+ * @param {string} expectedValue - Expected value text
+ */
+ function verifyAttributeValue(attributeName, expectedValue) {
+ getAttributeRow(attributeName).within(() => {
+ cy.contains(expectedValue).should('exist');
+ });
+ }
+
+ /**
+ * Click on an attribute to start editing
+ * @param {string} attributeName - Name of the attribute
+ */
+ function clickAttributeToEdit(attributeName) {
+ getAttributeRow(attributeName).within(() => {
+ // Click on the property value (empty state or existing value)
+ cy.findByTestId('property-value').click();
+ });
+ }
+
+ /**
+ * Edit a text attribute value
+ * @param {string} attributeName - Name of the attribute
+ * @param {string} value - Value to type
+ */
+ function editTextAttribute(attributeName, value) {
+ clickAttributeToEdit(attributeName);
+ cy.focused().type(value);
+ cy.get('body').click(0, 0);
+ }
+
+ /**
+ * Edit a select attribute value
+ * @param {string} attributeName - Name of the attribute
+ * @param {string} option - Option to select
+ */
+ function editSelectAttribute(attributeName, option) {
+ clickAttributeToEdit(attributeName);
+ cy.findByText(option).click();
+ }
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/taskinbox_spec.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/taskinbox_spec.js
new file mode 100644
index 00000000000..9637104669f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/runs/taskinbox_spec.js
@@ -0,0 +1,178 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('Task Inbox >', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+
+ let testViewerUser;
+ let testPublicPlaybook;
+ let testRun;
+
+ before(() => {
+ cy.apiInitSetup().then(({team, user}) => {
+ testTeam = team;
+ testUser = user;
+
+ cy.apiCreateCustomAdmin().then(({sysadmin: adminUser}) => {
+ cy.apiAddUserToTeam(testTeam.id, adminUser.id);
+ });
+
+ // Create another user in the same team
+ cy.apiCreateUser().then(({user: viewer}) => {
+ testViewerUser = viewer;
+ cy.apiAddUserToTeam(testTeam.id, testViewerUser.id);
+ });
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ // # Create a public playbook
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Public Playbook',
+ checklists: [
+ {
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ {title: 'Step 3'},
+ {title: 'Step 4'},
+ ],
+ },
+ ],
+ memberIDs: [],
+ }).then((playbook) => {
+ testPublicPlaybook = playbook;
+
+ cy.apiRunPlaybook({
+ teamId: testTeam.id,
+ playbookId: testPublicPlaybook.id,
+ playbookRunName: 'the run name',
+ ownerUserId: testUser.id,
+ }).then((playbookRun) => {
+ testRun = playbookRun;
+ cy.apiChangeChecklistItemAssignee(testRun.id, 0, 0, testUser.id);
+ });
+ });
+ });
+ });
+
+ beforeEach(() => {
+ // # Size the viewport to show the RHS without covering posts.
+ cy.viewport('macbook-13');
+
+ // # Login as testUser
+ cy.apiLogin(testUser);
+
+ cy.visit(`/playbooks/runs/${testRun.id}`);
+ cy.assertRunDetailsPageRenderComplete(testUser.username);
+ });
+
+ const getRHS = () => cy.get('#playbooks-backstage-sidebar-right');
+
+ it('icon in global header', () => {
+ // # Visit the playbooks product
+ cy.visit('/playbooks');
+
+ // # Verify icon present in global header icon to open
+ cy.findByTestId('header-task-inbox-icon').click();
+ });
+
+ it('icon toggles taskinbox view', () => {
+ // # Click on global header icon to open
+ cy.findByTestId('header-task-inbox-icon').click();
+
+ // * assert RHS is shown
+ getRHS().should('be.visible');
+
+ // * assert zero case
+ getRHS().within(() => {
+ cy.getStyledComponent('HeaderTitle').contains('Your tasks');
+ cy.getStyledComponent('Body').contains('1 assigned');
+ });
+
+ // # Click on global header icon to close
+ cy.findByTestId('header-task-inbox-icon').click();
+
+ // * assert RHS is not shown
+ getRHS().should('not.exist');
+ });
+
+ it('show unassigned tasks from runs I own', () => {
+ // # Click on global header icon to open
+ cy.findByTestId('header-task-inbox-icon').click();
+
+ // * assert 4 tasks are shown (all tasks from runs I own enabled by default)
+ getRHS().within(() => {
+ cy.getStyledComponent('TaskList').within(() => {
+ cy.getStyledComponent('Container').should('have.length', 4);
+ });
+ });
+ });
+
+ it('show only assigned tasks', () => {
+ // # Click on global header icon to open
+ cy.findByTestId('header-task-inbox-icon').click();
+
+ getRHS().within(() => {
+ cy.getStyledComponent('TaskList').within(() => {
+ // * assert 4 tasks are shown
+ cy.getStyledComponent('Container').should('have.length', 4);
+ });
+
+ // # Click on filters
+ cy.findByText('Filters').click();
+ });
+
+ // # Deactivate show alltasks
+ cy.findByText('Show all tasks from runs I own').click();
+
+ cy.getStyledComponent('TaskList').within(() => {
+ // * assert 1 tasks are shown
+ cy.getStyledComponent('Container').should('have.length', 1);
+ });
+ });
+
+ it('tasks can be checked', () => {
+ // # Click on global header icon to open
+ cy.findByTestId('header-task-inbox-icon').click();
+
+ getRHS().within(() => {
+ cy.getStyledComponent('TaskList').within(() => {
+ // * assert 4 tasks are shown
+ cy.getStyledComponent('Container').should('have.length', 4);
+
+ // # Check the first task
+ cy.getStyledComponent('Container').eq(0).within(() => {
+ cy.get('input').click();
+ });
+
+ // * assert 3 tasks are shown
+ cy.getStyledComponent('Container').should('have.length', 3);
+ });
+
+ // # Click on filters
+ cy.findByText('Filters').click();
+ });
+
+ // # Activate checked task visibility in filters
+ cy.findByText('Show checked tasks').click();
+
+ getRHS().within(() => {
+ cy.getStyledComponent('TaskList').within(() => {
+ // * assert 4 tasks are shown
+ cy.getStyledComponent('Container').should('have.length', 4);
+ });
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/tours_spec_ignore_.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/tours_spec_ignore_.js
new file mode 100644
index 00000000000..10983a06dcb
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/integration/playbooks/tours_spec_ignore_.js
@@ -0,0 +1,121 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***************************************************************
+// - [#] indicates a test step (e.g. # Go to a page)
+// - [*] indicates an assertion (e.g. * Check the title)
+// ***************************************************************
+
+// Stage: @prod
+// Group: @playbooks
+
+describe('playbook tour points', {testIsolation: true}, () => {
+ let testTeam;
+ let testUser;
+ let testSysadmin;
+ beforeEach(() => {
+ cy.apiInitSetup({promoteNewUserAsAdmin: true}).then(({team, user: sysadmin}) => {
+ testTeam = team;
+ testSysadmin = sysadmin;
+
+ // # Create a user with tutorials enabled
+ cy.apiCreateUser({bypassTutorial: false}).then(({user: userWithTours}) => {
+ testUser = userWithTours;
+ cy.apiAddUserToTeam(team.id, testUser.id);
+ cy.apiLogin(userWithTours);
+ });
+ });
+ });
+
+ afterEach(() => {
+ // # Ensure apiInitSetup() can run again
+ cy.apiLogin(testSysadmin);
+ });
+
+ it('creation tour', () => {
+ // # Open creation view from RHS
+ cy.visit(`/${testTeam.name}/channels/town-square`);
+ cy.get('#incidentIcon').click({force: true});
+ cy.findByRole('button', {name: /create playbook/i}).click();
+ cy.url().should('contain', '/playbooks/playbooks/new');
+
+ // * Verify the tutorial steps
+ cy.contains('Create and assign tasks').should('be.visible');
+ cy.findByRole('button', {name: /next/i}).click();
+
+ cy.contains('Set up assumptions').should('be.visible');
+ cy.findByRole('button', {name: /next/i}).click();
+
+ cy.contains('Keep stakeholders updated').should('be.visible');
+ cy.findByRole('button', {name: /next/i}).click();
+
+ cy.contains('Learn AND reflect').should('be.visible');
+ cy.findByRole('button', {name: /done/i}).click();
+ });
+
+ it('preview tour', () => {
+ // # Make a playbook to preview
+ cy.apiCreatePlaybook({
+ teamId: testTeam.id,
+ title: 'Preview Tour Test Playbook',
+ memberIDs: [],
+ }).then(() => {
+ // # Open the playbook
+ cy.visit('/playbooks/playbooks');
+ cy.findByText('Preview Tour Test Playbook').click();
+
+ // * Verify the tutorial steps
+ cy.contains('Welcome to the playbook preview page!').should('be.visible');
+ cy.findByRole('button', {name: /next/i}).click();
+
+ cy.contains('different sections of the playbook').should('be.visible');
+ cy.findByRole('button', {name: /next/i}).click();
+
+ cy.contains('Ready to run your playbook?').should('be.visible');
+ cy.findByRole('button', {name: /done/i}).click();
+ });
+ });
+
+ describe('run tour', () => {
+ beforeEach(() => {
+ // # Disable the preview tour which we would otherwise see
+ cy.apiSaveUserPreference([{
+ user_id: testUser.id,
+ category: 'playbook_preview',
+ name: testUser.id,
+ value: '999',
+ }], testUser.id);
+
+ // # Start a run from the tutorial template
+ cy.visit('/playbooks/playbooks');
+ cy.findByText('Learn how to use playbooks').click();
+ cy.findByRole('button', {name: /run playbook/i}).click({force: true});
+
+ // * Verify the tour confirmation modal is shown (other tours don't have one)
+ cy.contains('auto-created your run').should('be.visible');
+ });
+
+ it('follows the tour when chosen from modal', () => {
+ // # Accept the tour
+ cy.contains('quick tour').click();
+
+ // * Verify the tutorial steps
+ cy.contains('See who is involved').should('be.visible');
+ cy.findByRole('button', {name: /next/i}).click();
+
+ cy.contains('Post status updates').should('be.visible');
+ cy.findByRole('button', {name: /next/i}).click();
+
+ cy.contains('Track progress and ownership').should('be.visible');
+ cy.findByRole('button', {name: /done/i}).click();
+ });
+
+ it('does not follow the tour when dismissed from modal', () => {
+ // # Dismiss the tour
+ cy.findByRole('button', {name: /let me explore/i}).click();
+
+ // * Verify the first step is _not_ shown
+ cy.contains('See who is involved').should('not.exist');
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/client_request.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/client_request.js
new file mode 100644
index 00000000000..d6b7c3c6327
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/client_request.js
@@ -0,0 +1,36 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const axios = require('axios');
+
+module.exports = async ({data = {}, headers, method = 'get', url}) => {
+ let response;
+
+ try {
+ response = await axios({
+ data,
+ headers,
+ method,
+ url,
+ });
+ } catch (error) {
+ // If we have a response for the error, pull out the relevant parts
+ if (error.response) {
+ response = {
+ status: error.response.status,
+ statusText: error.response.statusText,
+ data: error.response.data,
+ };
+ } else {
+ // If we get here something else went wrong, so throw
+ throw error;
+ }
+ }
+
+ return {
+ data: response.data,
+ headers: response.headers,
+ status: response.status,
+ statusText: response.statusText,
+ };
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/db_request.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/db_request.js
new file mode 100644
index 00000000000..2579f44bbc2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/db_request.js
@@ -0,0 +1,124 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const mapKeys = require('lodash.mapkeys');
+
+function convertKeysToLowercase(obj) {
+ return mapKeys(obj, (_, k) => {
+ return k.toLowerCase();
+ });
+}
+
+function getKnexClient({client, connection}) {
+ return require('knex')({client, connection}); // eslint-disable-line global-require
+}
+
+// Reuse DB client connection
+let knexClient;
+
+const dbGetActiveUserSessions = async ({dbConfig, params: {username, userId, limit}}) => {
+ if (!knexClient) {
+ knexClient = getKnexClient(dbConfig);
+ }
+
+ const maxLimit = 50;
+
+ try {
+ let user;
+ if (username) {
+ user = await knexClient(toLowerCase(dbConfig, 'Users')).where('username', username).first();
+ user = convertKeysToLowercase(user);
+ }
+
+ const now = Date.now();
+ const sessions = await knexClient(toLowerCase(dbConfig, 'Sessions')).
+ where('userid', user ? user.id : userId).
+ where('expiresat', '>', now).
+ orderBy('lastactivityat', 'desc').
+ limit(limit && limit <= maxLimit ? limit : maxLimit);
+
+ return {
+ user,
+ sessions: sessions.map((session) => convertKeysToLowercase(session)),
+ };
+ } catch (error) {
+ const errorMessage = 'Failed to get active user sessions from the database.';
+ return {error, errorMessage};
+ }
+};
+
+const dbGetUser = async ({dbConfig, params: {username}}) => {
+ if (!knexClient) {
+ knexClient = getKnexClient(dbConfig);
+ }
+
+ try {
+ const user = await knexClient(toLowerCase(dbConfig, 'Users')).where('username', username).first();
+
+ return {user: convertKeysToLowercase(user)};
+ } catch (error) {
+ const errorMessage = 'Failed to get a user from the database.';
+ return {error, errorMessage};
+ }
+};
+
+const dbGetUserSession = async ({dbConfig, params: {sessionId}}) => {
+ if (!knexClient) {
+ knexClient = getKnexClient(dbConfig);
+ }
+
+ try {
+ const session = await knexClient(toLowerCase(dbConfig, 'Sessions')).
+ where('id', '=', sessionId).
+ first();
+
+ return {session: convertKeysToLowercase(session)};
+ } catch (error) {
+ const errorMessage = 'Failed to get a user session from the database.';
+ return {error, errorMessage};
+ }
+};
+
+const dbUpdateUserSession = async ({dbConfig, params: {sessionId, userId, fieldsToUpdate = {}}}) => {
+ if (!knexClient) {
+ knexClient = getKnexClient(dbConfig);
+ }
+
+ try {
+ let user = await knexClient(toLowerCase(dbConfig, 'Users')).where('id', userId).first();
+ if (!user) {
+ return {errorMessage: `No user found with id: ${userId}.`};
+ }
+
+ delete fieldsToUpdate.id;
+ delete fieldsToUpdate.userid;
+
+ user = convertKeysToLowercase(user);
+
+ await knexClient(toLowerCase(dbConfig, 'Sessions')).
+ where('id', '=', sessionId).
+ where('userid', '=', user.id).
+ update(fieldsToUpdate);
+
+ const session = await knexClient(toLowerCase(dbConfig, 'Sessions')).
+ where('id', '=', sessionId).
+ where('userid', '=', user.id).
+ first();
+
+ return {session: convertKeysToLowercase(session)};
+ } catch (error) {
+ const errorMessage = 'Failed to update a user session from the database.';
+ return {error, errorMessage};
+ }
+};
+
+function toLowerCase(config, name) {
+ return name.toLowerCase();
+}
+
+module.exports = {
+ dbGetActiveUserSessions,
+ dbGetUser,
+ dbGetUserSession,
+ dbUpdateUserSession,
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/external_request.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/external_request.ts
new file mode 100644
index 00000000000..6a9aefc9985
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/external_request.ts
@@ -0,0 +1,82 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import axios, {AxiosError, Method} from 'axios';
+
+import * as timeouts from '../fixtures/timeouts';
+
+export interface ExternalRequestUser{
+ username: string;
+ password: string;
+}
+interface ExternalRequestArg {
+ baseUrl: string;
+ user: ExternalRequestUser;
+ method: Method;
+ path: string;
+ data: any;
+}
+type ExternalRequestResult = { status: number; statusText: string; data: any; isError?: boolean } | { data: { id: string; isTimeout: boolean }; status?: undefined; statusText?: undefined; isError?: undefined };
+export default async function externalRequest(arg: ExternalRequestArg): Promise {
+ const {baseUrl, user, method = 'get', path, data = {}} = arg;
+ const loginUrl = `${baseUrl}/api/v4/users/login`;
+
+ // First we need to login with our external user to get cookies/tokens
+ let cookieString = '';
+ try {
+ const response = await axios({
+ url: loginUrl,
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'post',
+ timeout: timeouts.TEN_SEC,
+ data: {login_id: user.username, password: user.password},
+ });
+
+ const setCookie = response.headers['set-cookie'];
+ (setCookie as any).forEach((cookie: string) => {
+ const nameAndValue = cookie.split(';')[0];
+ cookieString += nameAndValue + ';';
+ });
+ } catch (error) {
+ return getErrorResponse(error);
+ }
+
+ try {
+ const response = await axios({
+ method,
+ url: `${baseUrl}/api/v4/${path}`,
+ headers: {
+ 'Content-Type': 'text/plain',
+ Cookie: cookieString,
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ timeout: timeouts.TEN_SEC,
+ data,
+ });
+
+ return {
+ status: response.status,
+ statusText: response.statusText,
+ data: response.data,
+ };
+ } catch (error) {
+ // If we have a response for the error, pull out the relevant parts
+ return getErrorResponse(error);
+ }
+}
+
+function getErrorResponse(error: AxiosError) {
+ if (error.response) {
+ return {
+ status: error.response.status,
+ statusText: error.response.statusText,
+ data: error.response.data,
+ isError: true,
+ };
+ } else if (error.code === 'ECONNABORTED') {
+ return {data: {id: error.code, isTimeout: true}};
+ }
+
+ // If we get here something else went wrong, so throw
+ throw error;
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/file_util.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/file_util.js
new file mode 100644
index 00000000000..375a4b5d5c2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/file_util.js
@@ -0,0 +1,39 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const fs = require('fs');
+
+const path = require('path');
+
+/**
+ * Checks whether a file exist in the fixtures folder
+ * @param {string} filename - filename to check if it exists
+ */
+const fileExist = (filename) => {
+ const filePath = path.resolve(__dirname, `../fixtures/${filename}`);
+
+ return fs.existsSync(filePath);
+};
+
+/**
+ * Write data to a file in the fixtures folder
+ * @param {string} filename - filename where to write data into
+ * @param {string} fixturesFolder - folder at tests/fixtures
+ * @param {string} data - The data to write
+ */
+const writeToFile = ({filename, fixturesFolder, data = ''}) => {
+ const folder = path.resolve(__dirname, `../fixtures/${fixturesFolder}`);
+ if (!fs.existsSync(folder)) {
+ fs.mkdirSync(folder, {recursive: true});
+ }
+
+ const filePath = `${folder}/${filename}`;
+
+ fs.writeFileSync(filePath, data);
+ return null;
+};
+
+module.exports = {
+ fileExist,
+ writeToFile,
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/get_pdf_content.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/get_pdf_content.js
new file mode 100644
index 00000000000..819aba38c28
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/get_pdf_content.js
@@ -0,0 +1,16 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const fs = require('fs');
+
+const pdf = require('pdf-parse');
+
+/**
+ * Checks whether a file exist in the tests/downloads folder and return the content of it.
+ * @param {string} filePath - pdf file path
+ */
+module.exports = async (filePath) => {
+ const dataBuffer = fs.readFileSync(filePath);
+ const data = await pdf(dataBuffer);
+ return data;
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/get_recent_email.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/get_recent_email.js
new file mode 100644
index 00000000000..ee183b535e8
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/get_recent_email.js
@@ -0,0 +1,32 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const axios = require('axios');
+
+module.exports = async ({username, mailUrl}) => {
+ const mailboxUrl = `${mailUrl}/${username}`;
+ let response;
+ let recentEmail;
+
+ try {
+ response = await axios({url: mailboxUrl, method: 'get'});
+ recentEmail = response.data[response.data.length - 1];
+ } catch (error) {
+ return {status: error.status, data: null};
+ }
+
+ if (!recentEmail || !recentEmail.id) {
+ return {status: 501, data: null};
+ }
+
+ let recentEmailMessage;
+ const mailMessageUrl = `${mailboxUrl}/${recentEmail.id}`;
+ try {
+ response = await axios({url: mailMessageUrl, method: 'get'});
+ recentEmailMessage = response.data;
+ } catch (error) {
+ return {status: error.status, data: null};
+ }
+
+ return {status: response.status, data: recentEmailMessage};
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/index.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/index.js
new file mode 100644
index 00000000000..a8b26b7172e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/index.js
@@ -0,0 +1,76 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+/* eslint-disable no-console */
+
+const clientRequest = require('./client_request');
+const {
+ dbGetActiveUserSessions,
+ dbGetUser,
+ dbGetUserSession,
+ dbUpdateUserSession,
+} = require('./db_request');
+const externalRequest = require('./external_request').default;
+const {fileExist, writeToFile} = require('./file_util');
+const getPdfContent = require('./get_pdf_content');
+const getRecentEmail = require('./get_recent_email');
+const keycloakRequest = require('./keycloak_request');
+const oktaRequest = require('./okta_request');
+const postBotMessage = require('./post_bot_message');
+const postIncomingWebhook = require('./post_incoming_webhook');
+const postMessageAs = require('./post_message_as');
+const postListOfMessages = require('./post_list_of_messages');
+const reactToMessageAs = require('./react_to_message_as');
+const {
+ shellFind,
+ shellRm,
+ shellUnzip,
+} = require('./shell');
+const urlHealthCheck = require('./url_health_check');
+
+const log = (message) => {
+ console.log(message);
+ return null;
+};
+
+module.exports = (on, config) => {
+ on('task', {
+ clientRequest,
+ dbGetActiveUserSessions,
+ dbGetUser,
+ dbGetUserSession,
+ dbUpdateUserSession,
+ externalRequest,
+ fileExist,
+ writeToFile,
+ getPdfContent,
+ getRecentEmail,
+ keycloakRequest,
+ log,
+ oktaRequest,
+ postBotMessage,
+ postIncomingWebhook,
+ postMessageAs,
+ postListOfMessages,
+ urlHealthCheck,
+ reactToMessageAs,
+ shellFind,
+ shellRm,
+ shellUnzip,
+ });
+
+ on('before:browser:launch', (browser = {}, launchOptions) => {
+ if (browser.name === 'chrome' && !config.chromeWebSecurity) {
+ launchOptions.args.push('--disable-features=CrossSiteDocumentBlockingIfIsolating,CrossSiteDocumentBlockingAlways,IsolateOrigins,site-per-process');
+ launchOptions.args.push('--load-extension=tests/extensions/Ignore-X-Frame-headers');
+ }
+
+ if (browser.family === 'chromium' && browser.name !== 'electron') {
+ launchOptions.args.push('--disable-dev-shm-usage');
+ }
+
+ return launchOptions;
+ });
+
+ return config;
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/keycloak_request.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/keycloak_request.js
new file mode 100644
index 00000000000..a4fb5e836f0
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/keycloak_request.js
@@ -0,0 +1,36 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const axios = require('axios');
+
+module.exports = async ({baseUrl, headers = [], method = 'get', path = '', data = {}}) => {
+ let response;
+ try {
+ response = await axios({
+ method,
+ url: `${baseUrl}/${path}`,
+ headers,
+ data,
+ });
+
+ return {
+ status: response.status,
+ statusText: response.statusText,
+ data: response.data,
+ };
+ } catch (error) {
+ // If we have a response for the error, pull out the relevant parts
+ if (error.response) {
+ response = {
+ status: error.response.status,
+ statusText: error.response.statusText,
+ data: error.response.data,
+ };
+ } else {
+ // If we get here something else went wrong, so throw
+ throw error;
+ }
+ }
+
+ return response;
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/okta_request.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/okta_request.js
new file mode 100644
index 00000000000..58388d150e1
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/okta_request.js
@@ -0,0 +1,40 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const axios = require('axios');
+
+module.exports = async ({baseUrl, urlSuffix, method = 'get', token, data = {}}) => {
+ let response;
+
+ try {
+ response = await axios({
+ url: baseUrl + urlSuffix,
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest',
+ Authorization: token,
+ },
+ method,
+ data,
+ });
+
+ return {
+ status: response.status,
+ statusText: response.statusText,
+ data: response.data,
+ };
+ } catch (error) {
+ // If we have a response for the error, pull out the relevant parts
+ if (error.response) {
+ response = {
+ status: error.response.status,
+ statusText: error.response.statusText,
+ data: error.response.data,
+ };
+ } else {
+ // If we get here something else went wrong, so throw
+ throw error;
+ }
+ }
+
+ return response;
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_bot_message.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_bot_message.js
new file mode 100644
index 00000000000..29406244f9d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_bot_message.js
@@ -0,0 +1,32 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const axios = require('axios');
+module.exports = async ({token, message, props = {}, channelId, rootId, createAt = 0, baseUrl}) => {
+ let response;
+ try {
+ response = await axios({
+ url: `${baseUrl}/api/v4/posts`,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ Authorization: `Bearer ${token}`,
+ },
+ method: 'post',
+ data: {
+ channel_id: channelId,
+ message,
+ props,
+ type: '',
+ create_at: createAt,
+ root_id: rootId,
+ },
+ });
+ } catch (err) {
+ if (err.response) {
+ response = err.response;
+ }
+ }
+
+ return {status: response.status, data: response.data};
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_incoming_webhook.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_incoming_webhook.js
new file mode 100644
index 00000000000..585a89a1f1b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_incoming_webhook.js
@@ -0,0 +1,18 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const axios = require('axios');
+
+module.exports = async ({url, data}) => {
+ let response;
+
+ try {
+ response = await axios({method: 'post', url, data});
+ } catch (err) {
+ if (err.response) {
+ response = err.response;
+ }
+ }
+
+ return {status: response.status, data: response.data};
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_list_of_messages.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_list_of_messages.js
new file mode 100644
index 00000000000..468ac4bdf85
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_list_of_messages.js
@@ -0,0 +1,18 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const postMessageAs = require('./post_message_as');
+
+module.exports = async ({numberOfMessages, ...rest}) => {
+ const results = [];
+
+ for (let i = 0; i < numberOfMessages; i++) {
+ // Parallel posting of the messages (Promise.all) is not handled well by the server
+ // resulting in random failed posts
+ // so we use serial posting
+ // eslint-disable-next-line no-await-in-loop
+ results.push(await postMessageAs({message: `Message ${i}`, ...rest}));
+ }
+
+ return results;
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_message_as.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_message_as.js
new file mode 100644
index 00000000000..3ec297121f2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/post_message_as.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};
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/react_to_message_as.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/react_to_message_as.js
new file mode 100644
index 00000000000..58937fff2c7
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/react_to_message_as.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, postId, reaction, 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/reactions`,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ Cookie: cookieString,
+ },
+ method: 'post',
+ data: {
+ user_id: sender.id,
+ post_id: postId,
+ emoji_name: reaction,
+ },
+ });
+ } catch (err) {
+ if (err.response) {
+ response = err.response;
+ }
+ }
+
+ return {status: response.status, data: response.data};
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/shell.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/shell.js
new file mode 100644
index 00000000000..e40e7651b50
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/shell.js
@@ -0,0 +1,30 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const extractZip = require('extract-zip');
+const shell = require('shelljs');
+
+const shellFind = ({path, pattern}) => {
+ return shell.find(path).filter((file) => {
+ return file.match(pattern);
+ });
+};
+
+const shellRm = ({option, file}) => {
+ return shell.rm(option, file);
+};
+
+const shellUnzip = async ({source, target}) => {
+ try {
+ await extractZip(source, {dir: target});
+ return null;
+ } catch (err) {
+ return err;
+ }
+};
+
+module.exports = {
+ shellFind,
+ shellRm,
+ shellUnzip,
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/url_health_check.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/url_health_check.js
new file mode 100644
index 00000000000..866d52eb590
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/plugins/url_health_check.js
@@ -0,0 +1,14 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const axios = require('axios');
+
+module.exports = async ({url, method}) => {
+ let response;
+ try {
+ response = await axios({url, method});
+ return {data: response.data, status: response.status, success: true};
+ } catch (err) {
+ return {success: false, errorCode: err.code};
+ }
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/bots.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/bots.d.ts
new file mode 100644
index 00000000000..08878ac7024
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/bots.d.ts
@@ -0,0 +1,67 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Create a bot.
+ * See https://api.mattermost.com/#tag/bots/paths/~1bots/post
+ * @param {string} options.bot - predefined `bot` object instead of random bot
+ * @param {string} options.prefix - 'bot' (default) or any prefix to easily identify a bot
+ * @returns {Bot} out.bot: `Bot` object
+ *
+ * @example
+ * cy.apiCreateBot().then(({bot}) => {
+ * // do something with bot
+ * });
+ */
+ apiCreateBot({bot: BotPatch, prefix: string}?): Chainable<{bot: Bot & {fullDisplayName: string}}>;
+
+ /**
+ * Get bots.
+ * See https://api.mattermost.com/#tag/bots/paths/~1bots/get
+ * @param {number} options.page - The page to select
+ * @param {number} options.perPage - The number of users per page. There is a maximum limit of 200 users per page
+ * @param {boolean} options.includeDeleted - If deleted bots should be returned
+ * @returns {Bot[]} out.bots: `Bot[]` object
+ *
+ * @example
+ * cy.apiGetBots();
+ */
+ apiGetBots(page: number, perPage: number, includeDeleted: boolean): Chainable<{bots: Bot[]}>;
+
+ /**
+ * Disable bot.
+ * See https://api.mattermost.com/#tag/bots/operation/DisableBot
+ * @param {string} userId - User ID
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiDisableBot('user-id);
+ */
+ apiDisableBot(userId: string): Chainable;
+
+ /**
+ * Deactivate test bots.
+ *
+ * @example
+ * cy.apiDeactivateTestBots();
+ */
+ apiDeactivateTestBots(): Chainable<>;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/bots.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/bots.js
new file mode 100644
index 00000000000..7e72f9483fb
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/bots.js
@@ -0,0 +1,73 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getRandomId} from '../../utils';
+
+// *****************************************************************************
+// Bots
+// https://api.mattermost.com/#tag/bots
+// *****************************************************************************
+
+Cypress.Commands.add('apiCreateBot', ({prefix, bot = createBotPatch(prefix)} = {}) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/bots',
+ method: 'POST',
+ body: bot,
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ const {body} = response;
+ return cy.wrap({
+ bot: {
+ ...body,
+ fullDisplayName: `${body.display_name} (@${body.username})`,
+ },
+ });
+ });
+});
+
+Cypress.Commands.add('apiGetBots', (page = 0, perPage = 200, includeDeleted = false) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/bots?page=${page}&per_page=${perPage}&include_deleted=${includeDeleted}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({bots: response.body});
+ });
+});
+
+Cypress.Commands.add('apiDisableBot', (userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/bots/${userId}/disable`,
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+export function createBotPatch(prefix = 'bot') {
+ const randomId = getRandomId();
+
+ return {
+ username: `${prefix}-${randomId}`,
+ display_name: `Test Bot ${randomId}`,
+ description: `Test bot description ${randomId}`,
+ };
+}
+
+Cypress.Commands.add('apiDeactivateTestBots', () => {
+ return cy.apiGetBots().then(({bots}) => {
+ bots.forEach((bot) => {
+ if (bot?.display_name?.includes('Test Bot') || bot?.username.startsWith('bot-')) {
+ cy.apiDisableBot(bot.user_id);
+ cy.apiDeactivateUser(bot.user_id);
+
+ // Log for debugging
+ cy.log(`Deactivated Bot: "${bot.username}"`);
+ }
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/brand.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/brand.d.ts
new file mode 100644
index 00000000000..ed1da8ffa3a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/brand.d.ts
@@ -0,0 +1,32 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Delete the custom brand image.
+ * See https://api.mattermost.com/#tag/brand/paths/~1brand~1image/delete
+ * @returns {Response} response: Cypress-chainable response which should have either a successful HTTP status of 200 OK
+ * or a 404 Not Found in case that the image didn't exists to continue or pass.
+ *
+ * @example
+ * cy.apiDeleteBrandImage();
+ */
+ apiDeleteBrandImage(): Chainable>;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/brand.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/brand.js
new file mode 100644
index 00000000000..480a78acfad
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/brand.js
@@ -0,0 +1,20 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// Brand
+// https://api.mattermost.com/#tag/brand
+// *****************************************************************************
+
+Cypress.Commands.add('apiDeleteBrandImage', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/brand/image',
+ method: 'DELETE',
+ failOnStatusCode: false,
+ }).then((response) => {
+ // both deleted and not existing responses are valid
+ expect(response.status).to.be.oneOf([200, 404]);
+ return cy.wrap(response);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/channel.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/channel.d.ts
new file mode 100644
index 00000000000..6affd09b3b4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/channel.d.ts
@@ -0,0 +1,216 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Create a new channel.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels/post
+ * @param {String} teamId - Unique handler for a team, will be present in the team URL
+ * @param {String} name - Unique handler for a channel, will be present in the team URL
+ * @param {String} displayName - Non-unique UI name for the channel
+ * @param {String} type - 'O' for a public channel (default), 'P' for a private channel
+ * @param {String} purpose - A short description of the purpose of the channel
+ * @param {String} header - Markdown-formatted text to display in the header of the channel
+ * @param {Boolean} [unique=true] - if true (default), it will create with unique/random channel name.
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiCreateChannel('team-id', 'test-channel', 'Test Channel').then(({channel}) => {
+ * // do something with channel
+ * });
+ */
+ apiCreateChannel(
+ teamId: string,
+ name: string,
+ displayName: string,
+ type?: string,
+ purpose?: string,
+ header?: string,
+ unique: boolean = true
+ ): Chainable<{channel: Channel}>;
+
+ /**
+ * Create a new direct message channel between two users.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1direct/post
+ * @param {string[]} userIds - The two user ids to be in the direct message
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiCreateDirectChannel(['user-1-id', 'user-2-id']).then(({channel}) => {
+ * // do something with channel
+ * });
+ */
+ apiCreateDirectChannel(userIds: string[]): Chainable<{channel: Channel}>;
+
+ /**
+ * Create a new group message channel to group of users via API. If the logged in user's id is not included in the list, it will be appended to the end.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1group/post
+ * @param {string[]} userIds - User ids to be in the group message channel
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiCreateGroupChannel(['user-1-id', 'user-2-id', 'current-user-id']).then(({channel}) => {
+ * // do something with channel
+ * });
+ */
+ apiCreateGroupChannel(userIds: string[]): Chainable<{channel: Channel}>;
+
+ /**
+ * Update a channel.
+ * The fields that can be updated are listed as parameters. Omitted fields will be treated as blanks.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1{channel_id}/put
+ * @param {string} channelId - The channel ID to be updated
+ * @param {Channel} channel - Channel object to be updated
+ * @param {string} channel.name - The unique handle for the channel, will be present in the channel URL
+ * @param {string} channel.display_name - The non-unique UI name for the channel
+ * @param {string} channel.purpose - A short description of the purpose of the channel
+ * @param {string} channel.header - Markdown-formatted text to display in the header of the channel
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiUpdateChannel('channel-id', {name: 'new-name', display_name: 'New Display Name'. 'purpose': 'Updated purpose', 'header': 'Updated header'});
+ */
+ apiUpdateChannel(channelId: string, channel: Channel): Chainable<{channel: Channel}>;
+
+ /**
+ * Partially update a channel by providing only the fields you want to update.
+ * Omitted fields will not be updated.
+ * The fields that can be updated are defined in the request body, all other provided fields will be ignored.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1{channel_id}~1patch/put
+ * @param {string} channelId - The channel ID to be patched
+ * @param {Channel} channel - Channel object to be patched
+ * @param {string} channel.name - The unique handle for the channel, will be present in the channel URL
+ * @param {string} channel.display_name - The non-unique UI name for the channel
+ * @param {string} channel.purpose - A short description of the purpose of the channel
+ * @param {string} channel.header - Markdown-formatted text to display in the header of the channel
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiPatchChannel('channel-id', {name: 'new-name', display_name: 'New Display Name'});
+ */
+ apiPatchChannel(channelId: string, channel: Partial): Chainable<{channel: Channel}>;
+
+ /**
+ * Updates channel's privacy allowing changing a channel from Public to Private and back.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1{channel_id}~1privacy/put
+ * @param {string} channelId - The channel ID to be patched
+ * @param {string} privacy - The privacy the channel should be set too. P = Private, O = Open
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiPatchChannelPrivacy('channel-id', 'P');
+ */
+ apiPatchChannelPrivacy(channelId: string, privacy: string): Chainable<{channel: Channel}>;
+
+ /**
+ * Get channel from the provided channel id string.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1{channel_id}/get
+ * @param {string} channelId - Channel ID
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiGetChannel('channel-id').then(({channel}) => {
+ * // do something with channel
+ * });
+ */
+ apiGetChannel(channelId: string): Chainable<{channel: Channel}>;
+
+ /**
+ * Gets a channel from the provided team name and channel name strings.
+ * See https://api.mattermost.com/#tag/channels/paths/~1teams~1name~1{team_name}~1channels~1name~1{channel_name}/get
+ * @param {string} teamName - Team name
+ * @param {string} channelName - Channel name
+ * @returns {Channel} `out.channel` as `Channel`
+ *
+ * @example
+ * cy.apiGetChannelByName('team-name', 'channel-name').then(({channel}) => {
+ * // do something with channel
+ * });
+ */
+ apiGetChannelByName(teamName: string, channelName: string): Chainable<{channel: Channel}>;
+
+ /**
+ * Get a list of all channels.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels/get
+ * @returns {Channel[]} `out.channels` as `Channel[]`
+ *
+ * @example
+ * cy.apiGetAllChannels().then(({channels}) => {
+ * // do something with channels
+ * });
+ */
+ apiGetAllChannels(): Chainable<{channels: Channel[]}>;
+
+ /**
+ * Get channels for user.
+ * See https://api.mattermost.com/#tag/channels/paths/~1users~1{user_id}~1teams~1{team_id}~1channels/get
+ * @returns {Channel[]} `out.channels` as `Channel[]`
+ *
+ * @example
+ * cy.apiGetChannelsForUser().then(({channels}) => {
+ * // do something with channels
+ * });
+ */
+ apiGetChannelsForUser(): Chainable<{channels: Channel[]}>;
+
+ /**
+ * Soft deletes a channel, by marking the channel as deleted in the database.
+ * Soft deleted channels will not be accessible in the user interface.
+ * Direct and group message channels cannot be deleted.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1{channel_id}/delete
+ * @param {string} channelId - The channel ID to be deleted
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiDeleteChannel('channel-id');
+ */
+ apiDeleteChannel(channelId: string): Chainable;
+
+ /**
+ * Add a user to a channel by creating a channel member object.
+ * See https://api.mattermost.com/#tag/channels/paths/~1channels~1{channel_id}~1members/post
+ * @param {string} channelId - Channel ID
+ * @param {string} userId - User ID to add to the channel
+ * @returns {ChannelMembership} `out.member` as `ChannelMembership`
+ *
+ * @example
+ * cy.apiAddUserToChannel('channel-id', 'user-id').then(({member}) => {
+ * // do something with member
+ * });
+ */
+ apiAddUserToChannel(channelId: string, userId: string): Chainable;
+
+ /**
+ * Convenient command that create, post into and then archived a channel.
+ * @param {string} name - name of channel to be created
+ * @param {string} displayName - display name of channel to be created
+ * @param {string} type - type of channel
+ * @param {string} teamId - team Id where the channel will be added
+ * @param {string[]} [messages] - messages to be posted before archiving a channel
+ * @param {UserProfile} [user] - user who will be posting the messages
+ * @returns {Channel} archived channel
+ *
+ * @example
+ * cy.apiCreateArchivedChannel('channel-name', 'channel-display-name', 'team-id', messages, user).then((channel) => {
+ * // do something with channel
+ * });
+ */
+ apiCreateArchivedChannel(name: string, displayName: string, type: string, teamId: string, messages?: string[], user?: UserProfile): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/channel.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/channel.js
new file mode 100644
index 00000000000..ef5d39631b6
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/channel.js
@@ -0,0 +1,188 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getRandomId} from '../../utils';
+
+// *****************************************************************************
+// Channels
+// https://api.mattermost.com/#tag/channels
+// *****************************************************************************
+
+export function createChannelPatch(teamId, name, displayName, type = 'O', purpose = '', header = '', unique = true) {
+ const randomSuffix = getRandomId();
+
+ return {
+ team_id: teamId,
+ name: unique ? `${name}-${randomSuffix}` : name,
+ display_name: unique ? `${displayName} ${randomSuffix}` : displayName,
+ type,
+ purpose,
+ header,
+ };
+}
+
+Cypress.Commands.add('apiCreateChannel', (...args) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels',
+ method: 'POST',
+ body: createChannelPatch(...args),
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiCreateDirectChannel', (userIds = []) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels/direct',
+ method: 'POST',
+ body: userIds,
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiCreateGroupChannel', (userIds = []) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels/group',
+ method: 'POST',
+ body: userIds,
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiUpdateChannel', (channelId, channelData) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels/' + channelId,
+ method: 'PUT',
+ body: {
+ id: channelId,
+ ...channelData,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiPatchChannel', (channelId, channelData) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'PUT',
+ url: `/api/v4/channels/${channelId}/patch`,
+ body: channelData,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiPatchChannelPrivacy', (channelId, privacy = 'O') => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'PUT',
+ url: `/api/v4/channels/${channelId}/privacy`,
+ body: {privacy},
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetChannel', (channelId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/channels/${channelId}`,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetChannelByName', (teamName, channelName) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/teams/name/${teamName}/channels/name/${channelName}`,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({channel: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetAllChannels', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({channels: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetChannelsForUser', (userId, teamId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/teams/${teamId}/channels`,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({channels: response.body});
+ });
+});
+
+Cypress.Commands.add('apiDeleteChannel', (channelId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels/' + channelId,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiAddUserToChannel', (channelId, userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels/' + channelId + '/members',
+ method: 'POST',
+ body: {
+ user_id: userId,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({member: response.body});
+ });
+});
+
+Cypress.Commands.add('apiRemoveUserFromChannel', (channelId, userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/channels/' + channelId + '/members/' + userId,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({member: response.body});
+ });
+});
+
+Cypress.Commands.add('apiCreateArchivedChannel', (name, displayName, type = 'O', teamId, messages = [], user) => {
+ return cy.apiCreateChannel(teamId, name, displayName, type).then(({channel}) => {
+ Cypress._.forEach(messages, (message) => {
+ cy.postMessageAs({
+ sender: user,
+ message,
+ channelId: channel.id,
+ });
+ });
+
+ cy.apiDeleteChannel(channel.id);
+ return cy.wrap(channel);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud.d.ts
new file mode 100644
index 00000000000..f3d5188c544
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud.d.ts
@@ -0,0 +1,41 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get products.
+ * See https://api.mattermost.com/#operation/GetCloudProducts
+ * @returns {Product[]} out.Products: `Product[]` object
+ *
+ * @example
+ * cy.apiGetCloudProducts();
+ */
+ apiGetCloudProducts(): Chainable<{products: Product[]}>;
+
+ /**
+ * Get subscriptions.
+ * See https://api.mattermost.com/#operation/GetSubscription
+ * @returns {Subscription} out.subscription: `Subscription` object
+ *
+ * @example
+ * cy.apiGetCloudSubscription();
+ */
+ apiGetCloudSubscription(): Chainable<{subscription: Subscription}>;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud.js
new file mode 100644
index 00000000000..36591d2c592
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud.js
@@ -0,0 +1,24 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('apiGetCloudProducts', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/cloud/products',
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({products: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetCloudSubscription', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/cloud/subscription',
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({subscription: response.body});
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud_default_config.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud_default_config.json
new file mode 100644
index 00000000000..66e894f60be
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cloud_default_config.json
@@ -0,0 +1,275 @@
+{
+ "ServiceSettings": {
+ "EnableOAuthServiceProvider": false,
+ "EnableIncomingWebhooks": true,
+ "EnableOutgoingWebhooks": true,
+ "EnableCommands": true,
+ "EnablePostUsernameOverride": false,
+ "EnablePostIconOverride": false,
+ "EnableLinkPreviews": false,
+ "EnableMultifactorAuthentication": false,
+ "EnforceMultifactorAuthentication": false,
+ "EnableUserAccessTokens": false,
+ "EnableCustomEmoji": false,
+ "EnableEmojiPicker": true,
+ "EnableGifPicker": false,
+ "PostEditTimeLimit": -1,
+ "EnablePreviewFeatures": true,
+ "EnableTutorial": true,
+ "EnableOnboardingFlow": false,
+ "ExperimentalEnableDefaultChannelLeaveJoinMessages": true,
+ "ExperimentalGroupUnreadChannels": "disabled",
+ "EnableAPITeamDeletion": true,
+ "ExperimentalEnableHardenedMode": false,
+ "EnableEmailInvitations": true,
+ "EnableBotAccountCreation": true,
+ "EnableSVGs": true,
+ "EnableLatex": false,
+ "EnableLegacySidebar": false,
+ "ThreadAutoFollow": true,
+ "ExperimentalStrictCSRFEnforcement": false,
+ "StrictCSRFEnforcement": false,
+ "CollapsedThreads": "disabled"
+ },
+ "TeamSettings": {
+ "SiteName": "Mattermost",
+ "MaxUsersPerTeam": 2000,
+ "EnableUserCreation": true,
+ "EnableOpenServer": true,
+ "EnableUserDeactivation": false,
+ "RestrictCreationToDomains": "",
+ "EnableCustomUserStatuses": true,
+ "EnableCustomBrand": false,
+ "CustomBrandText": "",
+ "CustomDescriptionText": "",
+ "RestrictDirectMessage": "any",
+ "UserStatusAwayTimeout": 300,
+ "MaxChannelsPerTeam": 2000,
+ "MaxNotificationsPerChannel": 1000,
+ "EnableConfirmNotificationsToChannel": true,
+ "TeammateNameDisplay": "username",
+ "ExperimentalEnableAutomaticReplies": false,
+ "LockTeammateNameDisplay": false,
+ "ExperimentalPrimaryTeam": "",
+ "ExperimentalDefaultChannels": []
+ },
+ "PasswordSettings": {
+ "MinimumLength": 5,
+ "Lowercase": false,
+ "Number": false,
+ "Uppercase": false,
+ "Symbol": false
+ },
+ "EmailSettings": {
+ "EnableSignUpWithEmail": true,
+ "EnableSignInWithEmail": true,
+ "EnableSignInWithUsername": true,
+ "SendEmailNotifications": true,
+ "UseChannelInEmailNotifications": false,
+ "RequireEmailVerification": false,
+ "FeedbackName": "",
+ "FeedbackOrganization": "",
+ "SendPushNotifications": true,
+ "PushNotificationServer": "https://push-test.mattermost.com",
+ "PushNotificationContents": "generic",
+ "EnableEmailBatching": false,
+ "EmailBatchingBufferSize": 256,
+ "EmailBatchingInterval": 30,
+ "EnablePreviewModeBanner": true,
+ "EmailNotificationContentsType": "full",
+ "LoginButtonColor": "#0000",
+ "LoginButtonBorderColor": "#2389D7",
+ "LoginButtonTextColor": "#2389D7"
+ },
+ "PrivacySettings": {
+ "ShowEmailAddress": true,
+ "ShowFullName": true
+ },
+ "SupportSettings": {
+ "SupportEmail": "",
+ "CustomTermsOfServiceEnabled": false,
+ "CustomTermsOfServiceReAcceptancePeriod": 365,
+ "EnableAskCommunityLink": true
+ },
+ "AnnouncementSettings": {
+ "EnableBanner": false,
+ "BannerText": "",
+ "BannerColor": "#f2a93b",
+ "BannerTextColor": "#333333",
+ "AllowBannerDismissal": true,
+ "AdminNoticesEnabled": false,
+ "UserNoticesEnabled": false
+ },
+ "ThemeSettings": {
+ "EnableThemeSelection": true,
+ "DefaultTheme": "default",
+ "AllowCustomThemes": true,
+ "AllowedThemes": []
+ },
+ "GitLabSettings": {
+ "Enable": false,
+ "Secret": "",
+ "Id": "",
+ "Scope": "",
+ "AuthEndpoint": "",
+ "TokenEndpoint": "",
+ "UserAPIEndpoint": ""
+ },
+ "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"
+ },
+ "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",
+ "DirectoryId": ""
+ },
+ "LdapSettings": {
+ "Enable": true,
+ "EnableSync": false,
+ "LdapServer": "localhost",
+ "LdapPort": 389,
+ "ConnectionSecurity": "",
+ "BaseDN": "dc=mm,dc=test,dc=com",
+ "BindUsername": "cn=admin,dc=mm,dc=test,dc=com",
+ "BindPassword": "mostest",
+ "UserFilter": "",
+ "GroupFilter": "",
+ "GuestFilter": "",
+ "EnableAdminFilter": false,
+ "AdminFilter": "",
+ "GroupDisplayNameAttribute": "cn",
+ "GroupIdAttribute": "entryUUID",
+ "FirstNameAttribute": "cn",
+ "LastNameAttribute": "sn",
+ "EmailAttribute": "mail",
+ "UsernameAttribute": "uid",
+ "NicknameAttribute": "cn",
+ "IdAttribute": "uid",
+ "PositionAttribute": "sAMAccountType",
+ "LoginIdAttribute": "uid",
+ "PictureAttribute": "",
+ "SyncIntervalMinutes": 10000,
+ "SkipCertificateVerification": true,
+ "QueryTimeout": 60,
+ "MaxPageSize": 500,
+ "LoginFieldName": "",
+ "LoginButtonColor": "#0000",
+ "LoginButtonBorderColor": "#2389D7",
+ "LoginButtonTextColor": "#2389D7",
+ "Trace": false
+ },
+ "ComplianceSettings": {
+ "Enable": false,
+ "EnableDaily": false
+ },
+ "LocalizationSettings": {
+ "DefaultServerLocale": "en",
+ "DefaultClientLocale": "en",
+ "AvailableLocales": ""
+ },
+ "SamlSettings": {
+ "Enable": false,
+ "EnableSyncWithLdap": false,
+ "EnableSyncWithLdapIncludeAuth": false,
+ "Verify": true,
+ "Encrypt": true,
+ "SignRequest": false,
+ "IdpURL": "",
+ "IdpDescriptorURL": "",
+ "IdpMetadataURL": "",
+ "AssertionConsumerServiceURL": "",
+ "SignatureAlgorithm": "RSAwithSHA1",
+ "CanonicalAlgorithm": "Canonical1.0",
+ "ScopingIDPProviderId": "",
+ "ScopingIDPName": "",
+ "IdpCertificateFile": "saml-idp.crt",
+ "PublicCertificateFile": "saml-public.crt",
+ "PrivateKeyFile": "saml-private.key",
+ "IdAttribute": "",
+ "GuestAttribute": "",
+ "EnableAdminAttribute": false,
+ "AdminAttribute": "",
+ "FirstNameAttribute": "",
+ "LastNameAttribute": "",
+ "EmailAttribute": "Email",
+ "UsernameAttribute": "Username",
+ "NicknameAttribute": "",
+ "LocaleAttribute": "",
+ "PositionAttribute": "",
+ "LoginButtonText": "SAML",
+ "LoginButtonColor": "#34a28b",
+ "LoginButtonBorderColor": "#2389D7",
+ "LoginButtonTextColor": "#ffffff"
+ },
+ "ClusterSettings": {
+ "Enable": false
+ },
+ "ExperimentalSettings": {
+ "RestrictSystemAdmin": true
+ },
+ "DataRetentionSettings": {
+ "EnableMessageDeletion": false,
+ "EnableFileDeletion": false,
+ "MessageRetentionDays": 365,
+ "FileRetentionDays": 365,
+ "DeletionJobStartTime": "02:00"
+ },
+ "MessageExportSettings": {
+ "EnableExport": false,
+ "ExportFormat": "actiance",
+ "DailyRunTime": "01:00",
+ "ExportFromTimestamp": 0,
+ "BatchSize": 10000,
+ "GlobalRelaySettings": {
+ "CustomerType": "A9",
+ "SMTPUsername": "",
+ "SMTPPassword": "",
+ "EmailAddress": ""
+ }
+ },
+ "PluginSettings": {
+ "Enable": true,
+ "Plugins": {},
+ "PluginStates": {
+ "com.mattermost.nps": {
+ "Enable": false
+ },
+ "com.mattermost.plugin-incident-response": {
+ "Enable": false
+ },
+ "com.mattermost.plugin-incident-management": {
+ "Enable": false
+ },
+ "focalboard": {
+ "Enable": false
+ }
+ }
+ },
+ "DisplaySettings": {
+ "CustomURLSchemes": [],
+ "ExperimentalTimezone": false
+ },
+ "GuestAccountsSettings": {
+ "Enable": true,
+ "AllowEmailAccounts": true,
+ "EnforceMultifactorAuthentication": false,
+ "RestrictCreationToDomains": ""
+ },
+ "ImageProxySettings": {
+ "Enable": true,
+ "ImageProxyType": "local",
+ "RemoteImageProxyURL": "",
+ "RemoteImageProxyOptions": ""
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cluster.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cluster.d.ts
new file mode 100644
index 00000000000..ab66dfcfe10
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cluster.d.ts
@@ -0,0 +1,31 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get cluster status
+ * See https://api.mattermost.com/#tag/cluster/operation/GetClusterStatus
+ * @returns {ClusterInfo[]} out.clusterInfo: `ClusterInfo[]` object
+ *
+ * @example
+ * cy.apiGetClusterStatus();
+ */
+ apiGetClusterStatus(): Chainable<{clusterInfo: ClusterInfo[]}>;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cluster.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cluster.js
new file mode 100644
index 00000000000..9101865dce6
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/cluster.js
@@ -0,0 +1,13 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('apiGetClusterStatus', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/cluster/status',
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({clusterInfo: response.body});
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/common.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/common.d.ts
new file mode 100644
index 00000000000..fb15c9b9f4b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/common.d.ts
@@ -0,0 +1,42 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Upload file directly via API.
+ * @param {String} name - name of form
+ * @param {String} filePath - path of the file to upload; can be relative or absolute
+ * @param {Object} options - request options
+ * @param {String} options.url - HTTP resource URL
+ * @param {String} options.method - HTTP request method
+ * @param {Number} options.successStatus - HTTP status code
+ *
+ * @example
+ * cy.apiUploadFile('certificate', filePath, {url: '/api/v4/saml/certificate/public', method: 'POST', successStatus: 200});
+ */
+ apiUploadFile(name: string, filePath: string, options: Record): Chainable;
+
+ /**
+ * Verify export file content-type
+ * @param {String} fileURL - Export file URL
+ * @param {String} contentType - File content-Type
+ */
+ apiDownloadFileAndVerifyContentType(fileURL: string, contentType: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/common.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/common.js
new file mode 100644
index 00000000000..4195657eed0
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/common.js
@@ -0,0 +1,68 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+const path = require('path');
+
+// *****************************************************************************
+// Common / Helper commands
+// *****************************************************************************
+
+Cypress.Commands.add('apiUploadFile', (name, filePath, options = {}) => {
+ const formData = new FormData();
+ const filename = path.basename(filePath);
+
+ cy.fixture(filePath, 'binary', {timeout: TIMEOUTS.TWENTY_MIN}).
+ then(Cypress.Blob.binaryStringToBlob).
+ then((blob) => {
+ formData.set(name, blob, filename);
+ formRequest(options.method, options.url, formData, options.successStatus);
+ });
+});
+
+Cypress.Commands.add('apiDownloadFileAndVerifyContentType', (fileURL, contentType = 'application/zip') => {
+ cy.request(fileURL).then((response) => {
+ // * Verify the download
+ expect(response.status).to.equal(200);
+
+ // * Confirm its content type
+ expect(response.headers['content-type']).to.equal(contentType);
+ });
+});
+
+/**
+ * Process binary file HTTP form request.
+ * @param {String} method - HTTP request method
+ * @param {String} url - HTTP resource URL
+ * @param {FormData} formData - Key value pairs representing form fields and value
+ * @param {Number} successStatus - HTTP status code
+ */
+function formRequest(method, url, formData, successStatus) {
+ const baseUrl = Cypress.config('baseUrl');
+ const xhr = new XMLHttpRequest();
+ xhr.open(method, url, false);
+ let cookies = '';
+ cy.getCookie('MMCSRF', {log: false}).then((token) => {
+ //get MMCSRF cookie value
+ const csrfToken = token.value;
+ cy.getCookies({log: false}).then((cookieValues) => {
+ //prepare cookie string
+ cookieValues.forEach((cookie) => {
+ cookies += cookie.name + '=' + cookie.value + '; ';
+ });
+
+ //set headers
+ xhr.setRequestHeader('Access-Control-Allow-Origin', baseUrl);
+ xhr.setRequestHeader('Access-Control-Allow-Methods', 'GET, POST, PUT');
+ xhr.setRequestHeader('X-CSRF-Token', csrfToken);
+ xhr.setRequestHeader('Cookie', cookies);
+ xhr.send(formData);
+ if (xhr.readyState === 4) {
+ expect(xhr.status, 'Expected form request to be processed successfully').to.equal(successStatus);
+ } else {
+ expect(xhr.status, 'Form request process delayed').to.equal(successStatus);
+ }
+ });
+ });
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/data_retention.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/data_retention.d.ts
new file mode 100644
index 00000000000..35671f37fe7
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/data_retention.d.ts
@@ -0,0 +1,33 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Delete all custom retention policies
+ */
+ apiDeleteAllCustomRetentionPolicies(): Chainable;
+
+ /**
+ * Create a post with create_at prop via API
+ * @param {string} channelId - Channel ID
+ * @param {string} message - Post a message
+ * @param {string} token - token
+ * @param {number} createat - epoch date
+ */
+ apiPostWithCreateDate(channelId: string, message: string, token: string, createat: number): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/data_retention.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/data_retention.js
new file mode 100644
index 00000000000..704670695c6
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/data_retention.js
@@ -0,0 +1,157 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// Data Retention
+// https://api.mattermost.com/#tag/data-retention
+// *****************************************************************************
+
+/**
+ * Get all Custom Retention Policies
+ * @param {Integer} page - The page to select
+ * @param {Integer} perPage - The number of policies per page
+ */
+Cypress.Commands.add('apiGetCustomRetentionPolicies', (page = 0, perPage = 100) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/data_retention/policies?page=${page}&per_page=${perPage}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+/**
+ * Get a Custom Retention Policy
+ * @param {string} id - The id of the policy
+ */
+Cypress.Commands.add('apiGetCustomRetentionPolicy', (id) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/data_retention/policies/${id}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+/**
+ * Delete Custom Retention Policy
+ * @param {string} id - The id of the policy
+ */
+Cypress.Commands.add('apiDeleteCustomRetentionPolicy', (id) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/data_retention/policies/${id}`,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+/**
+ * Get Custom Retention Policy teams
+ * @param {string} id - The id of the policy
+ * @param {Integer} page - The page to select
+ * @param {Integer} perPage - The number of policy teams per page
+ */
+Cypress.Commands.add('apiGetCustomRetentionPolicyTeams', (id, page = 0, perPage = 100) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/data_retention/policies/${id}/teams?page=${page}&per_page=${perPage}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+/**
+ * Get Custom Retention Policy channels
+ * @param {string} id - The id of the policy
+ * @param {Integer} page - The page to select
+ * @param {Integer} perPage - The number of policy channels per page
+ */
+Cypress.Commands.add('apiGetCustomRetentionPolicyChannels', (id, page = 0, perPage = 100) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/data_retention/policies/${id}/channels?page=${page}&per_page=${perPage}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+/**
+ * Search Custom Retention Policy teams
+ * @param {string} id - The id of the policy
+ * @param {string} term - The team search term
+ */
+Cypress.Commands.add('apiSearchCustomRetentionPolicyTeams', (id, term) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/data_retention/policies/${id}/teams/search`,
+ method: 'POST',
+ body: {term},
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+/**
+ * Search Custom Retention Policy teams
+ * @param {string} id - The id of the policy
+ * @param {string} term - The channel search term
+ */
+Cypress.Commands.add('apiSearchCustomRetentionPolicyChannels', (id, term) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/data_retention/policies/${id}/channels/search`,
+ method: 'POST',
+ body: {term},
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+/**
+ * Delete all custom retention policies
+ */
+Cypress.Commands.add('apiDeleteAllCustomRetentionPolicies', () => {
+ cy.apiGetCustomRetentionPolicies().then((result) => {
+ result.body.policies.forEach((policy) => {
+ cy.apiDeleteCustomRetentionPolicy(policy.id);
+ });
+ });
+});
+
+/**
+ * Create a post with create_at prop via API
+ * @param {string} channelId - Channel ID
+ * @param {string} message - Post a message
+ * @param {string} token - token
+ * @param {number} createAt - epoch date
+ */
+Cypress.Commands.add('apiPostWithCreateDate', (channelId, message, token, createAt) => {
+ const headers = {'X-Requested-With': 'XMLHttpRequest'};
+ if (token !== '') {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ return cy.request({
+ headers,
+ url: '/api/v4/posts',
+ method: 'POST',
+ body: {
+ channel_id: channelId,
+ create_at: createAt,
+ message,
+ },
+ });
+});
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/helpers.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/helpers.js
new file mode 100644
index 00000000000..26bbb248cf8
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/helpers.js
@@ -0,0 +1,15 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export function buildQueryString(queryParams = {}) {
+ let queryString = '';
+ Object.entries(queryParams).forEach(([k, v], index) => {
+ if (index > 0) {
+ queryString += '&';
+ }
+
+ queryString += `${k}=${encodeURIComponent(v)}`;
+ });
+
+ return queryString;
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/index.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/index.js
new file mode 100644
index 00000000000..e9edc95e1bc
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/index.js
@@ -0,0 +1,24 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import './brand';
+import './bots';
+import './channel';
+import './cloud';
+import './cluster';
+import './common';
+import './data_retention';
+import './keycloak';
+import './ldap';
+import './playbooks';
+import './preference';
+import './plugin';
+import './role';
+import './saml';
+import './scheme';
+import './setup';
+import './status';
+import './system';
+import './team';
+import './user';
+import './webhooks';
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak.d.ts
new file mode 100644
index 00000000000..bd1b0a17e66
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak.d.ts
@@ -0,0 +1,65 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiKeycloakGetAccessToken`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get access token from Keycloak
+ * See https://www.keycloak.org/documentation
+ * @returns {string} token
+ *
+ * @example
+ * cy.apiKeycloakGetAccessToken();
+ */
+ apiKeycloakGetAccessToken(): Chainable;
+
+ /**
+ * Save realm to Keycloak
+ * See https://www.keycloak.org/documentation
+ * @param {string} options.accessToken - valid token to authorize a request
+ * @param {Boolean} options.failOnStatusCode - whether to fail on status code, default is true
+ * @returns {Response} response: Cypress-chainable response
+ *
+ * @example
+ * cy.apiKeycloakSaveRealm('access-token');
+ */
+ apiKeycloakSaveRealm(accessToken: string, failOnStatusCode: boolean): Chainable;
+
+ /**
+ * Get realm from Keycloak
+ * See https://www.keycloak.org/documentation
+ * @param {string} options.accessToken - valid token to authorize a request
+ * @param {Boolean} options.failOnStatusCode - whether to fail on status code, default is true
+ * @returns {Response} response: Cypress-chainable response
+ *
+ * @example
+ * cy.apiKeycloakGetRealm('access-token');
+ */
+ apiKeycloakGetRealm(accessToken: string, failOnStatusCode: boolean): Chainable;
+
+ /**
+ * Verify Keycloak is reachable and has realm setup
+ * See https://www.keycloak.org/documentation
+ * @returns {Response} response: Cypress-chainable response
+ *
+ * @example
+ * cy.apiRequireKeycloak();
+ */
+ apiRequireKeycloak(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak.js
new file mode 100644
index 00000000000..3f114ce5072
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak.js
@@ -0,0 +1,89 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// Keycloak Admin REST API
+// https://www.keycloak.org/documentation
+// *****************************************************************************
+
+import realmJson from './keycloak_realm.json';
+
+const {
+ keycloakBaseUrl,
+ keycloakAppName,
+ keycloakUsername,
+ keycloakPassword,
+} = Cypress.env();
+
+Cypress.Commands.add('apiKeycloakGetAccessToken', () => {
+ return cy.task('keycloakRequest', {
+ baseUrl: `${keycloakBaseUrl}/auth/realms/master/protocol/openid-connect/token`,
+ method: 'POST',
+ headers: {'Content-type': 'application/x-www-form-urlencoded'},
+ data: `grant_type=password&username=${keycloakUsername}&password=${keycloakPassword}&client_id=admin-cli`,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ const token = response.data.access_token;
+ return cy.wrap(token);
+ });
+});
+
+function getRealmJson() {
+ const baseUrl = Cypress.config('baseUrl');
+ const {ldapServer, ldapPort} = Cypress.env();
+
+ const realm = JSON.stringify(realmJson).
+ replace(/localhost:389/g, `${ldapServer}:${ldapPort}`).
+ replace(/http:\/\/localhost:8065/g, baseUrl);
+ return JSON.parse(realm);
+}
+
+Cypress.Commands.add('apiKeycloakSaveRealm', (accessToken, failOnStatusCode = true) => {
+ const realm = getRealmJson();
+
+ return cy.task('keycloakRequest', {
+ baseUrl: `${keycloakBaseUrl}/auth/admin/realms`,
+ method: 'POST',
+ data: realm,
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }).then((response) => {
+ if (failOnStatusCode) {
+ expect(response.status).to.equal(201);
+ }
+
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiKeycloakGetRealm', (accessToken, failOnStatusCode = true) => {
+ return cy.task('keycloakRequest', {
+ baseUrl: `${keycloakBaseUrl}/auth/admin/realms/${keycloakAppName}`,
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ failOnStatusCode,
+ }).then((response) => {
+ if (failOnStatusCode) {
+ expect(response.status).to.equal(200);
+ }
+
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiRequireKeycloak', () => {
+ cy.apiKeycloakGetAccessToken().then((token) => {
+ cy.apiKeycloakGetRealm(token, false).then((response) => {
+ if (response.status !== 200) {
+ return cy.apiKeycloakSaveRealm(token);
+ }
+
+ return response;
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak_realm.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak_realm.json
new file mode 100644
index 00000000000..a0149da196a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/keycloak_realm.json
@@ -0,0 +1,1966 @@
+{
+ "id" : "mattermost",
+ "realm" : "mattermost",
+ "displayName" : "Keycloak",
+ "displayNameHtml" : "Keycloak
",
+ "notBefore" : 0,
+ "revokeRefreshToken" : false,
+ "refreshTokenMaxReuse" : 0,
+ "accessTokenLifespan" : 60,
+ "accessTokenLifespanForImplicitFlow" : 900,
+ "ssoSessionIdleTimeout" : 1800,
+ "ssoSessionMaxLifespan" : 36000,
+ "ssoSessionIdleTimeoutRememberMe" : 0,
+ "ssoSessionMaxLifespanRememberMe" : 0,
+ "offlineSessionIdleTimeout" : 2592000,
+ "offlineSessionMaxLifespanEnabled" : false,
+ "offlineSessionMaxLifespan" : 5184000,
+ "clientSessionIdleTimeout" : 0,
+ "clientSessionMaxLifespan" : 0,
+ "accessCodeLifespan" : 60,
+ "accessCodeLifespanUserAction" : 300,
+ "accessCodeLifespanLogin" : 1800,
+ "actionTokenGeneratedByAdminLifespan" : 43200,
+ "actionTokenGeneratedByUserLifespan" : 300,
+ "enabled" : true,
+ "sslRequired" : "none",
+ "registrationAllowed" : false,
+ "registrationEmailAsUsername" : false,
+ "rememberMe" : false,
+ "verifyEmail" : false,
+ "loginWithEmailAllowed" : true,
+ "duplicateEmailsAllowed" : false,
+ "resetPasswordAllowed" : false,
+ "editUsernameAllowed" : true,
+ "bruteForceProtected" : false,
+ "permanentLockout" : false,
+ "maxFailureWaitSeconds" : 900,
+ "minimumQuickLoginWaitSeconds" : 60,
+ "waitIncrementSeconds" : 60,
+ "quickLoginCheckMilliSeconds" : 1000,
+ "maxDeltaTimeSeconds" : 43200,
+ "failureFactor" : 30,
+ "roles" : {
+ "realm" : [ {
+ "id" : "1603a047-cc4c-405a-82e6-69e2c692776f",
+ "name" : "offline_access",
+ "description" : "${role_offline-access}",
+ "composite" : false,
+ "clientRole" : false,
+ "containerId" : "mattermost",
+ "attributes" : { }
+ }, {
+ "id" : "c7fdcde8-78f3-4255-bd19-7c945859d42f",
+ "name" : "create-realm",
+ "description" : "${role_create-realm}",
+ "composite" : false,
+ "clientRole" : false,
+ "containerId" : "mattermost",
+ "attributes" : { }
+ }, {
+ "id" : "41e2f2bd-b7a1-491d-9cdd-dc593f3d7483",
+ "name" : "uma_authorization",
+ "description" : "${role_uma_authorization}",
+ "composite" : false,
+ "clientRole" : false,
+ "containerId" : "mattermost",
+ "attributes" : { }
+ }, {
+ "id" : "86d6d932-461e-4e75-a2e1-0fe79802ee3b",
+ "name" : "admin",
+ "description" : "${role_admin}",
+ "composite" : true,
+ "composites" : {
+ "realm" : [ "create-realm" ],
+ "client" : {
+ "mattermost-realm" : [ "impersonation", "manage-clients", "view-events", "view-authorization", "view-realm", "create-client", "manage-authorization", "query-users", "manage-identity-providers", "view-users", "view-clients", "manage-users", "query-clients", "manage-realm", "manage-events", "view-identity-providers", "query-realms", "query-groups" ]
+ }
+ },
+ "clientRole" : false,
+ "containerId" : "mattermost",
+ "attributes" : { }
+ } ],
+ "client" : {
+ "security-admin-console" : [ ],
+ "http://localhost:8065/login/sso/saml" : [ ],
+ "admin-cli" : [ ],
+ "account-console" : [ ],
+ "broker" : [ {
+ "id" : "2d3154ca-4b7e-4a11-809b-b8ad236035f8",
+ "name" : "read-token",
+ "description" : "${role_read-token}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "1a5d8538-3004-48ad-a9ea-767e4ae09b53",
+ "attributes" : { }
+ } ],
+ "mattermost-realm" : [ {
+ "id" : "89f8999a-8b53-4aa8-ab1f-233c13954a88",
+ "name" : "impersonation",
+ "description" : "${role_impersonation}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "b214d48c-94f8-4fe3-bea9-e14dcd0daf8b",
+ "name" : "manage-clients",
+ "description" : "${role_manage-clients}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "a9875907-ea05-40f2-b7f5-2fa6da77d9fd",
+ "name" : "view-events",
+ "description" : "${role_view-events}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "3338e04d-5781-49ca-ba50-e5eab4b2abfc",
+ "name" : "view-realm",
+ "description" : "${role_view-realm}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "1ad5b686-8a60-48b1-8e69-ee7ad21f2e5d",
+ "name" : "view-authorization",
+ "description" : "${role_view-authorization}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "0634edc3-0452-4745-bb68-1bd8508b803b",
+ "name" : "create-client",
+ "description" : "${role_create-client}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "e4e141e2-7288-4e42-93c8-e7c3f369756b",
+ "name" : "manage-authorization",
+ "description" : "${role_manage-authorization}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "0fb67bd9-8e13-4f75-acaf-75ee459a8b6c",
+ "name" : "query-users",
+ "description" : "${role_query-users}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "7aff516a-4306-4ba1-92c7-aee738368321",
+ "name" : "manage-identity-providers",
+ "description" : "${role_manage-identity-providers}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "796eb07f-a07e-4ac0-a8f2-069c56ce147a",
+ "name" : "view-users",
+ "description" : "${role_view-users}",
+ "composite" : true,
+ "composites" : {
+ "client" : {
+ "mattermost-realm" : [ "query-users", "query-groups" ]
+ }
+ },
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "48db4ddf-db9e-48b9-8158-a4fa9aa6bfae",
+ "name" : "view-clients",
+ "description" : "${role_view-clients}",
+ "composite" : true,
+ "composites" : {
+ "client" : {
+ "mattermost-realm" : [ "query-clients" ]
+ }
+ },
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "644ee19e-6587-4cad-a0d0-8a3e165cc8df",
+ "name" : "manage-users",
+ "description" : "${role_manage-users}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "bc39205b-6498-47f2-b912-a7c9aabc7e6a",
+ "name" : "manage-realm",
+ "description" : "${role_manage-realm}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "031a8159-2ac9-473f-8031-30743390f4cb",
+ "name" : "query-clients",
+ "description" : "${role_query-clients}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "f522db6e-0623-4f59-89ef-5ffbad9d0301",
+ "name" : "manage-events",
+ "description" : "${role_manage-events}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "34ab4e47-ed0a-427e-a826-88b556b3e4f1",
+ "name" : "view-identity-providers",
+ "description" : "${role_view-identity-providers}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "e7c9c397-585e-4de5-b6bd-627aa622b27b",
+ "name" : "query-realms",
+ "description" : "${role_query-realms}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ }, {
+ "id" : "9d571819-a733-4e48-beef-61cd6f8ce604",
+ "name" : "query-groups",
+ "description" : "${role_query-groups}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "attributes" : { }
+ } ],
+ "account" : [ {
+ "id" : "659dde8f-c5ff-4db2-a8ad-b88479c1e2e0",
+ "name" : "manage-account",
+ "description" : "${role_manage-account}",
+ "composite" : true,
+ "composites" : {
+ "client" : {
+ "account" : [ "manage-account-links" ]
+ }
+ },
+ "clientRole" : true,
+ "containerId" : "7e08cc43-4e60-4a0e-b03e-4d62b69f21da",
+ "attributes" : { }
+ }, {
+ "id" : "fcff0626-3b86-4e98-ab97-666d1bc35aaa",
+ "name" : "manage-consent",
+ "description" : "${role_manage-consent}",
+ "composite" : true,
+ "composites" : {
+ "client" : {
+ "account" : [ "view-consent" ]
+ }
+ },
+ "clientRole" : true,
+ "containerId" : "7e08cc43-4e60-4a0e-b03e-4d62b69f21da",
+ "attributes" : { }
+ }, {
+ "id" : "cf2d2ae8-f0d3-4a70-aad1-77709b218316",
+ "name" : "view-applications",
+ "description" : "${role_view-applications}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "7e08cc43-4e60-4a0e-b03e-4d62b69f21da",
+ "attributes" : { }
+ }, {
+ "id" : "80379c27-f861-4b54-9ef1-399fd6a17f30",
+ "name" : "manage-account-links",
+ "description" : "${role_manage-account-links}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "7e08cc43-4e60-4a0e-b03e-4d62b69f21da",
+ "attributes" : { }
+ }, {
+ "id" : "625e8aa3-3b40-4353-a1c4-d6d9d8630deb",
+ "name" : "view-consent",
+ "description" : "${role_view-consent}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "7e08cc43-4e60-4a0e-b03e-4d62b69f21da",
+ "attributes" : { }
+ }, {
+ "id" : "87d75c32-10bc-49ad-a68e-832429a8d043",
+ "name" : "view-profile",
+ "description" : "${role_view-profile}",
+ "composite" : false,
+ "clientRole" : true,
+ "containerId" : "7e08cc43-4e60-4a0e-b03e-4d62b69f21da",
+ "attributes" : { }
+ } ]
+ }
+ },
+ "groups" : [ ],
+ "defaultRoles" : [ "offline_access", "uma_authorization" ],
+ "requiredCredentials" : [ "password" ],
+ "otpPolicyType" : "totp",
+ "otpPolicyAlgorithm" : "HmacSHA1",
+ "otpPolicyInitialCounter" : 0,
+ "otpPolicyDigits" : 6,
+ "otpPolicyLookAheadWindow" : 1,
+ "otpPolicyPeriod" : 30,
+ "otpSupportedApplications" : [ "FreeOTP", "Google Authenticator" ],
+ "webAuthnPolicyRpEntityName" : "keycloak",
+ "webAuthnPolicySignatureAlgorithms" : [ "ES256" ],
+ "webAuthnPolicyRpId" : "",
+ "webAuthnPolicyAttestationConveyancePreference" : "not specified",
+ "webAuthnPolicyAuthenticatorAttachment" : "not specified",
+ "webAuthnPolicyRequireResidentKey" : "not specified",
+ "webAuthnPolicyUserVerificationRequirement" : "not specified",
+ "webAuthnPolicyCreateTimeout" : 0,
+ "webAuthnPolicyAvoidSameAuthenticatorRegister" : false,
+ "webAuthnPolicyAcceptableAaguids" : [ ],
+ "webAuthnPolicyPasswordlessRpEntityName" : "keycloak",
+ "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ],
+ "webAuthnPolicyPasswordlessRpId" : "",
+ "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified",
+ "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified",
+ "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified",
+ "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified",
+ "webAuthnPolicyPasswordlessCreateTimeout" : 0,
+ "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false,
+ "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ],
+ "users" : [ {
+ "id" : "322fe373-2f32-4edb-b85b-426ed4a29509",
+ "createdTimestamp" : 1592608502143,
+ "username" : "mmuser",
+ "enabled" : true,
+ "totp" : false,
+ "emailVerified" : false,
+ "credentials" : [ {
+ "id" : "12b834cf-48e7-45ac-9798-f3c3e5f22852",
+ "type" : "password",
+ "createdDate" : 1592608502380,
+ "secretData" : "{\"value\":\"e+FszAkjUqp7PVyg3FfW3XtBa2tXB1bvpxDbNHgkNWhx1b7YNi154Yvm6nR0caj2lx95KYlEevinMKb4GZKmRQ==\",\"salt\":\"lnn/AkoOO1uPJGZ5Wbwu1Q==\"}",
+ "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\"}"
+ } ],
+ "disableableCredentialTypes" : [ ],
+ "requiredActions" : [ ],
+ "realmRoles" : [ "offline_access", "uma_authorization", "admin" ],
+ "clientRoles" : {
+ "account" : [ "manage-account", "view-profile" ]
+ },
+ "notBefore" : 0,
+ "groups" : [ ]
+ }, {
+ "id" : "ffeb5559-7348-4f75-b5a9-1a9217f7db58",
+ "createdTimestamp" : 1592655068090,
+ "username" : "test.one",
+ "enabled" : true,
+ "totp" : false,
+ "emailVerified" : false,
+ "firstName" : "Test1",
+ "lastName" : "User",
+ "email" : "success+testone@simulator.amazonses.com",
+ "federationLink" : "0d94859b-cd61-4314-9669-fbcac2322dfd",
+ "attributes" : {
+ "LDAP_ENTRY_DN" : [ "uid=test.one,ou=testusers,dc=mm,dc=test,dc=com" ],
+ "createTimestamp" : [ "20200620080847Z" ],
+ "modifyTimestamp" : [ "20200620080847Z" ],
+ "LDAP_ID" : [ "034ce904-4719-103a-9320-c588f0ff1b81" ]
+ },
+ "credentials" : [ ],
+ "disableableCredentialTypes" : [ ],
+ "requiredActions" : [ ],
+ "realmRoles" : [ "offline_access", "uma_authorization" ],
+ "clientRoles" : {
+ "account" : [ "manage-account", "view-profile" ]
+ },
+ "notBefore" : 0,
+ "groups" : [ ]
+ } ],
+ "scopeMappings" : [ {
+ "clientScope" : "offline_access",
+ "roles" : [ "offline_access" ]
+ } ],
+ "clientScopeMappings" : {
+ "account" : [ {
+ "client" : "account-console",
+ "roles" : [ "manage-account" ]
+ } ]
+ },
+ "clients" : [ {
+ "id" : "7e08cc43-4e60-4a0e-b03e-4d62b69f21da",
+ "clientId" : "account",
+ "name" : "${client_account}",
+ "rootUrl" : "${authBaseUrl}",
+ "baseUrl" : "/realms/mattermost/account/",
+ "surrogateAuthRequired" : false,
+ "enabled" : true,
+ "alwaysDisplayInConsole" : false,
+ "clientAuthenticatorType" : "client-secret",
+ "secret" : "7228d94d-bf02-4b5d-ab61-07a5b4d71b24",
+ "defaultRoles" : [ "manage-account", "view-profile" ],
+ "redirectUris" : [ "/realms/mattermost/account/*" ],
+ "webOrigins" : [ ],
+ "notBefore" : 0,
+ "bearerOnly" : false,
+ "consentRequired" : false,
+ "standardFlowEnabled" : true,
+ "implicitFlowEnabled" : false,
+ "directAccessGrantsEnabled" : false,
+ "serviceAccountsEnabled" : false,
+ "publicClient" : false,
+ "frontchannelLogout" : false,
+ "protocol" : "openid-connect",
+ "attributes" : { },
+ "authenticationFlowBindingOverrides" : { },
+ "fullScopeAllowed" : false,
+ "nodeReRegistrationTimeout" : 0,
+ "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ],
+ "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
+ }, {
+ "id" : "815a1e7b-f78e-413f-9c44-b5459df0e0c0",
+ "clientId" : "account-console",
+ "name" : "${client_account-console}",
+ "rootUrl" : "${authBaseUrl}",
+ "baseUrl" : "/realms/mattermost/account/",
+ "surrogateAuthRequired" : false,
+ "enabled" : true,
+ "alwaysDisplayInConsole" : false,
+ "clientAuthenticatorType" : "client-secret",
+ "secret" : "0406c700-8b2e-4163-9ab5-5091fdf15e5b",
+ "redirectUris" : [ "/realms/mattermost/account/*" ],
+ "webOrigins" : [ ],
+ "notBefore" : 0,
+ "bearerOnly" : false,
+ "consentRequired" : false,
+ "standardFlowEnabled" : true,
+ "implicitFlowEnabled" : false,
+ "directAccessGrantsEnabled" : false,
+ "serviceAccountsEnabled" : false,
+ "publicClient" : true,
+ "frontchannelLogout" : false,
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "pkce.code.challenge.method" : "S256"
+ },
+ "authenticationFlowBindingOverrides" : { },
+ "fullScopeAllowed" : false,
+ "nodeReRegistrationTimeout" : 0,
+ "protocolMappers" : [ {
+ "id" : "1079cafb-6192-4059-8412-0f7b4b39ff3c",
+ "name" : "audience resolve",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-audience-resolve-mapper",
+ "consentRequired" : false,
+ "config" : { }
+ } ],
+ "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ],
+ "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
+ }, {
+ "id" : "84e88764-21c4-43a0-8128-5ba882aa0990",
+ "clientId" : "admin-cli",
+ "name" : "${client_admin-cli}",
+ "surrogateAuthRequired" : false,
+ "enabled" : true,
+ "alwaysDisplayInConsole" : false,
+ "clientAuthenticatorType" : "client-secret",
+ "secret" : "da271203-180d-41a3-8f54-12d8a1a242b8",
+ "redirectUris" : [ ],
+ "webOrigins" : [ ],
+ "notBefore" : 0,
+ "bearerOnly" : false,
+ "consentRequired" : false,
+ "standardFlowEnabled" : false,
+ "implicitFlowEnabled" : false,
+ "directAccessGrantsEnabled" : true,
+ "serviceAccountsEnabled" : false,
+ "publicClient" : true,
+ "frontchannelLogout" : false,
+ "protocol" : "openid-connect",
+ "attributes" : { },
+ "authenticationFlowBindingOverrides" : { },
+ "fullScopeAllowed" : false,
+ "nodeReRegistrationTimeout" : 0,
+ "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ],
+ "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
+ }, {
+ "id" : "1a5d8538-3004-48ad-a9ea-767e4ae09b53",
+ "clientId" : "broker",
+ "name" : "${client_broker}",
+ "surrogateAuthRequired" : false,
+ "enabled" : true,
+ "alwaysDisplayInConsole" : false,
+ "clientAuthenticatorType" : "client-secret",
+ "secret" : "398f1561-be86-4d08-a1f3-4162dbcd0c59",
+ "redirectUris" : [ ],
+ "webOrigins" : [ ],
+ "notBefore" : 0,
+ "bearerOnly" : false,
+ "consentRequired" : false,
+ "standardFlowEnabled" : true,
+ "implicitFlowEnabled" : false,
+ "directAccessGrantsEnabled" : false,
+ "serviceAccountsEnabled" : false,
+ "publicClient" : false,
+ "frontchannelLogout" : false,
+ "protocol" : "openid-connect",
+ "attributes" : { },
+ "authenticationFlowBindingOverrides" : { },
+ "fullScopeAllowed" : false,
+ "nodeReRegistrationTimeout" : 0,
+ "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ],
+ "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
+ }, {
+ "id" : "52fef9a5-b43a-496d-be1d-024522142740",
+ "clientId" : "http://localhost:8065/login/sso/saml",
+ "adminUrl" : "http://localhost:8065/login/sso/saml",
+ "surrogateAuthRequired" : false,
+ "enabled" : true,
+ "alwaysDisplayInConsole" : false,
+ "clientAuthenticatorType" : "client-secret",
+ "secret" : "9c2edd74-9e20-454d-8cc2-0714e43f5f7e",
+ "redirectUris" : [ "http://localhost:8065/login/sso/saml" ],
+ "webOrigins" : [ ],
+ "notBefore" : 0,
+ "bearerOnly" : false,
+ "consentRequired" : false,
+ "standardFlowEnabled" : true,
+ "implicitFlowEnabled" : false,
+ "directAccessGrantsEnabled" : false,
+ "serviceAccountsEnabled" : false,
+ "publicClient" : false,
+ "frontchannelLogout" : true,
+ "protocol" : "saml",
+ "attributes" : {
+ "saml.assertion.signature" : "false",
+ "saml.force.post.binding" : "true",
+ "saml.multivalued.roles" : "false",
+ "saml.encrypt" : "false",
+ "saml.server.signature" : "true",
+ "saml.server.signature.keyinfo.ext" : "false",
+ "saml.signing.certificate" : "MIIC3zCCAccCBgFheaqsnDANBgkqhkiG9w0BAQsFADAzMTEwLwYDVQQDDChodHRwczovLzdjNTQzNTQyLm5ncm9rLmlvL2xvZ2luL3Nzby9zYW1sMB4XDTE4MDIwOTA4MjMwM1oXDTI4MDIwOTA4MjQ0M1owMzExMC8GA1UEAwwoaHR0cHM6Ly83YzU0MzU0Mi5uZ3Jvay5pby9sb2dpbi9zc28vc2FtbDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJg1eRqY8X4bGTFJgdftPoQlqwasfmZJDwkPlbWsNf4XQukwYqGcpDYKtdAd8mmLkYK1wXPjRvdZTNBA3nuMZYGcJjXMvBKGSqz9q2s8+wD+9TKcE6aSTS7+eOL9GjRWMdE5g4GsLkOjzJo6B39tniCCHHA1rlfUgDXFAvDRtS60ytuAnkD6YGdZ3moZtke8ssEZjxJRnGf3F8E1RfaP4An7a8D1ZlyvNndZpLtB/AixjIvasJpPrQX5UW/DkjKFQx+++GZdFBvGq4gva4pErmq1RfnIoEtG7V7DmgpBDx1Pv1EyJ61vowVwkUDJe2uim3s7h5iaZAeDq1NXNAYMMf0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAhA6yjZmkcyBgUfifrhDcK4X4PU2rtf+y9vql28Yq23Idoc0u4TavrZbEHedgAswW7D3bDnX3JAdRA3PbydGnAHeTPdsOvf/UAolPjsd/c7+KkMbrpVc/rjG8PFtVwfqD3kmoUpXZ5T1FGHO7Kjp/sP3lNXeugopxoh4lS3WAekKqD8DnLll6SspRmQrdgZQ/OSEmxXMi34Fg2zkClu/Vp59IR3yvJxkVd1tZKdSi1xkHaBdL/sG3X6D/wy68pYWB64UyP02PHaE2AytzqL/lm//KCjU8WbG+G9diCPKNp6udX1lFK4/N4gpnWSIsh1FE+/S22Er5/QjKDGPyNf35Pw==",
+ "saml.signature.algorithm" : "RSA_SHA256",
+ "saml_force_name_id_format" : "false",
+ "saml.client.signature" : "false",
+ "saml.authnstatement" : "true",
+ "saml.signing.private.key" : "MIIEpAIBAAKCAQEAmDV5GpjxfhsZMUmB1+0+hCWrBqx+ZkkPCQ+Vtaw1/hdC6TBioZykNgq10B3yaYuRgrXBc+NG91lM0EDee4xlgZwmNcy8EoZKrP2razz7AP71MpwTppJNLv544v0aNFYx0TmDgawuQ6PMmjoHf22eIIIccDWuV9SANcUC8NG1LrTK24CeQPpgZ1neahm2R7yywRmPElGcZ/cXwTVF9o/gCftrwPVmXK82d1mku0H8CLGMi9qwmk+tBflRb8OSMoVDH774Zl0UG8ariC9rikSuarVF+cigS0btXsOaCkEPHU+/UTInrW+jBXCRQMl7a6KbezuHmJpkB4OrU1c0Bgwx/QIDAQABAoIBAAiq4t6U3wujV2frG63EIM89peOXZwtEFcsaTBgwWlLB2FmXG8bAOMmrCndzfR5tiDe9SerjgmMLfshNKV43vIAI+FQP+JXFd/Mp7t0Id/Kykhvzr1rI8gQ/EXs7loZsciHL+KUlvOy1Iy2VKGAlSd/oCN6K8AaoXzSwp143Uu353ssrdj4EprMy7H0ZM9DMdR40ov7nrhD6ux2vC7FGmNchKu5whPb0X3Bq62v4ENebu6k9h/MN04hCEh5IoQBvjqSD6k0Wg+QrMo+DHFrTvtuPMtUOYi/08odx1Z4kQ34VppmkqvQnXKvL0sR5i0MOuvW/yt3UX6cjmME8knJHaDECgYEA7DD4yxnrzFKIYbeEwWbjXWwtGIq4hxH9c9lg4XQt/9TnTWPQaHOxmqL6cZgp40IKffVhc4wBRNnyH2iUZaOn8AUhOfeFIGyN3Yy3aDWsyD9nF8PqrvkEXsbRAJWY6jvFtbWYdEXDJx7mTxVsy9aeNlq+NH7NL2yj/fOzcl9KPpsCgYEApPll+o/yisM3B88Ac8fcfpS8Fs0bn5R63lIkaxKNFVHASkrMaCH4gW88o2+urYOp2dbfOkWcJ4yAT1zgv9Q+y0dwjT/eMg9Rlhi2lOUvysdJ5pQr62YTMUa0hA4uwR5fvEewbwbujcsRWpGvkVvPBrS+CXRme/ppJpgSWtYZT0cCgYEAnrxG6NDR7W7mY63f1c8dLTM/l4fbfkNz8ED+4GahZ5ehoBxd+2UNztyLrn5SYH6I6KBaTzqfu7MyCzPQ0AJOInyAGSIl4WWzbltdA/dW2PnrgkhUWCXZbwz1eAwSShHDzVxvSm18O7WDmVDP3qqth+AyhrtVkPLVwB3h0xMBpdMCgYBDnH7B6LrDSexEw/5wdQmVywkm4xqeFTEh6lJIm4q8oQuIpw0M5Fc/XMJiTQQu0pYK1DgaXqr3vmpbnDn0BF1T3ExxZyp+I68RL8GsVh13IqPT3wf86pGVEWAr+tAIj5U2yb6yUgn0jLPpBWoJzbGUEwELSOwzhVYQ3iQvnC01QwKBgQCa7bycaVyeON+fwehAzlWjvNuTOWvieOstVgLp8rHuflMaU2CHQ6G3jcM/asx9l15DT+nqPf9x6Ms2UQxnwbFS4xT2ZHXruxex7oWNPgQazOk+hBFG73G8PtPODRe2iPA9c3gKSi/y9M80zFHGNACuy7Fl7pLXAsz5eOjxIVOYTg==",
+ "saml_name_id_format" : "username",
+ "saml.onetimeuse.condition" : "false",
+ "saml_signature_canonicalization_method" : "http://www.w3.org/2001/10/xml-exc-c14n#"
+ },
+ "authenticationFlowBindingOverrides" : { },
+ "fullScopeAllowed" : true,
+ "nodeReRegistrationTimeout" : -1,
+ "protocolMappers" : [ {
+ "id" : "50e9a4b5-8350-4a0b-97c7-6cea4f41baad",
+ "name" : "username",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "username",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "preferred_username",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "8fa1d509-76af-446e-84e0-c7ca19df70d7",
+ "name" : "X500 email",
+ "protocol" : "saml",
+ "protocolMapper" : "saml-user-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "attribute.nameformat" : "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
+ "user.attribute" : "email",
+ "friendly.name" : "email",
+ "attribute.name" : "urn:oid:1.2.840.113549.1.9.1"
+ }
+ }, {
+ "id" : "e992fbae-5022-4faa-a9ac-ac2175f10626",
+ "name" : "email",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "email",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "email",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "9cc29dfc-8f88-49b0-a5ad-602414919e96",
+ "name" : "lastName",
+ "protocol" : "saml",
+ "protocolMapper" : "saml-user-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "attribute.nameformat" : "Basic",
+ "user.attribute" : "lastName",
+ "friendly.name" : "lastName"
+ }
+ }, {
+ "id" : "46cde274-7982-46ba-a8e2-0c83c86c0a83",
+ "name" : "username",
+ "protocol" : "saml",
+ "protocolMapper" : "saml-user-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "attribute.nameformat" : "Basic",
+ "user.attribute" : "username",
+ "friendly.name" : "username"
+ }
+ }, {
+ "id" : "eb511875-6279-4e16-bfbb-a5bf64eb9a84",
+ "name" : "full name",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-full-name-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "userinfo.token.claim" : "true"
+ }
+ }, {
+ "id" : "8c0b03ac-68ec-4bec-9d15-60d526c82f93",
+ "name" : "given name",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "firstName",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "given_name",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "820e0279-6e54-4787-90dd-dc9b983e7d21",
+ "name" : "id",
+ "protocol" : "saml",
+ "protocolMapper" : "saml-user-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "attribute.nameformat" : "Basic",
+ "user.attribute" : "id",
+ "friendly.name" : "id"
+ }
+ }, {
+ "id" : "185850a8-98fd-45dc-9e2a-0cce60ca79b1",
+ "name" : "family name",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "lastName",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "family_name",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "5c4933fa-deba-42ad-8895-4cb78c4a623a",
+ "name" : "role list",
+ "protocol" : "saml",
+ "protocolMapper" : "saml-role-list-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "single" : "false",
+ "attribute.nameformat" : "Basic",
+ "attribute.name" : "Role"
+ }
+ }, {
+ "id" : "944ad38e-c7c0-4197-956e-99bea3f4aa76",
+ "name" : "firstName",
+ "protocol" : "saml",
+ "protocolMapper" : "saml-user-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "attribute.nameformat" : "Basic",
+ "user.attribute" : "firstName",
+ "friendly.name" : "firstName"
+ }
+ } ],
+ "defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ],
+ "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
+ }, {
+ "id" : "9db3c486-1d1d-430a-84d9-304773d9b9b6",
+ "clientId" : "mattermost-realm",
+ "name" : "mattermost Realm",
+ "surrogateAuthRequired" : false,
+ "enabled" : true,
+ "alwaysDisplayInConsole" : false,
+ "clientAuthenticatorType" : "client-secret",
+ "secret" : "ba813ee3-da75-4a44-8b76-0583a25ab0a6",
+ "redirectUris" : [ ],
+ "webOrigins" : [ ],
+ "notBefore" : 0,
+ "bearerOnly" : true,
+ "consentRequired" : false,
+ "standardFlowEnabled" : true,
+ "implicitFlowEnabled" : false,
+ "directAccessGrantsEnabled" : false,
+ "serviceAccountsEnabled" : false,
+ "publicClient" : false,
+ "frontchannelLogout" : false,
+ "attributes" : { },
+ "authenticationFlowBindingOverrides" : { },
+ "fullScopeAllowed" : true,
+ "nodeReRegistrationTimeout" : 0,
+ "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ],
+ "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
+ }, {
+ "id" : "c00ad008-c2f3-43df-a3d5-2b79bf8aa055",
+ "clientId" : "security-admin-console",
+ "name" : "${client_security-admin-console}",
+ "rootUrl" : "${authAdminUrl}",
+ "baseUrl" : "/admin/mattermost/console/",
+ "surrogateAuthRequired" : false,
+ "enabled" : true,
+ "alwaysDisplayInConsole" : false,
+ "clientAuthenticatorType" : "client-secret",
+ "secret" : "e3ff2e21-394f-4536-90ce-d9d8697da91f",
+ "redirectUris" : [ "/admin/mattermost/console/*" ],
+ "webOrigins" : [ "+" ],
+ "notBefore" : 0,
+ "bearerOnly" : false,
+ "consentRequired" : false,
+ "standardFlowEnabled" : true,
+ "implicitFlowEnabled" : false,
+ "directAccessGrantsEnabled" : false,
+ "serviceAccountsEnabled" : false,
+ "publicClient" : true,
+ "frontchannelLogout" : false,
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "pkce.code.challenge.method" : "S256"
+ },
+ "authenticationFlowBindingOverrides" : { },
+ "fullScopeAllowed" : false,
+ "nodeReRegistrationTimeout" : 0,
+ "protocolMappers" : [ {
+ "id" : "d04c0393-31a7-400f-966e-919b19867ac7",
+ "name" : "locale",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "locale",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "locale",
+ "jsonType.label" : "String"
+ }
+ } ],
+ "defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ],
+ "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
+ } ],
+ "clientScopes" : [ {
+ "id" : "9604111a-194e-4dda-b92e-2b5792dc0806",
+ "name" : "address",
+ "description" : "OpenID Connect built-in scope: address",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "include.in.token.scope" : "true",
+ "display.on.consent.screen" : "true",
+ "consent.screen.text" : "${addressScopeConsentText}"
+ },
+ "protocolMappers" : [ {
+ "id" : "cd4cef7d-d064-4c37-8091-684755713eb1",
+ "name" : "address",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-address-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "user.attribute.formatted" : "formatted",
+ "user.attribute.country" : "country",
+ "user.attribute.postal_code" : "postal_code",
+ "userinfo.token.claim" : "true",
+ "user.attribute.street" : "street",
+ "id.token.claim" : "true",
+ "user.attribute.region" : "region",
+ "access.token.claim" : "true",
+ "user.attribute.locality" : "locality"
+ }
+ } ]
+ }, {
+ "id" : "d8096e80-d010-43dc-a882-296b3d3a7a09",
+ "name" : "email",
+ "description" : "OpenID Connect built-in scope: email",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "include.in.token.scope" : "true",
+ "display.on.consent.screen" : "true",
+ "consent.screen.text" : "${emailScopeConsentText}"
+ },
+ "protocolMappers" : [ {
+ "id" : "b67eed41-55e3-4f4a-8df7-d6ff87293b0c",
+ "name" : "email",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "email",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "email",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "5fe306a4-8f0a-497f-a832-a77b80dff8fc",
+ "name" : "email verified",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "emailVerified",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "email_verified",
+ "jsonType.label" : "boolean"
+ }
+ } ]
+ }, {
+ "id" : "599664c3-e555-4070-a665-bf31459ea0ab",
+ "name" : "microprofile-jwt",
+ "description" : "Microprofile - JWT built-in scope",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "include.in.token.scope" : "true",
+ "display.on.consent.screen" : "false"
+ },
+ "protocolMappers" : [ {
+ "id" : "4286f2f3-93f5-4720-9e0a-6c9bcecc8ed5",
+ "name" : "upn",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "username",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "upn",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "958a1c6c-1ecd-4550-babd-e527dd5f79ef",
+ "name" : "groups",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-realm-role-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "multivalued" : "true",
+ "user.attribute" : "foo",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "groups",
+ "jsonType.label" : "String"
+ }
+ } ]
+ }, {
+ "id" : "365bdebc-003b-4317-a2a2-8d41c2c3d57c",
+ "name" : "offline_access",
+ "description" : "OpenID Connect built-in scope: offline_access",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "consent.screen.text" : "${offlineAccessScopeConsentText}",
+ "display.on.consent.screen" : "true"
+ }
+ }, {
+ "id" : "d60a441a-4d9a-45a2-ab8d-167bfefe7dc7",
+ "name" : "phone",
+ "description" : "OpenID Connect built-in scope: phone",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "include.in.token.scope" : "true",
+ "display.on.consent.screen" : "true",
+ "consent.screen.text" : "${phoneScopeConsentText}"
+ },
+ "protocolMappers" : [ {
+ "id" : "ee47b76e-73ef-47c3-a907-2e8fe6d31749",
+ "name" : "phone number",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "phoneNumber",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "phone_number",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "5a864475-3ad8-4e95-8f20-536a6e1df159",
+ "name" : "phone number verified",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "phoneNumberVerified",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "phone_number_verified",
+ "jsonType.label" : "boolean"
+ }
+ } ]
+ }, {
+ "id" : "6412e99f-ad55-4e5c-b298-b4883a82207b",
+ "name" : "profile",
+ "description" : "OpenID Connect built-in scope: profile",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "include.in.token.scope" : "true",
+ "display.on.consent.screen" : "true",
+ "consent.screen.text" : "${profileScopeConsentText}"
+ },
+ "protocolMappers" : [ {
+ "id" : "5804dfa5-b72b-4204-80d2-d6bfb83f76fe",
+ "name" : "username",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "username",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "preferred_username",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "098106c8-d235-470a-b482-8447c2a1340e",
+ "name" : "full name",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-full-name-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "userinfo.token.claim" : "true"
+ }
+ }, {
+ "id" : "1fc223ba-b522-4680-8f2f-b99871d8b651",
+ "name" : "nickname",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "nickname",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "nickname",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "6d53f3eb-3d25-43ba-9adf-93617eb9c6ab",
+ "name" : "zoneinfo",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "zoneinfo",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "zoneinfo",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "f7797eb6-13a6-4245-a93d-ee8580a70675",
+ "name" : "website",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "website",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "website",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "5512bd46-9570-4b5b-b18f-479c477f7f51",
+ "name" : "gender",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "gender",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "gender",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "7e0a9d40-e1d1-483d-bc56-5ccb6e5ba1db",
+ "name" : "middle name",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "middleName",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "middle_name",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "6b7ac0bc-a801-4d61-9020-dff2393b3e2f",
+ "name" : "profile",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "profile",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "profile",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "21cb50d8-d4a0-4c34-8a21-a5d5a814c248",
+ "name" : "locale",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "locale",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "locale",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "fa57dead-2ea3-459a-b95a-71ef8adfab1a",
+ "name" : "family name",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "lastName",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "family_name",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "c7ceeaea-3c64-4846-9cb7-1781df7b5ad8",
+ "name" : "given name",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-property-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "firstName",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "given_name",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "4ccaeb42-32f0-420b-9408-5fdb8c7c3aff",
+ "name" : "birthdate",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "birthdate",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "birthdate",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "4eae9963-52fd-4b1d-9611-125f77371b0b",
+ "name" : "picture",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "picture",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "picture",
+ "jsonType.label" : "String"
+ }
+ }, {
+ "id" : "07000c6e-14e2-40b6-8aa0-c2b032ff98ae",
+ "name" : "updated at",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-attribute-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "userinfo.token.claim" : "true",
+ "user.attribute" : "updatedAt",
+ "id.token.claim" : "true",
+ "access.token.claim" : "true",
+ "claim.name" : "updated_at",
+ "jsonType.label" : "String"
+ }
+ } ]
+ }, {
+ "id" : "82b8263f-6e28-4301-8a15-0aeff9bc7cd1",
+ "name" : "role_list",
+ "description" : "SAML role list",
+ "protocol" : "saml",
+ "attributes" : {
+ "consent.screen.text" : "${samlRoleListScopeConsentText}",
+ "display.on.consent.screen" : "true"
+ },
+ "protocolMappers" : [ {
+ "id" : "8945e516-43b5-4137-8fa4-6d6a382dc75f",
+ "name" : "role list",
+ "protocol" : "saml",
+ "protocolMapper" : "saml-role-list-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "single" : "false",
+ "attribute.nameformat" : "Basic",
+ "attribute.name" : "Role"
+ }
+ } ]
+ }, {
+ "id" : "497468e6-7fc4-49dc-9377-ce14dc73df4c",
+ "name" : "roles",
+ "description" : "OpenID Connect scope for add user roles to the access token",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "include.in.token.scope" : "false",
+ "display.on.consent.screen" : "true",
+ "consent.screen.text" : "${rolesScopeConsentText}"
+ },
+ "protocolMappers" : [ {
+ "id" : "452ea040-f16d-4c2e-9660-57a8f7268d44",
+ "name" : "audience resolve",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-audience-resolve-mapper",
+ "consentRequired" : false,
+ "config" : { }
+ }, {
+ "id" : "e1cf8fda-5d90-49d8-b14d-dc14d1817ad6",
+ "name" : "realm roles",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-realm-role-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "user.attribute" : "foo",
+ "access.token.claim" : "true",
+ "claim.name" : "realm_access.roles",
+ "jsonType.label" : "String",
+ "multivalued" : "true"
+ }
+ }, {
+ "id" : "060321b7-cc01-4a40-a8c0-61054f2e9565",
+ "name" : "client roles",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-usermodel-client-role-mapper",
+ "consentRequired" : false,
+ "config" : {
+ "user.attribute" : "foo",
+ "access.token.claim" : "true",
+ "claim.name" : "resource_access.${client_id}.roles",
+ "jsonType.label" : "String",
+ "multivalued" : "true"
+ }
+ } ]
+ }, {
+ "id" : "c911dee4-e0d3-469f-a180-9aab921cd7db",
+ "name" : "web-origins",
+ "description" : "OpenID Connect scope for add allowed web origins to the access token",
+ "protocol" : "openid-connect",
+ "attributes" : {
+ "include.in.token.scope" : "false",
+ "display.on.consent.screen" : "false",
+ "consent.screen.text" : ""
+ },
+ "protocolMappers" : [ {
+ "id" : "9cd82ef2-2298-4e3b-b5c7-2741379c90e8",
+ "name" : "allowed web origins",
+ "protocol" : "openid-connect",
+ "protocolMapper" : "oidc-allowed-origins-mapper",
+ "consentRequired" : false,
+ "config" : { }
+ } ]
+ } ],
+ "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins" ],
+ "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ],
+ "browserSecurityHeaders" : {
+ "contentSecurityPolicyReportOnly" : "",
+ "xContentTypeOptions" : "nosniff",
+ "xRobotsTag" : "none",
+ "xFrameOptions" : "SAMEORIGIN",
+ "xXSSProtection" : "1; mode=block",
+ "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
+ "strictTransportSecurity" : "max-age=31536000; includeSubDomains"
+ },
+ "smtpServer" : { },
+ "eventsEnabled" : false,
+ "eventsListeners" : [ "jboss-logging" ],
+ "enabledEventTypes" : [ ],
+ "adminEventsEnabled" : false,
+ "adminEventsDetailsEnabled" : false,
+ "components" : {
+ "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ {
+ "id" : "c8d92569-aba3-4c3c-977d-a35951b5b051",
+ "name" : "Trusted Hosts",
+ "providerId" : "trusted-hosts",
+ "subType" : "anonymous",
+ "subComponents" : { },
+ "config" : {
+ "host-sending-registration-request-must-match" : [ "true" ],
+ "client-uris-must-match" : [ "true" ]
+ }
+ }, {
+ "id" : "afc06a86-b2fc-4575-a9d6-636797100557",
+ "name" : "Max Clients Limit",
+ "providerId" : "max-clients",
+ "subType" : "anonymous",
+ "subComponents" : { },
+ "config" : {
+ "max-clients" : [ "200" ]
+ }
+ }, {
+ "id" : "232ecdbb-d581-49f4-8935-f2dd29fd4906",
+ "name" : "Allowed Client Scopes",
+ "providerId" : "allowed-client-templates",
+ "subType" : "authenticated",
+ "subComponents" : { },
+ "config" : {
+ "allow-default-scopes" : [ "true" ]
+ }
+ }, {
+ "id" : "ff7e9d75-6932-4c48-847f-c4cd9b704e6a",
+ "name" : "Consent Required",
+ "providerId" : "consent-required",
+ "subType" : "anonymous",
+ "subComponents" : { },
+ "config" : { }
+ }, {
+ "id" : "9e4e98cc-e3ad-4e8f-8b29-4905c5fd5afc",
+ "name" : "Allowed Client Scopes",
+ "providerId" : "allowed-client-templates",
+ "subType" : "anonymous",
+ "subComponents" : { },
+ "config" : {
+ "allow-default-scopes" : [ "true" ]
+ }
+ }, {
+ "id" : "5e7e8083-346d-47da-b20b-ab5845177cd2",
+ "name" : "Full Scope Disabled",
+ "providerId" : "scope",
+ "subType" : "anonymous",
+ "subComponents" : { },
+ "config" : { }
+ }, {
+ "id" : "ccb37107-02f0-4346-8947-bf2f514c2cc1",
+ "name" : "Allowed Protocol Mapper Types",
+ "providerId" : "allowed-protocol-mappers",
+ "subType" : "anonymous",
+ "subComponents" : { },
+ "config" : {
+ "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "saml-role-list-mapper" ]
+ }
+ }, {
+ "id" : "ea1b47d2-28ca-4b32-869b-bb27c0a6c01e",
+ "name" : "Allowed Protocol Mapper Types",
+ "providerId" : "allowed-protocol-mappers",
+ "subType" : "authenticated",
+ "subComponents" : { },
+ "config" : {
+ "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "oidc-address-mapper" ]
+ }
+ } ],
+ "org.keycloak.storage.UserStorageProvider" : [ {
+ "id" : "0d94859b-cd61-4314-9669-fbcac2322dfd",
+ "name" : "ldap",
+ "providerId" : "ldap",
+ "subComponents" : {
+ "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" : [ {
+ "id" : "be8717de-8a53-4def-8a9c-fecac293726b",
+ "name" : "last name",
+ "providerId" : "user-attribute-ldap-mapper",
+ "subComponents" : { },
+ "config" : {
+ "ldap.attribute" : [ "sn" ],
+ "is.mandatory.in.ldap" : [ "true" ],
+ "always.read.value.from.ldap" : [ "true" ],
+ "read.only" : [ "true" ],
+ "user.model.attribute" : [ "lastName" ]
+ }
+ }, {
+ "id" : "bc253cfb-58f4-4567-9947-ffd9547cb0d5",
+ "name" : "username",
+ "providerId" : "user-attribute-ldap-mapper",
+ "subComponents" : { },
+ "config" : {
+ "ldap.attribute" : [ "uid" ],
+ "is.mandatory.in.ldap" : [ "true" ],
+ "always.read.value.from.ldap" : [ "false" ],
+ "read.only" : [ "true" ],
+ "user.model.attribute" : [ "username" ]
+ }
+ }, {
+ "id" : "1d123084-39d5-41da-9bef-824d5ba01985",
+ "name" : "creation date",
+ "providerId" : "user-attribute-ldap-mapper",
+ "subComponents" : { },
+ "config" : {
+ "ldap.attribute" : [ "createTimestamp" ],
+ "is.mandatory.in.ldap" : [ "false" ],
+ "read.only" : [ "true" ],
+ "always.read.value.from.ldap" : [ "true" ],
+ "user.model.attribute" : [ "createTimestamp" ]
+ }
+ }, {
+ "id" : "6d433563-823f-4361-b575-59c74f2ef92e",
+ "name" : "modify date",
+ "providerId" : "user-attribute-ldap-mapper",
+ "subComponents" : { },
+ "config" : {
+ "ldap.attribute" : [ "modifyTimestamp" ],
+ "is.mandatory.in.ldap" : [ "false" ],
+ "always.read.value.from.ldap" : [ "true" ],
+ "read.only" : [ "true" ],
+ "user.model.attribute" : [ "modifyTimestamp" ]
+ }
+ }, {
+ "id" : "6137c2fb-5672-4389-ae2c-4ef545b746e5",
+ "name" : "first name",
+ "providerId" : "user-attribute-ldap-mapper",
+ "subComponents" : { },
+ "config" : {
+ "ldap.attribute" : [ "cn" ],
+ "is.mandatory.in.ldap" : [ "true" ],
+ "read.only" : [ "true" ],
+ "always.read.value.from.ldap" : [ "true" ],
+ "user.model.attribute" : [ "firstName" ]
+ }
+ }, {
+ "id" : "faa4cd32-50d3-45c8-a553-60d55878b7e6",
+ "name" : "email",
+ "providerId" : "user-attribute-ldap-mapper",
+ "subComponents" : { },
+ "config" : {
+ "ldap.attribute" : [ "mail" ],
+ "is.mandatory.in.ldap" : [ "false" ],
+ "always.read.value.from.ldap" : [ "false" ],
+ "read.only" : [ "true" ],
+ "user.model.attribute" : [ "email" ]
+ }
+ } ]
+ },
+ "config" : {
+ "pagination" : [ "true" ],
+ "fullSyncPeriod" : [ "-1" ],
+ "usersDn" : [ "ou=testusers,dc=mm,dc=test,dc=com" ],
+ "connectionPooling" : [ "true" ],
+ "cachePolicy" : [ "DEFAULT" ],
+ "useKerberosForPasswordAuthentication" : [ "false" ],
+ "importEnabled" : [ "true" ],
+ "enabled" : [ "true" ],
+ "bindDn" : [ "cn=admin,dc=mm,dc=test,dc=com" ],
+ "changedSyncPeriod" : [ "-1" ],
+ "usernameLDAPAttribute" : [ "uid" ],
+ "bindCredential" : [ "mostest" ],
+ "lastSync" : [ "1518169262" ],
+ "vendor" : [ "other" ],
+ "uuidLDAPAttribute" : [ "entryUUID" ],
+ "connectionUrl" : [ "ldap://localhost:389" ],
+ "allowKerberosAuthentication" : [ "false" ],
+ "syncRegistrations" : [ "false" ],
+ "authType" : [ "simple" ],
+ "debug" : [ "false" ],
+ "searchScope" : [ "1" ],
+ "useTruststoreSpi" : [ "ldapsOnly" ],
+ "priority" : [ "0" ],
+ "userObjectClasses" : [ "inetOrgPerson, organizationalPerson" ],
+ "rdnLDAPAttribute" : [ "uid" ],
+ "validatePasswordPolicy" : [ "false" ],
+ "batchSizeForSync" : [ "1000" ]
+ }
+ } ],
+ "org.keycloak.keys.KeyProvider" : [ {
+ "id" : "284d2d18-f974-4b0f-b4f5-0155701257d4",
+ "name" : "aes-generated",
+ "providerId" : "aes-generated",
+ "subComponents" : { },
+ "config" : {
+ "kid" : [ "6a9f1872-bb81-4651-bc9e-71abb132734d" ],
+ "secret" : [ "DiUoJ0cgUAxUuQZfbxl6-A" ],
+ "priority" : [ "100" ]
+ }
+ }, {
+ "id" : "a6a66d52-a384-44c5-a0f8-dd57900fae8d",
+ "name" : "rsa-generated",
+ "providerId" : "rsa-generated",
+ "subComponents" : { },
+ "config" : {
+ "privateKey" : [ "MIIEowIBAAKCAQEAthewnvlKBr2kgbbqOGRDwEowz5drjCuAA3Iw3/SwWlRghLvWbNslSG+ORdu0axDEDsaYdpqQZykEGo5ZCItvAAQsU4FrzocPsPA/muoNsqYY0vIQeYwHIJMNo5ByCgX8jJ46sWUYt95Pu6AYWgyqLMgr04Shv0G6gtvd/3JLwWVCWixKCZ+LNHkKBNKEHpF4NEp34ceyagKrb6zl7bAAm+b2xhi52SHYvUsXCwwAu5h74lNnOxkCgBlS6OGi2JSZ6/G8u8iBBr2Jp4w8d/d1fF5bio3PwyLMhD/TOC5krc0UTHfNQ5mQjoNM8fAF1XKmQrBESzr35b19WzDO/0Lb3wIDAQABAoIBABElW+ksOg82bjYUnitfLY3+rmftrx/MvMoWR4nfBXgL9+antUIcxH70miXz0SI/uuZVRufsF+rOzucdPj7yuin7Op1GU3tn9k9H4AVbQpzuzOmYB3sad1VW43LiWAqfk689+vLXPSObGFDne0OHa8K5un65P229560IvPefsIhuMoM0T7JLvtLPIBgWrY/UXj/lFZP9f4y5E6SZ7ojQFvXJXhqa0IKaVl4rdyWjK7vgXGIH2AOR1sPiQzkymdz3cJX2Q4axi0qX+PZF2IazL8kDeK3MDvDW7TrzrighyCd8SsmEWVVuFAxkDLTh6XLJrHt4epogOzRbo+DDqTi5+JECgYEA855gd/gu5k5Khw4/EsqJypjTITeCFsLjHpQjgiR+zMafgxcXnbqoSNjH/cxfqMgQl32/u/KcOI6swZK7AWBBff7Ez5tw2n6SsLjNjpYfrVS7OURN3vrqKLziUXk9kFM8OK49nggf/mdGuux91IBGgBxHKN8Jspcu6q4uVchkIecCgYEAv1jSYRS9jRsI7kY8Qc6+Xzt/vYBz1zcW6AMWw0sjBjSYuWuDPdQZhuk07c5x4G7RhCIGyUz5T53/dZsa7fww3JAsfb4awIlxQ8lAPkRBdsETxtTs5lUQ77M/wg3t9IjYCKLzaxp6TDuPU+Fpd56i3bZc6F8sXKNbfvQ3SJ69J0kCgYEA5DueOQbEOXNjkv+fy6UATlO6iMYOE/DlAoLaeVRjjskOK6v4rgZvHkAprPZJMECuep6OgDAcd0gDRR6IIBPjh3ylObJwmeI232Vi/pBagPJ+rHn3Uk1UDnJWvOmO6aVxJ9DlXSZTgu2ScBCbGfhLFD5p1DqQRUYp6Cbite8VEEUCgYBaqW8k6HrXfNPCcizi0V6KKNrhoxdABa4oyC3k4pj5u7oRQMuyY+ikb6LQelyihl9nR+gHQR1vh+EejBs6X5+XIgiym3x5daXhBF4YIqcR6XHBZ+nHSM75g+jVvVvd3WjezrafLLB9pkrG56rdLqDkhB+JSm7uhcg4YuY+1lexYQKBgG1+WvqYPiHGAtgR5fUD/DaT+8aXcUoX3uFym3WDPHnrqOM0WW10iYs3Le/rKX+G+FrMR1rTik90Ij1EJKgjPiQ15XHra+mIgPEbPtVjUh0YiJw7vvl1SYwlrkvN0/4pL4ZNFEDDc5P+fMNH0qo4Mq0i6R1CBLMkYDBLden2X3j/" ],
+ "certificate" : [ "MIICmzCCAYMCBgFyzt0uTDANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjAwNjE5MjMxMzIxWhcNMzAwNjE5MjMxNTAxWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC2F7Ce+UoGvaSBtuo4ZEPASjDPl2uMK4ADcjDf9LBaVGCEu9Zs2yVIb45F27RrEMQOxph2mpBnKQQajlkIi28ABCxTgWvOhw+w8D+a6g2yphjS8hB5jAcgkw2jkHIKBfyMnjqxZRi33k+7oBhaDKosyCvThKG/QbqC293/ckvBZUJaLEoJn4s0eQoE0oQekXg0Snfhx7JqAqtvrOXtsACb5vbGGLnZIdi9SxcLDAC7mHviU2c7GQKAGVLo4aLYlJnr8by7yIEGvYmnjDx393V8XluKjc/DIsyEP9M4LmStzRRMd81DmZCOg0zx8AXVcqZCsERLOvflvX1bMM7/QtvfAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHCyf7wTY8ZrPcqWuBj6JfD9iw+45dT4ZOAIlPXL5+wwYzdA7kkSfF7GCXLyYD0U6QEB2SA0RFPXU25WfIVMbDP1OyM4oCbzEqQvAeWkTxe0P+ZWgEUfVN9jgv4N9l/oUXiHkvyZi9K1KM8oLK3j1/YSAqBx60P6iS69a49Pry4eb6ab5mZyU/Tp7Ll7wTpdFW1o/pY9GCcX8cEBhfp+Jm2sVGczIF0s/aJ69rtcK1f8wmXOgY2VKx0eQ00wSOtkHvcPPWAmZzlkpYzdPSmMjluWVusA1T4QPOj44dxB+xI62i25BKUlQpWMmKaZX4Zb6QTUAyvDZusySnwMbr20ijE=" ],
+ "priority" : [ "100" ]
+ }
+ }, {
+ "id" : "c72c3e08-b8cd-4b7d-b4f3-45b9f58874e5",
+ "name" : "hmac-generated",
+ "providerId" : "hmac-generated",
+ "subComponents" : { },
+ "config" : {
+ "kid" : [ "1505fd02-fdc4-439d-a1ef-493a6be548f1" ],
+ "secret" : [ "J2XMixVTpZh87FyTpu3NRBriVQplri-1mKrGg2tPolH0r-os-wpQt9HMAWC3oQRCFOH7QicxjubQN2OHt8-lWA" ],
+ "priority" : [ "100" ],
+ "algorithm" : [ "HS256" ]
+ }
+ } ]
+ },
+ "internationalizationEnabled" : false,
+ "supportedLocales" : [ ],
+ "authenticationFlows" : [ {
+ "id" : "cb3e226a-5d7d-4e81-808e-4e4cf0ecde9e",
+ "alias" : "Account verification options",
+ "description" : "Method with which to verity the existing account",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "idp-email-verification",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "ALTERNATIVE",
+ "priority" : 20,
+ "flowAlias" : "Verify Existing Account by Re-authentication",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "41a1248f-a43b-48b1-b75a-ddaed38e191c",
+ "alias" : "Authentication Options",
+ "description" : "Authentication options.",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "basic-auth",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "basic-auth-otp",
+ "requirement" : "DISABLED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "auth-spnego",
+ "requirement" : "DISABLED",
+ "priority" : 30,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "f4424450-7c5a-4af4-b78d-37e2aba0d3b1",
+ "alias" : "Browser - Conditional OTP",
+ "description" : "Flow to determine if the OTP is required for the authentication",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "conditional-user-configured",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "auth-otp-form",
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "e1062ec1-2fae-47e1-8e03-375ba2eacd43",
+ "alias" : "Direct Grant - Conditional OTP",
+ "description" : "Flow to determine if the OTP is required for the authentication",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "conditional-user-configured",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "direct-grant-validate-otp",
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "0c3a1bd6-5a42-4765-a458-f33dd1383dfa",
+ "alias" : "First broker login - Conditional OTP",
+ "description" : "Flow to determine if the OTP is required for the authentication",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "conditional-user-configured",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "auth-otp-form",
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "fcb1e54b-403a-4f15-a068-d5ca926389b4",
+ "alias" : "Handle Existing Account",
+ "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "idp-confirm-link",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "flowAlias" : "Account verification options",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "06a646f8-ffa1-4fb2-89e9-0ca6e8f19869",
+ "alias" : "Reset - Conditional OTP",
+ "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "conditional-user-configured",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "reset-otp",
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "b239d54c-319f-4018-a702-ae1bd13653a0",
+ "alias" : "User creation or linking",
+ "description" : "Flow for the existing/non-existing user alternatives",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticatorConfig" : "create unique user config",
+ "authenticator" : "idp-create-user-if-unique",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "ALTERNATIVE",
+ "priority" : 20,
+ "flowAlias" : "Handle Existing Account",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "46cf3d95-06f6-43b9-8bad-1fa4ae654e73",
+ "alias" : "Verify Existing Account by Re-authentication",
+ "description" : "Reauthentication of existing account",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "idp-username-password-form",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "CONDITIONAL",
+ "priority" : 20,
+ "flowAlias" : "First broker login - Conditional OTP",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "b7479f88-1610-4fe7-9645-9315bb74f6c1",
+ "alias" : "browser",
+ "description" : "browser based authentication",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "auth-cookie",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "auth-spnego",
+ "requirement" : "DISABLED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "identity-provider-redirector",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 25,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "ALTERNATIVE",
+ "priority" : 30,
+ "flowAlias" : "forms",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "10d69204-6f7a-4571-aa01-19037b107d58",
+ "alias" : "clients",
+ "description" : "Base authentication for clients",
+ "providerId" : "client-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "client-secret",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "client-jwt",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "client-secret-jwt",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 30,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "client-x509",
+ "requirement" : "ALTERNATIVE",
+ "priority" : 40,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "e48be033-0deb-435d-a65b-2783e4e41b11",
+ "alias" : "direct grant",
+ "description" : "OpenID Connect Resource Owner Grant",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "direct-grant-validate-username",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "direct-grant-validate-password",
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "CONDITIONAL",
+ "priority" : 30,
+ "flowAlias" : "Direct Grant - Conditional OTP",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "66e56029-4089-4a7b-a94a-80f3a068ef91",
+ "alias" : "docker auth",
+ "description" : "Used by Docker clients to authenticate against the IDP",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "docker-http-basic-authenticator",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "72a99b6b-160c-4677-bf0f-37eceeafe4d5",
+ "alias" : "first broker login",
+ "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticatorConfig" : "review profile config",
+ "authenticator" : "idp-review-profile",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "flowAlias" : "User creation or linking",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "ee07e243-f09a-4913-9ec8-8cd33037ec0b",
+ "alias" : "forms",
+ "description" : "Username, password, otp and other auth forms.",
+ "providerId" : "basic-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "auth-username-password-form",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "CONDITIONAL",
+ "priority" : 20,
+ "flowAlias" : "Browser - Conditional OTP",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "14b48d37-31ef-45c2-88fd-46aafec1dd53",
+ "alias" : "http challenge",
+ "description" : "An authentication flow based on challenge-response HTTP Authentication Schemes",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "no-cookie-redirect",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "flowAlias" : "Authentication Options",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "899ded70-7ac9-4883-b9d5-146581ec9cbf",
+ "alias" : "registration",
+ "description" : "registration flow",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "registration-page-form",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "flowAlias" : "registration form",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "5ee4cf5f-19db-4f80-98f3-0879169152c6",
+ "alias" : "registration form",
+ "description" : "registration form",
+ "providerId" : "form-flow",
+ "topLevel" : false,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "registration-user-creation",
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "registration-profile-action",
+ "requirement" : "REQUIRED",
+ "priority" : 40,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "registration-password-action",
+ "requirement" : "REQUIRED",
+ "priority" : 50,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "registration-recaptcha-action",
+ "requirement" : "DISABLED",
+ "priority" : 60,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ }, {
+ "id" : "da5e8e7f-0c0b-4e33-a182-67a4866ee147",
+ "alias" : "reset credentials",
+ "description" : "Reset credentials for a user if they forgot their password or something",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "reset-credentials-choose-user",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "reset-credential-email",
+ "requirement" : "REQUIRED",
+ "priority" : 20,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "authenticator" : "reset-password",
+ "requirement" : "REQUIRED",
+ "priority" : 30,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ }, {
+ "requirement" : "CONDITIONAL",
+ "priority" : 40,
+ "flowAlias" : "Reset - Conditional OTP",
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : true
+ } ]
+ }, {
+ "id" : "7db42ea8-5e7d-4e86-8898-3ba577ae27f7",
+ "alias" : "saml ecp",
+ "description" : "SAML ECP Profile Authentication Flow",
+ "providerId" : "basic-flow",
+ "topLevel" : true,
+ "builtIn" : true,
+ "authenticationExecutions" : [ {
+ "authenticator" : "http-basic-authenticator",
+ "requirement" : "REQUIRED",
+ "priority" : 10,
+ "userSetupAllowed" : false,
+ "autheticatorFlow" : false
+ } ]
+ } ],
+ "authenticatorConfig" : [ {
+ "id" : "29be9f9a-ad39-482d-8a9c-5e0021863588",
+ "alias" : "create unique user config",
+ "config" : {
+ "require.password.update.after.registration" : "false"
+ }
+ }, {
+ "id" : "bcefb4dc-8784-4bb0-9138-7f18deb9b184",
+ "alias" : "review profile config",
+ "config" : {
+ "update.profile.on.first.login" : "missing"
+ }
+ } ],
+ "requiredActions" : [ {
+ "alias" : "CONFIGURE_TOTP",
+ "name" : "Configure OTP",
+ "providerId" : "CONFIGURE_TOTP",
+ "enabled" : true,
+ "defaultAction" : false,
+ "priority" : 10,
+ "config" : { }
+ }, {
+ "alias" : "terms_and_conditions",
+ "name" : "Terms and Conditions",
+ "providerId" : "terms_and_conditions",
+ "enabled" : false,
+ "defaultAction" : false,
+ "priority" : 20,
+ "config" : { }
+ }, {
+ "alias" : "UPDATE_PASSWORD",
+ "name" : "Update Password",
+ "providerId" : "UPDATE_PASSWORD",
+ "enabled" : true,
+ "defaultAction" : false,
+ "priority" : 30,
+ "config" : { }
+ }, {
+ "alias" : "UPDATE_PROFILE",
+ "name" : "Update Profile",
+ "providerId" : "UPDATE_PROFILE",
+ "enabled" : true,
+ "defaultAction" : false,
+ "priority" : 40,
+ "config" : { }
+ }, {
+ "alias" : "VERIFY_EMAIL",
+ "name" : "Verify Email",
+ "providerId" : "VERIFY_EMAIL",
+ "enabled" : true,
+ "defaultAction" : false,
+ "priority" : 50,
+ "config" : { }
+ }, {
+ "alias" : "update_user_locale",
+ "name" : "Update User Locale",
+ "providerId" : "update_user_locale",
+ "enabled" : true,
+ "defaultAction" : false,
+ "priority" : 1000,
+ "config" : { }
+ } ],
+ "browserFlow" : "browser",
+ "registrationFlow" : "registration",
+ "directGrantFlow" : "direct grant",
+ "resetCredentialsFlow" : "reset credentials",
+ "clientAuthenticationFlow" : "clients",
+ "dockerAuthenticationFlow" : "docker auth",
+ "attributes" : { },
+ "keycloakVersion" : "10.0.2",
+ "userManagedAccessAllowed" : false
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/ldap.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/ldap.d.ts
new file mode 100644
index 00000000000..6654365484c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/ldap.d.ts
@@ -0,0 +1,48 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLDAPSync`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Synchronize any user attribute changes in the configured AD/LDAP server with Mattermost.
+ * See https://api.mattermost.com/#operation/SyncLdap
+ *
+ * @example
+ * cy.apiLDAPSync();
+ */
+ apiLDAPSync(): Chainable;
+
+ /**
+ * Test the current AD/LDAP configuration to see if the AD/LDAP server can be contacted successfully.
+ * See https://api.mattermost.com/#operation/TestLdap
+ *
+ * @example
+ * cy.apiLDAPTest();
+ */
+ apiLDAPTest(): Chainable;
+
+ /**
+ * Sync LDAP user
+ * @returns {UserProfile} user - user object
+ *
+ * @example
+ * cy.apiSyncLDAPUser();
+ */
+ apiSyncLDAPUser(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/ldap.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/ldap.js
new file mode 100644
index 00000000000..a3814249aae
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/ldap.js
@@ -0,0 +1,50 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// LDAP
+// https://api.mattermost.com/#tag/LDAP
+// *****************************************************************************
+
+Cypress.Commands.add('apiLDAPSync', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/ldap/sync',
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiLDAPTest', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/ldap/test',
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiSyncLDAPUser', ({
+ ldapUser = {},
+ bypassTutorial = true,
+}) => {
+ // # Test LDAP connection and synchronize user
+ cy.apiLDAPTest();
+ cy.apiLDAPSync();
+
+ // # Login to sync LDAP user
+ return cy.apiLogin(ldapUser).then(({user}) => {
+ if (bypassTutorial) {
+ cy.apiAdminLogin();
+ }
+ if (bypassTutorial) {
+ cy.apiSaveTutorialStep(user.id, '999');
+ }
+
+ return cy.wrap(user);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/on_prem_default_config.json b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/on_prem_default_config.json
new file mode 100644
index 00000000000..f6660db9461
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/on_prem_default_config.json
@@ -0,0 +1,468 @@
+{
+ "ServiceSettings": {
+ "SiteURL": "http://localhost:8065",
+ "WebsocketURL": "",
+ "LicenseFileLocation": "",
+ "ListenAddress": ":8065",
+ "ConnectionSecurity": "",
+ "TLSCertFile": "",
+ "TLSKeyFile": "",
+ "TLSMinVer": "1.2",
+ "TLSStrictTransport": false,
+ "TLSStrictTransportMaxAge": 63072000,
+ "TLSOverwriteCiphers": [],
+ "UseLetsEncrypt": false,
+ "Forward80To443": false,
+ "TrustedProxyIPHeader": [],
+ "ReadTimeout": 300,
+ "WriteTimeout": 300,
+ "IdleTimeout": 300,
+ "GoroutineHealthThreshold": -1,
+ "GoogleDeveloperKey": "",
+ "EnableOAuthServiceProvider": false,
+ "EnableIncomingWebhooks": true,
+ "EnableOutgoingWebhooks": true,
+ "EnableCommands": true,
+ "EnablePostUsernameOverride": false,
+ "EnablePostIconOverride": false,
+ "EnableLinkPreviews": false,
+ "EnableTesting": false,
+ "EnableDeveloper": false,
+ "EnableOpenTracing": false,
+ "EnableSecurityFixAlert": true,
+ "EnableInsecureOutgoingConnections": false,
+ "AllowedUntrustedInternalConnections": "localhost",
+ "EnableMultifactorAuthentication": false,
+ "EnforceMultifactorAuthentication": false,
+ "EnableUserAccessTokens": false,
+ "AllowCorsFrom": "",
+ "CorsExposedHeaders": "",
+ "CorsAllowCredentials": false,
+ "CorsDebug": false,
+ "AllowCookiesForSubdomains": false,
+ "ExtendSessionLengthWithActivity": true,
+ "SessionLengthWebInHours": 720,
+ "SessionLengthMobileInHours": 720,
+ "SessionLengthSSOInHours": 720,
+ "SessionCacheInMinutes": 10,
+ "SessionIdleTimeoutInMinutes": 43200,
+ "WebsocketSecurePort": 443,
+ "WebsocketPort": 80,
+ "WebserverMode": "gzip",
+ "EnableCustomEmoji": false,
+ "EnableEmojiPicker": true,
+ "EnableGifPicker": false,
+ "PostEditTimeLimit": -1,
+ "TimeBetweenUserTypingUpdatesMilliseconds": 5000,
+ "EnablePostSearch": true,
+ "MinimumHashtagLength": 3,
+ "EnableUserTypingMessages": true,
+ "EnableChannelViewedMessages": true,
+ "EnableUserStatuses": true,
+ "ExperimentalEnableAuthenticationTransfer": true,
+ "ClusterLogTimeoutMilliseconds": 2000,
+ "EnablePreviewFeatures": true,
+ "EnableTutorial": true,
+ "EnableOnboardingFlow": false,
+ "ExperimentalEnableDefaultChannelLeaveJoinMessages": true,
+ "ExperimentalGroupUnreadChannels": "disabled",
+ "EnableAPITeamDeletion": true,
+ "ExperimentalEnableHardenedMode": false,
+ "ExperimentalStrictCSRFEnforcement": false,
+ "StrictCSRFEnforcement": false,
+ "EnableEmailInvitations": true,
+ "DisableBotsWhenOwnerIsDeactivated": true,
+ "EnableBotAccountCreation": true,
+ "EnableSVGs": true,
+ "EnableLatex": false,
+ "EnableLegacySidebar": false,
+ "ThreadAutoFollow": true,
+ "CollapsedThreads": "disabled"
+ },
+ "TeamSettings": {
+ "SiteName": "Mattermost",
+ "MaxUsersPerTeam": 2000,
+ "EnableUserCreation": true,
+ "EnableOpenServer": true,
+ "EnableUserDeactivation": false,
+ "RestrictCreationToDomains": "",
+ "EnableCustomUserStatuses": true,
+ "EnableCustomBrand": false,
+ "CustomBrandText": "",
+ "CustomDescriptionText": "",
+ "RestrictDirectMessage": "any",
+ "UserStatusAwayTimeout": 300,
+ "MaxChannelsPerTeam": 2000,
+ "MaxNotificationsPerChannel": 1000,
+ "EnableConfirmNotificationsToChannel": true,
+ "TeammateNameDisplay": "username",
+ "ExperimentalEnableAutomaticReplies": false,
+ "LockTeammateNameDisplay": false,
+ "ExperimentalPrimaryTeam": "",
+ "ExperimentalDefaultChannels": []
+ },
+ "ClientRequirements": {
+ "AndroidLatestVersion": "",
+ "AndroidMinVersion": "",
+ "IosLatestVersion": "",
+ "IosMinVersion": ""
+ },
+ "SqlSettings": {
+ "DataSourceReplicas": [],
+ "DataSourceSearchReplicas": [],
+ "MaxIdleConns": 20,
+ "ConnMaxLifetimeMilliseconds": 3600000,
+ "MaxOpenConns": 300,
+ "Trace": false,
+ "AtRestEncryptKey": "",
+ "QueryTimeout": 30
+ },
+ "LogSettings": {
+ "EnableConsole": true,
+ "ConsoleLevel": "DEBUG",
+ "ConsoleJson": true,
+ "EnableFile": true,
+ "FileLevel": "INFO",
+ "FileJson": true,
+ "FileLocation": "",
+ "EnableWebhookDebugging": true,
+ "EnableDiagnostics": true,
+ "EnableSentry": false
+ },
+ "ExperimentalAuditSettings": {
+ "FileEnabled": false,
+ "FileName": "",
+ "FileMaxSizeMB": 100,
+ "FileMaxAgeDays": 0,
+ "FileMaxBackups": 0,
+ "FileCompress": false,
+ "FileMaxQueueSize": 1000
+ },
+ "NotificationLogSettings": {
+ "EnableConsole": true,
+ "ConsoleLevel": "DEBUG",
+ "ConsoleJson": true,
+ "EnableFile": true,
+ "FileLevel": "INFO",
+ "FileJson": true,
+ "FileLocation": ""
+ },
+ "PasswordSettings": {
+ "MinimumLength": 5,
+ "Lowercase": false,
+ "Number": false,
+ "Uppercase": false,
+ "Symbol": false,
+ "Enable": false
+ },
+ "FileSettings": {
+ "EnableFileAttachments": true,
+ "EnableMobileUpload": true,
+ "EnableMobileDownload": true,
+ "MaxFileSize": 104857600,
+ "DriverName": "local",
+ "Directory": "./data/",
+ "EnablePublicLink": false,
+ "PublicLinkSalt": "",
+ "InitialFont": "nunito-bold.ttf",
+ "AmazonS3AccessKeyId": "",
+ "AmazonS3SecretAccessKey": "",
+ "AmazonS3Bucket": "",
+ "AmazonS3Region": "",
+ "AmazonS3Endpoint": "s3.amazonaws.com",
+ "AmazonS3SSL": true,
+ "AmazonS3SignV2": false,
+ "AmazonS3SSE": false,
+ "AmazonS3Trace": false
+ },
+ "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",
+ "PushNotificationContents": "generic",
+ "EnableEmailBatching": false,
+ "EmailBatchingBufferSize": 256,
+ "EmailBatchingInterval": 30,
+ "EnablePreviewModeBanner": true,
+ "SkipServerCertificateVerification": false,
+ "EmailNotificationContentsType": "full",
+ "LoginButtonColor": "#0000",
+ "LoginButtonBorderColor": "#2389D7",
+ "LoginButtonTextColor": "#2389D7"
+ },
+ "RateLimitSettings": {
+ "Enable": false,
+ "PerSec": 10,
+ "MaxBurst": 100,
+ "MemoryStoreSize": 10000,
+ "VaryByRemoteAddr": true,
+ "VaryByUser": false,
+ "VaryByHeader": ""
+ },
+ "PrivacySettings": {
+ "ShowEmailAddress": true,
+ "ShowFullName": true
+ },
+ "SupportSettings": {
+ "TermsOfServiceLink": "https://mattermost.com/pl/terms-of-use/",
+ "PrivacyPolicyLink": "https://mattermost.com/pl/privacy-policy/",
+ "AboutLink": "https://docs.mattermost.com/pl/about-mattermost",
+ "HelpLink": "https://mattermost.com/pl/help/",
+ "ReportAProblemLink": "https://mattermost.com/pl/report-a-bug",
+ "SupportEmail": "",
+ "CustomTermsOfServiceEnabled": false,
+ "CustomTermsOfServiceReAcceptancePeriod": 365,
+ "EnableAskCommunityLink": true
+ },
+ "AnnouncementSettings": {
+ "EnableBanner": false,
+ "BannerText": "",
+ "BannerColor": "#f2a93b",
+ "BannerTextColor": "#333333",
+ "AllowBannerDismissal": true,
+ "AdminNoticesEnabled": false,
+ "UserNoticesEnabled": false
+ },
+ "ThemeSettings": {
+ "EnableThemeSelection": true,
+ "DefaultTheme": "default",
+ "AllowCustomThemes": true,
+ "AllowedThemes": []
+ },
+ "GitLabSettings": {
+ "Enable": false,
+ "Secret": "",
+ "Id": "",
+ "Scope": "",
+ "AuthEndpoint": "",
+ "TokenEndpoint": "",
+ "UserAPIEndpoint": ""
+ },
+ "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"
+ },
+ "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",
+ "DirectoryId": ""
+ },
+ "LdapSettings": {
+ "Enable": true,
+ "EnableSync": false,
+ "LdapServer": "localhost",
+ "LdapPort": 389,
+ "ConnectionSecurity": "",
+ "BaseDN": "dc=mm,dc=test,dc=com",
+ "BindUsername": "cn=admin,dc=mm,dc=test,dc=com",
+ "BindPassword": "mostest",
+ "UserFilter": "",
+ "GroupFilter": "",
+ "GuestFilter": "",
+ "EnableAdminFilter": false,
+ "AdminFilter": "",
+ "GroupDisplayNameAttribute": "cn",
+ "GroupIdAttribute": "entryUUID",
+ "FirstNameAttribute": "cn",
+ "LastNameAttribute": "sn",
+ "EmailAttribute": "mail",
+ "UsernameAttribute": "uid",
+ "NicknameAttribute": "cn",
+ "IdAttribute": "uid",
+ "PositionAttribute": "sAMAccountType",
+ "LoginIdAttribute": "uid",
+ "PictureAttribute": "",
+ "SyncIntervalMinutes": 10000,
+ "SkipCertificateVerification": true,
+ "QueryTimeout": 60,
+ "MaxPageSize": 500,
+ "LoginFieldName": "",
+ "LoginButtonColor": "#0000",
+ "LoginButtonBorderColor": "#2389D7",
+ "LoginButtonTextColor": "#2389D7",
+ "Trace": false
+ },
+ "ComplianceSettings": {
+ "Enable": false,
+ "Directory": "./data/",
+ "EnableDaily": false
+ },
+ "LocalizationSettings": {
+ "DefaultServerLocale": "en",
+ "DefaultClientLocale": "en",
+ "AvailableLocales": ""
+ },
+ "SamlSettings": {
+ "Enable": false,
+ "EnableSyncWithLdap": false,
+ "EnableSyncWithLdapIncludeAuth": false,
+ "Verify": true,
+ "Encrypt": true,
+ "SignRequest": false,
+ "IdpURL": "",
+ "IdpDescriptorURL": "",
+ "IdpMetadataURL": "",
+ "AssertionConsumerServiceURL": "",
+ "SignatureAlgorithm": "RSAwithSHA1",
+ "CanonicalAlgorithm": "Canonical1.0",
+ "ScopingIDPProviderId": "",
+ "ScopingIDPName": "",
+ "IdpCertificateFile": "saml-idp.crt",
+ "PublicCertificateFile": "saml-public.crt",
+ "PrivateKeyFile": "saml-private.key",
+ "IdAttribute": "",
+ "GuestAttribute": "",
+ "EnableAdminAttribute": false,
+ "AdminAttribute": "",
+ "FirstNameAttribute": "",
+ "LastNameAttribute": "",
+ "EmailAttribute": "Email",
+ "UsernameAttribute": "Username",
+ "NicknameAttribute": "",
+ "LocaleAttribute": "",
+ "PositionAttribute": "",
+ "LoginButtonText": "SAML",
+ "LoginButtonColor": "#34a28b",
+ "LoginButtonBorderColor": "#2389D7",
+ "LoginButtonTextColor": "#ffffff"
+ },
+ "NativeAppSettings": {
+ "AppDownloadLink": "https://mattermost.com/pl/download-apps",
+ "AndroidAppDownloadLink": "https://mattermost.com/pl/android-app/",
+ "IosAppDownloadLink": "https://mattermost.com/pl/ios-app/"
+ },
+ "MetricsSettings": {
+ "Enable": false,
+ "BlockProfileRate": 0,
+ "ListenAddress": ":8067"
+ },
+ "ExperimentalSettings": {
+ "ClientSideCertEnable": false,
+ "ClientSideCertCheck": "secondary",
+ "LinkMetadataTimeoutMilliseconds": 5000,
+ "RestrictSystemAdmin": false,
+ "UseNewSAMLLibrary": false,
+ "DisableAppBar": false
+ },
+ "AnalyticsSettings": {
+ "MaxUsersForStatistics": 2500
+ },
+ "ElasticsearchSettings": {
+ "ConnectionURL": "http://localhost:9200",
+ "Username": "elastic",
+ "Password": "changeme",
+ "EnableIndexing": false,
+ "EnableSearching": false,
+ "EnableAutocomplete": false,
+ "Sniff": false,
+ "PostIndexReplicas": 1,
+ "PostIndexShards": 1,
+ "ChannelIndexReplicas": 1,
+ "ChannelIndexShards": 1,
+ "UserIndexReplicas": 1,
+ "UserIndexShards": 1,
+ "AggregatePostsAfterDays": 365,
+ "PostsAggregatorJobStartTime": "03:00",
+ "IndexPrefix": "",
+ "LiveIndexingBatchSize": 1,
+ "BulkIndexingTimeWindowSeconds": 3600,
+ "RequestTimeoutSeconds": 30,
+ "SkipTLSVerification": false,
+ "Trace": ""
+ },
+ "DataRetentionSettings": {
+ "EnableMessageDeletion": false,
+ "EnableFileDeletion": false,
+ "MessageRetentionDays": 365,
+ "FileRetentionDays": 365,
+ "DeletionJobStartTime": "02:00"
+ },
+ "MessageExportSettings": {
+ "EnableExport": false,
+ "ExportFormat": "actiance",
+ "DailyRunTime": "01:00",
+ "ExportFromTimestamp": 0,
+ "BatchSize": 10000,
+ "GlobalRelaySettings": {
+ "CustomerType": "A9",
+ "SMTPUsername": "",
+ "SMTPPassword": "",
+ "EmailAddress": ""
+ }
+ },
+ "JobSettings": {
+ "RunJobs": true,
+ "RunScheduler": true
+ },
+ "PluginSettings": {
+ "Enable": true,
+ "EnableUploads": true,
+ "AllowInsecureDownloadURL": false,
+ "EnableHealthCheck": true,
+ "Directory": "./plugins",
+ "ClientDirectory": "./client/plugins",
+ "Plugins": {},
+ "PluginStates": {
+ "com.mattermost.nps": {
+ "Enable": false
+ },
+ "com.mattermost.plugin-incident-response": {
+ "Enable": false
+ },
+ "com.mattermost.plugin-incident-management": {
+ "Enable": false
+ },
+ "focalboard": {
+ "Enable": false
+ }
+ },
+ "EnableMarketplace": true,
+ "EnableRemoteMarketplace": true,
+ "AutomaticPrepackagedPlugins": true,
+ "RequirePluginSignature": false,
+ "MarketplaceURL": "https://api.integrations.mattermost.com",
+ "SignaturePublicKeyFiles": []
+ },
+ "DisplaySettings": {
+ "CustomURLSchemes": [],
+ "ExperimentalTimezone": false
+ },
+ "GuestAccountsSettings": {
+ "Enable": true,
+ "AllowEmailAccounts": true,
+ "EnforceMultifactorAuthentication": false,
+ "RestrictCreationToDomains": ""
+ },
+ "ImageProxySettings": {
+ "Enable": true,
+ "ImageProxyType": "local",
+ "RemoteImageProxyURL": "",
+ "RemoteImageProxyOptions": ""
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/playbooks.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/playbooks.js
new file mode 100644
index 00000000000..07750d9b6b4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/playbooks.js
@@ -0,0 +1,599 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const playbookRunsEndpoint = '/plugins/playbooks/api/v0/runs';
+
+const StatusOK = 200;
+const StatusCreated = 201;
+
+/**
+ * Get all playbook runs directly via API
+ */
+Cypress.Commands.add('apiGetAllPlaybookRuns', (teamId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/runs',
+ qs: {team_id: teamId, per_page: 10000},
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+/**
+ * Get all InProgress playbook runs directly via API
+ */
+Cypress.Commands.add('apiGetAllInProgressPlaybookRuns', (teamId, userId = '') => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/runs',
+ qs: {team_id: teamId, status: 'InProgress', participant_id: userId},
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+/**
+ * Get playbook run by name directly via API
+ */
+Cypress.Commands.add('apiGetPlaybookRunByName', (teamId, name) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/runs',
+ qs: {team_id: teamId, search_term: name},
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+/**
+ * Get a playbook run directly via API
+ * @param {String} playbookRunId
+ * All parameters required
+ */
+Cypress.Commands.add('apiGetPlaybookRun', (playbookRunId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `${playbookRunsEndpoint}/${playbookRunId}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+/**
+ * Start a playbook run directly via API.
+ */
+Cypress.Commands.add('apiRunPlaybook', (
+ {
+ teamId,
+ playbookId,
+ playbookRunName,
+ ownerUserId,
+ channelId,
+ description,
+ }, options) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: playbookRunsEndpoint,
+ method: 'POST',
+ body: {
+ name: playbookRunName,
+ owner_user_id: ownerUserId,
+ team_id: teamId,
+ playbook_id: playbookId,
+ channel_id: channelId,
+ description,
+ },
+ failOnStatusCode: !(options?.expectedStatusCode),
+ }).then((response) => {
+ const statusCode = options?.expectedStatusCode || StatusCreated;
+ expect(response.status).to.equal(statusCode);
+ cy.wrap(response.body);
+ });
+});
+
+// Finish a playbook's run programmaticially. Uses currently logged in user, so that user must
+// have edit permissions on the run
+Cypress.Commands.add('apiFinishRun', (playbookRunId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `${playbookRunsEndpoint}/${playbookRunId}/finish`,
+ method: 'PUT',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+// Update a playbook run's status programmatically.
+Cypress.Commands.add('apiUpdateStatus', (
+ {
+ playbookRunId,
+ message,
+ reminder = 300,
+ }) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `${playbookRunsEndpoint}/${playbookRunId}/status`,
+ method: 'POST',
+ body: {
+ message,
+ reminder,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+/**
+ * Change the owner of a playbook run directly via API
+ * @param {String} playbookRunId
+ * @param {String} userId
+ * All parameters required
+ */
+Cypress.Commands.add('apiChangePlaybookRunOwner', (playbookRunId, userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: playbookRunsEndpoint + '/' + playbookRunId + '/owner',
+ method: 'POST',
+ body: {
+ owner_id: userId,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+/**
+ * Change the assignee of a checklist item directly via API
+ * @param {String} playbookRunId
+ * @param {String} checklistId
+ * @param {String} itemId
+ * @param {String} userId
+ * All parameters required
+ */
+Cypress.Commands.add('apiChangeChecklistItemAssignee', (playbookRunId, checklistId, itemId, userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: playbookRunsEndpoint + `/${playbookRunId}/checklists/${checklistId}/item/${itemId}/assignee`,
+ method: 'PUT',
+ body: {
+ assignee_id: userId,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+/**
+ * Check a checklist item directly via API
+ * @param {String} playbookRunId
+ * @param {String} checklistId
+ * @param {String} itemId
+ * @param {String} state ('' or 'closed')
+ */
+Cypress.Commands.add('apiSetChecklistItemState', (playbookRunId, checklistId, itemId, state) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: playbookRunsEndpoint + `/${playbookRunId}/checklists/${checklistId}/item/${itemId}/state`,
+ method: 'PUT',
+ body: {
+ new_state: state,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+// Verify playbook run is created
+Cypress.Commands.add('verifyPlaybookRunActive', (teamId, playbookRunName, playbookRunDescription) => {
+ cy.apiGetPlaybookRunByName(teamId, playbookRunName).then((response) => {
+ const returnedPlaybookRuns = response.body;
+ const playbookRun = returnedPlaybookRuns.items.find((inc) => inc.name === playbookRunName);
+ assert.isDefined(playbookRun);
+ assert.equal(playbookRun.end_at, 0);
+ assert.equal(playbookRun.name, playbookRunName);
+
+ cy.log('test 1');
+
+ // Only check the description if provided. The server may supply a default depending
+ // on how the playbook run was started.
+ if (playbookRunDescription) {
+ assert.equal(playbookRun.description, playbookRunDescription);
+ }
+ });
+});
+
+// Verify playbook run exists but is not active
+Cypress.Commands.add('verifyPlaybookRunEnded', (teamId, playbookRunName) => {
+ cy.apiGetPlaybookRunByName(teamId, playbookRunName).then((response) => {
+ const returnedPlaybookRuns = response.body;
+ const playbookRun = returnedPlaybookRuns.items.find((inc) => inc.name === playbookRunName);
+ assert.isDefined(playbookRun);
+ assert.notEqual(playbookRun.end_at, 0);
+ });
+});
+
+// Create a playbook programmatically.
+Cypress.Commands.add('apiCreatePlaybook', (
+ {
+ teamId,
+ title,
+ description,
+ createPublicPlaybookRun,
+ createChannelMemberOnNewParticipant = true,
+ checklists,
+ memberIDs,
+ makePublic = true,
+ broadcastEnabled,
+ broadcastChannelIds,
+ reminderMessageTemplate,
+ reminderTimerDefaultSeconds = 24 * 60 * 60, // 24 hours
+ statusUpdateEnabled = true,
+ retrospectiveReminderIntervalSeconds,
+ retrospectiveTemplate,
+ retrospectiveEnabled = true,
+ invitedUserIds,
+ inviteUsersEnabled,
+ defaultOwnerId,
+ defaultOwnerEnabled,
+ announcementChannelId,
+ announcementChannelEnabled,
+ webhookOnCreationURLs,
+ webhookOnCreationEnabled,
+ webhookOnStatusUpdateURLs,
+ webhookOnStatusUpdateEnabled,
+ messageOnJoin,
+ messageOnJoinEnabled,
+ signalAnyKeywords,
+ signalAnyKeywordsEnabled,
+ channelNameTemplate,
+ runSummaryTemplate,
+ runSummaryTemplateEnabled,
+ channelMode = 'create_new_channel',
+ channelId = '',
+ metrics,
+ }) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/playbooks',
+ method: 'POST',
+ body: {
+ title,
+ description,
+ team_id: teamId,
+ create_public_playbook_run: createPublicPlaybookRun,
+ create_channel_member_on_new_participant: createChannelMemberOnNewParticipant,
+ checklists,
+ public: makePublic,
+ members: memberIDs?.map((val) => ({user_id: val, roles: ['playbook_member', 'playbook_admin']})),
+ broadcast_enabled: broadcastEnabled,
+ broadcast_channel_ids: broadcastChannelIds,
+ reminder_message_template: reminderMessageTemplate,
+ reminder_timer_default_seconds: reminderTimerDefaultSeconds,
+ status_update_enabled: statusUpdateEnabled,
+ retrospective_reminder_interval_seconds: retrospectiveReminderIntervalSeconds,
+ retrospective_template: retrospectiveTemplate,
+ retrospective_enabled: retrospectiveEnabled,
+ invited_user_ids: invitedUserIds,
+ invite_users_enabled: inviteUsersEnabled,
+ default_owner_id: defaultOwnerId,
+ default_owner_enabled: defaultOwnerEnabled,
+ announcement_channel_id: announcementChannelId,
+ announcement_channel_enabled: announcementChannelEnabled,
+ webhook_on_creation_urls: webhookOnCreationURLs,
+ webhook_on_creation_enabled: webhookOnCreationEnabled,
+ webhook_on_status_update_urls: webhookOnStatusUpdateURLs,
+ webhook_on_status_update_enabled: webhookOnStatusUpdateEnabled,
+ message_on_join: messageOnJoin,
+ message_on_join_enabled: messageOnJoinEnabled,
+ signal_any_keywords: signalAnyKeywords,
+ signal_any_keywords_enabled: signalAnyKeywordsEnabled,
+ channel_name_template: channelNameTemplate,
+ run_summary_template: runSummaryTemplate,
+ run_summary_template_enabled: runSummaryTemplateEnabled,
+ channel_mode: channelMode,
+ channel_id: channelId,
+ metrics,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ cy.wrap(response.headers.location);
+ }).then((location) => {
+ cy.request({
+ url: location,
+ method: 'GET',
+ }).then((response) => {
+ cy.wrap(response.body);
+ });
+ });
+});
+
+// Create a test playbook programmatically.
+Cypress.Commands.add('apiCreateTestPlaybook', (
+ {
+ teamId,
+ title,
+ userId,
+ broadcastEnabled,
+ broadcastChannelIds,
+ reminderMessageTemplate,
+ checklists,
+ inviteUsersEnabled,
+ reminderTimerDefaultSeconds = 24 * 60 * 60, // 24 hours
+ otherMembers = [],
+ invitedUserIds = [],
+ channelNameTemplate = '',
+ }) => (
+ cy.apiCreatePlaybook({
+ teamId,
+ title,
+ checklists: checklists || [{
+ title: 'Stage 1',
+ items: [
+ {title: 'Step 1'},
+ {title: 'Step 2'},
+ ],
+ }],
+ memberIDs: [
+ userId,
+ ...otherMembers,
+ ],
+ broadcastEnabled,
+ broadcastChannelIds,
+ reminderMessageTemplate,
+ reminderTimerDefaultSeconds,
+ invitedUserIds,
+ inviteUsersEnabled,
+ channelNameTemplate,
+ createChannelMemberOnNewParticipant: true,
+ removeChannelMemberOnRemovedParticipant: true,
+ })
+));
+
+// Verify that the playbook was created
+Cypress.Commands.add('verifyPlaybookCreated', (teamId, playbookTitle) => (
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/playbooks',
+ qs: {team_id: teamId, sort: 'title', direction: 'asc'},
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ const playbookResults = response.body;
+ const playbook = playbookResults.items.find((p) => p.title === playbookTitle);
+ assert.isDefined(playbook);
+ })
+));
+
+// Get a playbook
+Cypress.Commands.add('apiGetPlaybook', (playbookId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/playbooks/${playbookId}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+// Update a playbook
+Cypress.Commands.add('apiUpdatePlaybook', (playbook, expectedHttpCode = StatusOK) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/playbooks/${playbook.id}`,
+ method: 'PUT',
+ body: JSON.stringify(playbook),
+ failOnStatusCode: false,
+ }).then((response) => {
+ expect(response.status).to.equal(expectedHttpCode);
+ cy.wrap(response.body);
+ });
+});
+
+// Archive a playbook
+Cypress.Commands.add('apiArchivePlaybook', (playbookId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/playbooks/${playbookId}`,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(204);
+ });
+});
+
+// Follow a playbook run
+Cypress.Commands.add('apiFollowPlaybookRun', (playbookRunId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/runs/${playbookRunId}/followers`,
+ method: 'PUT',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+// Unfollow a playbook run
+Cypress.Commands.add('apiUnfollowPlaybookRun', (playbookRunId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/runs/${playbookRunId}/followers`,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+//addUsersToRun
+Cypress.Commands.add('apiAddUsersToRun', (playbookRunId, usersIds) => {
+ const query = `
+ mutation AddRunParticipants($runID: String!, $userIDs: [String!]!) {
+ addRunParticipants(runID: $runID, userIDs: $userIDs)
+ }
+ `;
+ const vars = {
+ runID: playbookRunId,
+ userIDs: usersIds,
+ };
+ return doGraphqlQuery(query, 'AddRunParticipants', vars).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+//updateRun
+Cypress.Commands.add('apiUpdateRun', (playbookRunId, updates) => {
+ const query = `
+ mutation UpdateRun($id: String!, $updates: RunUpdates!) {
+ updateRun(id: $id, updates: $updates)
+ }
+ `;
+ const vars = {
+ id: playbookRunId,
+ updates,
+ };
+ return doGraphqlQuery(query, 'UpdateRun', vars).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+const doGraphqlQuery = (query, operationName, variables) => {
+ const payload = {query, operationName, variables};
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/plugins/playbooks/api/v0/query',
+ body: JSON.stringify(payload),
+ method: 'POST',
+ });
+};
+
+/**
+ * Add a property field to a playbook via GraphQL API
+ * @param {String} playbookId - The playbook ID
+ * @param {Object} propertyField - The property field to add
+ * @param {String} propertyField.name - Name of the property field (e.g., "Priority")
+ * @param {String} propertyField.type - Type of property (text, select, multiselect)
+ * @param {Object} propertyField.attrs - Attributes object
+ * @param {String} propertyField.attrs.visibility - Visibility setting (default: "public")
+ * @param {Number} propertyField.attrs.sortOrder - Sort order (default: 0)
+ * @param {Array} propertyField.attrs.options - Array of options for select/multiselect types
+ */
+Cypress.Commands.add('apiAddPropertyField', (playbookId, propertyField) => {
+ const query = `
+ mutation AddPlaybookPropertyField($playbookID: String!, $propertyField: PropertyFieldInput!) {
+ addPlaybookPropertyField(playbookID: $playbookID, propertyField: $propertyField)
+ }
+ `;
+ const vars = {
+ playbookID: playbookId,
+ propertyField,
+ };
+ return doGraphqlQuery(query, 'AddPlaybookPropertyField', vars).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+/**
+ * Get property fields for a playbook via REST API
+ * @param {String} playbookId - The playbook ID
+ * @returns {Array} Array of property field objects
+ */
+Cypress.Commands.add('apiGetPropertyFields', (playbookId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/playbooks/${playbookId}/property_fields`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+/**
+ * Create a condition for a playbook via REST API
+ * @param {String} playbookId - The playbook ID
+ * @param {Object} conditionExpr - The condition expression object
+ * @returns {Object} The created condition with ID
+ */
+Cypress.Commands.add('apiCreatePlaybookCondition', (playbookId, conditionExpr) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/playbooks/${playbookId}/conditions`,
+ method: 'POST',
+ body: {
+ version: 1,
+ condition_expr: conditionExpr,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ cy.wrap(response.body);
+ });
+});
+
+/**
+ * Update a condition for a playbook via REST API
+ * @param {String} playbookId - The playbook ID
+ * @param {String} conditionId - The condition ID
+ * @param {Object} conditionExpr - The updated condition expression object
+ * @returns {Object} The updated condition
+ */
+Cypress.Commands.add('apiUpdatePlaybookCondition', (playbookId, conditionId, conditionExpr) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/playbooks/${playbookId}/conditions/${conditionId}`,
+ method: 'PUT',
+ body: {
+ version: 1,
+ condition_expr: conditionExpr,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response.body);
+ });
+});
+
+/**
+ * Delete a condition from a playbook via REST API
+ * @param {String} playbookId - The playbook ID
+ * @param {String} conditionId - The condition ID
+ */
+Cypress.Commands.add('apiDeletePlaybookCondition', (playbookId, conditionId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/plugins/playbooks/api/v0/playbooks/${playbookId}/conditions/${conditionId}`,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(StatusOK);
+ cy.wrap(response);
+ });
+});
+
+/**
+ * Attach a condition to a checklist item
+ * @param {String} playbookId - The playbook ID
+ * @param {Number} checklistIndex - The checklist index (0-based)
+ * @param {Number} itemIndex - The item index within the checklist (0-based)
+ * @param {String} conditionId - The condition ID to attach
+ */
+Cypress.Commands.add('apiAttachConditionToTask', (playbookId, checklistIndex, itemIndex, conditionId) => {
+ return cy.apiGetPlaybook(playbookId).then((playbook) => {
+ playbook.checklists[checklistIndex].items[itemIndex].condition_id = conditionId;
+ return cy.apiUpdatePlaybook(playbook);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/plugin.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/plugin.d.ts
new file mode 100644
index 00000000000..a02aaf56acd
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/plugin.d.ts
@@ -0,0 +1,152 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+interface PluginStatus {
+ isInstalled: boolean;
+ isActive: boolean;
+}
+
+interface PluginTestInfo {
+ id: string;
+ version: string;
+ url: string;
+ filename: string;
+}
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get plugins.
+ * See https://api.mattermost.com/#tag/plugins/paths/~1plugins/get
+ * @returns {PluginsResponse} `out.plugins` as `PluginsResponse`
+ *
+ * @example
+ * cy.apiGetAllPlugins().then(({plugins}) => {
+ * // do something with plugins
+ * });
+ */
+ apiGetAllPlugins(): Chainable;
+
+ /**
+ * Get plugins.
+ * @param {string} pluginId - plugin ID
+ * @param {string} version - plugin version
+ *
+ * @returns {PluginStatus} - plugin status if upload and active
+ *
+ * @example
+ * cy.apiGetPluginStatus(pluginId, version).then((status) => {
+ * // do something with status
+ * });
+ */
+ apiGetPluginStatus(pluginId: string, version?: string): Chainable;
+
+ /**
+ * Upload plugin.
+ * See https://api.mattermost.com/#tag/plugins/paths/~1plugins/post
+ * @param {string} filename - name of the plugin to upload
+ * @returns {Response} response: Cypress-chainable response
+ *
+ * @example
+ * cy.apiUploadPlugin('filename');
+ */
+ apiUploadPlugin(filename: string): Chainable;
+
+ /**
+ * Upload a plugin and enable.
+ * - If a plugin is already active, then it will immediately return.
+ * - If a plugin is inactive, then it will be enabled only.
+ * - If a plugin is not found in the server, then it will be uploaded
+ * and the enabled.
+ * - On plugin upload, if `pluginTestInfo` includes a `url` field, then
+ * the plugin will be installed via URL. Otherwise if `filename` field
+ * is present, then it will look at such filename under fixtures folder
+ * and then use the file to upload.
+ *
+ * @param {PluginTestInfo} pluginTestInfo - plugin test info
+ * @returns {Response} response: Cypress-chainable response
+ *
+ * @example
+ * cy.apiUploadAndEnablePlugin(pluginTestInfo);
+ */
+ apiUploadAndEnablePlugin(pluginTestInfo: PluginTestInfo): Chainable;
+
+ /**
+ * Install plugin from url.
+ * See https://api.mattermost.com/#tag/plugins/paths/~1plugins~1install_from_url/post
+ * @param {string} pluginDownloadUrl - URL used to download the plugin
+ * @param {string} force - Set to 'true' to overwrite a previously installed plugin with the same ID, if any
+ * @returns {PluginManifest} `out.plugin` as `PluginManifest`
+ *
+ * @example
+ * cy.apiInstallPluginFromUrl('url', 'true').then(({plugin}) => {
+ * // do something with plugin
+ * });
+ */
+ apiInstallPluginFromUrl(pluginDownloadUrl: string, force: string): Chainable;
+
+ /**
+ * Enable plugin.
+ * See https://api.mattermost.com/#tag/plugins/paths/~1plugins~1{plugin_id}~1enable/post
+ * @param {string} pluginId - Id of the plugin to enable
+ * @returns {string} `out.status`
+ *
+ * @example
+ * cy.apiEnablePluginById('pluginId');
+ */
+ apiEnablePluginById(pluginId: string): Chainable>;
+
+ /**
+ * Disable plugin.
+ * See https://api.mattermost.com/#tag/plugins/paths/~1plugins~1{plugin_id}~disable/post
+ * @param {string} pluginId - Id of the plugin to disable
+ * @returns {string} `out.status`
+ *
+ * @example
+ * cy.apiDisablePluginById('pluginId');
+ */
+ apiDisablePluginById(pluginId: string): Chainable>;
+
+ /**
+ * Disable all plugins installed that are not prepackaged.
+ *
+ * @example
+ * cy.apiDisableNonPrepackagedPlugins();
+ */
+ apiDisableNonPrepackagedPlugins(): Chainable>;
+
+ /**
+ * Remove plugin.
+ * See https://api.mattermost.com/#tag/plugins/paths/~1plugins~1{plugin_id}/delete
+ * @param {string} pluginId - Id of the plugin to uninstall
+ * @returns {string} `out.status`
+ *
+ * @example
+ * cy.apiRemovePluginById('url');
+ */
+ apiRemovePluginById(pluginId: string, force: string): Chainable>;
+
+ /**
+ * Removes all active and inactive plugins.
+ *
+ * @example
+ * cy.apiUninstallAllPlugins();
+ */
+ apiUninstallAllPlugins(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/plugin.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/plugin.js
new file mode 100644
index 00000000000..0d9b7e07343
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/plugin.js
@@ -0,0 +1,199 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+// *****************************************************************************
+// Plugins
+// https://api.mattermost.com/#tag/plugins
+// *****************************************************************************
+
+Cypress.Commands.add('apiGetAllPlugins', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/plugins',
+ method: 'GET',
+ failOnStatusCode: false,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({plugins: response.body});
+ });
+});
+
+function getPlugin(plugins, pluginId, version) {
+ return Cypress._.find(plugins, (plugin) => {
+ return version ? plugin.id === pluginId && plugin.version === version : plugin.id === pluginId;
+ });
+}
+
+Cypress.Commands.add('apiGetPluginStatus', (pluginId, version) => {
+ return cy.apiGetAllPlugins().then(({plugins}) => {
+ const active = getPlugin(plugins.active, pluginId, version);
+ const inactive = getPlugin(plugins.inactive, pluginId, version);
+
+ if (active) {
+ return cy.wrap({isInstalled: true, isActive: true});
+ }
+
+ if (inactive) {
+ return cy.wrap({isInstalled: true, isActive: false});
+ }
+
+ return cy.wrap({isInstalled: false, isActive: false});
+ });
+});
+
+Cypress.Commands.add('apiUploadPlugin', (filename) => {
+ const options = {
+ url: '/api/v4/plugins',
+ method: 'POST',
+ successStatus: 201,
+ };
+ return cy.apiUploadFile('plugin', filename, options).then(() => {
+ return cy.wait(TIMEOUTS.THREE_SEC);
+ });
+});
+
+Cypress.Commands.add('apiUploadAndEnablePlugin', ({filename, url, id, version}) => {
+ return cy.apiGetPluginStatus(id, version).then((data) => {
+ // # If already active, then only return the data
+ if (data.isActive) {
+ cy.log(`${id}: Plugin is active.`);
+ return cy.wrap(data);
+ }
+
+ // # If already installed, then only enable the plugin
+ if (data.isInstalled) {
+ cy.log(`${id}: Plugin is inactive. Only going to enable.`);
+ return cy.apiEnablePluginById(id).then(() => {
+ cy.wait(TIMEOUTS.ONE_SEC);
+ return cy.wrap(data);
+ });
+ }
+
+ if (url) {
+ // # Upload plugin by URL then enable
+ cy.log(`${id}: Plugin is to be uploaded via URL and then enable.`);
+ return cy.apiInstallPluginFromUrl(url).then(() => {
+ cy.wait(TIMEOUTS.FIVE_SEC);
+ return cy.apiEnablePluginById(id).then(() => {
+ cy.wait(TIMEOUTS.ONE_SEC);
+ return cy.wrap({isInstalled: true, isActive: true});
+ });
+ });
+ }
+
+ // # Upload plugin by file then enable
+ cy.log(`${id}: Plugin is to be uploaded by filename and then enable.`);
+ return cy.apiUploadPlugin(filename).then(() => {
+ return cy.apiEnablePluginById(id).then(() => {
+ return cy.wrap({isInstalled: true, isActive: true});
+ });
+ });
+ });
+});
+
+Cypress.Commands.add('apiInstallPluginFromUrl', (url, force = true) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/plugins/install_from_url?plugin_download_url=${encodeURIComponent(url)}&force=${force}`,
+ method: 'POST',
+ timeout: TIMEOUTS.TWO_MIN,
+ failOnStatusCode: false,
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+
+ cy.wait(TIMEOUTS.THREE_SEC);
+ return cy.wrap({plugin: response.body});
+ });
+});
+
+Cypress.Commands.add('apiEnablePluginById', (pluginId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/plugins/${encodeURIComponent(pluginId)}/enable`,
+ method: 'POST',
+ timeout: TIMEOUTS.TWO_MIN,
+ failOnStatusCode: false,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiDisablePluginById', (pluginId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/plugins/${encodeURIComponent(pluginId)}/disable`,
+ method: 'POST',
+ timeout: TIMEOUTS.ONE_MIN,
+ failOnStatusCode: false,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+const prepackagedPlugins = [
+ 'antivirus',
+ 'mattermost-autolink',
+ 'com.mattermost.aws-sns',
+ 'com.mattermost.plugin-channel-export',
+ 'com.mattermost.custom-attributes',
+ 'github',
+ 'com.github.manland.mattermost-plugin-gitlab',
+ 'com.mattermost.plugin-incident-management',
+ 'jenkins',
+ 'jira',
+ 'com.mattermost.nps',
+ 'com.mattermost.welcomebot',
+ 'zoom',
+ 'playbooks',
+];
+
+Cypress.Commands.add('apiDisableNonPrepackagedPlugins', () => {
+ cy.apiGetAllPlugins().then(({plugins}) => {
+ plugins.active.forEach((plugin) => {
+ if (!prepackagedPlugins.includes(plugin.id)) {
+ cy.apiDisablePluginById(plugin.id);
+ }
+ });
+ });
+});
+
+Cypress.Commands.add('apiRemovePluginById', (pluginId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/plugins/${encodeURIComponent(pluginId)}`,
+ method: 'DELETE',
+ timeout: TIMEOUTS.TWO_MIN,
+ failOnStatusCode: false,
+ }).then((response) => {
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiUninstallAllPlugins', () => {
+ // # Uninstall all plugins
+ cy.apiGetAllPlugins().then(({plugins}) => {
+ const {active, inactive} = plugins;
+ inactive.forEach((plugin) => cy.apiRemovePluginById(plugin.id));
+ active.forEach((plugin) => cy.apiRemovePluginById(plugin.id));
+ });
+
+ // * Check that all plugins are uninstalled
+ cy.apiGetAllPlugins().then(({plugins}) => {
+ const {active, inactive} = plugins;
+
+ // # Log all uninstalled plugins for debugging
+ if (active.length) {
+ cy.log(JSON.stringify(active));
+ }
+ if (inactive.length) {
+ cy.log(JSON.stringify(active));
+ }
+
+ expect(active.length).to.equal(0);
+ expect(inactive.length).to.equal(0);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/preference.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/preference.d.ts
new file mode 100644
index 00000000000..6c964fb1a87
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/preference.d.ts
@@ -0,0 +1,166 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ // *******************************************************************************
+ // Preferences
+ // https://api.mattermost.com/#tag/preferences
+ // *******************************************************************************
+
+ /**
+ * Save a list of the user's preferences.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {PreferenceType[]} preferences - List of preference objects
+ * @param {string} userId - User ID
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveUserPreference([{user_id: 'user-id', category: 'display_settings', name: 'channel_display_mode', value: 'full'}], 'user-id');
+ */
+ apiSaveUserPreference(preferences: PreferenceType[], userId: string): Chainable;
+
+ /**
+ * Get the full list of the user's preferences.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/get
+ * @param {string} userId - User ID
+ * @returns {Response} response: Cypress-chainable response which should have a list of preference objects
+ *
+ * @example
+ * cy.apiGetUserPreference('user-id');
+ */
+ apiGetUserPreference(userId: string): Chainable;
+
+ /**
+ * Save clock display mode to 24-hour preference.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {boolean} is24Hour - true (default) or false for 12-hour
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveClockDisplayModeTo24HourPreference(true);
+ */
+ apiSaveClockDisplayModeTo24HourPreference(is24Hour: boolean): Chainable;
+
+ /**
+ * Save onboarding tasklist preference.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @param {string} name - options are complete_profile, team_setup, invite_members or hide
+ * @param {string} value - options are 'true' or 'false'
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveOnboardingTaskListPreference('user-id', 'hide', 'true');
+ */
+ apiSaveOnboardingTaskListPreference(userId: string, name: string, value: string): Chainable;
+
+ /**
+ * Save DM channel show preference.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @param {string} otherUserId - Other user in a DM channel
+ * @param {string} value - options are 'true' or 'false'
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveDirectChannelShowPreference('user-id', 'other-user-id', 'false');
+ */
+ apiSaveDirectChannelShowPreference(userId: string, otherUserId: string, value: string): Chainable;
+
+ /**
+ * Save Collapsed Reply Threads preference.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @param {string} value - options are 'on' or 'off'
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveCRTPreference('user-id', 'on');
+ */
+ apiSaveCRTPreference(userId: string, value: string): Chainable;
+
+ /**
+ * Saves tutorial step of a user
+ * @param {string} userId - User ID
+ * @param {string} value - value of tutorial step, e.g. '999' (default, completed tutorial)
+ */
+ apiSaveTutorialStep(userId: string, value: string): Chainable;
+
+ /**
+ * Save cloud trial banner preference.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @param {string} name - options are trial or hide
+ * @param {string} value - options are 'max_days_banner' or '3_days_banner' for trial, and 'true' or 'false' for hide
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveCloudTrialBannerPreference('user-id', 'hide', 'true');
+ */
+ apiSaveCloudTrialBannerPreference(userId: string, name: string, value: string): Chainable;
+
+ /**
+ * Save actions menu preference.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @param {string} value - true (default) or false
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveActionsMenuPreference('user-id', true);
+ */
+ apiSaveActionsMenuPreference(userId: string, value: boolean): Chainable;
+
+ /**
+ * Save show trial modal.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @param {string} name - trial_modal_auto_shown
+ * @param {string} value - values are 'true' or 'false'
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveStartTrialModal('user-id', 'true');
+ */
+ apiSaveStartTrialModal(userId: string, value: string): Chainable;
+
+ /**
+ * Save drafts tour tip preference.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @param {string} value - values are 'true' or 'false'
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiSaveDraftsTourTipPreference('user-id', 'true');
+ */
+ apiSaveDraftsTourTipPreference(userId: string, value: boolean): Chainable;
+
+ /**
+ * Mark Boards welcome page as viewed.
+ * See https://api.mattermost.com/#tag/preferences/paths/~1users~1{user_id}~1preferences/put
+ * @param {string} userId - User ID
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiBoardsWelcomePageViewed('user-id');
+ */
+ apiBoardsWelcomePageViewed(userId: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/preference.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/preference.js
new file mode 100644
index 00000000000..66436bd48ad
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/preference.js
@@ -0,0 +1,447 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import theme from '../../fixtures/theme.json';
+
+// *****************************************************************************
+// Preferences
+// https://api.mattermost.com/#tag/preferences
+// *****************************************************************************
+
+/**
+ * Saves user's preference directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {Array} preference - a list of user's preferences
+ * Note: failOnStatusCode is false to allow tests to continue even if preference
+ * setting fails (e.g., 403 with Enterprise Advanced license for onboarding prefs)
+ */
+Cypress.Commands.add('apiSaveUserPreference', (preferences = [], userId = 'me') => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/preferences`,
+ method: 'PUT',
+ body: preferences,
+ failOnStatusCode: false, // Allow non-critical preference failures
+ });
+});
+
+/**
+ * Saves clock display mode 24-hour preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {Boolean} is24Hour - Either true (default) or false
+ */
+Cypress.Commands.add('apiSaveClockDisplayModeTo24HourPreference', (is24Hour = true) => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'display_settings',
+ name: 'use_military_time',
+ value: is24Hour.toString(),
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves channel display mode preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {String} value - Either "full" (default) or "centered"
+ */
+Cypress.Commands.add('apiSaveChannelDisplayModePreference', (value = 'full') => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'display_settings',
+ name: 'channel_display_mode',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves message display preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {String} value - Either "clean" (default) or "compact"
+ */
+Cypress.Commands.add('apiSaveMessageDisplayPreference', (value = 'clean') => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'display_settings',
+ name: 'message_display',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves show markdown preview option preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {String} value - Either "true" to show the options (default) or "false"
+ */
+Cypress.Commands.add('apiSaveShowMarkdownPreviewPreference', (value = 'true') => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'advanced_settings',
+ name: 'feature_enabled_markdown_preview',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves teammate name display preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {String} value - Either "username" (default), "nickname_full_name" or "full_name"
+ */
+Cypress.Commands.add('apiSaveTeammateNameDisplayPreference', (value = 'username') => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'display_settings',
+ name: 'name_format',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves theme preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {Object} value - theme object. Will pass default value if none is provided.
+ */
+Cypress.Commands.add('apiSaveThemePreference', (value = JSON.stringify(theme.default)) => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'theme',
+ name: '',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+const defaultSidebarSettingPreference = {
+ grouping: 'by_type',
+ unreads_at_top: 'true',
+ favorite_at_top: 'true',
+ sorting: 'alpha',
+};
+
+/**
+ * Saves theme preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {Object} value - sidebar settings object. Will pass default value if none is provided.
+ */
+Cypress.Commands.add('apiSaveSidebarSettingPreference', (value = {}) => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const newValue = {
+ ...defaultSidebarSettingPreference,
+ ...value,
+ };
+
+ const preference = {
+ user_id: cookie.value,
+ category: 'sidebar_settings',
+ name: '',
+ value: JSON.stringify(newValue),
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves the preference on whether to show link and image previews
+ * This API assume that the user is logged in and has cookie to access
+ * @param {boolean} show - Either "true" to show link and images previews (default), or "false"
+ */
+Cypress.Commands.add('apiSaveLinkPreviewsPreference', (show = 'true') => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'display_settings',
+ name: 'link_previews',
+ value: show,
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves the preference on whether to show link and image previews expanded
+ * This API assume that the user is logged in and has cookie to access
+ * @param {boolean} collapse - Either "true" to show previews collapsed (default), or "false"
+ */
+Cypress.Commands.add('apiSaveCollapsePreviewsPreference', (collapse = 'true') => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const preference = {
+ user_id: cookie.value,
+ category: 'display_settings',
+ name: 'collapse_previews',
+ value: collapse,
+ };
+
+ return cy.apiSaveUserPreference([preference]);
+ });
+});
+
+/**
+ * Saves tutorial step of a user
+ * This API assume that the user is logged in and has cookie to access
+ * @param {string} value - value of tutorial step, e.g. '999' (default, completed tutorial)
+ */
+Cypress.Commands.add('apiSaveTutorialStep', (userId, value = '999') => {
+ const preference = {
+ user_id: userId,
+ category: 'tutorial_step',
+ name: userId,
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveOnboardingPreference', (userId, name, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'recommended_next_steps',
+ name,
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveDirectChannelShowPreference', (userId, otherUserId, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'direct_channel_show',
+ name: otherUserId,
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiHideSidebarWhatsNewModalPreference', (userId, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'whats_new_modal',
+ name: 'has_seen_sidebar_whats_new_modal',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiGetUserPreference', (userId) => {
+ return cy.request(`/api/v4/users/${userId}/preferences`).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response.body);
+ });
+});
+
+Cypress.Commands.add('apiSaveCRTPreference', (userId, value = 'on') => {
+ const preference = {
+ user_id: userId,
+ category: 'display_settings',
+ name: 'collapsed_reply_threads',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveCloudTrialBannerPreference', (userId, name, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'cloud_trial_banner',
+ name,
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveActionsMenuPreference', (userId, value = true) => {
+ const preference = {
+ user_id: userId,
+ category: 'actions_menu',
+ name: 'actions_menu_tutorial_state',
+ value: JSON.stringify({actions_menu_modal_viewed: value}),
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveStartTrialModal', (userId, value = 'true') => {
+ const preference = {
+ user_id: userId,
+ category: 'start_trial_modal',
+ name: 'trial_modal_auto_shown',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveOnboardingTaskListPreference', (userId, name, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'onboarding_task_list',
+ name,
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveSkipStepsPreference', (userId, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'recommended_next_steps',
+ name: 'skip',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveUnreadScrollPositionPreference', (userId, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'advanced_settings',
+ name: 'unread_scroll_position',
+ value,
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiSaveDraftsTourTipPreference', (userId, value) => {
+ const preference = {
+ user_id: userId,
+ category: 'drafts',
+ name: 'drafts_tour_tip_showed',
+ value: JSON.stringify({drafts_tour_tip_showed: value}),
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+Cypress.Commands.add('apiBoardsWelcomePageViewed', (userId) => {
+ const preferences = [{
+ user_id: userId,
+ category: 'boards',
+ name: 'welcomePageViewed',
+ value: '1',
+ },
+ {
+ user_id: userId,
+ category: 'boards',
+ name: 'version72MessageCanceled',
+ value: 'true',
+ }];
+
+ return cy.apiSaveUserPreference(preferences, userId);
+});
+
+/**
+ * Saves Join/Leave messages preference of a user directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {Boolean} enable - Either true (default) or false
+ */
+Cypress.Commands.add('apiSaveJoinLeaveMessagesPreference', (userId, enable = true) => {
+ const preference = {
+ user_id: userId,
+ category: 'advanced_settings',
+ name: 'join_leave',
+ value: enable.toString(),
+ };
+
+ return cy.apiSaveUserPreference([preference], userId);
+});
+
+/**
+ * Disables tutorials for user by marking them finished
+ */
+Cypress.Commands.add('apiDisableTutorials', (userId) => {
+ const preferences = [
+ {
+ user_id: userId,
+ category: 'playbook_edit',
+ name: userId,
+ value: '999',
+ },
+ {
+ user_id: userId,
+ category: 'tutorial_pb_run_details',
+ name: userId,
+ value: '999',
+ },
+ {
+ user_id: userId,
+ category: 'crt_thread_pane_step',
+ name: userId,
+ value: '999',
+ },
+ {
+ user_id: userId,
+ category: 'playbook_preview',
+ name: userId,
+ value: '999',
+ },
+ {
+ user_id: userId,
+ category: 'tutorial_step',
+ name: userId,
+ value: '999',
+ },
+ {
+ user_id: userId,
+ category: 'crt_tutorial_triggered',
+ name: userId,
+ value: '999',
+ },
+ {
+ user_id: userId,
+ category: 'crt_thread_pane_step',
+ name: userId,
+ value: '999',
+ },
+ {
+ user_id: userId,
+ category: 'actions_menu',
+ name: 'actions_menu_tutorial_state',
+ value: '{"actions_menu_modal_viewed":true}',
+ },
+ {
+ user_id: userId,
+ category: 'drafts',
+ name: 'drafts_tour_tip_showed',
+ value: '{"drafts_tour_tip_showed":true}',
+ },
+ {
+ user_id: userId,
+ category: 'app_bar',
+ name: 'channel_with_board_tip_showed',
+ value: '{"channel_with_board_tip_showed":true}',
+ },
+ ];
+
+ return cy.apiSaveUserPreference(preferences, userId);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/role.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/role.d.ts
new file mode 100644
index 00000000000..d7ee8a611cd
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/role.d.ts
@@ -0,0 +1,69 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get a role from the provided role name.
+ * See https://api.mattermost.com/#tag/roles/paths/~1roles~1name~1{role_name}/get
+ * @param {string} name - role name, e.g. 'system_user'
+ * @returns {Role} `out.role` as `Role`
+ *
+ * @example
+ * cy.getRoleByName('system_user').then(({role}) => {
+ * // do something with role
+ * });
+ */
+ getRoleByName(name: string): Chainable;
+
+ /**
+ * Get a list of roles by name.
+ * See https://api.mattermost.com/#tag/roles/paths/~1roles~1names/post
+ * @param {string[]} names - list of role names, e.g. ['system_user']
+ * @returns {Role[]} `out.roles` as list of `Role` objects
+ *
+ * @example
+ * cy.apiGetRolesByNames(['system_user']).then(({roles}) => {
+ * // do something with roles
+ * });
+ */
+ apiGetRolesByNames(names: string[]): Chainable;
+
+ /**
+ * Patch a role by ID.
+ * See https://api.mattermost.com/#tag/roles/paths/~1roles~1{role_id}~1patch/put
+ * @param {string} id - role ID
+ * @param {Permissions} patch.permissions - permissions
+ * @returns {Role} `out.role` as `Role`
+ *
+ * @example
+ * cy.apiPatchRole('role_id', patch).then(({role}) => {
+ * // do something with role
+ * });
+ */
+ apiPatchRole(id: string, patch: Record): Chainable;
+
+ /**
+ * Reset roles to default values.
+ *
+ * @example
+ * cy.apiResetRoles();
+ */
+ apiResetRoles();
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/role.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/role.js
new file mode 100644
index 00000000000..4180581d36f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/role.js
@@ -0,0 +1,91 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import xor from 'lodash.xor';
+
+// *****************************************************************************
+// Preferences
+// https://api.mattermost.com/#tag/roles
+// *****************************************************************************
+
+export const defaultRolesPermissions = {
+ channel_admin: 'use_channel_mentions remove_reaction manage_public_channel_members use_group_mentions manage_channel_roles manage_private_channel_members add_reaction read_public_channel_groups create_post read_private_channel_groups add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel',
+ channel_guest: 'upload_file edit_post create_post use_channel_mentions read_channel read_channel_content add_reaction remove_reaction',
+ channel_user: 'manage_private_channel_members read_public_channel_groups delete_post read_private_channel_groups use_group_mentions manage_private_channel_properties delete_public_channel add_reaction manage_public_channel_properties edit_post upload_file use_channel_mentions get_public_link read_channel read_channel_content delete_private_channel manage_public_channel_members create_post remove_reaction add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel',
+ custom_group_user: '',
+ playbook_admin: 'playbook_private_manage_properties playbook_public_make_private playbook_public_manage_members playbook_public_manage_roles playbook_public_manage_properties playbook_private_manage_members playbook_private_manage_roles',
+ playbook_member: 'playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view playbook_private_manage_members playbook_private_manage_properties run_create',
+ run_admin: 'run_manage_properties run_manage_members',
+ run_member: 'run_view',
+ system_admin: 'sysconsole_write_environment_elasticsearch playbook_public_manage_properties sysconsole_write_authentication_ldap run_view manage_jobs manage_roles playbook_public_create manage_public_channel_properties sysconsole_read_plugins delete_post purge_elasticsearch_indexes sysconsole_read_integrations_bot_accounts read_data_retention_job manage_private_channel_members create_elasticsearch_post_indexing_job manage_elasticsearch_post_indexing_job sysconsole_read_authentication_guest_access create_elasticsearch_post_aggregation_job manage_elasticsearch_post_aggregation_job join_public_teams sysconsole_read_site_public_links add_saml_idp_cert sysconsole_write_site_announcement_banner sysconsole_write_site_notices sysconsole_read_experimental_feature_flags sysconsole_read_site_users_and_teams manage_slash_commands sysconsole_read_authentication_ldap read_channel read_channel_content sysconsole_write_authentication_password list_users_without_team sysconsole_read_authentication_email add_saml_public_cert playbook_private_create promote_guest sysconsole_read_user_management_system_roles manage_public_channel_members create_data_retention_job manage_data_retention_job add_saml_private_cert sysconsole_write_user_management_users sysconsole_read_compliance_compliance_monitoring playbook_public_manage_members sysconsole_write_environment_database sysconsole_write_user_management_teams playbook_private_manage_roles read_public_channel sysconsole_write_plugins sysconsole_read_authentication_openid sysconsole_write_user_management_groups sysconsole_write_site_file_sharing_and_downloads playbook_private_manage_properties sysconsole_read_site_customization join_public_channels add_user_to_team restore_custom_group download_compliance_export_result sysconsole_write_user_management_system_roles sysconsole_write_environment_session_lengths create_custom_group manage_private_channel_properties create_post_public remove_ldap_private_cert sysconsole_write_site_public_links import_team sysconsole_read_environment_developer sysconsole_read_environment_database sysconsole_read_environment_web_server sysconsole_read_environment_mobile_security sysconsole_write_environment_mobile_security use_channel_mentions view_team remove_others_reactions sysconsole_read_environment_session_lengths sysconsole_write_integrations_bot_accounts playbook_public_view use_group_mentions sysconsole_write_environment_web_server add_ldap_private_cert read_public_channel_groups invite_guest sysconsole_read_environment_smtp create_post sysconsole_read_about_edition_and_license sysconsole_read_authentication_signup sysconsole_read_authentication_saml sysconsole_read_environment_file_storage sysconsole_write_experimental_feature_flags sysconsole_write_site_localization sysconsole_write_environment_rate_limiting sysconsole_read_environment_rate_limiting sysconsole_read_products_boards get_saml_cert_status sysconsole_read_environment_high_availability manage_secure_connections read_compliance_export_job sysconsole_write_compliance_custom_terms_of_service read_user_access_token edit_post sysconsole_write_environment_logging sysconsole_read_environment_push_notification_server sysconsole_write_site_customization read_other_users_teams read_elasticsearch_post_aggregation_job sysconsole_write_compliance_data_retention_policy sysconsole_read_user_management_permissions sysconsole_read_site_emoji sysconsole_read_compliance_data_retention_policy read_license_information sysconsole_read_experimental_features read_deleted_posts sysconsole_read_environment_logging sysconsole_read_reporting_site_statistics test_elasticsearch sysconsole_read_site_posts add_reaction sysconsole_write_authentication_signup manage_outgoing_webhooks create_post_ephemeral sysconsole_read_environment_image_proxy invite_user manage_others_outgoing_webhooks create_user_access_token sysconsole_write_environment_image_proxy sysconsole_write_products_boards read_elasticsearch_post_indexing_job purge_bleve_indexes sysconsole_write_environment_performance_monitoring sysconsole_write_authentication_guest_access sysconsole_read_compliance_custom_terms_of_service edit_others_posts sysconsole_write_billing get_saml_metadata_from_idp sysconsole_write_authentication_saml create_post_bleve_indexes_job manage_post_bleve_indexes_job invalidate_caches sysconsole_write_experimental_bleve view_members manage_others_bots run_create join_private_teams convert_private_channel_to_public read_audits assign_bot read_jobs remove_user_from_team revoke_user_access_token manage_team sysconsole_read_reporting_server_logs get_public_link manage_others_slash_commands manage_system delete_public_channel read_private_channel_groups sysconsole_read_authentication_mfa delete_emojis list_private_teams create_emojis sysconsole_read_billing sysconsole_write_site_emoji invalidate_email_invite sysconsole_write_environment_file_storage sysconsole_write_compliance_compliance_monitoring remove_saml_public_cert sysconsole_read_compliance_compliance_export sysconsole_read_site_localization manage_team_roles list_public_teams get_logs sysconsole_write_integrations_integration_management sysconsole_read_integrations_cors manage_oauth manage_outgoing_oauth_connections delete_others_emojis sysconsole_write_integrations_gif manage_incoming_webhooks sysconsole_write_authentication_email create_private_channel playbook_private_make_public manage_bots add_ldap_public_cert remove_ldap_public_cert sysconsole_write_site_notifications sysconsole_write_environment_developer playbook_private_manage_members sysconsole_read_user_management_teams edit_custom_group remove_reaction playbook_public_manage_roles sysconsole_write_reporting_server_logs read_others_bots sysconsole_write_site_posts sysconsole_read_site_notifications sysconsole_read_authentication_password playbook_private_view manage_system_wide_oauth get_analytics list_team_channels sysconsole_write_user_management_channels delete_private_channel manage_custom_group_members test_s3 create_ldap_sync_job manage_ldap_sync_job sysconsole_read_integrations_integration_management test_site_url recycle_database_connections sysconsole_read_site_announcement_banner test_email manage_shared_channels read_bots sysconsole_write_environment_smtp sysconsole_read_experimental_bleve sysconsole_write_environment_push_notification_server sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_write_reporting_site_statistics sysconsole_write_site_users_and_teams demote_to_guest create_team test_ldap remove_saml_idp_cert delete_others_posts edit_other_users sysconsole_write_reporting_team_statistics sysconsole_read_integrations_gif sysconsole_read_site_notices sysconsole_write_about_edition_and_license manage_others_incoming_webhooks run_manage_members create_bot sysconsole_write_authentication_mfa sysconsole_read_user_management_users assign_system_admin_role sysconsole_write_experimental_features edit_brand create_group_channel sysconsole_write_authentication_openid create_direct_channel manage_license_information reload_config manage_channel_roles sysconsole_read_user_management_groups create_compliance_export_job manage_compliance_export_job read_ldap_sync_job upload_file sysconsole_read_site_file_sharing_and_downloads delete_custom_group sysconsole_read_user_management_channels sysconsole_write_compliance_compliance_export remove_saml_private_cert sysconsole_read_environment_performance_monitoring create_public_channel sysconsole_write_integrations_cors sysconsole_write_environment_high_availability playbook_public_make_private run_manage_properties sysconsole_read_reporting_team_statistics convert_public_channel_to_private add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel',
+ system_custom_group_admin: 'create_custom_group edit_custom_group delete_custom_group restore_custom_group manage_custom_group_members',
+ system_guest: 'create_group_channel create_direct_channel',
+ system_manager: 'sysconsole_read_site_announcement_banner manage_private_channel_properties edit_brand read_private_channel_groups manage_private_channel_members manage_team_roles sysconsole_write_environment_session_lengths sysconsole_read_site_emoji sysconsole_write_environment_developer sysconsole_read_user_management_groups sysconsole_write_user_management_groups sysconsole_write_environment_rate_limiting delete_private_channel sysconsole_read_environment_performance_monitoring sysconsole_read_environment_rate_limiting sysconsole_write_user_management_teams sysconsole_write_integrations_integration_management sysconsole_write_site_public_links sysconsole_read_authentication_ldap sysconsole_write_integrations_cors reload_config sysconsole_write_user_management_channels sysconsole_read_environment_high_availability sysconsole_read_site_users_and_teams sysconsole_read_user_management_teams sysconsole_write_site_users_and_teams sysconsole_read_site_customization sysconsole_write_environment_high_availability sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_guest_access sysconsole_read_site_public_links read_elasticsearch_post_indexing_job sysconsole_read_user_management_channels sysconsole_read_reporting_team_statistics invalidate_caches sysconsole_read_authentication_signup read_elasticsearch_post_aggregation_job sysconsole_write_environment_smtp manage_public_channel_members list_public_teams add_user_to_team sysconsole_read_environment_web_server sysconsole_read_site_localization get_logs sysconsole_write_site_posts sysconsole_write_integrations_bot_accounts sysconsole_write_user_management_permissions sysconsole_read_environment_elasticsearch sysconsole_read_environment_smtp list_private_teams read_public_channel_groups sysconsole_write_environment_file_storage sysconsole_write_integrations_gif manage_public_channel_properties sysconsole_write_environment_performance_monitoring sysconsole_write_site_notifications sysconsole_read_site_notifications sysconsole_read_environment_image_proxy sysconsole_write_site_announcement_banner sysconsole_write_site_emoji test_site_url sysconsole_read_integrations_gif sysconsole_write_environment_logging convert_public_channel_to_private get_analytics sysconsole_read_user_management_permissions sysconsole_write_environment_image_proxy test_elasticsearch recycle_database_connections sysconsole_write_site_localization sysconsole_read_reporting_server_logs create_elasticsearch_post_indexing_job manage_elasticsearch_post_indexing_job sysconsole_read_reporting_site_statistics test_ldap delete_public_channel sysconsole_write_environment_push_notification_server read_license_information sysconsole_write_products_boards sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_integrations_integration_management create_elasticsearch_post_aggregation_job manage_elasticsearch_post_aggregation_job purge_elasticsearch_indexes sysconsole_read_environment_database join_public_teams sysconsole_read_authentication_email sysconsole_read_environment_push_notification_server view_team read_channel sysconsole_read_authentication_password read_ldap_sync_job sysconsole_read_integrations_cors sysconsole_read_environment_logging manage_team sysconsole_read_authentication_openid read_public_channel sysconsole_write_environment_elasticsearch sysconsole_read_plugins manage_channel_roles remove_user_from_team test_email sysconsole_write_site_file_sharing_and_downloads test_s3 sysconsole_read_site_file_sharing_and_downloads sysconsole_read_site_notices sysconsole_read_environment_file_storage join_private_teams sysconsole_read_products_boards sysconsole_read_environment_session_lengths sysconsole_write_environment_database sysconsole_read_authentication_saml sysconsole_read_authentication_mfa sysconsole_write_site_notices sysconsole_write_environment_web_server sysconsole_read_site_posts sysconsole_read_environment_developer sysconsole_write_site_customization sysconsole_read_environment_mobile_security sysconsole_write_environment_mobile_security manage_outgoing_oauth_connections',
+ system_post_all: 'use_group_mentions use_channel_mentions create_post',
+ system_post_all_public: 'use_group_mentions use_channel_mentions create_post_public',
+ system_read_only_admin: 'sysconsole_read_authentication_guest_access download_compliance_export_result sysconsole_read_compliance_data_retention_policy get_logs sysconsole_read_environment_file_storage read_channel sysconsole_read_integrations_integration_management sysconsole_read_compliance_custom_terms_of_service sysconsole_read_site_notices sysconsole_read_environment_rate_limiting sysconsole_read_about_edition_and_license read_public_channel sysconsole_read_experimental_features test_ldap sysconsole_read_user_management_permissions read_elasticsearch_post_aggregation_job sysconsole_read_environment_image_proxy sysconsole_read_compliance_compliance_export sysconsole_read_integrations_bot_accounts sysconsole_read_authentication_openid sysconsole_read_site_posts sysconsole_read_user_management_users sysconsole_read_experimental_feature_flags sysconsole_read_reporting_team_statistics sysconsole_read_site_localization read_private_channel_groups sysconsole_read_site_file_sharing_and_downloads sysconsole_read_user_management_channels sysconsole_read_authentication_email read_data_retention_job read_audits sysconsole_read_plugins view_team get_analytics sysconsole_read_user_management_groups sysconsole_read_experimental_bleve sysconsole_read_products_boards read_compliance_export_job sysconsole_read_environment_logging sysconsole_read_authentication_signup sysconsole_read_environment_smtp sysconsole_read_environment_session_lengths sysconsole_read_environment_developer sysconsole_read_environment_high_availability sysconsole_read_environment_mobile_security read_ldap_sync_job sysconsole_read_environment_performance_monitoring sysconsole_read_authentication_saml read_public_channel_groups sysconsole_read_integrations_gif sysconsole_read_authentication_mfa list_public_teams sysconsole_read_environment_database list_private_teams sysconsole_read_authentication_ldap sysconsole_read_compliance_compliance_monitoring sysconsole_read_site_notifications sysconsole_read_site_announcement_banner read_other_users_teams sysconsole_read_authentication_password sysconsole_read_environment_push_notification_server sysconsole_read_site_users_and_teams sysconsole_read_site_public_links sysconsole_read_site_emoji sysconsole_read_environment_elasticsearch read_license_information sysconsole_read_integrations_cors sysconsole_read_user_management_teams sysconsole_read_reporting_server_logs sysconsole_read_site_customization sysconsole_read_reporting_site_statistics sysconsole_read_environment_web_server read_elasticsearch_post_indexing_job',
+ system_user: 'delete_custom_group create_emojis edit_custom_group create_direct_channel view_members join_public_teams restore_custom_group create_custom_group manage_custom_group_members delete_emojis list_public_teams create_team create_group_channel',
+ system_user_access_token: 'create_user_access_token read_user_access_token revoke_user_access_token',
+ system_user_manager: 'sysconsole_read_authentication_password sysconsole_read_authentication_openid sysconsole_write_user_management_groups list_private_teams sysconsole_read_user_management_groups sysconsole_read_authentication_email manage_public_channel_properties delete_private_channel sysconsole_read_authentication_signup read_private_channel_groups sysconsole_read_user_management_teams test_ldap read_channel view_team manage_team sysconsole_write_user_management_teams manage_channel_roles sysconsole_read_authentication_saml sysconsole_read_authentication_guest_access convert_private_channel_to_public sysconsole_read_user_management_permissions join_public_teams sysconsole_write_user_management_channels read_public_channel_groups sysconsole_read_user_management_channels list_public_teams manage_team_roles join_private_teams manage_public_channel_members convert_public_channel_to_private remove_user_from_team sysconsole_read_authentication_ldap manage_private_channel_properties delete_public_channel manage_private_channel_members read_public_channel add_user_to_team sysconsole_read_authentication_mfa read_ldap_sync_job',
+ team_admin: 'manage_others_slash_commands manage_channel_roles manage_others_outgoing_webhooks manage_team_roles use_channel_mentions manage_incoming_webhooks manage_slash_commands manage_public_channel_members convert_private_channel_to_public manage_private_channel_members manage_team convert_public_channel_to_private use_group_mentions delete_post read_public_channel_groups delete_others_posts playbook_private_manage_roles add_reaction remove_reaction remove_user_from_team read_private_channel_groups manage_outgoing_webhooks create_post playbook_public_manage_roles import_team manage_others_incoming_webhooks add_bookmark_public_channel edit_bookmark_public_channel delete_bookmark_public_channel order_bookmark_public_channel add_bookmark_private_channel edit_bookmark_private_channel delete_bookmark_private_channel order_bookmark_private_channel',
+ team_guest: 'view_team',
+ team_post_all: 'create_post use_channel_mentions use_group_mentions',
+ team_post_all_public: 'create_post_public use_channel_mentions use_group_mentions',
+ team_user: 'add_user_to_team view_team playbook_private_create playbook_public_create invite_user join_public_channels list_team_channels read_public_channel create_private_channel create_public_channel',
+};
+
+Cypress.Commands.add('getRoleByName', (name) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/roles/name/${name}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({name: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetRolesByNames', (names) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/roles/names',
+ method: 'POST',
+ body: names || Object.keys(defaultRolesPermissions),
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({roles: response.body});
+ });
+});
+
+Cypress.Commands.add('apiPatchRole', (roleID, patch) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/roles/${roleID}/patch`,
+ method: 'PUT',
+ body: patch,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({role: response.body});
+ });
+});
+
+Cypress.Commands.add('apiResetRoles', () => {
+ cy.apiGetRolesByNames().then(({roles}) => {
+ roles.forEach((role) => {
+ const permissions = getPermissions(role.name);
+ const diff = xor(role.permissions, permissions)?.filter((p) => p?.length);
+
+ if (diff?.length > 0) {
+ cy.apiPatchRole(role.id, {permissions});
+ }
+ });
+ });
+});
+
+function getPermissions(roleName) {
+ const permissions = defaultRolesPermissions[roleName];
+ if (!permissions) {
+ return [];
+ }
+ return permissions.split(' ').map((permission) => permission.trim()).filter((permission) => permission !== '');
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/saml.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/saml.d.ts
new file mode 100644
index 00000000000..55cc2c877d6
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/saml.d.ts
@@ -0,0 +1,78 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get the status of the uploaded certificates and keys in use by your SAML configuration.
+ * See https://api.mattermost.com/#tag/SAML/paths/~1saml~1certificate~1status/get
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiGetSAMLCertificateStatus();
+ */
+ apiGetSAMLCertificateStatus(): Chainable;
+
+ /**
+ * Get SAML metadata from the Identity Provider. SAML must be configured properly.
+ * See https://api.mattermost.com/#tag/SAML/paths/~1saml~1metadatafromidp/post
+ * @param {String} samlMetadataUrl - SAML metadata URL
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiGetMetadataFromIdp(samlMetadataUrl);
+ */
+ apiGetMetadataFromIdp(samlMetadataUrl: string): Chainable;
+
+ /**
+ * Upload the IDP certificate to be used with your SAML configuration. The server will pick a hard-coded filename for the IdpCertificateFile setting in your config.json.
+ * See https://api.mattermost.com/#tag/SAML/paths/~1saml~1certificate~1idp/post
+ * @param {String} filePath - path of the IDP certificate file relative to fixture folder
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * const filePath = 'saml-idp.crt';
+ * cy.apiUploadSAMLIDPCert(filePath);
+ */
+ apiUploadSAMLIDPCert(filePath: string): Chainable;
+
+ /**
+ * Upload the public certificate to be used for encryption with your SAML configuration. The server will pick a hard-coded filename for the PublicCertificateFile setting in your config.json.
+ * See https://api.mattermost.com/#tag/SAML/paths/~1saml~1certificate~1public/post
+ * @param {String} filePath - path of the public certificate file relative to fixture folder
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * const filePath = 'saml-public.crt';
+ * cy.apiUploadSAMLPublicCert(filePath);
+ */
+ apiUploadSAMLPublicCert(filePath: string): Chainable;
+
+ /**
+ * Upload the private key to be used for encryption with your SAML configuration. The server will pick a hard-coded filename for the PrivateKeyFile setting in your config.json.
+ * See https://api.mattermost.com/#tag/SAML/paths/~1saml~1certificate~1private/post
+ * @param {String} filePath - path of the private certificate file relative to fixture folder
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * const filePath = 'saml-private.crt';
+ * cy.apiUploadSAMLPublicCert(filePath);
+ */
+ apiUploadSAMLPrivateKey(filePath: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/saml.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/saml.js
new file mode 100644
index 00000000000..0088b9988cf
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/saml.js
@@ -0,0 +1,42 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// SAML
+// https://api.mattermost.com/#tag/SAML
+// *****************************************************************************
+
+Cypress.Commands.add('apiGetSAMLCertificateStatus', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/saml/certificate/status',
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.be.oneOf([200, 201]);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiGetMetadataFromIdp', (samlMetadataUrl) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/saml/metadatafromidp',
+ method: 'POST',
+ body: {saml_metadata_url: samlMetadataUrl},
+ }).then((response) => {
+ expect(response.status, 'Failed to obtain metadata from Identity Provider URL').to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiUploadSAMLIDPCert', (filePath) => {
+ cy.apiUploadFile('certificate', filePath, {url: '/api/v4/saml/certificate/idp', method: 'POST', successStatus: 200});
+});
+
+Cypress.Commands.add('apiUploadSAMLPublicCert', (filePath) => {
+ cy.apiUploadFile('certificate', filePath, {url: '/api/v4/saml/certificate/public', method: 'POST', successStatus: 200});
+});
+
+Cypress.Commands.add('apiUploadSAMLPrivateKey', (filePath) => {
+ cy.apiUploadFile('certificate', filePath, {url: '/api/v4/saml/certificate/private', method: 'POST', successStatus: 200});
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/scheme.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/scheme.d.ts
new file mode 100644
index 00000000000..4d45c434376
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/scheme.d.ts
@@ -0,0 +1,45 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get the schemes.
+ * See https://api.mattermost.com/#tag/schemes/paths/~1schemes/get
+ * @param {string} scope - Limit the results returned to the provided scope, either team or channel.
+ * @returns {Scheme[]} `out.schemes` as `Scheme[]`
+ *
+ * @example
+ * cy.apiGetSchemes('team').then(({schemes}) => {
+ * // do something with schemes
+ * });
+ */
+ apiGetSchemes(scope: string): Chainable<{schemes: Scheme[]}>;
+
+ /**
+ * Delete a scheme.
+ * See https://api.mattermost.com/#tag/schemes/paths/~1schemes~1{scheme_id}/delete
+ * @param {string} schemeId - ID of the scheme to delete
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiDeleteScheme('scheme_id');
+ */
+ apiDeleteScheme(schemeId: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/scheme.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/scheme.js
new file mode 100644
index 00000000000..970952eceaf
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/scheme.js
@@ -0,0 +1,41 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// Schemes
+// https://api.mattermost.com/#tag/schemes
+// *****************************************************************************
+
+Cypress.Commands.add('apiGetSchemes', (scope) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/schemes?scope=${scope}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({schemes: response.body});
+ });
+});
+
+Cypress.Commands.add('apiCreateScheme', (name, scope, description) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/schemes',
+ method: 'POST',
+ body: {display_name: name, scope, description},
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({scheme: response.body});
+ });
+});
+
+Cypress.Commands.add('apiDeleteScheme', (schemeId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/schemes/' + schemeId,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/setup.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/setup.ts
new file mode 100644
index 00000000000..cd462014be7
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/setup.ts
@@ -0,0 +1,116 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ChainableT} from '../../types';
+
+interface SetupResult {
+ user: Cypress.UserProfile;
+ team: Cypress.Team;
+ channel: Cypress.Channel;
+ channelUrl: string;
+ offTopicUrl: string;
+ townSquareUrl: string;
+}
+interface SetupParam {
+ loginAfter?: boolean;
+ promoteNewUserAsAdmin?: boolean;
+ hideAdminTrialModal?: boolean;
+ userPrefix?: string;
+ userCreateAt?: number;
+ teamPrefix?: {name: string; displayName: string};
+ channelPrefix?: {name: string; displayName: string};
+ skipBoardsWelcomePage?: boolean;
+}
+function apiInitSetup(arg: SetupParam = {}): ChainableT {
+ const {
+ loginAfter = false,
+ promoteNewUserAsAdmin = false,
+ hideAdminTrialModal = true,
+ userPrefix,
+ userCreateAt,
+ teamPrefix = {name: 'team', displayName: 'Team'},
+ channelPrefix = {name: 'channel', displayName: 'Channel'},
+ skipBoardsWelcomePage = true,
+ } = arg;
+
+ return (cy.apiCreateTeam(teamPrefix.name, teamPrefix.displayName) as any).then(({team}) => {
+ // # Add public channel
+ return (cy.apiCreateChannel(team.id, channelPrefix.name, channelPrefix.displayName) as any).then(({channel}) => {
+ return (cy.apiCreateUser({prefix: userPrefix || (promoteNewUserAsAdmin ? 'admin' : 'user'), createAt: userCreateAt}) as any).then(({user}) => {
+ if (promoteNewUserAsAdmin) {
+ (cy as any).apiPatchUserRoles(user.id, ['system_admin', 'system_user']);
+
+ // Only hide start trial modal for admin since it's not applicable to other users
+ cy.apiSaveStartTrialModal(user.id, hideAdminTrialModal.toString());
+ }
+
+ if (skipBoardsWelcomePage) {
+ cy.apiBoardsWelcomePageViewed(user.id);
+ }
+
+ return cy.apiAddUserToTeam(team.id, user.id).then(() => {
+ return cy.apiAddUserToChannel(channel.id, user.id).then(() => {
+ const getUrl = (channelName: string) => `/${team.name}/channels/${channelName}`;
+
+ const data = {
+ channel,
+ team,
+ user,
+ channelUrl: getUrl(channel.name),
+ offTopicUrl: getUrl('off-topic'),
+ townSquareUrl: getUrl('town-square'),
+ };
+
+ if (loginAfter) {
+ return cy.apiLogin(user).then(() => {
+ return cy.wrap(data);
+ });
+ }
+
+ return cy.wrap(data);
+ });
+ });
+ });
+ });
+ });
+}
+
+Cypress.Commands.add('apiInitSetup', apiInitSetup);
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Creates a new user and make it a member of the new public team and its channels - one public channel, town-square and off-topic.
+ * Created user has an option to log in after all are setup.
+ * Requires sysadmin session to initiate this command.
+ * @param {boolean} options.loginAfter - false (default) or true if wants to login as the new user after setting up. Note that when true, succeeding API request will be limited to access/permission of a regular system user.
+ * @param {boolean} options.promoteNewUserAsAdmin - false (default) or true if wants to promote the newly created user as sysadmin.
+ * @param {boolean} options.hideAdminTrialModal - true (default) or false if wants to hide Start Enterprise Trial modal.
+ * @param {string} options.userPrefix - 'user' (default) or any prefix to easily identify a user
+ * @param {string} options.teamPrefix - {name: 'team', displayName: 'Team'} (default) or any prefix to easily identify a team
+ * @param {string} options.channelPrefix - {name: 'team', displayName: 'Team'} (default) or any prefix to easily identify a channel
+ * @returns {Object} `out` Cypress-chainable, yielded with element passed into .wrap().
+ * @returns {Cypress.UserProfile} `out.user` as `UserProfile` object
+ * @returns {Cypress.Team} `out.team` as `Team` object
+ * @returns {Cypress.Channel} `out.channel` as `Channel` object
+ * @returns {string} `out.channelUrl` as channel URL
+ * @returns {string} `out.offTopicUrl` as off-topic URL
+ * @returns {string} `out.townSquareUrl` as town-square URL
+ *
+ * @example
+ * let testUser;
+ * let testTeam;
+ * let testChannel;
+ * cy.apiInitSetup(options).then(({team, channel, user}) => {
+ * testUser = user;
+ * testTeam = team;
+ * testChannel = channel;
+ * });
+ */
+ apiInitSetup: typeof apiInitSetup;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/status.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/status.d.ts
new file mode 100644
index 00000000000..4c0afc7929d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/status.d.ts
@@ -0,0 +1,67 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Update status of a current user.
+ * See https://api.mattermost.com/#tag/status/paths/~1users~1{user_id}~1status/put
+ * @param {String} status - "online" (default), "offline", "away" or "dnd"
+ * @returns {UserStatus} `out.status` as `UserStatus`
+ *
+ * @example
+ * cy.apiUpdateUserStatus('offline').then(({status}) => {
+ * // do something with status
+ * });
+ */
+ apiUpdateUserStatus(status: string): Chainable;
+
+ /**
+ * Get status of a current user.
+ * See https://api.mattermost.com/#tag/status/paths/~1users~1{user_id}~1status/get
+ * @param {String} userId - ID of a given user
+ * @returns {UserStatus} `out.status` as `UserStatus`
+ *
+ * @example
+ * cy.apiGetUserStatus('userId').then(({status}) => {
+ * // examine the status information of the user
+ * });
+ */
+ apiGetStatus(userId: string): Chainable;
+
+ /**
+ * Update custom status of current user.
+ * See https://api.mattermost.com/#tag/custom_status/paths/~1users~1{user_id}~1status/custom/put
+ * @param {UserCustomStatus} customStatus - custom status to be updated
+ *
+ * @example
+ * cy.apiUpdateUserCustomStatus({emoji: 'calendar', text: 'In a meeting'});
+ */
+ apiUpdateUserCustomStatus(customStatus: UserCustomStatus);
+
+ /**
+ * Clear custom status of the current user.
+ * See https://api.mattermost.com/#tag/custom_status/paths/~1users~1{user_id}~1status/custom/delete
+ * @param {UserCustomStatus} customStatus - custom status to be updated
+ *
+ * @example
+ * cy.apiClearUserCustomStatus();
+ */
+ apiClearUserCustomStatus();
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/status.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/status.js
new file mode 100644
index 00000000000..07ebff29e2b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/status.js
@@ -0,0 +1,55 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// Status
+// https://api.mattermost.com/#tag/status
+// *****************************************************************************
+
+Cypress.Commands.add('apiUpdateUserStatus', (status = 'online') => {
+ return cy.getCookie('MMUSERID').then((cookie) => {
+ const data = {user_id: cookie.value, status};
+
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/me/status',
+ method: 'PUT',
+ body: data,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({status: response.body});
+ });
+ });
+});
+
+Cypress.Commands.add('apiGetUserStatus', (userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/status`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({status: response.body});
+ });
+});
+
+Cypress.Commands.add('apiUpdateUserCustomStatus', (customStatus) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/me/status/custom',
+ method: 'PUT',
+ body: JSON.stringify(customStatus),
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+});
+
+Cypress.Commands.add('apiClearUserCustomStatus', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/me/status/custom',
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/system.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/system.d.ts
new file mode 100644
index 00000000000..9be17117b95
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/system.d.ts
@@ -0,0 +1,203 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get a subset of the server license needed by the client.
+ * See https://api.mattermost.com/#tag/system/paths/~1license~1client/get
+ * @returns {ClientLicense} `out.license` as `ClientLicense`
+ * @returns {Boolean} `out.isLicensed`
+ * @returns {Boolean} `out.isCloudLicensed`
+ *
+ * @example
+ * cy.apiGetClientLicense().then(({license}) => {
+ * // do something with license
+ * });
+ */
+ apiGetClientLicense(): Chainable;
+
+ /**
+ * Verify if server has license for a certain feature and fail test if not found.
+ * Upload a license if it does not exist.
+ * @param {string[]} ...features - accepts multiple arguments of features to check, e.g. 'LDAP'
+ * @returns {ClientLicense} `out.license` as `ClientLicense`
+ *
+ * @example
+ * cy.apiRequireLicenseForFeature('LDAP');
+ * cy.apiRequireLicenseForFeature('LDAP', 'SAML');
+ */
+ apiRequireLicenseForFeature(...features: string[]): Chainable;
+
+ /**
+ * Verify if server has license and fail test if not found.
+ * Upload a license if it does not exist.
+ * @returns {ClientLicense} `out.license` as `ClientLicense`
+ *
+ * @example
+ * cy.apiRequireLicense();
+ */
+ apiRequireLicense(): Chainable;
+
+ /**
+ * Upload a license to enable enterprise features.
+ * See https://api.mattermost.com/#tag/system/paths/~1license/post
+ * @param {String} filePath - path of the license file relative to fixtures folder
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * const filePath = 'mattermost-license.txt';
+ * cy.apiUploadLicense(filePath);
+ */
+ apiUploadLicense(filePath: string): Chainable;
+
+ /**
+ * Request and install a trial license for your server.
+ * See https://api.mattermost.com/#tag/system/paths/~1trial-license/post
+ * @returns {Object} `out.data` as response status
+ *
+ * @example
+ * cy.apiInstallTrialLicense();
+ */
+ apiInstallTrialLicense(contactEmail: string): Chainable>;
+
+ /**
+ * Remove the license file from the server. This will disable all enterprise features.
+ * See https://api.mattermost.com/#tag/system/paths/~1license/delete
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiDeleteLicense();
+ */
+ apiDeleteLicense(): Chainable;
+
+ /**
+ * Update configuration.
+ * See https://api.mattermost.com/#tag/system/paths/~1config/put
+ * @param {AdminConfig} newConfig - new config
+ * @returns {AdminConfig} `out.config` as `AdminConfig`
+ *
+ * @example
+ * cy.apiUpdateConfig().then(({config}) => {
+ * // do something with config
+ * });
+ */
+ apiUpdateConfig(newConfig: DeepPartial): Chainable<{config: AdminConfig}>;
+
+ /**
+ * Reload the configuration file to pick up on any changes made to it.
+ * See https://api.mattermost.com/#tag/system/paths/~1config~1reload/post
+ * @returns {AdminConfig} `out.config` as `AdminConfig`
+ *
+ * @example
+ * cy.apiReloadConfig().then(({config}) => {
+ * // do something with config
+ * });
+ */
+ apiReloadConfig(): Chainable;
+
+ /**
+ * Get configuration.
+ * See https://api.mattermost.com/#tag/system/paths/~1config/get
+ * @param {Boolean} old - false (default) or true to return old format of client config
+ * @returns {AdminConfig} `out.config` as `AdminConfig`
+ *
+ * @example
+ * cy.apiGetConfig().then(({config}) => {
+ * // do something with config
+ * });
+ */
+ apiGetConfig(): Chainable<{config: AdminConfig}>;
+
+ /**
+ * Get analytics.
+ * See https://api.mattermost.com/#tag/system/paths/~1analytics~1old/get
+ * @returns {AnalyticsRow[]} `out.analytics` as `AnalyticsRow[]`
+ *
+ * @example
+ * cy.apiGetAnalytics().then(({analytics}) => {
+ * // do something with analytics
+ * });
+ */
+ apiGetAnalytics(): Chainable;
+
+ /**
+ * Invalidate all the caches.
+ * See https://api.mattermost.com/#tag/system/paths/~1caches~1invalidate/post
+ * @returns {Object} `out.data` as response status
+ *
+ * @example
+ * cy.apiInvalidateCache();
+ */
+ apiInvalidateCache(): Chainable>;
+
+ /**
+ * Allow test for server other than Cloud edition or with Cloud license.
+ * Otherwise, fail fast.
+ * @example
+ * cy.shouldNotRunOnCloudEdition();
+ */
+ shouldNotRunOnCloudEdition(): Chainable;
+
+ /**
+ * Allow test for server on Team edition or without license.
+ * Otherwise, fail fast.
+ * @example
+ * cy.shouldRunOnTeamEdition();
+ */
+ shouldRunOnTeamEdition(): Chainable;
+
+ /**
+ * Allow test for server with Plugin upload enabled.
+ * Otherwise, fail fast.
+ * @example
+ * cy.shouldHavePluginUploadEnabled();
+ */
+ shouldHavePluginUploadEnabled(): Chainable;
+
+ /**
+ * Allow test for server running with subpath.
+ * Otherwise, fail fast.
+ * @example
+ * cy.shouldRunWithSubpath();
+ */
+ shouldRunWithSubpath(): Chainable;
+
+ /**
+ * Allow test if matches feature flag setting
+ * Otherwise, fail fast.
+ *
+ * @param {string} feature - feature name
+ * @param {string} expectedValue - expected value
+ *
+ * @example
+ * cy.shouldHaveFeatureFlag('feature', 'expected-value');
+ */
+ shouldHaveFeatureFlag(feature: string, expectedValue: any): Chainable;
+
+ /**
+ * Require email service to be reachable by the server
+ * thru "/api/v4/email/test" if sysadmin account has
+ * permission to do so. Otherwise, skip email test.
+ *
+ * @example
+ * cy.shouldHaveEmailEnabled();
+ */
+ shouldHaveEmailEnabled(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/system.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/system.js
new file mode 100644
index 00000000000..5eaa1ee193a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/system.js
@@ -0,0 +1,339 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import merge from 'deepmerge';
+
+import {Constants} from '../../utils';
+
+import onPremDefaultConfig from './on_prem_default_config.json';
+import cloudDefaultConfig from './cloud_default_config.json';
+
+// *****************************************************************************
+// System
+// https://api.mattermost.com/#tag/system
+// *****************************************************************************
+
+function hasLicenseForFeature(license, key) {
+ let hasLicense = false;
+
+ for (const [k, v] of Object.entries(license)) {
+ if (k === key && v === 'true') {
+ hasLicense = true;
+ break;
+ }
+ }
+
+ return hasLicense;
+}
+
+Cypress.Commands.add('apiGetClientLicense', () => {
+ return cy.request('/api/v4/license/client?format=old').then((response) => {
+ expect(response.status).to.equal(200);
+
+ const license = response.body;
+ const isLicensed = license.IsLicensed === 'true';
+ const isCloudLicensed = hasLicenseForFeature(license, 'Cloud');
+
+ return cy.wrap({
+ license: response.body,
+ isLicensed,
+ isCloudLicensed,
+ });
+ });
+});
+
+Cypress.Commands.add('apiRequireLicenseForFeature', (...keys) => {
+ Cypress.log({name: 'EE License', message: `Checking if server has license for feature: __${Object.values(keys).join(', ')}__.`});
+
+ return uploadLicenseIfNotExist().then((data) => {
+ const {license, isLicensed} = data;
+ const hasLicenseMessage = `Server ${isLicensed ? 'has' : 'has no'} EE license.`;
+ expect(isLicensed, hasLicenseMessage).to.equal(true);
+
+ Object.values(keys).forEach((key) => {
+ const hasLicenseKey = hasLicenseForFeature(license, key);
+ const hasLicenseKeyMessage = `Server ${hasLicenseKey ? 'has' : 'has no'} EE license for feature: __${key}__`;
+ expect(hasLicenseKey, hasLicenseKeyMessage).to.equal(true);
+ });
+
+ return cy.wrap(data);
+ });
+});
+
+Cypress.Commands.add('apiRequireLicense', () => {
+ Cypress.log({name: 'EE License', message: 'Checking if server has license.'});
+
+ return uploadLicenseIfNotExist().then((data) => {
+ const hasLicenseMessage = `Server ${data.isLicensed ? 'has' : 'has no'} EE license.`;
+ expect(data.isLicensed, hasLicenseMessage).to.equal(true);
+
+ return cy.wrap(data);
+ });
+});
+
+Cypress.Commands.add('apiUploadLicense', (filePath) => {
+ cy.apiUploadFile('license', filePath, {url: '/api/v4/license', method: 'POST', successStatus: 200});
+});
+
+Cypress.Commands.add('apiInstallTrialLicense', (contactEmail) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/trial-license',
+ method: 'POST',
+ body: {
+ receive_emails_accepted: true,
+ terms_accepted: true,
+ users: Cypress.env('numberOfTrialUsers'),
+
+ // Enriched fields required for trial license as of v10.7
+ company_country: 'US',
+ contact_email: contactEmail,
+ contact_name: 'Test Mattermost',
+ company_name: 'MattermostTest',
+ company_size: '1-10',
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response.body);
+ });
+});
+
+Cypress.Commands.add('apiDeleteLicense', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/license',
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({response});
+ });
+});
+
+export const getDefaultConfig = () => {
+ const cypressEnv = Cypress.env();
+
+ const fromCypressEnv = {
+ ElasticsearchSettings: {
+ ConnectionURL: cypressEnv.elasticsearchConnectionURL,
+ },
+ LdapSettings: {
+ LdapServer: cypressEnv.ldapServer,
+ LdapPort: cypressEnv.ldapPort,
+ },
+ ServiceSettings: {
+ AllowedUntrustedInternalConnections: cypressEnv.allowedUntrustedInternalConnections,
+ SiteURL: Cypress.config('baseUrl'),
+ },
+ };
+
+ const isCloud = cypressEnv.serverEdition === Constants.ServerEdition.CLOUD;
+
+ if (isCloud) {
+ fromCypressEnv.CloudSettings = {
+ CWSURL: cypressEnv.cwsURL,
+ CWSAPIURL: cypressEnv.cwsAPIURL,
+ };
+ }
+
+ const defaultConfig = isCloud ? cloudDefaultConfig : onPremDefaultConfig;
+
+ return merge(defaultConfig, fromCypressEnv);
+};
+
+const expectConfigToBeUpdatable = (currentConfig, newConfig) => {
+ function errorMessage(name) {
+ return `${name} is restricted or not available to update. You may check user/sysadmin access, license requirement, server version or edition (on-prem/cloud) compatibility.`;
+ }
+
+ Object.entries(newConfig).forEach(([newMainKey, newSubSetting]) => {
+ const setting = currentConfig[newMainKey];
+
+ if (setting) {
+ Object.keys(newSubSetting).forEach((newSubKey) => {
+ const isAvailable = setting.hasOwnProperty(newSubKey);
+ const name = `${newMainKey}.${newSubKey}`;
+ expect(isAvailable, isAvailable ? `${name} setting can be updated.` : errorMessage(name)).to.equal(true);
+ });
+ } else {
+ const withSetting = Boolean(setting);
+ expect(withSetting, withSetting ? `${newMainKey} setting can be updated.` : errorMessage(newMainKey)).to.equal(true);
+ }
+ });
+};
+
+Cypress.Commands.add('apiUpdateConfig', (newConfig = {}) => {
+ // # Get current config
+ return cy.apiGetConfig().then(({config: currentConfig}) => {
+ // * Check if config can be updated
+ expectConfigToBeUpdatable(currentConfig, newConfig);
+
+ const config = merge.all([currentConfig, getDefaultConfig(), newConfig]);
+
+ // # Set the modified config
+ return cy.request({
+ url: '/api/v4/config',
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'PUT',
+ body: config,
+ }).then((updateResponse) => {
+ expect(updateResponse.status).to.equal(200);
+ return cy.apiGetConfig();
+ });
+ });
+});
+
+Cypress.Commands.add('apiReloadConfig', () => {
+ // # Reload the config
+ return cy.request({
+ url: '/api/v4/config/reload',
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ }).then((reloadResponse) => {
+ expect(reloadResponse.status).to.equal(200);
+ return cy.apiGetConfig();
+ });
+});
+
+Cypress.Commands.add('apiGetConfig', (old = false) => {
+ // # Get current settings
+ return cy.request(`/api/v4/config${old ? '/client?format=old' : ''}`).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({config: response.body});
+ });
+});
+
+Cypress.Commands.add('apiEnsureFeatureFlag', (key, value) => {
+ cy.apiGetConfig().then(({config}) => {
+ cy.log(JSON.stringify(config.PluginSettings.Plugins.playbooks));
+ const currentValue = config.PluginSettings.Plugins.playbooks[key];
+ if (currentValue !== value) {
+ cy.apiUpdateConfig({
+ PluginSettings: {Plugins: {playbooks: {[key]: value}}},
+ }).then(() => {
+ return cy.wrap({prevValue: currentValue, value});
+ });
+ }
+ return cy.wrap({prevValue: currentValue, value});
+ });
+});
+
+Cypress.Commands.add('apiGetAnalytics', () => {
+ cy.apiAdminLogin();
+
+ return cy.request('/api/v4/analytics/old').then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({analytics: response.body});
+ });
+});
+
+Cypress.Commands.add('apiInvalidateCache', () => {
+ return cy.request({
+ url: '/api/v4/caches/invalidate',
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+function isCloudEdition() {
+ return cy.apiGetClientLicense().then(({isCloudLicensed}) => {
+ return cy.wrap(isCloudLicensed);
+ });
+}
+
+Cypress.Commands.add('shouldNotRunOnCloudEdition', () => {
+ isCloudEdition().then((isCloud) => {
+ expect(isCloud, isCloud ? 'Should not run on Cloud server' : '').to.equal(false);
+ });
+});
+
+function isTeamEdition() {
+ return cy.apiGetClientLicense().then(({isLicensed}) => {
+ return cy.wrap(!isLicensed);
+ });
+}
+
+Cypress.Commands.add('shouldRunOnTeamEdition', () => {
+ isTeamEdition().then((isTeam) => {
+ expect(isTeam, isTeam ? '' : 'Should run on Team edition only').to.equal(true);
+ });
+});
+
+function isElasticsearchEnabled() {
+ return cy.apiGetConfig().then(({config}) => {
+ let isEnabled = false;
+
+ if (config.ElasticsearchSettings) {
+ const {EnableAutocomplete, EnableIndexing, EnableSearching} = config.ElasticsearchSettings;
+
+ isEnabled = EnableAutocomplete && EnableIndexing && EnableSearching;
+ }
+
+ return cy.wrap(isEnabled);
+ });
+}
+
+Cypress.Commands.add('shouldHaveElasticsearchDisabled', () => {
+ isElasticsearchEnabled().then((data) => {
+ expect(data, data ? 'Should have Elasticsearch disabled' : '').to.equal(false);
+ });
+});
+
+Cypress.Commands.add('shouldHavePluginUploadEnabled', () => {
+ return cy.apiGetConfig().then(({config}) => {
+ const isUploadEnabled = config.PluginSettings.EnableUploads;
+ expect(isUploadEnabled, isUploadEnabled ? '' : 'Should have Plugin upload enabled').to.equal(true);
+ });
+});
+
+Cypress.Commands.add('shouldHaveClusterEnabled', () => {
+ return cy.apiGetConfig().then(({config}) => {
+ const {Enable, ClusterName} = config.ClusterSettings;
+ expect(Enable, Enable ? '' : 'Should have cluster enabled').to.equal(true);
+
+ const sameClusterName = ClusterName === Cypress.env('serverClusterName');
+ expect(sameClusterName, sameClusterName ? '' : `Should have cluster name set and as expected. Got "${ClusterName}" but expected "${Cypress.env('serverClusterName')}"`).to.equal(true);
+ });
+});
+
+Cypress.Commands.add('shouldRunWithSubpath', () => {
+ return cy.apiGetConfig().then(({config}) => {
+ const isSubpath = Boolean(config.ServiceSettings.SiteURL.replace(/^https?:\/\//, '').split('/')[1]);
+ expect(isSubpath, isSubpath ? '' : 'Should run on server running with subpath only').to.equal(true);
+ });
+});
+
+Cypress.Commands.add('shouldHaveFeatureFlag', (key, expectedValue) => {
+ return cy.apiGetConfig().then(({config}) => {
+ const actualValue = config.FeatureFlags[key];
+ const message = actualValue === expectedValue ? `Matches feature flag - "${key}: ${expectedValue}"` : `Expected feature flag "${key}" to be "${expectedValue}", but was "${actualValue}"`;
+ expect(actualValue, message).to.equal(expectedValue);
+ });
+});
+
+Cypress.Commands.add('shouldHaveEmailEnabled', () => {
+ return cy.apiGetConfig().then(({config}) => {
+ if (!config.ExperimentalSettings.RestrictSystemAdmin) {
+ cy.apiEmailTest();
+ }
+ });
+});
+
+/**
+ * Upload a license if it does not exist.
+ */
+function uploadLicenseIfNotExist() {
+ return cy.apiGetClientLicense().then((data) => {
+ if (data.isLicensed) {
+ return cy.wrap(data);
+ }
+
+ return cy.apiGetMe().then(({user}) => {
+ return cy.apiInstallTrialLicense(user.email).then(() => {
+ return cy.apiGetClientLicense();
+ });
+ });
+ });
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/team.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/team.d.ts
new file mode 100644
index 00000000000..ce3bde2cd1d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/team.d.ts
@@ -0,0 +1,185 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Create a team.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams/post
+ * @param {String} name - Unique handler for a team, will be present in the team URL
+ * @param {String} displayName - Non-unique UI name for the team
+ * @param {String} type - 'O' for open (default), 'I' for invite only
+ * @param {Boolean} unique - if true (default), it will create with unique/random team name.
+ * @param {Partial} options - other fields of team to include
+ * @returns {Team} `out.team` as `Team`
+ *
+ * @example
+ * cy.apiCreateTeam('test-team', 'Test Team').then(({team}) => {
+ * // do something with team
+ * });
+ */
+ apiCreateTeam(name: string, displayName: string, type?: string, unique?: boolean, options?: Partial): Chainable<{team: Team}>;
+
+ /**
+ * Delete a team.
+ * Soft deletes a team, by marking the team as deleted in the database.
+ * Optionally use the permanent query parameter to hard delete the team.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams~1{team_id}/delete
+ * @param {String} teamId - The team ID to be deleted
+ * @param {Boolean} permanent - false (default) as soft delete and true as permanent delete
+ * @returns {Object} `out.data` as response status
+ *
+ * @example
+ * cy.apiDeleteTeam('test-id');
+ */
+ apiDeleteTeam(teamId: string, permanent?: boolean): Chainable>;
+
+ /**
+ * Delete the team member object for a user, effectively removing them from a team.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams~1{team_id}~1members~1{user_id}/delete
+ * @param {String} teamId - The team ID which the user is to be removed from
+ * @param {String} userId - The user ID to be removed from team
+ * @returns {Object} `out.data` as response status
+ *
+ * @example
+ * cy.apiDeleteUserFromTeam('team-id', 'user-id');
+ */
+ apiDeleteUserFromTeam(teamId: string, userId: string): Chainable>;
+
+ /**
+ * Patch a team.
+ * Partially update a team by providing only the fields you want to update.
+ * Omitted fields will not be updated.
+ * The fields that can be updated are defined in the request body, all other provided fields will be ignored.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams/post
+ * @param {String} teamId - The team ID to be patched
+ * @param {String} patch.display_name - Display name
+ * @param {String} patch.description - Description
+ * @param {String} patch.company_name - Company name
+ * @param {String} patch.allowed_domains - Allowed domains
+ * @param {Boolean} patch.allow_open_invite - Allow open invite
+ * @param {Boolean} patch.group_constrained - Group constrained
+ * @returns {Team} `out.team` as `Team`
+ *
+ * @example
+ * cy.apiPatchTeam('test-team', {display_name: 'New Team', group_constrained: true}).then(({team}) => {
+ * // do something with team
+ * });
+ */
+ apiPatchTeam(teamId: string, patch: Partial): Chainable;
+
+ /**
+ * Get a team based on provided name string.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams~1name~1{name}/get
+ * @param {String} name - Name of a team
+ * @returns {Team} `out.team` as `Team`
+ *
+ * @example
+ * cy.apiGetTeamByName('team-name').then(({team}) => {
+ * // do something with team
+ * });
+ */
+ apiGetTeamByName(name: string): Chainable;
+
+ /**
+ * Get teams.
+ * For regular users only returns open teams.
+ * Users with the "manage_system" permission will return teams regardless of type.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams/get
+ * @param {String} queryParams.page - Page to select, 0 (default)
+ * @param {String} queryParams.perPage - The number of teams per page, 60 (default)
+ * @returns {Team[]} `out.teams` as `Team[]`
+ * @returns {number} `out.totalCount` as `number`
+ *
+ * @example
+ * cy.apiGetAllTeams().then(({teams}) => {
+ * // do something with teams
+ * });
+ */
+ apiGetAllTeams(queryParams?: Record): Chainable<{teams: Team[]}>;
+
+ /**
+ * Get a list of teams that a user is on.
+ * See https://api.mattermost.com/#tag/teams/paths/~1users~1{user_id}~1teams/get
+ * @param {String} userId - User ID to get teams, or 'me' (default)
+ * @returns {Team[]} `out.teams` as `Team[]`
+ *
+ * @example
+ * cy.apiGetTeamsForUser().then(({teams}) => {
+ * // do something with teams
+ * });
+ */
+ apiGetTeamsForUser(userId: string): Chainable;
+
+ /**
+ * Add user to the team by user_id.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams~1{team_id}~1members/post
+ * @param {String} teamId - Team ID
+ * @param {String} userId - User ID to be added into a team
+ * @returns {TeamMembership} `out.member` as `TeamMembership`
+ *
+ * @example
+ * cy.apiAddUserToTeam('team-id', 'user-id').then(({member}) => {
+ * // do something with member
+ * });
+ */
+ apiAddUserToTeam(teamId: string, userId: string): Chainable;
+
+ /**
+ * Get team members.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams~1{team_id}~1members/get
+ * @param {string} teamId - team ID
+ * @returns {TeamMembership[]} `out.members` as `TeamMembership[]`
+ *
+ * @example
+ * cy.apiGetTeamMembers(teamId).then(({members}) => {
+ * // do something with members
+ * });
+ */
+ apiGetTeamMembers(teamId: string): Chainable;
+
+ /**
+ * Add a number of users to the team.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams~1{team_id}~1members~1batch/post
+ * @param {string} teamId - team ID
+ * @param {TeamMembership[]} teamMembers - users to add
+ * @returns {TeamMembership[]} `out.members` as `TeamMembership[]`
+ *
+ * @example
+ * cy.apiAddUsersToTeam(teamId, [{team_id: 'team-id', user_id: 'user-id'}]).then(({members}) => {
+ * // do something with members
+ * });
+ */
+ apiAddUsersToTeam(teamId: string, teamMembers: TeamMembership[]): Chainable;
+
+ /**
+ * Update the scheme-derived roles of a team member.
+ * Requires sysadmin session to initiate this command.
+ * See https://api.mattermost.com/#tag/teams/paths/~1teams~1{team_id}~1members~1{user_id}~1schemeRoles/put
+ * @param {string} teamId - team ID
+ * @param {string} userId - user ID
+ * @param {Object} schemeRoles.scheme_admin - false (default) or true to change into team admin
+ * @param {Object} schemeRoles.scheme_user - true (default) or false to change not to be a team user
+ * @returns {Object} `out.data` as response status
+ *
+ * @example
+ * cy.apiUpdateTeamMemberSchemeRole(teamId, userId, {scheme_admin: false, scheme_user: true});
+ */
+ apiUpdateTeamMemberSchemeRole(teamId: string, userId: string, schemeRoles: Record): Chainable>;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/team.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/team.js
new file mode 100644
index 00000000000..2bcb1cbebb1
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/team.js
@@ -0,0 +1,163 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getRandomId} from '../../utils';
+
+// *****************************************************************************
+// Teams
+// https://api.mattermost.com/#tag/teams
+// *****************************************************************************
+
+export function createTeamPatch(name = 'team', displayName = 'Team', type = 'O', unique = true) {
+ const randomSuffix = getRandomId();
+
+ return {
+ name: unique ? `${name}-${randomSuffix}` : name,
+ display_name: unique ? `${displayName} ${randomSuffix}` : displayName,
+ type,
+ };
+}
+
+Cypress.Commands.add('apiCreateTeam', (name, displayName, type, unique, options) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/teams',
+ method: 'POST',
+ body: {
+ ...createTeamPatch(name, displayName, type, unique),
+ ...options,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({team: response.body});
+ });
+});
+
+Cypress.Commands.add('apiDeleteTeam', (teamId, permanent = false) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/teams/' + teamId + (permanent ? '?permanent=true' : ''),
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({data: response.body});
+ });
+});
+
+Cypress.Commands.add('apiDeleteUserFromTeam', (teamId, userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/teams/' + teamId + '/members/' + userId,
+ method: 'DELETE',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({data: response.body});
+ });
+});
+
+Cypress.Commands.add('apiPatchTeam', (teamId, teamData) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/teams/${teamId}/patch`,
+ method: 'PUT',
+ body: teamData,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({team: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetTeamByName', (name) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/teams/name/' + name,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({team: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetAllTeams', ({page = 0, perPage = 60} = {}) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `api/v4/teams?page=${page}&per_page=${perPage}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({teams: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetTeamsForUser', (userId = 'me') => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `api/v4/users/${userId}/teams`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({teams: response.body});
+ });
+});
+
+Cypress.Commands.add('apiAddUserToTeam', (teamId, userId) => {
+ return cy.request({
+ method: 'POST',
+ url: `/api/v4/teams/${teamId}/members`,
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ body: {team_id: teamId, user_id: userId},
+ qs: {team_id: teamId},
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({member: response.body});
+ });
+});
+
+Cypress.Commands.add('apiAddUsersToTeam', (teamId, teamMembers) => {
+ return cy.request({
+ method: 'POST',
+ url: `/api/v4/teams/${teamId}/members/batch`,
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ body: teamMembers,
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({members: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetTeamMembers', (teamId) => {
+ return cy.request({
+ method: 'GET',
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/teams/${teamId}/members`,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({members: response.body});
+ });
+});
+
+Cypress.Commands.add('apiUpdateTeamMemberSchemeRole', (teamId, userId, schemeRoles = {}) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/teams/${teamId}/members/${userId}/schemeRoles`,
+ method: 'PUT',
+ body: schemeRoles,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({data: response.body});
+ });
+});
+
+Cypress.Commands.add('apiSetTeamScheme', (teamId, schemeId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/teams/${teamId}/scheme`,
+ method: 'PUT',
+ body: {
+ scheme_id: schemeId,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({data: response.body});
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/user.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/user.d.ts
new file mode 100644
index 00000000000..0d04ef83c83
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/user.d.ts
@@ -0,0 +1,379 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Login to server via API.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1login/post
+ * @param {string} user.username - username of a user
+ * @param {string} user.password - password of user
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiLogin({username: 'sysadmin', password: 'secret'});
+ */
+ apiLogin(user: UserProfile): Chainable;
+
+ /**
+ * Login to server via API.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1login/post
+ * @param {string} user.username - username of a user
+ * @param {string} user.password - password of user
+ * @param {string} token - MFA token for the session
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiLoginWithMFA({username: 'sysadmin', password: 'secret', token: '123456'});
+ */
+ apiLoginWithMFA(user: UserProfile, token: string): Chainable<{user: UserProfile}>;
+
+ /**
+ * Login as admin via API.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1login/post
+ * @param {Object} requestOptions - cypress' request options object, see https://docs.cypress.io/api/commands/request#Arguments
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiAdminLogin();
+ */
+ apiAdminLogin(requestOptions?: Record): Chainable;
+
+ /**
+ * Login as admin via API.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1login/post
+ * @param {string} token - MFA token for the session
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiAdminLoginWithMFA(token);
+ */
+ apiAdminLoginWithMFA(token: string): Chainable<{user: UserProfile}>;
+
+ /**
+ * Logout a user's active session from server via API.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1logout/post
+ * Clears all cookies especially `MMAUTHTOKEN`, `MMUSERID` and `MMCSRF`.
+ *
+ * @example
+ * cy.apiLogout();
+ */
+ apiLogout();
+
+ /**
+ * Get current user.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}/get
+ * @returns {user: UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiGetMe().then(({user}) => {
+ * // do something with user
+ * });
+ */
+ apiGetMe(): Chainable<{user: UserProfile}>;
+
+ /**
+ * Get a user by ID.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}/get
+ * @param {String} userId - ID of a user to get profile
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiGetUserById('user-id').then(({user}) => {
+ * // do something with user
+ * });
+ */
+ apiGetUserById(userId: string): Chainable;
+
+ /**
+ * Get a user by email.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1email~1{email}/get
+ * @param {String} email - email address of a user to get profile
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiGetUserByEmail('email').then(({user}) => {
+ * // do something with user
+ * });
+ */
+ apiGetUserByEmail(email: string): Chainable<{user: UserProfile}>;
+
+ /**
+ * Get users by usernames.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1usernames/post
+ * @param {String[]} usernames - list of usernames to get profiles
+ * @returns {UserProfile[]} out.users: list of `UserProfile` objects
+ *
+ * @example
+ * cy.apiGetUsersByUsernames().then(({users}) => {
+ * // do something with users
+ * });
+ */
+ apiGetUsersByUsernames(usernames: string[]): Chainable;
+
+ /**
+ * Patch a user.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1patch/put
+ * @param {String} userId - ID of user to patch
+ * @param {UserProfile} userData - user profile to be updated
+ * @param {string} userData.email
+ * @param {string} userData.username
+ * @param {string} userData.first_name
+ * @param {string} userData.last_name
+ * @param {string} userData.nickname
+ * @param {string} userData.locale
+ * @param {Object} userData.timezone
+ * @param {string} userData.position
+ * @param {Object} userData.props
+ * @param {Object} userData.notify_props
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiPatchUser('user-id', {locale: 'en'}).then(({user}) => {
+ * // do something with user
+ * });
+ */
+ apiPatchUser(userId: string, userData: UserProfile): Chainable<{user: UserProfile}>;
+
+ /**
+ * Convenient command to patch a current user.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1patch/put
+ * @param {UserProfile} userData - user profile to be updated
+ * @param {string} userData.email
+ * @param {string} userData.username
+ * @param {string} userData.first_name
+ * @param {string} userData.last_name
+ * @param {string} userData.nickname
+ * @param {string} userData.locale
+ * @param {Object} userData.timezone
+ * @param {string} userData.position
+ * @param {Object} userData.props
+ * @param {Object} userData.notify_props
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiPatchMe({locale: 'en'}).then(({user}) => {
+ * // do something with user
+ * });
+ */
+ apiPatchMe(userData: UserProfile): Chainable;
+
+ /**
+ * Create an admin account based from the env variables defined in Cypress env.
+ * @param {string} options.namePrefix - 'user' (default) or any prefix to easily identify a user
+ * @param {boolean} options.bypassTutorial - true (default) or false for user to go thru tutorial steps
+ * @param {boolean} options.showOnboarding - false (default) to hide or true to show Onboarding steps
+ * @returns {UserProfile} `out.sysadmin` as `UserProfile` object
+ *
+ * @example
+ * cy.apiCreateAdmin(options);
+ */
+ apiCreateAdmin(options: Record): Chainable;
+
+ /**
+ * Create a randomly named admin account
+ *
+ * @param {boolean} options.loginAfter - false (default) or true if wants to login as the new admin.
+ * @param {boolean} options.hideAdminTrialModal - true (default) or false if wants to hide Start Enterprise Trial modal.
+ *
+ * @returns {UserProfile} `out.sysadmin` as `UserProfile` object
+ */
+ apiCreateCustomAdmin(options: {loginAfter: boolean; hideAdminTrialModal?: boolean}): Chainable<{sysadmin: UserProfile}>;
+
+ /**
+ * Create a new user with an options to set name prefix and be able to bypass tutorial steps.
+ * @param {string} options.user - predefined `user` object instead on random user
+ * @param {string} options.prefix - 'user' (default) or any prefix to easily identify a user
+ * @param {boolean} options.bypassTutorial - true (default) or false for user to go thru tutorial steps
+ * @param {boolean} options.showOnboarding - false (default) to hide or true to show Onboarding steps
+ * @returns {UserProfile} `out.user` as `UserProfile` object
+ *
+ * @example
+ * cy.apiCreateUser(options);
+ */
+ apiCreateUser(options?: {
+ user?: Partial;
+ prefix?: string;
+ createAt?: number;
+ bypassTutorial?: boolean;
+ showOnboarding?: boolean;
+ }): Chainable<{user: UserProfile}>;
+
+ /**
+ * Create a new guest user with an options to set name prefix and be able to bypass tutorial steps.
+ * @param {string} options.prefix - 'guest' (default) or any prefix to easily identify a guest
+ * @param {boolean} options.bypassTutorial - true (default) or false for guest to go thru tutorial steps
+ * @param {boolean} options.showOnboarding - false (default) to hide or true to show Onboarding steps
+ * @returns {UserProfile} `out.guest` as `UserProfile` object
+ *
+ * @example
+ * cy.apiCreateGuestUser(options);
+ */
+ apiCreateGuestUser(options: Record): Chainable<{guest: UserProfile}>;
+
+ /**
+ * Revoke all active sessions for a user.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1sessions~1revoke~1all/post
+ * @param {String} userId - ID of a user
+ * @returns {Object} `out.data` as response status
+ *
+ * @example
+ * cy.apiRevokeUserSessions('user-id');
+ */
+ apiRevokeUserSessions(userId: string): Chainable>;
+
+ /**
+ * Get list of users based on query parameters
+ * See https://api.mattermost.com/#tag/users/paths/~1users/get
+ * @param {String} queryParams - see link on available query parameters
+ * @returns {UserProfile[]} `out.users` as `UserProfile[]` object
+ *
+ * @example
+ * cy.apiGetUsers().then(({users}) => {
+ * // do something with users
+ * });
+ */
+ apiGetUsers(queryParams: Record): Chainable;
+
+ /**
+ * Get list of users that are not team members.
+ * See https://api.mattermost.com/#tag/users/paths/~1users/get
+ * @param {String} queryParams.teamId - Team ID
+ * @param {String} queryParams.page - Page to select, 0 (default)
+ * @param {String} queryParams.perPage - The number of users per page, 60 (default)
+ * @returns {UserProfile[]} `out.users` as `UserProfile[]` object
+ *
+ * @example
+ * cy.apiGetUsersNotInTeam({teamId: 'team-id'}).then(({users}) => {
+ * // do something with users
+ * });
+ */
+ apiGetUsersNotInTeam(queryParams: Record): Chainable;
+
+ /**
+ * Reactivate a user account.
+ * @param {string} userId - User ID
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiActivateUser('user-id');
+ */
+ apiActivateUser(userId: string): Chainable;
+
+ /**
+ * Deactivate a user account.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}/delete
+ * @param {string} userId - User ID
+ * @returns {Response} response: Cypress-chainable response which should have successful HTTP status of 200 OK to continue or pass.
+ *
+ * @example
+ * cy.apiDeactivateUser('user-id');
+ */
+ apiDeactivateUser(userId: string): Chainable;
+
+ /**
+ * Convert a regular user into a guest. This will convert the user into a guest for the whole system while retaining their existing team and channel memberships.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1demote/post
+ * @param {string} userId - User ID
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiDemoteUserToGuest('user-id');
+ */
+ apiDemoteUserToGuest(userId: string): Chainable;
+
+ /**
+ * Convert a guest into a regular user. This will convert the guest into a user for the whole system while retaining any team and channel memberships and automatically joining them to the default channels.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1promote/post
+ * @param {string} userId - User ID
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiPromoteGuestToUser('user-id');
+ */
+ apiPromoteGuestToUser(userId: string): Chainable;
+
+ /**
+ * Verifies a user's email via userId without having to go to the user's email inbox.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1email~1verify~1member/post
+ * @param {string} userId - User ID
+ * @returns {UserProfile} out.user: `UserProfile` object
+ *
+ * @example
+ * cy.apiVerifyUserEmailById('user-id').then(({user}) => {
+ * // do something with user
+ * });
+ */
+ apiVerifyUserEmailById(userId: string): Chainable;
+
+ /**
+ * Update a user MFA.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1mfa/put
+ * @param {String} userId - ID of user to patch
+ * @param {boolean} activate - Whether MFA is going to be enabled or disabled
+ * @param {string} token - MFA token/code
+ * @example
+ * cy.apiActivateUserMFA('user-id', activate: false);
+ */
+ apiActivateUserMFA(userId: string, activate: boolean, token: string): Chainable;
+
+ /**
+ * Create a user access token
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1tokens/post
+ * @param {String} userId - ID of user for whom to generate token
+ * @param {String} description - The description of the token usage
+ * @example
+ * cy.apiAccessToken('user-id', 'token for cypress tests');
+ */
+ apiAccessToken(userId: string, description: string): Chainable;
+
+ /**
+ * Revoke a user access token
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1tokens~1revoke/post
+ * @param {String} tokenId - The id of the token to revoke
+ * @example
+ * cy.apiRevokeAccessToken('token-id')
+ */
+ apiRevokeAccessToken(tokenId: string): Chainable;
+
+ /**
+ * Update a user auth method.
+ * See https://api.mattermost.com/#tag/users/paths/~1users~1{user_id}~1mfa/put
+ * @param {String} userId - ID of user to patch
+ * @param {String} authData
+ * @param {String} password
+ * @param {String} authService
+ * @example
+ * cy.apiUpdateUserAuth('user-id', 'auth-data', 'password', 'auth-service');
+ */
+ apiUpdateUserAuth(userId: string, authData: string, password: string, authService: string): Chainable;
+
+ /**
+ * Get total count of users in the system
+ * See https://api.mattermost.com/#operation/GetTotalUsersStats
+ *
+ * @returns {number} - total count of all users
+ *
+ * @example
+ * cy.apiGetTotalUsers().then(() => {
+ * // do something with total users
+ * });
+ */
+ apiGetTotalUsers(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/user.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/user.js
new file mode 100644
index 00000000000..325658dc212
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/user.js
@@ -0,0 +1,508 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import authenticator from 'authenticator';
+
+import {getRandomId} from '../../utils';
+import {getAdminAccount} from '../env';
+
+import {buildQueryString} from './helpers';
+
+// *****************************************************************************
+// Users
+// https://api.mattermost.com/#tag/users
+// *****************************************************************************
+
+Cypress.Commands.add('apiLogin', (user, requestOptions = {}) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/login',
+ method: 'POST',
+ body: {login_id: user.username || user.email, password: user.password},
+ ...requestOptions,
+ }).then((response) => {
+ if (requestOptions.failOnStatusCode) {
+ expect(response.status).to.equal(200);
+ }
+
+ if (response.status === 200) {
+ return cy.wrap({
+ user: {
+ ...response.body,
+ password: user.password,
+ },
+ });
+ }
+
+ return cy.wrap({error: response.body});
+ });
+});
+
+Cypress.Commands.add('apiLoginWithMFA', (user, token) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/login',
+ method: 'POST',
+ body: {login_id: user.username, password: user.password, token},
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({
+ user: {
+ ...response.body,
+ password: user.password,
+ },
+ });
+ });
+});
+
+Cypress.Commands.add('apiAdminLogin', (requestOptions = {}) => {
+ const admin = getAdminAccount();
+
+ // First, login with username
+ cy.apiLogin(admin, requestOptions).then((resp) => {
+ if (resp.error) {
+ if (resp.error.id === 'mfa.validate_token.authenticate.app_error') {
+ // On fail, try to login via MFA
+ return cy.dbGetUser({username: admin.username}).then(({user: {mfasecret}}) => {
+ const token = authenticator.generateToken(mfasecret);
+ return cy.apiLoginWithMFA(admin, token);
+ });
+ }
+
+ // Or, try to login via email
+ delete admin.username;
+ return cy.apiLogin(admin, requestOptions);
+ }
+
+ return resp;
+ });
+});
+
+Cypress.Commands.add('apiAdminLoginWithMFA', (token) => {
+ const admin = getAdminAccount();
+
+ return cy.apiLoginWithMFA(admin, token);
+});
+
+Cypress.Commands.add('apiLogout', () => {
+ cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/logout',
+ method: 'POST',
+ log: false,
+ });
+
+ // * Verify logged out
+ cy.visit('/login?extra=expired').url().should('include', '/login');
+
+ // # Ensure we clear out these specific cookies
+ ['MMAUTHTOKEN', 'MMUSERID', 'MMCSRF'].forEach((cookie) => {
+ cy.clearCookie(cookie);
+ });
+
+ // # Clear remainder of cookies
+ cy.clearCookies();
+});
+
+Cypress.Commands.add('apiGetMe', () => {
+ return cy.apiGetUserById('me');
+});
+
+Cypress.Commands.add('apiGetUserById', (userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/' + userId,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({user: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetUserByEmail', (email, failOnStatusCode = true) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/email/' + email,
+ failOnStatusCode,
+ }).then((response) => {
+ const {body, status} = response;
+
+ if (failOnStatusCode) {
+ expect(status).to.equal(200);
+ return cy.wrap({user: body});
+ }
+ return cy.wrap({user: status === 200 ? body : null});
+ });
+});
+
+Cypress.Commands.add('apiGetUsersByUsernames', (usernames = []) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/usernames',
+ method: 'POST',
+ body: usernames,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({users: response.body});
+ });
+});
+
+Cypress.Commands.add('apiPatchUser', (userId, userData) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'PUT',
+ url: `/api/v4/users/${userId}/patch`,
+ body: userData,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({user: response.body});
+ });
+});
+
+Cypress.Commands.add('apiPatchMe', (data) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/me/patch',
+ method: 'PUT',
+ body: data,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({user: response.body});
+ });
+});
+
+Cypress.Commands.add('apiCreateCustomAdmin', ({loginAfter = false, hideAdminTrialModal = true} = {}) => {
+ const sysadminUser = generateRandomUser('other-admin');
+
+ return cy.apiCreateUser({user: sysadminUser}).then(({user}) => {
+ return cy.apiPatchUserRoles(user.id, ['system_admin', 'system_user']).then(() => {
+ const data = {sysadmin: user};
+
+ cy.apiSaveStartTrialModal(user.id, hideAdminTrialModal.toString());
+
+ if (loginAfter) {
+ return cy.apiLogin(user).then(() => {
+ return cy.wrap(data);
+ });
+ }
+
+ return cy.wrap(data);
+ });
+ });
+});
+
+Cypress.Commands.add('apiCreateAdmin', () => {
+ const {username, password} = getAdminAccount();
+
+ const sysadminUser = {
+ username,
+ password,
+ first_name: 'Kenneth',
+ last_name: 'Moreno',
+ email: 'sysadmin@sample.mattermost.com',
+ };
+
+ const options = {
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ url: '/api/v4/users',
+ body: sysadminUser,
+ };
+
+ // # Create a new user
+ return cy.request(options).then((res) => {
+ expect(res.status).to.equal(201);
+
+ return cy.wrap({sysadmin: {...res.body, password}});
+ });
+});
+
+function generateRandomUser(prefix = 'user', createAt = 0) {
+ const randomId = getRandomId();
+
+ return {
+ email: `${prefix}${randomId}@sample.mattermost.com`,
+ username: `${prefix}${randomId}`,
+ password: 'passwd',
+ first_name: `First${randomId}`,
+ last_name: `Last${randomId}`,
+ nickname: `Nickname${randomId}`,
+ create_at: createAt,
+ };
+}
+
+Cypress.Commands.add('apiCreateUser', ({
+ prefix = 'user',
+ createAt = 0,
+ bypassTutorial = true,
+ hideActionsMenu = true,
+ hideOnboarding = true,
+ bypassWhatsNewModal = true,
+ user = null,
+} = {}) => {
+ const newUser = user || generateRandomUser(prefix, createAt);
+
+ const createUserOption = {
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ url: '/api/v4/users',
+ body: newUser,
+ };
+
+ return cy.request(createUserOption).then((userRes) => {
+ expect(userRes.status).to.equal(201);
+
+ const createdUser = userRes.body;
+
+ // hide the onboarding task list by default so it doesn't block the execution of subsequent tests
+ cy.apiSaveSkipStepsPreference(createdUser.id, 'true');
+ cy.apiSaveOnboardingTaskListPreference(createdUser.id, 'onboarding_task_list_open', 'false');
+ cy.apiSaveOnboardingTaskListPreference(createdUser.id, 'onboarding_task_list_show', 'false');
+
+ // hide drafts tour tip so it doesn't block the execution of subsequent tests
+ cy.apiSaveDraftsTourTipPreference(createdUser.id, true);
+
+ if (bypassTutorial) {
+ cy.apiDisableTutorials(createdUser.id);
+ }
+
+ if (hideActionsMenu) {
+ cy.apiSaveActionsMenuPreference(createdUser.id, true);
+ }
+
+ if (hideOnboarding) {
+ cy.apiSaveOnboardingPreference(createdUser.id, 'hide', 'true');
+ cy.apiSaveOnboardingPreference(createdUser.id, 'skip', 'true');
+ }
+
+ if (bypassWhatsNewModal) {
+ cy.apiHideSidebarWhatsNewModalPreference(createdUser.id, 'false');
+ }
+
+ return cy.wrap({user: {...createdUser, password: newUser.password}});
+ });
+});
+
+Cypress.Commands.add('apiCreateGuestUser', ({
+ prefix = 'guest',
+ bypassTutorial = true,
+} = {}) => {
+ return cy.apiCreateUser({prefix, bypassTutorial}).then(({user}) => {
+ cy.apiDemoteUserToGuest(user.id);
+
+ return cy.wrap({guest: user});
+ });
+});
+
+/**
+ * Revoke all active sessions for a user
+ * @param {String} userId - ID of user to revoke sessions
+ */
+Cypress.Commands.add('apiRevokeUserSessions', (userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/sessions/revoke/all`,
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({data: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetUsers', (queryParams = {}) => {
+ const queryString = buildQueryString(queryParams);
+
+ return cy.request({
+ method: 'GET',
+ url: `/api/v4/users?${queryString}`,
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({users: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGetUsersNotInTeam', ({teamId, page = 0, perPage = 60} = {}) => {
+ return cy.apiGetUsers({not_in_team: teamId, page, per_page: perPage});
+});
+
+Cypress.Commands.add('apiPatchUserRoles', (userId, roleNames = ['system_user']) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/roles`,
+ method: 'PUT',
+ body: {roles: roleNames.join(' ')},
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({user: response.body});
+ });
+});
+
+Cypress.Commands.add('apiDeactivateUser', (userId) => {
+ const options = {
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'DELETE',
+ url: `/api/v4/users/${userId}`,
+ };
+
+ // # Deactivate a user account
+ return cy.request(options).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiActivateUser', (userId) => {
+ const options = {
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'PUT',
+ url: `/api/v4/users/${userId}/active`,
+ body: {
+ active: true,
+ },
+ };
+
+ // # Activate a user account
+ return cy.request(options).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiDemoteUserToGuest', (userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/demote`,
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.apiGetUserById(userId).then(({user}) => {
+ return cy.wrap({guest: user});
+ });
+ });
+});
+
+Cypress.Commands.add('apiPromoteGuestToUser', (userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/promote`,
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.apiGetUserById(userId);
+ });
+});
+
+/**
+ * Verify a user email via API
+ * @param {String} userId - ID of user of email to verify
+ */
+Cypress.Commands.add('apiVerifyUserEmailById', (userId) => {
+ const options = {
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ url: `/api/v4/users/${userId}/email/verify/member`,
+ };
+
+ return cy.request(options).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({user: response.body});
+ });
+});
+
+Cypress.Commands.add('apiActivateUserMFA', (userId, activate, token) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/mfa`,
+ method: 'PUT',
+ body: {
+ activate,
+ code: token,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiResetPassword', (userId, currentPass, newPass) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'PUT',
+ url: `/api/v4/users/${userId}/password`,
+ body: {
+ current_password: currentPass,
+ new_password: newPass,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({user: response.body});
+ });
+});
+
+Cypress.Commands.add('apiGenerateMfaSecret', (userId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ url: `/api/v4/users/${userId}/mfa/generate`,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap({code: response.body});
+ });
+});
+
+Cypress.Commands.add('apiAccessToken', (userId, description) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/' + userId + '/tokens',
+ method: 'POST',
+ body: {
+ description,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response.body);
+ });
+});
+
+Cypress.Commands.add('apiRevokeAccessToken', (tokenId) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/users/tokens/revoke',
+ method: 'POST',
+ body: {
+ token_id: tokenId,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiUpdateUserAuth', (userId, authData, password, authService) => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'PUT',
+ url: `/api/v4/users/${userId}/auth`,
+ body: {
+ auth_data: authData,
+ password,
+ auth_service: authService,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+});
+
+Cypress.Commands.add('apiGetTotalUsers', () => {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'GET',
+ url: '/api/v4/users/stats',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response.body.total_users_count);
+ });
+});
+
+export {generateRandomUser};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/webhooks.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/webhooks.d.ts
new file mode 100644
index 00000000000..ae6b6a5255d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/webhooks.d.ts
@@ -0,0 +1,40 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get an incoming webhook given the hook id.
+ * @param {string} hookId - Incoming Webhook GUID
+ * @returns {IncomingWebhook} `out.webhook` as `IncomingWebhook`
+ * @returns {string} `out.status`
+ * @example
+ * cy.apiGetIncomingWebhook('hook-id')
+ */
+ apiGetIncomingWebhook(hookId: string): Chainable>;
+
+ /**
+ * Get an outgoing webhook given the hook id.
+ * @param {string} hookId - Outgoing Webhook GUID
+ * @returns {OutgoingWebhook} `out.webhook` as `OutgoingWebhook`
+ * @returns {string} `out.status`
+ * @example
+ * cy.apiGetOutgoingWebhook('hook-id')
+ */
+ apiGetOutgoingWebhook(hookId: string): Chainable>;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/webhooks.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/webhooks.js
new file mode 100644
index 00000000000..1c60230e3b5
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api/webhooks.js
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// *****************************************************************************
+// Webhooks
+// https://api.mattermost.com/#tag/webhooks
+// *****************************************************************************
+
+Cypress.Commands.add('apiGetIncomingWebhook', (hookId) => {
+ const options = {
+ url: `api/v4/hooks/incoming/${hookId}`,
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'GET',
+ failOnStatusCode: false,
+ };
+
+ return cy.request(options).then((response) => {
+ const {body, status} = response;
+ return cy.wrap({webhook: body, status});
+ });
+});
+
+Cypress.Commands.add('apiGetOutgoingWebhook', (hookId) => {
+ const options = {
+ url: `api/v4/hooks/outgoing/${hookId}`,
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'GET',
+ failOnStatusCode: false,
+ };
+
+ return cy.request(options).then((response) => {
+ const {body, status} = response;
+ return cy.wrap({webhook: body, status});
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api_commands.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api_commands.ts
new file mode 100644
index 00000000000..8b5b59f7d31
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/api_commands.ts
@@ -0,0 +1,584 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ChainableT, ResponseT} from 'tests/types';
+
+import {getAdminAccount, User} from './env';
+
+// *****************************************************************************
+// Read more:
+// - https://on.cypress.io/custom-commands on writing Cypress commands
+// - https://api.mattermost.com/ for Mattermost API reference
+// *****************************************************************************
+
+// *****************************************************************************
+// Commands
+// https://api.mattermost.com/#tag/commands
+// *****************************************************************************
+
+type CypressResponseAny = Cypress.Response
+function apiCreateCommand(command: Record = {}): Cypress.Chainable<{data: CypressResponseAny['body']; status: CypressResponseAny['status']}> {
+ const options = {
+ url: '/api/v4/commands',
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ body: command,
+ };
+
+ return cy.request(options).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap({data: response.body, status: response.status});
+ });
+}
+
+Cypress.Commands.add('apiCreateCommand', apiCreateCommand);
+
+// *****************************************************************************
+// Email
+// *****************************************************************************
+function apiEmailTest(): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/email/test',
+ method: 'POST',
+ }).then((response) => {
+ expect(response.status, 'SMTP not setup at sysadmin config').to.equal(200);
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiEmailTest', apiEmailTest);
+
+// *****************************************************************************
+// Posts
+// https://api.mattermost.com/#tag/posts
+// *****************************************************************************
+
+function apiCreatePost(channelId: string, message: string, rootId: string, props: Record, token = '', failOnStatusCode = true): ResponseT {
+ const headers: Record = {'X-Requested-With': 'XMLHttpRequest'};
+ if (token !== '') {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ return cy.request({
+ headers,
+ failOnStatusCode,
+ url: '/api/v4/posts',
+ method: 'POST',
+ body: {
+ channel_id: channelId,
+ root_id: rootId,
+ message,
+ props,
+ },
+ });
+}
+
+Cypress.Commands.add('apiCreatePost', apiCreatePost);
+
+function apiDeletePost(postId: string, user: User = getAdminAccount()): Cypress.Chainable<{status: number}> {
+ return cy.externalRequest({
+ user,
+ method: 'delete',
+ path: `posts/${postId}`,
+ }).then((response) => {
+ // * Validate that request was successful
+ expect(response.status).to.equal(200);
+ return cy.wrap({status: response.status});
+ });
+}
+Cypress.Commands.add('apiDeletePost', apiDeletePost);
+
+function apiCreateToken(userId: string): Cypress.Chainable<{token: string}> {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/users/${userId}/tokens`,
+ method: 'POST',
+ body: {
+ description: 'some text',
+ },
+ }).then((response) => {
+ // * Validate that request was successful
+ expect(response.status).to.equal(200);
+ return cy.wrap({token: response.body.token});
+ });
+}
+Cypress.Commands.add('apiCreateToken', apiCreateToken);
+
+/**
+ * Unpins pinned posts of given postID directly via API
+ * This API assume that the user is logged in and has cookie to access
+ */
+function apiUnpinPosts(postId: string): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/posts/' + postId + '/unpin',
+ method: 'POST',
+ });
+}
+Cypress.Commands.add('apiUnpinPosts', apiUnpinPosts);
+
+// *****************************************************************************
+// Webhooks
+// https://api.mattermost.com/#tag/webhooks
+// *****************************************************************************
+
+function apiCreateWebhook(hook: Record = {}, isIncoming = true): ChainableT<{data: CypressResponseAny['body']; url: string}> {
+ const hookUrl = isIncoming ? '/api/v4/hooks/incoming' : '/api/v4/hooks/outgoing';
+ const options = {
+ url: hookUrl,
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ method: 'POST',
+ body: hook,
+ };
+
+ return cy.request(options).then((response) => {
+ const data = response.body;
+ return cy.wrap(Promise.resolve({...data, url: isIncoming ? `${Cypress.config().baseUrl}/hooks/${data.id}` : ''}));
+ });
+}
+
+Cypress.Commands.add('apiCreateWebhook', apiCreateWebhook);
+
+function apiGetTeam(teamId: string): ChainableT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `api/v4/teams/${teamId}`,
+ method: 'GET',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiGetTeam', apiGetTeam);
+
+function removeUserFromChannel(channelId: string, userId: string): ReturnType {
+ const admin = getAdminAccount();
+
+ return cy.externalRequest({user: admin, method: 'delete', path: `channels/${channelId}/members/${userId}`});
+}
+Cypress.Commands.add('removeUserFromChannel', removeUserFromChannel);
+
+function removeUserFromTeam(teamId: string, userId: string): ReturnType {
+ const admin = getAdminAccount();
+
+ return cy.externalRequest({user: admin, method: 'delete', path: `teams/${teamId}/members/${userId}`});
+}
+Cypress.Commands.add('removeUserFromTeam', removeUserFromTeam);
+
+interface LDAPSyncResponse {
+ status: number;
+ body: Array<{status: string; last_activity_at: number}>;
+}
+
+function apiGetLDAPSync(): Cypress.Chainable {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: '/api/v4/jobs/type/ldap_sync?page=0&per_page=50',
+ method: 'GET',
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiGetLDAPSync', apiGetLDAPSync);
+
+// *****************************************************************************
+// Groups
+// https://api.mattermost.com/#tag/groups
+// *****************************************************************************
+function apiGetGroups(page = 0, perPage = 100): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/groups?page=${page}&per_page=${perPage}`,
+ method: 'GET',
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiGetGroups', apiGetGroups);
+
+function apiPatchGroup(groupID: string, patch: Record): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/groups/${groupID}/patch`,
+ method: 'PUT',
+ timeout: 60000,
+ body: patch,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiPatchGroup', apiPatchGroup);
+
+function apiGetLDAPGroups(page = 0, perPage = 100): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/ldap/groups?page=${page}&per_page=${perPage}`,
+ method: 'GET',
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+
+Cypress.Commands.add('apiGetLDAPGroups', apiGetLDAPGroups);
+
+function apiAddLDAPGroupLink(remoteId: string) {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/ldap/groups/${remoteId}/link`,
+ method: 'POST',
+ timeout: 60000,
+ }).then((response) => {
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiAddLDAPGroupLink', apiAddLDAPGroupLink);
+
+function apiGetTeamGroups(teamId: string) {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/teams/${teamId}/groups`,
+ method: 'GET',
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiGetTeamGroups', apiGetTeamGroups);
+
+function apiDeleteLinkFromTeamToGroup(groupId: string, teamId: string): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/groups/${groupId}/teams/${teamId}/link`,
+ method: 'DELETE',
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('apiDeleteLinkFromTeamToGroup', apiDeleteLinkFromTeamToGroup);
+
+function apiLinkGroup(groupID: string): ResponseT {
+ return linkUnlinkGroup(groupID, 'POST');
+}
+Cypress.Commands.add('apiLinkGroup', apiLinkGroup);
+
+function apiUnlinkGroup(groupID: string): ResponseT {
+ return linkUnlinkGroup(groupID, 'DELETE');
+}
+Cypress.Commands.add('apiUnlinkGroup', apiUnlinkGroup);
+
+function linkUnlinkGroup(groupID: string, httpMethod: string): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/ldap/groups/${groupID}/link`,
+ method: httpMethod,
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.be.oneOf([200, 201, 204]);
+ return cy.wrap(response);
+ });
+}
+
+function apiGetGroupTeams(groupID: string): ResponseT {
+ return getGroupSyncables(groupID, 'team');
+}
+Cypress.Commands.add('apiGetGroupTeams', apiGetGroupTeams);
+
+function apiGetGroupTeam(groupID: string, teamID: string): ResponseT {
+ return getGroupSyncable(groupID, 'team', teamID);
+}
+Cypress.Commands.add('apiGetGroupTeam', apiGetGroupTeam);
+
+function apiGetGroupChannels(groupID: string): ResponseT {
+ return getGroupSyncables(groupID, 'channel');
+}
+Cypress.Commands.add('apiGetGroupChannels', apiGetGroupChannels);
+
+function apiGetGroupChannel(groupID: string, channelID: string): ResponseT {
+ return getGroupSyncable(groupID, 'channel', channelID);
+}
+Cypress.Commands.add('apiGetGroupChannel', apiGetGroupChannel);
+
+function getGroupSyncable(groupID: string, syncableType: string, syncableID: string): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/groups/${groupID}/${syncableType}s/${syncableID}`,
+ method: 'GET',
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+
+function getGroupSyncables(groupID: string, syncableType: string): ResponseT {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/groups/${groupID}/${syncableType}s?page=0&per_page=100`,
+ method: 'GET',
+ timeout: 60000,
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ return cy.wrap(response);
+ });
+}
+
+function apiUnlinkGroupTeam(groupID: string, teamID: string): ResponseT {
+ return linkUnlinkGroupSyncable(groupID, teamID, 'team', 'DELETE');
+}
+Cypress.Commands.add('apiUnlinkGroupTeam', apiUnlinkGroupTeam);
+
+function apiLinkGroupTeam(groupID: string, teamID: string): ResponseT {
+ return linkUnlinkGroupSyncable(groupID, teamID, 'team', 'POST');
+}
+Cypress.Commands.add('apiLinkGroupTeam', apiLinkGroupTeam);
+
+function apiUnlinkGroupChannel(groupID: string, channelID: string): ResponseT {
+ return linkUnlinkGroupSyncable(groupID, channelID, 'channel', 'DELETE');
+}
+Cypress.Commands.add('apiUnlinkGroupChannel', apiUnlinkGroupChannel);
+
+function apiLinkGroupChannel(groupID: string, channelID: string): ResponseT {
+ return linkUnlinkGroupSyncable(groupID, channelID, 'channel', 'POST');
+}
+Cypress.Commands.add('apiLinkGroupChannel', apiLinkGroupChannel);
+
+function simulateSubscription(subscription, withLimits = true) {
+ cy.intercept('GET', '**/api/v4/cloud/subscription', {
+ statusCode: 200,
+ body: subscription,
+ });
+
+ cy.intercept('GET', '**/api/v4/cloud/products**', {
+ statusCode: 200,
+ body: [
+ {
+ id: 'prod_1',
+ sku: 'cloud-starter',
+ price_per_seat: 0,
+ recurring_interval: 'month',
+ name: 'Cloud Free',
+ cross_sells_to: '',
+ },
+ {
+ id: 'prod_2',
+ sku: 'cloud-professional',
+ price_per_seat: 10,
+ recurring_interval: 'month',
+ name: 'Cloud Professional',
+ cross_sells_to: 'prod_4',
+ },
+ {
+ id: 'prod_3',
+ sku: 'cloud-enterprise',
+ price_per_seat: 30,
+ recurring_interval: 'month',
+ name: 'Cloud Enterprise',
+ cross_sells_to: 'prod_5',
+ },
+ {
+ id: 'prod_4',
+ sku: 'cloud-professional',
+ price_per_seat: 96,
+ recurring_interval: 'year',
+ name: 'Cloud Professional Yearly',
+ cross_sells_to: 'prod_2',
+ },
+ {
+ id: 'prod_5',
+ sku: 'cloud-enterprise',
+ price_per_seat: 96,
+ recurring_interval: 'year',
+ name: 'Cloud Enterprise Yearly',
+ cross_sells_to: 'prod_3',
+ },
+ ],
+ });
+
+ if (withLimits) {
+ cy.intercept('GET', '**/api/v4/cloud/limits', {
+ statusCode: 200,
+ body: {
+ messages: {
+ history: 10000,
+ },
+ },
+ });
+ }
+}
+
+Cypress.Commands.add('simulateSubscription', simulateSubscription);
+
+function linkUnlinkGroupSyncable(groupID: string, syncableID: string, syncableType: string, httpMethod: string) {
+ return cy.request({
+ headers: {'X-Requested-With': 'XMLHttpRequest'},
+ url: `/api/v4/groups/${groupID}/${syncableType}s/${syncableID}/link`,
+ method: httpMethod,
+ body: {auto_add: true},
+ }).then((response) => {
+ expect(response.status).to.be.oneOf([200, 201, 204]);
+ return cy.wrap(response);
+ });
+}
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get LDAP Group Sync Job Status
+ *
+ * @example
+ * cy.apiGetLDAPSync().then((response) => {
+ */
+ apiGetLDAPSync: typeof apiGetLDAPSync;
+
+ /**
+ * Test SMTP setup
+ */
+ apiEmailTest: typeof apiEmailTest;
+
+ /**
+ * Creates a post directly via API
+ * This API assume that the user is logged in and has cookie to access
+ * @param {String} channelId - Where to post
+ * @param {String} message - What to post
+ * @param {String} rootId - Parent post ID. Set to "" to avoid nesting
+ * @param {Object} props - Post props
+ * @param {String} token - Optional token to use for auth. If not provided - posts as current user
+ */
+ apiCreatePost: typeof apiCreatePost;
+
+ /**
+ * Deletes a post directly via API
+ * @param {String} postId - Post ID
+ * @param {Object} [user] - the user trying to invoke the API
+ */
+ apiDeletePost: typeof apiDeletePost;
+
+ /**
+ * Creates a post directly via API
+ * This API assume that the user is logged in as admin
+ * @param {String} userId - user for whom to create the token
+ */
+ apiCreateToken: typeof apiCreateToken;
+
+ /**
+ * Unpins pinned posts of given postID directly via API
+ * This API assume that the user is logged in and has cookie to access
+ */
+ apiUnpinPosts: typeof apiUnpinPosts;
+
+ /**
+ * Creates a command directly via API
+ * This API assume that the user is logged in and has required permission to create a command
+ * @param {Object} command - command to be created
+ */
+ apiCreateCommand: typeof apiCreateCommand;
+
+ apiCreateWebhook: typeof apiCreateWebhook;
+
+ /**
+ * Gets a team on the system
+ * * @param {String} teamId - The team ID to get
+ * All parameter required
+ */
+ apiGetTeam: typeof apiGetTeam;
+
+ /**
+ * Remove a User from a Channel directly via API
+ * @param {String} channelId - The channel ID
+ * @param {String} userId - The user ID
+ * All parameter required
+ */
+ removeUserFromChannel: typeof removeUserFromChannel;
+
+ /**
+ * Remove a User from a Team directly via API
+ * @param {String} teamID - The team ID
+ * @param {String} userId - The user ID
+ * All parameter required
+ */
+ removeUserFromTeam: typeof removeUserFromTeam;
+
+ /**
+ * Get all groups via the API
+ *
+ * @param {Integer} page - The desired page of the paginated list
+ * @param {Integer} perPage - The number of groups per page
+ *
+ */
+ apiGetGroups: typeof apiGetGroups;
+
+ /**
+ * Patch a group directly via API
+ *
+ * @param {String} name - The new name for the group
+ * @param {Object} patch
+ * {Boolean} allow_reference - Whether to allow reference (group mention) or not - true/false
+ * {String} name - Name for the group, used for group mentions
+ * {String} display_name - Display name for the group
+ * {String} description - Description for the group
+ *
+ */
+ apiPatchGroup: typeof apiPatchGroup;
+
+ /**
+ * Get all LDAP groups via API
+ * @param {Integer} page - The page to select
+ * @param {Integer} perPage - The number of groups per page
+ */
+ apiGetLDAPGroups: typeof apiGetLDAPGroups;
+
+ /**
+ * Add a link for LDAP group via API
+ * @param {String} remoteId - remote ID of the group
+ */
+ apiAddLDAPGroupLink: typeof apiAddLDAPGroupLink;
+
+ /**
+ * Retrieve the list of groups associated with a given team via API
+ * @param {String} teamId - Team GUID
+ */
+ apiGetTeamGroups: typeof apiGetTeamGroups;
+
+ /**
+ * Delete a link from a team to a group via API
+ * @param {String} groupId - Group GUID
+ * @param {String} teamId - Team GUID
+ */
+ apiDeleteLinkFromTeamToGroup: typeof apiDeleteLinkFromTeamToGroup;
+
+ apiLinkGroup: typeof apiLinkGroup;
+
+ apiUnlinkGroup: typeof apiUnlinkGroup;
+
+ apiLinkGroupTeam: typeof apiLinkGroupTeam;
+
+ apiUnlinkGroupTeam: typeof apiUnlinkGroupTeam;
+
+ apiUnlinkGroupChannel: typeof apiUnlinkGroupChannel;
+
+ apiLinkGroupChannel: typeof apiLinkGroupChannel;
+
+ apiGetGroupTeams: typeof apiGetGroupTeams;
+
+ apiGetGroupTeam: typeof apiGetGroupTeam;
+
+ apiGetGroupChannels: typeof apiGetGroupChannels;
+
+ apiGetGroupChannel: typeof apiGetGroupChannel;
+
+ simulateSubscription: typeof simulateSubscription;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/assertions.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/assertions.js
new file mode 100644
index 00000000000..81140f020d4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/assertions.js
@@ -0,0 +1,26 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// Asserts that an item in the channel sidebar is not unread.
+export function beRead(items) {
+ expect(items).to.have.length(1);
+ expect(items[0].className).to.not.match(/unread-title/);
+}
+
+// Asserts that an item in the channel sidebar is read.
+export function beUnread(items) {
+ expect(items).to.have.length(1);
+ expect(items[0].className).to.match(/unread-title/);
+}
+
+// Asserts that an item in the channel sidebar is muted.
+export function beMuted(items) {
+ expect(items).to.have.length(1);
+ expect(items[0].className).to.match(/muted/);
+}
+
+// Asserts that an item in the channel sidebar is unmuted.
+export function beUnmuted(items) {
+ expect(items).to.have.length(1);
+ expect(items[0].className).to.not.match(/muted/);
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client-impl.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client-impl.js
new file mode 100644
index 00000000000..248f934cb7f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client-impl.js
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Client4} from '@mattermost/client';
+
+import clientRequest from '../plugins/client_request';
+
+export class E2EClient extends Client4 {
+ async doFetchWithResponse(url, options) {
+ const {
+ body,
+ headers,
+ method,
+ } = this.getOptions(options);
+
+ let data;
+ if (body) {
+ data = JSON.parse(body);
+ }
+
+ const response = await clientRequest({
+ headers,
+ url,
+ method,
+ data,
+ });
+
+ if (url.endsWith('/api/v4/users/login')) {
+ this.setToken(response.headers.token);
+ this.setUserId(response.data.id);
+ this.setUserRoles(response.data.roles);
+ }
+ return response;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client.d.ts
new file mode 100644
index 00000000000..e639b79fd1a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client.d.ts
@@ -0,0 +1,22 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Specific link to https://api.mattermost.com
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `api` prefix, e.g. `apiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+ makeClient(options?: {user: Pick}): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client.js
new file mode 100644
index 00000000000..6302526e2b8
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/client.js
@@ -0,0 +1,30 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getAdminAccount} from './env';
+
+import {E2EClient} from './client-impl';
+
+const clients = {};
+
+async function makeClient({user = getAdminAccount(), useCache = true} = {}) {
+ const cacheKey = user.username + user.password;
+ if (useCache && clients[cacheKey] != null) {
+ return clients[cacheKey];
+ }
+
+ const client = new E2EClient();
+
+ const baseUrl = Cypress.config('baseUrl');
+ client.setUrl(baseUrl);
+
+ await client.login(user.username, user.password);
+
+ if (useCache) {
+ clients[cacheKey] = client;
+ }
+
+ return client;
+}
+
+Cypress.Commands.add('makeClient', makeClient);
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/common_login_commands.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/common_login_commands.d.ts
new file mode 100644
index 00000000000..fd0be422722
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/common_login_commands.d.ts
@@ -0,0 +1,25 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * checkForLDAPError verifies that an LDAP error is displayed.
+ * @returns {boolean} - true if error successfully found.
+ */
+ checkForLDAPError(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/common_login_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/common_login_commands.js
new file mode 100644
index 00000000000..8cf8efc7dd0
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/common_login_commands.js
@@ -0,0 +1,129 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../fixtures/timeouts';
+
+Cypress.Commands.add('checkLoginPage', (settings = {}) => {
+ // # Remove autofocus from login input
+ cy.get('.login-body-card-content').should('be.visible').focus();
+
+ // * Check elements in the body
+ cy.get('#input_loginId', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').and(($loginTextbox) => {
+ const placeholder = $loginTextbox[0].placeholder;
+ expect(placeholder).to.match(/Email/);
+ expect(placeholder).to.match(/Username/);
+ }).focus();
+
+ cy.get('#input_password-input').should('be.visible').and('have.attr', 'placeholder', 'Password');
+ cy.get('#saveSetting').should('be.visible');
+
+ // * Check the title
+ cy.title().should('include', settings.siteName);
+});
+
+Cypress.Commands.add('checkLoginFailed', () => {
+ // * Check the alert banner
+ cy.get('.AlertBanner.danger', {timeout: TIMEOUTS.ONE_MIN}).then(() => {
+ // * Check the login input in error
+ cy.get('.login-body-card-form-input .Input_fieldset').should('have.class', 'Input_fieldset___error');
+
+ // * Check the password input in error
+ cy.get('.login-body-card-form-password-input.Input_fieldset').should('have.class', 'Input_fieldset___error');
+
+ // * Check the Log in button enabled
+ cy.get('#saveSetting').should('not.be.disabled');
+ });
+});
+
+Cypress.Commands.add('checkGuestNoChannels', () => {
+ cy.findByText('Your guest account has no channels assigned. Please contact an administrator.').should('be.visible');
+});
+
+Cypress.Commands.add('checkMemberNoChannels', () => {
+ cy.findByText('No teams are available to join. Please create a new team or ask your administrator for an invite.').should('be.visible');
+});
+
+Cypress.Commands.add('checkLeftSideBar', (settings = {}) => {
+ if (settings.teamName != null && settings.teamName.length > 0) {
+ cy.uiGetLHSHeader().should('contain', settings.teamName);
+ }
+
+ if (settings.user.username.length > 0) {
+ // * Verify username info
+ cy.uiOpenUserMenu().findByText(`@${settings.user.username}`);
+
+ // # Close status menu
+ cy.uiGetSetStatusButton().click();
+ }
+
+ if (settings.user.userType === 'Admin' || settings.user.isAdmin) {
+ // # Check that user is an admin
+ cy.uiOpenProductMenu().findByText('System Console');
+ } else {
+ // # Check that user is not an admin
+ cy.uiOpenProductMenu().findByText('System Console').should('not.exist');
+ }
+
+ // # Close product switch menu
+ cy.uiGetProductMenuButton().click();
+
+ cy.get('#channel_view').should('be.visible');
+});
+
+Cypress.Commands.add('checkInvitePeoplePage', (settings = {}) => {
+ cy.findByText('Copy invite link', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
+ if (settings.teamName != null && settings.teamName.length > 0) {
+ const inviteRegexp = new RegExp(`Invite .* to ${settings.teamName}`);
+ cy.findByText(inviteRegexp).should('be.visible');
+ }
+});
+
+Cypress.Commands.add('checkInvitePeopleAdminPage', (settings = {}) => {
+ cy.findByText('Members', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
+ cy.findByText('Guests').should('be.visible');
+ if (settings.teamName != null && settings.teamName.length > 0) {
+ cy.findByText('Invite people to ' + settings.teamName).should('be.visible');
+ }
+});
+
+Cypress.Commands.add('doLogoutFromSignUp', () => {
+ cy.checkGuestNoChannels();
+ cy.findByText('Logout').should('be.visible').click();
+});
+
+Cypress.Commands.add('doMemberLogoutFromSignUp', () => {
+ cy.checkMemberNoChannels();
+ cy.findByText('Logout').should('be.visible').click();
+});
+
+Cypress.Commands.add('skipOrCreateTeam', (settings, userId) => {
+ cy.wait(TIMEOUTS.FIVE_SEC);
+ return cy.get('body').then((body) => {
+ let teamName = '';
+
+ // # Create a team if a user is not member of any team
+ if (body.text().includes('Create a team')) {
+ teamName = 't' + userId.substring(0, 14);
+
+ cy.checkCreateTeamPage(settings);
+
+ cy.get('#createNewTeamLink').scrollIntoView().should('be.visible').click();
+ cy.get('#teamNameInput').should('be.visible').typeWithForce(teamName);
+ cy.findByText('Next').should('be.visible').click();
+ cy.findByText('Finish').should('be.visible').click();
+ }
+
+ return cy.wrap(teamName);
+ });
+});
+
+Cypress.Commands.add('checkForLDAPError', () => {
+ cy.wait(TIMEOUTS.FIVE_SEC);
+ return cy.get('body').then((body) => {
+ if (body.text().includes('User not registered on AD/LDAP server.')) {
+ cy.findByText('Back to Mattermost').should('exist').and('be.visible').click().wait(TIMEOUTS.FIVE_SEC);
+ return cy.wrap(true);
+ }
+ return cy.wrap(false);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/constants.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/constants.js
new file mode 100644
index 00000000000..b385556c6d9
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/constants.js
@@ -0,0 +1,7 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// Default team is meant for sysadmin's primary team,
+// selected for compatibility with existing local development.
+// It should not be used for testing.
+export const DEFAULT_TEAM = {name: 'ad-1', display_name: 'eligendi', type: 'O'};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/db_commands.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/db_commands.ts
new file mode 100644
index 00000000000..05ddac6f469
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/db_commands.ts
@@ -0,0 +1,155 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ChainableT} from '../types';
+
+const dbClient = Cypress.env('dbClient');
+const dbConnection = Cypress.env('dbConnection');
+const dbConfig = {
+ client: dbClient,
+ connection: dbConnection,
+};
+
+const message = `Compare "cypress.json" against "config.json" of mattermost-server. It should match database driver and connection string.
+
+The value at "cypress.json" is based on default mattermost-server's local database:
+{"dbClient": "${dbClient}", "dbConnection": "${dbConnection}"}
+
+If your server is using database other than the default, you may export those as env variables, like:
+"__CYPRESS_dbClient=[dbClient] CYPRESS_dbConnection=[dbConnection] npm run cypress:open__"
+`;
+
+function apiRequireServerDBToMatch(): ChainableT {
+ return cy.apiGetConfig().then(({config}) => {
+ // On Cloud, SqlSettings is not being returned.
+ // With that, checking of server DB will be ignored and will assume it does match with
+ // the one being expected by Cypress.
+ if (config.SqlSettings && config.SqlSettings.DriverName !== dbClient) {
+ expect(config.SqlSettings.DriverName, message).to.equal(dbClient);
+ }
+ });
+}
+Cypress.Commands.add('apiRequireServerDBToMatch', apiRequireServerDBToMatch);
+
+interface GetActiveUserSessionsParam {
+ username: string;
+ userId?: string;
+ limit?: number;
+}
+interface GetActiveUserSessionsResult {
+ user: Cypress.UserProfile;
+ sessions: Array>;
+}
+function dbGetActiveUserSessions(params: GetActiveUserSessionsParam): ChainableT {
+ return cy.task('dbGetActiveUserSessions', {dbConfig, params}).then(({user, sessions, errorMessage}) => {
+ expect(errorMessage).to.be.undefined;
+
+ return cy.wrap({user, sessions});
+ });
+}
+Cypress.Commands.add('dbGetActiveUserSessions', dbGetActiveUserSessions);
+
+interface GetUserParam {
+ username: string;
+}
+interface GetUserResult {
+ user: Cypress.UserProfile;
+}
+function dbGetUser(params: GetUserParam): ChainableT {
+ return cy.task('dbGetUser', {dbConfig, params}).then(({user, errorMessage, error}) => {
+ verifyError(error, errorMessage);
+
+ return cy.wrap({user});
+ });
+}
+Cypress.Commands.add('dbGetUser', dbGetUser);
+
+interface GetUserSessionParam {
+ sessionId: string;
+}
+interface GetUserSessionResult {
+ session: Record;
+}
+function dbGetUserSession(params: GetUserSessionParam): ChainableT {
+ return cy.task('dbGetUserSession', {dbConfig, params}).then(({session, errorMessage}) => {
+ expect(errorMessage).to.be.undefined;
+
+ return cy.wrap({session});
+ });
+}
+Cypress.Commands.add('dbGetUserSession', dbGetUserSession);
+
+interface UpdateUserSessionParam {
+ sessionId: string;
+ userId: string;
+ fieldsToUpdate: Record;
+}
+interface UpdateUserSessionResult {
+ session: Record;
+}
+function dbUpdateUserSession(params: UpdateUserSessionParam): ChainableT {
+ return cy.task('dbUpdateUserSession', {dbConfig, params}).then(({session, errorMessage}) => {
+ expect(errorMessage).to.be.undefined;
+
+ return cy.wrap({session});
+ });
+}
+Cypress.Commands.add('dbUpdateUserSession', dbUpdateUserSession);
+
+function verifyError(error, errorMessage) {
+ if (errorMessage) {
+ expect(errorMessage, `${errorMessage}\n\n${message}\n\n${JSON.stringify(error)}`).to.be.undefined;
+ }
+}
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Gets server config, and assert if it matches with the database connection being used by Cypress
+ *
+ * @example
+ * cy.apiRequireServerDBToMatch();
+ */
+ apiRequireServerDBToMatch: typeof apiRequireServerDBToMatch;
+
+ /**
+ * Gets active sessions of a user on a given username or user ID directly from the database
+ * @param {String} username
+ * @param {String} userId
+ * @param {String} limit - maximum number of active sessions to return, e.g. 50 (default)
+ * @returns {Object} user - user object
+ * @returns {[Object]} sessions - an array of active sessions
+ */
+ dbGetActiveUserSessions: typeof dbGetActiveUserSessions;
+
+ /**
+ * Gets user on a given username directly from the database
+ * @param {Object} options
+ * @param {String} options.username
+ * @returns {UserProfile} user - user object
+ */
+ dbGetUser: typeof dbGetUser;
+
+ /**
+ * Gets session of a user on a given session ID directly from the database
+ * @param {Object} options
+ * @param {String} options.sessionId
+ * @returns {Session} session
+ */
+ dbGetUserSession: typeof dbGetUserSession;
+
+ /**
+ * Updates session of a user on a given user ID and session ID with fields to update directly from the database
+ * @param {Object} options
+ * @param {String} options.sessionId
+ * @param {String} options.userId
+ * @param {Object} options.fieldsToUpdate - will update all except session ID and user ID
+ * @returns {Session} session
+ */
+ dbUpdateUserSession: typeof dbUpdateUserSession;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/email.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/email.ts
new file mode 100644
index 00000000000..c658d447c8d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/email.ts
@@ -0,0 +1,50 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getEmailUrl, splitEmailBodyText} from '../utils';
+
+/**
+* getRecentEmail is a task to get email from email service provider
+* @param {string} username - username of the user
+* @param {string} username - email of the user
+*/
+
+Cypress.Commands.add('getRecentEmail', ({username, email}) => {
+ return cy.task('getRecentEmail', {username, email, mailUrl: getEmailUrl()}).then(({status, data}) => {
+ expect(status).to.equal(200);
+
+ const {to, date, body: {text}} = data;
+
+ // * Verify that email is addressed to a user
+ expect(to.length).to.equal(1);
+ expect(to[0]).to.contain(email);
+
+ // * Verify that date is current
+ const isoDate = new Date().toISOString().substring(0, 10);
+ expect(date).to.contain(isoDate);
+
+ const body = splitEmailBodyText(text);
+ return cy.wrap({...data, body});
+ });
+});
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * getRecentEmail is a task to get an email sent to a user
+ * from the email service provider
+ * @param options.username - username of the user
+ * @param options.email - email of the user
+ *
+ * @example
+ * cy.getRecentEmail().then((data) => {
+ * // do something with the email data/content
+ * });
+ */
+ getRecentEmail(options: Pick): Chainable;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/env.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/env.ts
new file mode 100644
index 00000000000..2880c8f8488
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/env.ts
@@ -0,0 +1,23 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export interface User {
+ username: string;
+ password: string;
+ email: string;
+}
+
+export function getAdminAccount() {
+ return {
+ username: Cypress.env('adminUsername'),
+ password: Cypress.env('adminPassword'),
+ email: Cypress.env('adminEmail'),
+ };
+}
+
+export function getDBConfig() {
+ return {
+ client: Cypress.env('dbClient'),
+ connection: Cypress.env('dbConnection'),
+ };
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/extended_commands.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/extended_commands.d.ts
new file mode 100644
index 00000000000..82c4d2e043e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/extended_commands.d.ts
@@ -0,0 +1,42 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Reload the page, same as cy.reload but extended with explicit wait to allow page to load freely
+ * @param forceReload — Whether to reload the current page without using the cache. true forces the reload without cache.
+ * @param options — Pass in an options object to change the default behavior of cy.reload()
+ * @param duration — wait duration with 3 seconds by default
+ *
+ * @example
+ * cy.reload();
+ */
+ reload(forceReload: boolean, options?: Partial, duration?: number): Chainable;
+
+ /**
+ * Visit the given url, same as cy.visit but extended with explicit wait to allow page to load freely
+ * @param url — The URL to visit. If relative uses baseUrl
+ * @param options — Pass in an options object to change the default behavior of cy.visit()
+ * @param duration — wait duration with 3 seconds by default
+ *
+ * @example
+ * cy.visit('url');
+ */
+ visit(url: string, options?: Partial, duration?: number): Chainable;
+
+ /**
+ * types the given string with `TypeOption.force` set to true
+ *
+ * @param text - the string that should be force-typed
+ * @param [options] - optional TypeOptions object (`force` option is omitted because it is manually set on the command)
+ *
+ * @example
+ * cy.get('#emailInput').typeWithForce('john.doe@example.com');
+ */
+ typeWithForce(text: string, options?: Omit, 'force'>): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/extended_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/extended_commands.js
new file mode 100644
index 00000000000..ca09ac77f1e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/extended_commands.js
@@ -0,0 +1,20 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../fixtures/timeouts';
+
+Cypress.Commands.overwrite('reload', (originalFn, forceReload, options, duration = TIMEOUTS.THREE_SEC) => {
+ localStorage.setItem('__landingPageSeen__', 'true');
+ originalFn(forceReload, options);
+ cy.wait(duration);
+});
+
+Cypress.Commands.overwrite('visit', (originalFn, url, options, duration = TIMEOUTS.THREE_SEC) => {
+ localStorage.setItem('__landingPageSeen__', 'true');
+ originalFn(url, options);
+ cy.wait(duration);
+});
+
+Cypress.Commands.add('typeWithForce', {prevSubject: true}, (subject, text, options = {}) => {
+ cy.get(subject).type(text, {force: true, ...options});
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/external_commands.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/external_commands.d.ts
new file mode 100644
index 00000000000..5b5389ada3c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/external_commands.d.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `external` prefix, e.g. `externalActivateUser`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Makes an external request as a sysadmin and activate/deactivate a user directly via API
+ * @param {String} userId - The user ID
+ * @param {Boolean} active - Whether to activate or deactivate - true/false
+ *
+ * @example
+ * cy.externalActivateUser('user-id', false);
+ */
+ externalActivateUser(userId: string, activate: boolean): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/external_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/external_commands.js
new file mode 100644
index 00000000000..a8b68df64aa
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/external_commands.js
@@ -0,0 +1,11 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getAdminAccount} from './env';
+
+Cypress.Commands.add('externalActivateUser', (userId, active = true) => {
+ const baseUrl = Cypress.config('baseUrl');
+ const admin = getAdminAccount();
+
+ cy.externalRequest({user: admin, method: 'put', baseUrl, path: `users/${userId}/active`, data: {active}});
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/fetch_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/fetch_commands.js
new file mode 100644
index 00000000000..2d6450a5c44
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/fetch_commands.js
@@ -0,0 +1,63 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('delayRequestToRoutes', (routes = [], delay = 0) => {
+ cy.on('window:before:load', (win) => addDelay(win, routes, delay));
+});
+
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+const addDelay = (win, routes, delay) => {
+ const fetch = win.fetch;
+ cy.stub(win, 'fetch').callsFake((...args) => {
+ for (let i = 0; i < routes.length; i++) {
+ if (args[0].includes(routes[i])) {
+ return wait(delay).then(() => fetch(...args));
+ }
+ }
+
+ return fetch(...args);
+ });
+};
+
+// Websocket list to use with mockWebsockets
+window.mockWebsockets = [];
+
+// Wrap websocket to be able to connect and close connections on demand
+Cypress.Commands.add('mockWebsockets', () => {
+ cy.on('window:before:load', (win) => mockWebsockets(win));
+});
+
+const mockWebsockets = (win) => {
+ const RealWebSocket = WebSocket;
+ cy.stub(win, 'WebSocket').callsFake((...args) => {
+ const mockWebSocket = {
+ wrappedSocket: null,
+ onopen: null,
+ onmessage: null,
+ onerror: null,
+ onclose: null,
+ send(data) {
+ if (this.wrappedSocket) {
+ this.wrappedSocket.send(data);
+ } else {
+ onerror();
+ }
+ },
+ close() {
+ if (this.wrappedSocket) {
+ this.wrappedSocket.close(1000);
+ }
+ },
+ connect() {
+ this.wrappedSocket = new RealWebSocket(...args);
+ this.wrappedSocket.onopen = this.onopen;
+ this.wrappedSocket.onmessage = this.onmessage;
+ this.wrappedSocket.onerror = this.onerror;
+ this.wrappedSocket.onclose = this.onclose;
+ },
+ };
+ window.mockWebsockets.push(mockWebSocket);
+ return mockWebSocket;
+ });
+};
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/index.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/index.d.ts
new file mode 100644
index 00000000000..8723d3449ed
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/index.d.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+declare namespace Cypress {
+ type AdminConfig = import('@mattermost/types/config').AdminConfig;
+ type AnalyticsRow = import('@mattermost/types/admin').AnalyticsRow;
+ type Bot = import('@mattermost/types/bots').Bot;
+ type BotPatch = import('@mattermost/types/bots').BotPatch;
+ type Channel = import('@mattermost/types/channels').Channel;
+ type ClusterInfo = import('@mattermost/types/admin').ClusterInfo;
+ type Client = import('./client-impl').E2EClient;
+ type ClientLicense = import('@mattermost/types/config').ClientLicense;
+ type ChannelMembership = import('@mattermost/types/channels').ChannelMembership;
+ type ChannelType = import('@mattermost/types/channels').ChannelType;
+ type IncomingWebhook = import('@mattermost/types/integrations').IncomingWebhook;
+ type OutgoingWebhook = import('@mattermost/types/integrations').OutgoingWebhook;
+ type Permissions = string[];
+ type PluginManifest = import('@mattermost/types/plugins').PluginManifest;
+ type PluginsResponse = import('@mattermost/types/plugins').PluginsResponse;
+ type PreferenceType = import('@mattermost/types/preferences').PreferenceType;
+ type Product = import('@mattermost/types/cloud').Product;
+ type Role = import('@mattermost/types/roles').Role;
+ type Scheme = import('@mattermost/types/schemes').Scheme;
+ type Session = import('@mattermost/types/sessions').Session;
+ type Subscription = import('@mattermost/types/cloud').Subscription;
+ type Team = import('@mattermost/types/teams').Team;
+ type TeamMembership = import('@mattermost/types/teams').TeamMembership;
+ type TermsOfService = import('@mattermost/types/terms_of_service').TermsOfService;
+ type UserProfile = import('@mattermost/types/users').UserProfile;
+ type UserStatus = import('@mattermost/types/users').UserStatus;
+ type UserCustomStatus = import('@mattermost/types/users').UserCustomStatus;
+ type UserAccessToken = import('@mattermost/types/users').UserAccessToken;
+ type DeepPartial = import('@mattermost/types/utilities').DeepPartial;
+ interface Chainable {
+ tab: (options?: {shift?: boolean}) => Chainable;
+ waitForNetworkIdle: (options?: {
+ idleTime?: number;
+ timeout?: number;
+ method?: string;
+ urlPattern?: string | RegExp;
+ }) => Chainable;
+ waitForGraphQLQueries: (options?: {
+ idleTime?: number;
+ timeout?: number;
+ }) => Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/index.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/index.js
new file mode 100644
index 00000000000..6691efef360
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/index.js
@@ -0,0 +1,266 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// ***********************************************************
+// Read more at: https://on.cypress.io/configuration
+// ***********************************************************
+
+/* eslint-disable no-loop-func */
+
+import dayjs from 'dayjs';
+import localforage from 'localforage';
+import 'cypress-real-events';
+
+import '@testing-library/cypress/add-commands';
+import 'cypress-file-upload';
+import 'cypress-wait-until';
+import 'cypress-plugin-tab';
+import addContext from 'mochawesome/addContext';
+
+import './api';
+import './api_commands'; // soon to deprecate
+import './client';
+import './common_login_commands';
+import './db_commands';
+import './email';
+import './external_commands';
+import './extended_commands';
+import './fetch_commands';
+import './keycloak_commands';
+import './ldap_commands';
+import './ldap_server_commands';
+import './network_commands';
+import './okta_commands';
+import './saml_commands';
+import './shell';
+import './task_commands';
+import './ui';
+import './ui_commands'; // soon to deprecate
+import {DEFAULT_TEAM} from './constants';
+
+import {getDefaultConfig} from './api/system';
+
+Cypress.dayjs = dayjs;
+
+Cypress.on('test:after:run', (test, runnable) => {
+ // Only if the test is failed do we want to add
+ // the additional context of the screenshot.
+ if (test.state === 'failed') {
+ let parentNames = '';
+
+ // Define our starting parent
+ let parent = runnable.parent;
+
+ // If the test failed due to a hook, we have to handle
+ // getting our starting parent to form the correct filename.
+ if (test.failedFromHookId) {
+ // Failed from hook Id is always something like 'h2'
+ // We just need the trailing number to match with parent id
+ const hookId = test.failedFromHookId.split('')[1];
+
+ // If the current parentId does not match our hook id
+ // start digging upwards until we get the parent that
+ // has the same hook id, or until we get to a tile of ''
+ // (which means we are at the top level)
+ if (parent.id !== `r${hookId}`) {
+ while (parent.parent && parent.parent.id !== `r${hookId}`) {
+ if (parent.title === '') {
+ // If we have a title of '' we have reached the top parent
+ break;
+ } else {
+ parent = parent.parent;
+ }
+ }
+ }
+ }
+
+ // Now we can go from parent to parent to generate the screenshot filename
+ while (parent) {
+ // Only append parents that have actual content for their titles
+ if (parent.title !== '') {
+ parentNames = parent.title + ' -- ' + parentNames;
+ }
+
+ parent = parent.parent;
+ }
+
+ // Clean up strings of characters that Cypress strips out
+ const charactersToStrip = /[;:"<>/]/g;
+ parentNames = parentNames.replace(charactersToStrip, '');
+ const testTitle = test.title.replace(charactersToStrip, '');
+
+ // If the test has a hook name, that means it failed due to a hook
+ // and consequently Cypress appends some text to the file name
+ const hookName = test.hookName ? ' -- ' + test.hookName + ' hook' : '';
+
+ const filename = encodeURIComponent(`${parentNames}${testTitle}${hookName} (failed).png`);
+
+ // Add context to the mochawesome report which includes the screenshot
+ addContext({test}, {
+ title: 'Failing Screenshot: >> screenshots/' + Cypress.spec.name + '/' + filename,
+ value: 'screenshots/' + Cypress.spec.name + '/' + filename,
+ });
+ }
+});
+
+// Turn off all uncaught exception handling
+Cypress.on('uncaught:exception', () => {
+ return false;
+});
+
+before(() => {
+ // # Clear localforage state
+ localforage.clear();
+
+ // # Try to login using existing sysadmin account
+ cy.apiAdminLogin({failOnStatusCode: false}).then((response) => {
+ if (response.user) {
+ sysadminSetup(response.user);
+ } else {
+ // # Create and login a newly created user as sysadmin
+ cy.apiCreateAdmin().then(({sysadmin}) => {
+ cy.apiAdminLogin().then(() => sysadminSetup(sysadmin));
+ });
+ }
+
+ switch (Cypress.env('serverEdition')) {
+ case 'Cloud':
+ cy.apiRequireLicenseForFeature('Cloud');
+ break;
+ case 'E20':
+ cy.apiRequireLicense();
+ break;
+ default:
+ break;
+ }
+
+ if (Cypress.env('serverClusterEnabled')) {
+ cy.log('Checking cluster information...');
+
+ // * Ensure cluster is set up properly when enabled
+ cy.shouldHaveClusterEnabled();
+ cy.apiGetClusterStatus().then(({clusterInfo}) => {
+ const sameCount = clusterInfo?.length === Cypress.env('serverClusterHostCount');
+ expect(sameCount, sameCount ? '' : `Should match number of hosts in a cluster as expected. Got "${clusterInfo?.length}" but expected "${Cypress.env('serverClusterHostCount')}"`).to.equal(true);
+
+ clusterInfo.forEach((info) => cy.log(`hostname: ${info.hostname}, version: ${info.version}, config_hash: ${info.config_hash}`));
+ });
+ }
+
+ // Log license status and server details before test
+ printLicenseStatus();
+ printServerDetails();
+ });
+});
+
+beforeEach(() => {
+ // Temporary fix for error related to this.get('prev') being undefined with @testing-library/cypress@9.0.0
+ cy.then(() => null);
+});
+
+function printLicenseStatus() {
+ cy.apiGetClientLicense().then(({license}) => {
+ cy.log(`Server License:
+ - IsLicensed = ${license.IsLicensed}
+ - IsTrial = ${license.IsTrial}
+ - SkuName = ${license.SkuName}
+ - SkuShortName = ${license.SkuShortName}
+ - Cloud = ${license.Cloud}
+ - Users = ${license.Users}`);
+ });
+}
+
+function printServerDetails() {
+ cy.apiGetConfig(true).then(({config}) => {
+ cy.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}`);
+ });
+}
+
+function sysadminSetup(user) {
+ if (Cypress.env('firstTest')) {
+ // Sends dummy call to update the config to server
+ // Without this, first call to `cy.apiUpdateConfig()` consistently getting time out error in CI against remote server.
+ cy.externalRequest({user, method: 'put', path: 'config', data: getDefaultConfig(), failOnStatusCode: false});
+ }
+
+ if (!user.email_verified) {
+ cy.apiVerifyUserEmailById(user.id);
+ }
+
+ // # Reset config to default
+ cy.apiUpdateConfig();
+
+ // # Reset admin preference, online status and locale
+ resetUserPreference(user.id);
+ cy.apiUpdateUserStatus('online');
+ cy.apiPatchMe({
+ locale: 'en',
+ timezone: {automaticTimezone: '', manualTimezone: 'UTC', useAutomaticTimezone: 'false'},
+ });
+
+ // # Reset roles
+ cy.apiGetClientLicense().then(({isLicensed}) => {
+ if (isLicensed) {
+ cy.apiResetRoles();
+ }
+ });
+
+ // # Disable plugins not included in prepackaged
+ cy.apiDisableNonPrepackagedPlugins();
+
+ // # Deactivate test bots if any
+ cy.apiDeactivateTestBots();
+
+ // # Disable welcome tours if any
+ cy.apiDisableTutorials(user.id);
+
+ // # Check if default team is present; create if not found.
+ cy.apiGetTeamsForUser().then(({teams}) => {
+ const defaultTeam = teams && teams.length > 0 && teams.find((team) => team.name === DEFAULT_TEAM.name);
+
+ if (!defaultTeam) {
+ cy.apiCreateTeam(DEFAULT_TEAM.name, DEFAULT_TEAM.display_name, 'O', false);
+ } else if (defaultTeam && Cypress.env('resetBeforeTest')) {
+ teams.forEach((team) => {
+ if (team.name !== DEFAULT_TEAM.name) {
+ cy.apiDeleteTeam(team.id);
+ }
+ });
+
+ cy.apiGetChannelsForUser('me', defaultTeam.id).then(({channels}) => {
+ channels.forEach((channel) => {
+ if (
+ (channel.team_id === defaultTeam.id || channel.team_name === defaultTeam.name) &&
+ (channel.name !== 'town-square' && channel.name !== 'off-topic')
+ ) {
+ cy.apiDeleteChannel(channel.id);
+ }
+ });
+ });
+ }
+ });
+}
+
+function resetUserPreference(userId) {
+ cy.apiSaveTeammateNameDisplayPreference('username');
+ cy.apiSaveLinkPreviewsPreference('true');
+ cy.apiSaveCollapsePreviewsPreference('false');
+ cy.apiSaveClockDisplayModeTo24HourPreference(false);
+ cy.apiSaveTutorialStep(userId, '999');
+ cy.apiSaveOnboardingTaskListPreference(userId, 'onboarding_task_list_open', 'false');
+ cy.apiSaveOnboardingTaskListPreference(userId, 'onboarding_task_list_show', 'false');
+ cy.apiSaveCloudTrialBannerPreference(userId, 'trial', 'max_days_banner');
+ cy.apiSaveActionsMenuPreference(userId);
+ cy.apiSaveSkipStepsPreference(userId, 'true');
+ cy.apiSaveStartTrialModal(userId, 'true');
+ cy.apiSaveUnreadScrollPositionPreference(userId, 'start_from_left_off');
+ cy.apiSaveDraftsTourTipPreference(userId, 'true');
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/keycloak_commands.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/keycloak_commands.d.ts
new file mode 100644
index 00000000000..34c485ba082
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/keycloak_commands.d.ts
@@ -0,0 +1,179 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `keycloak` prefix, e.g. `keycloakActivateUser`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * keycloakGetAccessTokenAPI is a task wrapped as command with post-verification
+ * that an Access Token is successfully retrieved
+ * @returns {string} - access token
+ */
+ keycloakGetAccessTokenAPI(): Chainable;
+
+ /**
+ * keycloakCreateUserAPI is a task wrapped as command with post-verification
+ * that a user is successfully created in keycloak
+ * @param {string} accessToken - a valid access token
+ * @param {object} user - a keycloak user object to create
+ *
+ * @example
+ * cy.keycloakCreateUserAPI('abcde', {firstName: 'test', lastName: 'test', email: 'test', username: 'test', enabled: true,});
+ */
+ keycloakCreateUserAPI(accessToken: string, user: any): Chainable;
+
+ /**
+ * keycloakResetPasswordAPI is a task wrapped as command with post-verification
+ * that a user password is successfully reset in keycloak
+ * @param {string} accessToken - a valid access token
+ * @param {string} userId - a keycloak userId
+ * @param {string} password - new password to set
+ *
+ * @example
+ * cy.keycloakResetPasswordAPI('abcde', '12345', 'password');
+ */
+ keycloakResetPasswordAPI(accessToken: string, userId: string, password: string): Chainable;
+
+ /**
+ * keycloakGetUserAPI is a task wrapped as command with post-verification
+ * that a user is successfully found in keycloak
+ * @param {string} accessToken - a valid access token
+ * @param {string} email - an email to query
+ * @returns {string} - keycloak userId if found
+ *
+ * @example
+ * cy.keycloakGetUserAPI('abcde', 'test@mm.com');
+ */
+ keycloakGetUserAPI(accessToken: string, email: string): Chainable;
+
+ /**
+ * keycloakDeleteUserAPI is a task wrapped as command with post-verification
+ * that a user is successfully deleted in keycloak
+ * @param {string} accessToken - a valid access token
+ * @param {string} userId - keycloak user id to delete
+ *
+ * @example
+ * cy.keycloakDeleteUserAPI('abcde', '12345');
+ */
+ keycloakDeleteUserAPI(accessToken: string, userId: string): Chainable;
+
+ /**
+ * keycloakUpdateUserAPI is a task wrapped as command with post-verification
+ * that a user is successfully updated in keycloak
+ * @param {string} accessToken - a valid access token
+ * @param {string} userId - keycloak user id to delete
+ * @param {object} data - keycloak user object
+ *
+ * @example
+ * cy.keycloakUpdateUserAPI('abcde', '12345', {'enabled': false}});
+ */
+ keycloakUpdateUserAPI(accessToken: string, userId: string, data: any): Chainable;
+
+ /**
+ * keycloakDeleteSessionAPI is a task wrapped as command with post-verification
+ * that a users session is successfully deleted in keycloak
+ * @param {string} accessToken - a valid access token
+ * @param {string} sessionId- keycloak session id to delete
+ *
+ * @example
+ * cy.keycloakDeleteSessionAPI('abcde', '12345');
+ */
+ keycloakDeleteSessionAPI(accessToken: string, sessionId: string): Chainable;
+
+ /**
+ * keycloakGetUserSessionsAPI is a task wrapped as command with post-verification
+ * that a users sessions are successfully found
+ * @param {string} accessToken - a valid access token
+ * @param {string} userId - keycloak user id to find sessions
+ * @returns {string[]} - array of keycloak session ids
+ *
+ * @example
+ * cy.keycloakGetUserSessionsAPI('abcde', '12345');
+ */
+ keycloakGetUserSessionsAPI(accessToken: string, userId: string): Chainable ;
+
+ /**
+ * keycloakDeleteUserSessions is a command that finds a user's sessions
+ * and deletes them.
+ * @param {string} accessToken - a valid access token
+ * @param {string} userId- keycloak user id to delete sessions
+ *
+ * @example
+ * cy.keycloakDeleteUserSessions('abcde', '12345');
+ */
+ keycloakDeleteUserSessions(accessToken: string, userId: string): Chainable;
+
+ /**
+ * keycloakResetUsers is a command that "resets" (deletes and re-creates) the users.
+ * @param {object[]} users - an array of user objects
+ *
+ * @example
+ * cy.keycloakResetUsers([{firstName: 'test', lastName: 'test', email: 'test', username: 'test', enabled: true}]);
+ */
+ keycloakResetUsers(users: any[]): Chainable;
+
+ /**
+ * keycloakCreateUser is a command that creates a keycloak user.
+ * @param {User} user - a user object
+ *
+ * @example
+ * cy.keycloakCreateUser({firstName: 'test', lastName: 'test', email: 'test', username: 'test', enabled: true});
+ */
+ keycloakCreateUser(user: any): Chainable;
+
+ /**
+ * keycloakSuspendUser is a command that suspends a user (enabled=false)
+ * @param {string} userEmail - email of keycloak user
+ *
+ * @example
+ * cy.keycloakSuspendUser('user@test.com');
+ */
+ keycloakSuspendUser(userEmail: string): Chainable;
+
+ /**
+ * keycloakUnsuspendUser is a command that re-activates a user (enabled=true)
+ * @param {string} userEmail - email of keycloak user
+ *
+ * @example
+ * cy.keycloakUnsuspendUser('user@test.com');
+ */
+ keycloakUnsuspendUser(userEmail: string): Chainable;
+
+ /**
+ * checkKeycloakLoginPage is a command that verifies the keycloak login page is displayed
+ *
+ * @example
+ * cy.checkKeycloakLoginPage();
+ */
+ checkKeycloakLoginPage(): Chainable;
+
+ /**
+ * doKeycloakLogin is a command that attempts to log a user into keycloak.
+ *
+ * @example
+ * cy.doKeycloakLogin();
+ */
+ doKeycloakLogin(user): Chainable;
+
+ /**
+ * verifyKeycloakLoginFailed is a command that verifies a keycloak login failed.
+ *
+ * @example
+ * cy.verifyKeycloakLoginFailed();
+ */
+ verifyKeycloakLoginFailed(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/keycloak_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/keycloak_commands.js
new file mode 100644
index 00000000000..41beba58efa
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/keycloak_commands.js
@@ -0,0 +1,236 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../fixtures/timeouts';
+
+const {
+ keycloakBaseUrl,
+ keycloakAppName,
+} = Cypress.env();
+
+const baseUrl = `${keycloakBaseUrl}/auth/admin/realms/${keycloakAppName}`;
+const loginUrl = `${keycloakBaseUrl}/auth/realms/master/protocol/openid-connect/token`;
+
+function buildProfile(user) {
+ return {
+ firstName: user.firstname,
+ lastName: user.lastname,
+ email: user.email,
+ username: user.username,
+ enabled: true,
+ };
+}
+
+Cypress.Commands.add('keycloakGetAccessTokenAPI', () => {
+ return cy.task('keycloakRequest', {
+ baseUrl: loginUrl,
+ path: '',
+ method: 'post',
+ headers: {'Content-type': 'application/x-www-form-urlencoded'},
+ data: 'grant_type=password&username=mmuser&password=mostest&client_id=admin-cli',
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ const token = response.data.access_token;
+ return cy.wrap(token);
+ });
+});
+
+Cypress.Commands.add('keycloakCreateUserAPI', (accessToken, user = {}) => {
+ const profile = buildProfile(user);
+ return cy.task('keycloakRequest', {
+ baseUrl,
+ path: 'users',
+ method: 'post',
+ data: profile,
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ });
+});
+
+Cypress.Commands.add('keycloakResetPasswordAPI', (accessToken, userId, password) => {
+ return cy.task('keycloakRequest', {
+ baseUrl,
+ path: `users/${userId}/reset-password`,
+ method: 'put',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ data: {type: 'password', temporary: false, value: password},
+ }).then((response) => {
+ if (response.status === 200 && response.data.length > 0) {
+ return cy.wrap(response.data[0].id);
+ }
+ return null;
+ });
+});
+
+Cypress.Commands.add('keycloakGetUserAPI', (accessToken, email) => {
+ return cy.task('keycloakRequest', {
+ baseUrl,
+ path: 'users?email=' + email,
+ method: 'get',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }).then((response) => {
+ if (response.status === 200 && response.data.length > 0) {
+ return cy.wrap(response.data[0].id);
+ }
+ return null;
+ });
+});
+
+Cypress.Commands.add('keycloakDeleteUserAPI', (accessToken, userId) => {
+ return cy.task('keycloakRequest', {
+ baseUrl,
+ path: `users/${userId}`,
+ method: 'delete',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(204);
+ expect(response.data).is.empty;
+ });
+});
+
+Cypress.Commands.add('keycloakUpdateUserAPI', (accessToken, userId, data) => {
+ return cy.task('keycloakRequest', {
+ baseUrl,
+ path: 'users/' + userId,
+ method: 'put',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ data,
+ }).then((response) => {
+ expect(response.status).to.equal(204);
+ expect(response.data).is.empty;
+ });
+});
+
+Cypress.Commands.add('keycloakDeleteSessionAPI', (accessToken, sessionId) => {
+ return cy.task('keycloakRequest', {
+ baseUrl,
+ path: `sessions/${sessionId}`,
+ method: 'delete',
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }).then((delResponse) => {
+ expect(delResponse.status).to.equal(204);
+ expect(delResponse.data).is.empty;
+ });
+});
+
+Cypress.Commands.add('keycloakGetUserSessionsAPI', (accessToken, userId) => {
+ return cy.task('keycloakRequest', {
+ baseUrl,
+ path: `users/${userId}/sessions`,
+ method: 'get',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ expect(response.data);
+ return cy.wrap(response.data);
+ });
+});
+
+Cypress.Commands.add('keycloakDeleteUserSessions', (accessToken, userId) => {
+ return cy.keycloakGetUserSessionsAPI(accessToken, userId).then((responseData) => {
+ if (responseData.length > 0) {
+ Object.values(responseData).forEach((data) => {
+ const sessionId = data.id;
+ cy.keycloakDeleteSession(accessToken, sessionId);
+ });
+
+ // Ensure we clear out these specific cookies
+ ['JSESSIONID'].forEach((cookie) => {
+ cy.clearCookie(cookie);
+ });
+ }
+ });
+});
+
+Cypress.Commands.add('keycloakResetUsers', (users) => {
+ return cy.keycloakGetAccessTokenAPI().then((accessToken) => {
+ Object.values(users).forEach((_user) => {
+ cy.keycloakGetUserAPI(accessToken, _user.email).then((userId) => {
+ if (userId) {
+ cy.keycloakDeleteUserAPI(accessToken, userId);
+ }
+ }).then(() => {
+ cy.keycloakCreateUser(accessToken, _user).then((_id) => {
+ _user.keycloakId = _id;
+ });
+ });
+ });
+ });
+});
+
+Cypress.Commands.add('keycloakCreateUser', (accessToken, user) => {
+ return cy.keycloakCreateUserAPI(accessToken, user).then(() => {
+ cy.keycloakGetUserAPI(accessToken, user.email).then((newId) => {
+ cy.keycloakResetPasswordAPI(accessToken, newId, user.password).then(() => {
+ cy.keycloakDeleteUserSessions(accessToken, newId).then(() => {
+ return cy.wrap(newId);
+ });
+ });
+ });
+ });
+});
+
+Cypress.Commands.add('keycloakCreateUsers', (users = []) => {
+ return cy.keycloakGetAccessTokenAPI().then((accessToken) => {
+ return users.forEach((user) => {
+ return cy.keycloakCreateUser(accessToken, user);
+ });
+ });
+});
+
+Cypress.Commands.add('keycloakUpdateUser', (userEmail, data) => {
+ return cy.keycloakGetAccessTokenAPI().then((accessToken) => {
+ return cy.keycloakGetUserAPI(accessToken, userEmail).then((userId) => {
+ return cy.keycloakUpdateUserAPI(accessToken, userId, data);
+ });
+ });
+});
+
+Cypress.Commands.add('keycloakSuspendUser', (userEmail) => {
+ const data = {enabled: false};
+ cy.keycloakUpdateUser(userEmail, data);
+});
+
+Cypress.Commands.add('keycloakUnsuspendUser', (userEmail) => {
+ const data = {enabled: true};
+ cy.keycloakUpdateUser(userEmail, data);
+});
+
+Cypress.Commands.add('checkKeycloakLoginPage', () => {
+ cy.findByText('Username or email', {timeout: TIMEOUTS.ONE_SEC}).should('be.visible');
+ cy.findByText('Password').should('be.visible');
+ cy.findAllByText('Log In').should('be.visible');
+});
+
+Cypress.Commands.add('doKeycloakLogin', (user) => {
+ cy.apiLogout();
+ cy.visit('/login');
+ cy.findByText('SAML').click();
+ cy.findByText('Username or email').type(user.email);
+ cy.findByText('Password').type(user.password);
+ cy.findAllByText('Log In').last().click();
+});
+
+Cypress.Commands.add('verifyKeycloakLoginFailed', () => {
+ cy.findAllByText('Account is disabled, contact your administrator.').should('be.visible');
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_commands.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_commands.d.ts
new file mode 100644
index 00000000000..a7be4c19b4b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_commands.d.ts
@@ -0,0 +1,42 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * runLdapSync is a task that runs an external request to run an ldap sync job.
+ * it then waits for the ldap sync job to complete.
+ * @param {UserProfile} admin - an admin user
+ * @returns {boolean} - true if sync run successfully
+ */
+ runLdapSync(admin: {UserProfile}): boolean;
+
+ /**
+ * getLdapSyncJobStatus is a task that runs an external request for ldap_sync job status
+ * @param {number} start - start time of the job.
+ * @returns {string} - current status of job
+ */
+ getLdapSyncJobStatus(start: number): string;
+
+ /**
+ * waitForLdapSyncCompletion is a task that runs recursively
+ * until getLdapSyncJobStatus completes or timeouts.
+ * @param {number} start - start time of the job.
+ * @param {number} timeout - the maxmimum time to wait for the job to complete
+ */
+ waitForLdapSyncCompletion(start: number, timeout: number): void;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_commands.js
new file mode 100644
index 00000000000..3b75d565a6c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_commands.js
@@ -0,0 +1,95 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../fixtures/timeouts';
+
+import {getAdminAccount} from './env';
+
+Cypress.Commands.add('visitLDAPSettings', () => {
+ // # Go to LDAP settings Page
+ cy.visit('/admin_console/authentication/ldap');
+ cy.get('.admin-console__header').should('be.visible').and('have.text', 'AD/LDAP');
+});
+
+Cypress.Commands.add('doLDAPLogin', (settings = {}, useEmail = false) => {
+ // # Go to login page
+ cy.apiLogout();
+ cy.visit('/login');
+ cy.wait(TIMEOUTS.FIVE_SEC);
+ cy.checkLoginPage(settings);
+ cy.performLDAPLogin(settings, useEmail);
+});
+
+Cypress.Commands.add('performLDAPLogin', (settings = {}, useEmail = false) => {
+ const loginId = useEmail ? settings.user.email : settings.user.username;
+ cy.get('#input_loginId').type(loginId);
+ cy.get('#input_password-input').type(settings.user.password);
+
+ //click the login button
+ cy.get('#saveSetting').should('not.be.disabled').click();
+});
+
+Cypress.Commands.add('doLDAPLogout', (settings = {}) => {
+ cy.checkLeftSideBar(settings);
+
+ // # Logout then check login page
+ cy.uiLogout();
+ cy.checkLoginPage(settings);
+});
+
+Cypress.Commands.add('doSkipTutorial', () => {
+ cy.wait(TIMEOUTS.FIVE_SEC);
+ cy.get('body').then((body) => {
+ if (body.find('#tutorialSkipLink').length > 0) {
+ cy.get('#tutorialSkipLink').click().wait(TIMEOUTS.HALF_SEC);
+ }
+ });
+});
+
+Cypress.Commands.add('runLdapSync', (admin) => {
+ cy.externalRequest({user: admin, method: 'post', path: 'ldap/sync'}).then(() => {
+ cy.waitForLdapSyncCompletion(Date.now(), TIMEOUTS.THREE_MIN).then(() => {
+ return cy.wrap(true);
+ });
+ });
+});
+
+Cypress.Commands.add('getLdapSyncJobStatus', (start) => {
+ const admin = getAdminAccount();
+ cy.externalRequest({user: admin, method: 'get', path: 'jobs/type/ldap_sync'}).then((result) => {
+ const jobs = result.data;
+ if (jobs && jobs[0]) {
+ if (Math.abs(jobs[0].create_at - start) < TIMEOUTS.TWO_SEC) {
+ switch (jobs[0].status) {
+ case 'success':
+ return cy.wrap('success');
+ case 'pending':
+ case 'in_progress':
+ return cy.wrap('pending');
+ default:
+ return cy.wrap('unsuccessful');
+ }
+ }
+ }
+ return cy.wrap('not found');
+ });
+});
+
+Cypress.Commands.add('waitForLdapSyncCompletion', (start, timeout) => {
+ if (Date.now() - start > timeout) {
+ throw new Error('Timeout Waiting for LdapSync');
+ }
+
+ cy.getLdapSyncJobStatus(start).then((status) => {
+ if (status === 'success') {
+ return;
+ }
+ if (status === 'unsuccessful') {
+ throw new Error('LdapSync Unsuccessful');
+ }
+
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(TIMEOUTS.FIVE_SEC);
+ cy.waitForLdapSyncCompletion(start, timeout);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_server_commands.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_server_commands.d.ts
new file mode 100644
index 00000000000..39587f67341
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_server_commands.d.ts
@@ -0,0 +1,27 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `external` prefix, e.g. `externalActivateUser`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * addLDAPUsers is a cy.exec() wrapped as command to run ldap modify
+ * against a local docker installation of OpenLdap.
+ * @returns {string} - access token
+ */
+ addLDAPUsers(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_server_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_server_commands.js
new file mode 100644
index 00000000000..094d54fd7c1
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ldap_server_commands.js
@@ -0,0 +1,112 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getRandomId} from '../utils';
+
+const ldapTmpFolder = 'ldap_tmp';
+
+Cypress.Commands.add('modifyLDAPUsers', (filename) => {
+ cy.exec(`ldapmodify -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest -H ldap://${Cypress.env('ldapServer')}:${Cypress.env('ldapPort')} -f tests/fixtures/${filename} -c`, {failOnNonZeroExit: false});
+});
+
+Cypress.Commands.add('resetLDAPUsers', () => {
+ cy.modifyLDAPUsers('ldap-reset-data.ldif');
+});
+
+Cypress.Commands.add('createLDAPUser', ({prefix = 'ldap', user} = {}) => {
+ const ldapUser = user || generateLDAPUser(prefix);
+ const data = generateContent(ldapUser);
+ const filename = `new_user_${Date.now()}.ldif`;
+ const filePath = `tests/fixtures/${ldapTmpFolder}/${filename}`;
+
+ cy.task('writeToFile', ({filename, fixturesFolder: ldapTmpFolder, data}));
+
+ return cy.ldapAdd(filePath).then(() => {
+ return cy.wrap(ldapUser);
+ });
+});
+
+Cypress.Commands.add('updateLDAPUser', (user) => {
+ const data = generateContent(user, true);
+ const filename = `update_user_${Date.now()}.ldif`;
+ const filePath = `tests/fixtures/${ldapTmpFolder}/${filename}`;
+
+ cy.task('writeToFile', ({filename, fixturesFolder: ldapTmpFolder, data}));
+
+ return cy.ldapModify(filePath).then(() => {
+ return cy.wrap(user);
+ });
+});
+
+Cypress.Commands.add('ldapAdd', (filePath) => {
+ const {host, bindDn, password} = getLDAPCredentials();
+
+ return cy.exec(
+ `ldapadd -x -D "${bindDn}" -w ${password} -H ${host} -f ${filePath} -c`,
+ {failOnNonZeroExit: false},
+ ).then(({code, stdout, stderr}) => {
+ cy.log(`ldapadd code: ${code}, stdout: ${stdout}, stderr: ${stderr}`);
+ });
+});
+
+Cypress.Commands.add('ldapModify', (filePath) => {
+ const {host, bindDn, password} = getLDAPCredentials();
+
+ return cy.exec(
+ `ldapmodify -x -D "${bindDn}" -w ${password} -H ${host} -f ${filePath} -c`,
+ {failOnNonZeroExit: false},
+ ).then(({code, stdout, stderr}) => {
+ cy.log(`ldapmodify code: ${code}, stdout: ${stdout}, stderr: ${stderr}`);
+ });
+});
+
+function getLDAPCredentials() {
+ const host = `ldap://${Cypress.env('ldapServer')}:${Cypress.env('ldapPort')}`;
+ const bindDn = 'cn=admin,dc=mm,dc=test,dc=com';
+ const password = 'mostest';
+
+ return {host, bindDn, password};
+}
+
+export function generateLDAPUser(prefix = 'ldap') {
+ const randomId = getRandomId();
+ const username = `${prefix}user${randomId}`;
+
+ return {
+ username,
+ password: 'Password1',
+ email: `${username}@mmtest.com`,
+ firstname: `Firstname-${randomId}`,
+ lastname: `Lastname-${randomId}`,
+ ldapfirstname: `${prefix.toUpperCase()}Firstname-${randomId}`,
+ ldaplastname: `${prefix.toUpperCase()}Lastname-${randomId}`,
+ keycloakId: '',
+ };
+}
+
+function generateContent(user = {}, isUpdate = false) {
+ let deleteContent = '';
+ if (isUpdate) {
+ deleteContent = `dn: uid=${user.username},ou=e2etest,dc=mm,dc=test,dc=com
+changetype: delete
+`;
+ }
+
+ return `
+${deleteContent}
+
+dn: ou=e2etest,dc=mm,dc=test,dc=com
+changetype: add
+objectclass: organizationalunit
+
+# generic test users
+dn: uid=${user.username},ou=e2etest,dc=mm,dc=test,dc=com
+changetype: add
+objectclass: iNetOrgPerson
+cn: ${user.firstname}
+sn: ${user.lastname}
+uid: ${user.username}
+mail: ${user.email}
+userPassword: Password1
+`;
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/network_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/network_commands.js
new file mode 100644
index 00000000000..1ea4dfbd02e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/network_commands.js
@@ -0,0 +1,63 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('waitForNetworkIdle', (options = {}) => {
+ const {
+ idleTime = 500,
+ timeout = 2000,
+ method = null,
+ urlPattern = null,
+ } = options;
+
+ let lastRequestTime = Date.now();
+ let pendingRequests = 0;
+ const requestStartTime = Date.now();
+
+ cy.intercept('*', (req) => {
+ if (method && req.method !== method) {
+ return;
+ }
+ if (urlPattern && !req.url.match(urlPattern)) {
+ return;
+ }
+
+ pendingRequests++;
+ lastRequestTime = Date.now();
+
+ req.continue(() => {
+ pendingRequests--;
+ lastRequestTime = Date.now();
+ });
+ });
+
+ cy.waitUntil(
+ () => {
+ const timeSinceLastRequest = Date.now() - lastRequestTime;
+ const totalElapsedTime = Date.now() - requestStartTime;
+
+ if (totalElapsedTime >= timeout) {
+ return true;
+ }
+
+ return pendingRequests === 0 && timeSinceLastRequest >= idleTime;
+ },
+ {
+ timeout: timeout + 100,
+ interval: 50,
+ errorMsg: `Network did not become idle within ${timeout}ms`,
+ },
+ );
+});
+
+Cypress.Commands.add('waitForGraphQLQueries', (options = {}) => {
+ const {
+ idleTime = 500,
+ timeout = 2000,
+ } = options;
+
+ cy.waitForNetworkIdle({
+ idleTime,
+ timeout,
+ urlPattern: /\/plugins\/playbooks\/api\/v0\/query/,
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/notification.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/notification.ts
new file mode 100644
index 00000000000..4a03aec60ec
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/notification.ts
@@ -0,0 +1,14 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// Stub the browser notification API with the given name and permission
+export function spyNotificationAs(name: string, permission: NotificationPermission) {
+ cy.window().then((win) => {
+ win.Notification = Notification;
+ win.Notification.requestPermission = () => Promise.resolve(permission);
+
+ cy.stub(win, 'Notification').as(name);
+ });
+
+ cy.window().should('have.property', 'Notification');
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/okta_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/okta_commands.js
new file mode 100644
index 00000000000..bf471aec63f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/okta_commands.js
@@ -0,0 +1,238 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../fixtures/timeouts';
+
+const token = 'SSWS ' + Cypress.env('oktaMMAppToken');
+
+function buildProfile(user) {
+ const profile = {
+ firstName: user.firstname,
+ lastName: user.lastname,
+ email: user.email,
+ login: user.email,
+ userType: user.userType,
+ isAdmin: user.isAdmin,
+ isGuest: user.isGuest,
+ };
+ return profile;
+}
+
+Cypress.Commands.add('oktaCreateUser', (user = {}) => {
+ const profile = buildProfile(user);
+ return cy.task('oktaRequest', {
+ baseUrl: Cypress.env('oktaApiUrl'),
+ urlSuffix: '/users/',
+ method: 'post',
+ token,
+ data: {
+ profile,
+ credentials: {
+ password: {value: user.password},
+ recovery_question: {
+ question: 'What is the best open source messaging platform for developers?',
+ answer: 'Mattermost',
+ },
+ },
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(200);
+ const userId = response.data.id;
+ return cy.wrap(userId);
+ });
+});
+
+Cypress.Commands.add('oktaGetUser', (userId = '') => {
+ return cy.task('oktaRequest', {
+ baseUrl: Cypress.env('oktaApiUrl'),
+ urlSuffix: '/users?q=' + userId,
+ method: 'get',
+ token,
+ }).then((response) => {
+ expect(response.status).to.be.equal(200);
+ if (response.data.length > 0) {
+ return cy.wrap(response.data[0].id);
+ }
+ return cy.wrap(null);
+ });
+});
+
+Cypress.Commands.add('oktaUpdateUser', (userId = '', user = {}) => {
+ const profile = buildProfile(user);
+
+ return cy.task('oktaRequest', {
+ baseUrl: Cypress.env('oktaApiUrl'),
+ urlSuffix: '/users/' + userId,
+ method: 'post',
+ token,
+ data: {
+ profile,
+ },
+ }).then((response) => {
+ expect(response.status).to.equal(201);
+ return cy.wrap(response.data);
+ });
+});
+
+//first we deactivate the user, then we actually delete it
+Cypress.Commands.add('oktaDeleteUser', (userId = '') => {
+ cy.task('oktaRequest', {
+ baseUrl: Cypress.env('oktaApiUrl'),
+ urlSuffix: '/users/' + userId,
+ method: 'delete',
+ token,
+ }).then((response) => {
+ expect(response.status).to.equal(204);
+ expect(response.data).is.empty;
+ cy.task('oktaRequest', {
+ baseUrl: Cypress.env('oktaApiUrl'),
+ urlSuffix: '/users/' + userId,
+ method: 'delete',
+ token,
+ }).then((_response) => {
+ expect(_response.status).to.equal(204);
+ expect(_response.data).is.empty;
+ });
+ });
+});
+
+Cypress.Commands.add('oktaDeleteSession', (userId = '') => {
+ cy.task('oktaRequest', {
+ baseUrl: Cypress.env('oktaApiUrl'),
+ urlSuffix: '/users/' + userId + '/sessions',
+ method: 'delete',
+ token,
+ }).then((response) => {
+ expect(response.status).to.equal(204);
+ expect(response.data).is.empty;
+
+ // Ensure we clear out these specific cookies
+ ['JSESSIONID'].forEach((cookie) => {
+ cy.clearCookie(cookie);
+ });
+ });
+});
+
+Cypress.Commands.add('oktaAssignUserToApplication', (userId = '', user = {}) => {
+ return cy.task('oktaRequest', {
+ baseUrl: Cypress.env('oktaApiUrl'),
+ urlSuffix: '/apps/' + Cypress.env('oktaMMAppId') + '/users',
+ method: 'post',
+ token,
+ data: {
+ id: userId,
+ scope: 'USER',
+ profile: {
+ firstName: user.firstName,
+ lastName: user.lastName,
+ email: user.email,
+ },
+ },
+ }).then((response) => {
+ expect(response.status).to.be.equal(200);
+ return cy.wrap(response.data);
+ });
+});
+
+Cypress.Commands.add('oktaGetOrCreateUser', (user) => {
+ let userId;
+ return cy.oktaGetUser(user.email).then((uId) => {
+ userId = uId;
+ if (userId == null) {
+ cy.oktaCreateUser(user).then((_uId) => {
+ userId = _uId;
+ cy.oktaAssignUserToApplication(userId, user);
+ });
+ } else {
+ cy.oktaAssignUserToApplication(userId, user);
+ }
+ return cy.wrap(userId);
+ });
+});
+
+Cypress.Commands.add('oktaAddUsers', (users) => {
+ let userId;
+ Object.values(users.regulars).forEach((_user) => {
+ cy.oktaGetUser(_user.email).then((uId) => {
+ userId = uId;
+ if (userId == null) {
+ cy.oktaCreateUser(_user).then((_uId) => {
+ userId = _uId;
+ cy.oktaAssignUserToApplication(userId, _user);
+ cy.oktaDeleteSession(userId);
+ });
+ }
+ });
+ });
+
+ Object.values(users.guests).forEach((_user) => {
+ cy.oktaGetUser(_user.email).then((uId) => {
+ userId = uId;
+ if (userId == null) {
+ cy.oktaCreateUser(_user).then((_uId) => {
+ userId = _uId;
+ cy.oktaAssignUserToApplication(userId, _user);
+ cy.oktaDeleteSession(userId);
+ });
+ }
+ });
+ });
+
+ Object.values(users.admins).forEach((_user) => {
+ cy.oktaGetUser(_user.email).then((uId) => {
+ userId = uId;
+ if (userId == null) {
+ cy.oktaCreateUser(_user).then((_uId) => {
+ userId = _uId;
+ cy.oktaAssignUserToApplication(userId, _user);
+ cy.oktaDeleteSession(userId);
+ });
+ }
+ });
+ });
+});
+
+Cypress.Commands.add('oktaRemoveUsers', (users) => {
+ let userId;
+ Object.values(users.regulars).forEach((_user) => {
+ cy.oktaGetUser(_user.email).then((_uId) => {
+ userId = _uId;
+ if (userId != null) {
+ cy.oktaDeleteUser(userId);
+ }
+ });
+ });
+
+ Object.values(users.guests).forEach((_user) => {
+ cy.oktaGetUser(_user.email).then((_uId) => {
+ userId = _uId;
+ if (userId != null) {
+ cy.oktaDeleteUser(userId);
+ }
+ });
+ });
+
+ Object.values(users.admins).forEach((_user) => {
+ cy.oktaGetUser(_user.email).then((_uId) => {
+ userId = _uId;
+ if (userId != null) {
+ cy.oktaDeleteUser(userId);
+ }
+ });
+ });
+});
+
+Cypress.Commands.add('checkOktaLoginPage', () => {
+ cy.findByText('Powered by').should('be.visible');
+ cy.findAllByText('Sign In').should('be.visible');
+ cy.get('#okta-signin-password').should('be.visible');
+ cy.get('#okta-signin-submit').should('be.visible');
+});
+
+Cypress.Commands.add('doOktaLogin', (user) => {
+ cy.checkOktaLoginPage();
+
+ cy.get('#okta-signin-username').type(user.email);
+ cy.get('#okta-signin-password').type(user.password);
+ cy.findAllByText('Sign In').last().click().wait(TIMEOUTS.FIVE_SEC);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/saml_commands.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/saml_commands.js
new file mode 100644
index 00000000000..14f29716b6e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/saml_commands.js
@@ -0,0 +1,57 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../fixtures/timeouts';
+import {stubClipboard} from '../utils';
+
+Cypress.Commands.add('checkCreateTeamPage', (settings = {}) => {
+ if (settings.user.userType === 'Guest' || settings.user.isGuest) {
+ cy.findByText('Create a team').scrollIntoView().should('not.exist');
+ } else {
+ cy.findByText('Create a team').scrollIntoView().should('be.visible');
+ }
+});
+
+Cypress.Commands.add('doSamlLogin', (settings = {}) => {
+ // # Go to login page
+ cy.apiLogout();
+ cy.visit('/login');
+ cy.checkLoginPage(settings);
+
+ //click the login button
+ cy.findByText(settings.loginButtonText).should('be.visible').click().wait(TIMEOUTS.ONE_SEC);
+});
+
+Cypress.Commands.add('doSamlLogout', (settings = {}) => {
+ cy.checkLeftSideBar(settings);
+
+ // # Logout then check login page
+ cy.uiLogout();
+ cy.checkLoginPage(settings);
+});
+
+Cypress.Commands.add('getInvitePeopleLink', (settings = {}) => {
+ cy.checkLeftSideBar(settings);
+
+ // # Open team menu and click 'Invite People'
+ cy.uiOpenTeamMenu('Invite People');
+
+ stubClipboard().as('clipboard');
+ cy.checkInvitePeoplePage();
+ cy.findByTestId('InviteView__copyInviteLink').click();
+ cy.get('@clipboard').its('contents').then((text) => {
+ // # Close Invite People modal
+ cy.uiClose();
+ return cy.wrap(text);
+ });
+});
+
+Cypress.Commands.add('setTestSettings', (loginButtonText, config) => {
+ return {
+ loginButtonText,
+ siteName: config.TeamSettings.SiteName,
+ siteUrl: config.ServiceSettings.SiteURL,
+ teamName: '',
+ user: null,
+ };
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/shell.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/shell.d.ts
new file mode 100644
index 00000000000..673f8d277ee
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/shell.d.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Find file/s similar to "find" shell command
+ * Extends find of shelljs, https://github.com/shelljs/shelljs#findpath--path-
+ *
+ * @param {string} path - file path
+ * @param {RegExp} pattern - pattern to match with
+ *
+ * @example
+ * cy.shellFind('path', '/file.xml/').then((files) => {
+ * // do something with files
+ * });
+ */
+ shellFind(path: string, pattern: RegExp): Chainable;
+
+ /**
+ * Remove file/s similar to "rm" shell command
+ * Extends rm of shelljs, https://github.com/shelljs/shelljs#rmoptions-file--file-
+ *
+ * @param {string} option - ex. -rf
+ * @param {string} file - file/pattern to remove
+ *
+ * @example
+ * cy.shellRm('-rf', 'file.png');
+ */
+ shellRm(option: string, file: string): Chainable;
+
+ /**
+ * Unzip source file into a target folder
+ *
+ * @param {string} source - source file
+ * @param {string} target - target folder
+ *
+ * @example
+ * cy.shellUnzip('source.zip', 'target-folder');
+ */
+ shellUnzip(source: string, target: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/shell.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/shell.js
new file mode 100644
index 00000000000..5f4327e8d6e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/shell.js
@@ -0,0 +1,14 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('shellFind', (path, pattern) => {
+ return cy.task('shellFind', {path, pattern});
+});
+
+Cypress.Commands.add('shellRm', (option, file) => {
+ return cy.task('shellRm', {option, file});
+});
+
+Cypress.Commands.add('shellUnzip', (source, target) => {
+ return cy.task('shellUnzip', {source, target});
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/task_commands.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/task_commands.ts
new file mode 100644
index 00000000000..2e0360d0960
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/task_commands.ts
@@ -0,0 +1,289 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {AxiosResponse} from 'axios';
+
+import {ChainableT} from '../types';
+
+/**
+* postMessageAs is a task which is wrapped as command with post-verification
+* that a message is successfully posted by the user/sender
+* @param {Object} sender - a user object who will post a message
+* @param {String} message - message in a post
+* @param {Object} channelId - where a post will be posted
+*/
+
+interface PostMessageResp {
+ id: string;
+ status: number;
+ data: any;
+}
+
+interface PostMessageArg {
+ sender: {
+ username: string;
+ password: string;
+ };
+ message: string;
+ channelId: string;
+ rootId?: string;
+ createAt?: number;
+}
+
+function postMessageAs(arg: PostMessageArg): ChainableT {
+ const {sender, message, channelId, rootId, createAt} = arg;
+ const baseUrl = Cypress.config('baseUrl');
+
+ return cy.task('postMessageAs', {sender, message, channelId, rootId, createAt, baseUrl}).then((response: AxiosResponse<{id: string}>) => {
+ const {status, data} = response;
+ expect(status).to.equal(201);
+
+ // # Return the data so it can be interacted in a test
+ return cy.wrap({id: data.id, status, data});
+ });
+}
+Cypress.Commands.add('postMessageAs', postMessageAs);
+
+/**
+ * @param {string} [numberOfMessages = 30] - Number of messages
+ * @param {Object} sender - a user object who will post a message
+ * @param {String} message - message in a post
+ * @param {Object} channelId - where a post will be posted
+ */
+
+function postListOfMessages({numberOfMessages = 30, ...rest}): ChainableT {
+ const baseUrl = Cypress.config('baseUrl');
+
+ return (cy as any).
+ task('postListOfMessages', {numberOfMessages, baseUrl, ...rest}, {timeout: numberOfMessages * 200}).
+ each((message) => expect(message.status).to.equal(201));
+}
+
+Cypress.Commands.add('postListOfMessages', postListOfMessages);
+
+/**
+* reactToMessageAs is a task wrapped as command with post-verification
+* that a reaction is added successfully to a message by a user/sender
+* @param {Object} sender - a user object who will post a message
+* @param {String} postId - post on which reaction is intended
+* @param {String} reaction - emoji text eg. smile
+*/
+Cypress.Commands.add('reactToMessageAs', ({sender, postId, reaction}) => {
+ const baseUrl = Cypress.config('baseUrl');
+
+ return cy.task('reactToMessageAs', {sender, postId, reaction, baseUrl}).then(({status, data}) => {
+ expect(status).to.equal(200);
+
+ // # Return the response after reaction is added
+ return cy.wrap({status, data});
+ });
+});
+
+/**
+* postIncomingWebhook is a task which is wrapped as command with post-verification
+* that the incoming webhook is successfully posted
+* @param {String} url - incoming webhook URL
+* @param {Object} data - payload on incoming webhook
+*/
+
+function postIncomingWebhook({url, data, waitFor}: {
+ url: string;
+ data: Record;
+ waitFor?: string;
+}): ChainableT {
+ cy.task('postIncomingWebhook', {url, data}).its('status').should('be.equal', 200);
+
+ if (!waitFor) {
+ return;
+ }
+
+ cy.waitUntil(() => cy.getLastPost().then((el) => {
+ switch (waitFor) {
+ case 'text': {
+ const textEl = el.find('.post-message__text > p')[0];
+ return Boolean(textEl && textEl.textContent.includes(data.text));
+ }
+ case 'attachment-pretext': {
+ const attachmentPretextEl = el.find('.attachment__thumb-pretext > p')[0];
+ return Boolean(attachmentPretextEl && attachmentPretextEl.textContent.includes(data.attachments[0].pretext));
+ }
+ default:
+ return false;
+ }
+ }));
+}
+
+Cypress.Commands.add('postIncomingWebhook', postIncomingWebhook);
+
+interface ExternalRequestArg {
+ user: Record;
+ method: string;
+ path: string;
+ data?: T;
+ failOnStatusCode?: boolean;
+}
+function externalRequest(arg: ExternalRequestArg): ChainableT, 'data' | 'status'>> {
+ const {user, method, path, data, failOnStatusCode = true} = arg;
+ const baseUrl = Cypress.config('baseUrl');
+
+ return cy.task('externalRequest', {baseUrl, user, method, path, data}).then((response: Pick, 'data' | 'status'>) => {
+ // Temporarily ignore error related to Cloud
+ const cloudErrorId = [
+ 'ent.cloud.request_error',
+ 'api.cloud.get_subscription.error',
+ ];
+
+ if (response.data && !cloudErrorId.includes(response.data.id) && failOnStatusCode) {
+ expect(response.status).to.be.oneOf([200, 201, 204]);
+ }
+
+ return cy.wrap(response);
+ });
+}
+Cypress.Commands.add('externalRequest', externalRequest);
+
+/**
+* postMessageAs is a task which is wrapped as command with post-verification
+* that a message is successfully posted by the bot
+* @param {String} message - message in a post
+* @param {Object} channelId - where a post will be posted
+*/
+
+function postBotMessage({token, message, props, channelId, rootId, createAt, failOnStatus = true}): ChainableT {
+ const baseUrl = Cypress.config('baseUrl');
+
+ return cy.task('postBotMessage', {token, message, props, channelId, rootId, createAt, baseUrl}).then(({status, data}) => {
+ if (failOnStatus) {
+ expect(status).to.equal(201);
+ }
+
+ // # Return the data so it can be interacted in a test
+ return cy.wrap({id: data.id, status, data});
+ });
+}
+
+Cypress.Commands.add('postBotMessage', postBotMessage);
+
+/**
+* urlHealthCheck is a task wrapped as command that checks whether
+* a URL is healthy and reachable.
+* @param {String} name - name of service to check
+* @param {String} url - URL to check
+* @param {String} helperMessage - a message to display on error to help resolve the issue
+* @param {String} method - a request using a specific method
+* @param {String} httpStatus - expected HTTP status
+*/
+
+function urlHealthCheck({name, url, helperMessage, method, httpStatus}): ChainableT {
+ Cypress.log({name, message: `Checking URL health at ${url}`});
+
+ return cy.task('urlHealthCheck', {url, method}).then(({data, errorCode, status, success}) => {
+ const urlService = `__${name}__ at ${url}`;
+
+ const successMessage = success ?
+ `${urlService}: reachable` :
+ `${errorCode}: The test you're running requires ${urlService} to be reachable. \n${helperMessage}`;
+ expect(success, successMessage).to.equal(true);
+
+ const statusMessage = status === httpStatus ?
+ `${urlService}: responded with ${status} HTTP status` :
+ `${urlService}: expected to respond with ${httpStatus} but got ${status} HTTP status`;
+ expect(status, statusMessage).to.equal(httpStatus);
+
+ return cy.wrap({data, status});
+ });
+}
+
+Cypress.Commands.add('urlHealthCheck', urlHealthCheck);
+
+Cypress.Commands.add('requireWebhookServer', () => {
+ const baseUrl = Cypress.config('baseUrl');
+ const webhookBaseUrl = Cypress.env('webhookBaseUrl');
+ const adminUsername = Cypress.env('adminUsername');
+ const adminPassword = Cypress.env('adminPassword');
+ const helperMessage = `
+__Tips:__
+ 1. In local development, you may run "__npm run start:webhook__" at "/e2e" folder.
+ 2. If reachable from remote host, you may export it as env variable, like "__CYPRESS_webhookBaseUrl=[url] npm run cypress:open__".
+`;
+
+ cy.urlHealthCheck({
+ name: 'Webhook Server',
+ url: webhookBaseUrl,
+ helperMessage,
+ method: 'get',
+ httpStatus: 200,
+ });
+
+ cy.task('postIncomingWebhook', {
+ url: `${webhookBaseUrl}/setup`,
+ data: {
+ baseUrl,
+ webhookBaseUrl,
+ adminUsername,
+ adminPassword,
+ }}).
+ its('status').should('be.equal', 201);
+});
+
+Cypress.Commands.overwrite('log', (subject, message) => cy.task('log', message));
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * externalRequest is a task which is wrapped as command with post-verification
+ * that the external request is successfully completed
+ * @param {Object} options
+ * @param {} options.user - a user initiating external request
+ * @param {String} options.method - an HTTP method (e.g. get, post, etc)
+ * @param {String} options.path - API path that is relative to Cypress.config().baseUrl
+ * @param {Object} options.data - payload
+ * @param {Boolean} options.failOnStatusCode - whether to fail on status code, default is true
+ *
+ * @example
+ * cy.externalRequest({user: sysadmin, method: 'POST', path: 'config', data});
+ */
+ externalRequest(options?: {
+ user: Pick;
+ method: string;
+ path: string;
+ data?: Record;
+ failOnStatusCode?: boolean;
+ }): Chainable;
+
+ /**
+ * Adds a given reaction to a specific post from a user
+ * @param {Object} reactToMessageObject - Information on person and post to which a reaction needs to be added
+ * @param {Object} reactToMessageObject.sender - a user object who will post a message
+ * @param {string} reactToMessageObject.postId - post on which reaction is intended
+ * @param {string} reactToMessageObject.reaction - emoji text eg. smile
+ * @returns {Response} response: Cypress-chainable response
+ *
+ * @example
+ * cy.reactToMessageAs({sender:user2, postId:"ABC123", reaction: 'smile'});
+ */
+ reactToMessageAs({sender, postId, reaction}: {sender: Record; postId: string; reaction: string}): Chainable;
+
+ /**
+ * Verify that the webhook server is accessible, and then sets up base URLs and credential.
+ *
+ * @example
+ * cy.requireWebhookServer();
+ */
+ requireWebhookServer(): Chainable;
+
+ postMessageAs: typeof postMessageAs;
+
+ postListOfMessages: typeof postListOfMessages;
+
+ postIncomingWebhook: typeof postIncomingWebhook;
+
+ postBotMessage: typeof postBotMessage;
+
+ urlHealthCheck: typeof urlHealthCheck;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/account_settings_modal.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/account_settings_modal.d.ts
new file mode 100644
index 00000000000..af61eb6098e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/account_settings_modal.d.ts
@@ -0,0 +1,59 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiOpenProfileModal`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Open the profile settings modal
+ * @param {string} section - such as `'General'`, `'Security'`, `'Notifications'`, `'Display'`, `'Sidebar'` and `'Advanced'`
+ * @return the "#accountSettingsModal"
+ *
+ * @example
+ * cy.uiOpenProfileModal('Profile Settings').within(() => {
+ * // Do something here
+ * });
+ */
+ uiOpenProfileModal(section: string): Chainable>;
+
+ /**
+ * Close the profile settings modal given that the modal itself is opened.
+ *
+ * @example
+ * cy.uiCloseAccountSettingsModal();
+ */
+ uiCloseAccountSettingsModal(): Chainable;
+
+ /**
+ * Navigate to profile settings and verify the user's first, last name
+ * @param {String} firstname - expected user firstname
+ * @param {String} lastname - expected user lastname
+ */
+ verifyAccountNameSettings(firstname: string, lastname: string): Chainable;
+
+ /**
+ * Navigate to account display settings and change collapsed reply threads setting
+ * @param {String} setting - ON or OFF
+ */
+ uiChangeCRTDisplaySetting(setting: string): Chainable;
+
+ /**
+ * Navigate to account display settings and change message display setting
+ * @param {String} setting - COMPACT or STANDARD
+ */
+ uiChangeMessageDisplaySetting(setting: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/account_settings_modal.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/account_settings_modal.js
new file mode 100644
index 00000000000..041f5517cd3
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/account_settings_modal.js
@@ -0,0 +1,60 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiOpenProfileModal', (section = '') => {
+ // # Open profile settings modal
+ cy.uiOpenUserMenu('Profile');
+
+ const profileSettingsModal = () => cy.findByRole('dialog', {name: 'Profile'}).should('be.visible');
+
+ if (!section) {
+ return profileSettingsModal();
+ }
+
+ // # Click on a particular section
+ cy.findByRoleExtended('tab', {name: section}).should('be.visible').click();
+
+ return profileSettingsModal();
+});
+
+Cypress.Commands.add('verifyAccountNameSettings', (firstname, lastname) => {
+ // # Go to Profile
+ cy.uiOpenProfileModal('Profile Settings');
+
+ // * Check name value
+ cy.get('#nameDesc').should('have.text', `${firstname} ${lastname}`);
+ cy.uiClose();
+});
+
+Cypress.Commands.add('uiChangeGenericDisplaySetting', (setting, option) => {
+ cy.uiOpenSettingsModal('Display');
+ cy.get(setting).scrollIntoView();
+ cy.get(setting).click();
+ cy.get('.section-max').scrollIntoView();
+
+ cy.get(option).check().should('be.checked');
+
+ cy.uiSaveAndClose();
+});
+
+/*
+ * Change the message display setting
+ * @param {String} setting - as 'STANDARD' or 'COMPACT'
+ */
+Cypress.Commands.add('uiChangeMessageDisplaySetting', (setting = 'STANDARD') => {
+ const SETTINGS = {STANDARD: '#message_displayFormatA', COMPACT: '#message_displayFormatB'};
+ cy.uiChangeGenericDisplaySetting('#message_displayTitle', SETTINGS[setting]);
+});
+
+/*
+ * Change the collapsed reply threads display setting
+ * @param {String} setting - as 'OFF' or 'ON'
+ */
+Cypress.Commands.add('uiChangeCRTDisplaySetting', (setting = 'OFF') => {
+ const SETTINGS = {
+ ON: '#collapsed_reply_threadsFormatA',
+ OFF: '#collapsed_reply_threadsFormatB',
+ };
+
+ cy.uiChangeGenericDisplaySetting('#collapsed_reply_threadsTitle', SETTINGS[setting]);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/announcement_bar.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/announcement_bar.d.ts
new file mode 100644
index 00000000000..0406385b18d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/announcement_bar.d.ts
@@ -0,0 +1,28 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiCloseAnnouncementBar`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Close the announcement bar if shown in the UI
+ *
+ * @example
+ * cy.uiCloseAnnouncementBar();
+ */
+ uiCloseAnnouncementBar(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/announcement_bar.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/announcement_bar.js
new file mode 100644
index 00000000000..0f5f8f1bbc9
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/announcement_bar.js
@@ -0,0 +1,11 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiCloseAnnouncementBar', () => {
+ cy.document().then((doc) => {
+ const announcementBar = doc.getElementsByClassName('announcement-bar')[0];
+ if (announcementBar) {
+ cy.get('.announcement-bar__close').click();
+ }
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/boards.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/boards.d.ts
new file mode 100644
index 00000000000..12944d12d24
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/boards.d.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiCreateEmptyBoard`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Create a board on a given menu item.
+ *
+ * @param {string} item - one of the template menu options, ex. 'Empty board'
+ */
+ uiCreateBoard(item: string): Chainable;
+
+ /**
+ * Create an empty board.
+ * @example
+ * cy.uiCreateEmptyBoard();
+ */
+ uiCreateEmptyBoard(): Chainable;
+
+ /**
+ * Create a board with the given title
+ *
+ * @param {string} title - title of the new board
+ */
+ uiCreateNewBoard: (title?: string) => Chainable;
+
+ /**
+ * Create a new group with the given name
+ *
+ * @param {string} name - name of the new group
+ */
+ uiAddNewGroup: (name?: string) => Chainable;
+
+ /**
+ * Create a card with the given title
+ *
+ * @param {string} title - title of the new card
+ * @param {string} columnIndex - the column index to create the card
+ */
+ uiAddNewCard: (title?: string, columnIndex?: number) => Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/boards.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/boards.js
new file mode 100644
index 00000000000..1cc2a6b4ca1
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/boards.js
@@ -0,0 +1,66 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import timeouts from '../../fixtures/timeouts';
+
+/* eslint-disable cypress/no-unnecessary-waiting */
+Cypress.Commands.add('uiCreateBoard', (item) => {
+ cy.log(`Create new board: ${item}`);
+
+ cy.uiAddBoard('Create new board');
+ cy.contains(item).click();
+ cy.contains('Use this template').click({force: true}).wait(timeouts.ONE_SEC);
+});
+
+Cypress.Commands.add('uiCreateEmptyBoard', () => {
+ cy.log('Create new empty board');
+
+ cy.contains('Create an empty board').click({force: true}).wait(timeouts.ONE_SEC);
+});
+
+Cypress.Commands.add('uiAddBoard', (item) => {
+ cy.get('.add-board-icon').should('be.visible').click();
+ cy.get('.menu-contents').should('be.visible');
+
+ if (item) {
+ cy.findByRole('button', {name: item}).click();
+ }
+});
+
+Cypress.Commands.add('uiCreateNewBoard', (title) => {
+ cy.log('**Create new empty board**');
+ cy.uiCreateEmptyBoard();
+
+ cy.findByPlaceholderText('Untitled board').should('be.visible');
+ cy.wait(timeouts.QUARTER_SEC);
+ if (title) {
+ cy.log('**Rename board**');
+ cy.findByPlaceholderText('Untitled board').type(`${title}{enter}`);
+ cy.findByRole('textbox', {name: title}).should('exist');
+ }
+ cy.wait(timeouts.HALF_SEC);
+});
+
+Cypress.Commands.add('uiAddNewGroup', (name) => {
+ cy.log('**Add a new group**');
+ cy.findByRole('button', {name: '+ Add a group'}).click();
+ cy.findByRole('textbox', {name: 'New group'}).should('exist');
+
+ if (name) {
+ cy.log('**Rename group**');
+ cy.findByRole('textbox', {name: 'New group'}).type(`{selectall}${name}{enter}`);
+ cy.findByRole('textbox', {name}).should('exist');
+ }
+ cy.wait(timeouts.HALF_SEC);
+});
+
+Cypress.Commands.add('uiAddNewCard', (title, columnIndex) => {
+ cy.log('**Add a new card**');
+ cy.findByRole('button', {name: '+ New'}).eq(columnIndex || 0).click();
+ cy.findByRole('dialog').should('exist');
+
+ if (title) {
+ cy.log('**Change card title**');
+ cy.findByPlaceholderText('Untitled').type(title);
+ }
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel.d.ts
new file mode 100644
index 00000000000..6fe8344f38b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel.d.ts
@@ -0,0 +1,68 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiCreateChannel`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Create a new channel in the current team.
+ * @param {string} options.prefix - Prefix for the name of the channel, it will be added a random string ot it.
+ * @param {boolean} options.isPrivate - is the channel private or public (default)?
+ * @param {string} options.purpose - Channel's purpose
+ * @param {string} options.header - Channel's header
+ * @param {boolean} options.isNewSidebar) - the new sidebar has a different ui flow, set this setting to true to use that. Defaults to false.
+ * @param {string} options.createBoard) - Board template to create
+ *
+ * @example
+ * cy.uiCreateChannel({prefix: 'private-channel-', isPrivate: true, purpose: 'my private channel', header: 'my private header', isNewSidebar: false});
+ */
+ uiCreateChannel(options: Record): Chainable;
+
+ /**
+ * Add users to the current channel.
+ * @param {string[]} usernameList - list of userids to add to the channel
+ *
+ * @example
+ * cy.uiAddUsersToCurrentChannel(['user1', 'user2']);
+ */
+ uiAddUsersToCurrentChannel(usernameList: string[]);
+
+ /**
+ * Archive the current channel.
+ *
+ * @example
+ * cy.uiArchiveChannel();
+ */
+ uiArchiveChannel();
+
+ /**
+ * Unarchive the current channel.
+ *
+ * @example
+ * cy.uiUnarchiveChannel();
+ */
+ uiUnarchiveChannel();
+
+ /**
+ * Leave the current channel.
+ * @param {boolean} isPrivate - is the channel private or public (default)?
+ *
+ * @example
+ * cy.uiLeaveChannel(true);
+ */
+ uiLeaveChannel(isPrivate?: boolean);
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel.js
new file mode 100644
index 00000000000..c4c18fdfeca
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel.js
@@ -0,0 +1,108 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getRandomId} from '../../utils';
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+Cypress.Commands.add('uiCreateChannel', ({
+ prefix = 'channel-',
+ isPrivate = false,
+ purpose = '',
+ name = '',
+ createBoard = '',
+}) => {
+ cy.uiBrowseOrCreateChannel('Create new channel');
+
+ cy.get('#new-channel-modal').should('be.visible');
+ if (isPrivate) {
+ cy.get('#public-private-selector-button-P').click().wait(TIMEOUTS.HALF_SEC);
+ } else {
+ cy.get('#public-private-selector-button-O').click().wait(TIMEOUTS.HALF_SEC);
+ }
+ const channelName = name || `${prefix}${getRandomId()}`;
+ cy.get('#input_new-channel-modal-name').should('be.visible').clear().type(channelName);
+ if (purpose) {
+ cy.get('#new-channel-modal-purpose').clear().type(purpose);
+ }
+
+ if (createBoard) {
+ cy.get('#add-board-to-channel').should('be.visible');
+ cy.findByTestId('add-board-to-channel-check').then((el) => {
+ if (el && !el.hasClass('checked')) {
+ el.click();
+ cy.get('.templates-selector').find('input').click({force: true});
+ cy.findByText(createBoard).scrollIntoView().should('be.visible').click({force: true});
+ }
+ });
+ }
+ cy.findByText('Create channel').click();
+ cy.get('#new-channel-modal').should('not.exist');
+ cy.get('#channelIntro').should('be.visible');
+ return cy.wrap({name: channelName});
+});
+
+Cypress.Commands.add('uiAddUsersToCurrentChannel', (usernameList) => {
+ if (usernameList.length) {
+ cy.get('#channelHeaderTitle').click();
+ cy.get('#channelMembers').click();
+ cy.uiGetButton('Add').click();
+ cy.get('#addUsersToChannelModal').should('be.visible');
+ usernameList.forEach((username) => {
+ cy.get('#selectItems input').typeWithForce(`@${username}{enter}`);
+ });
+ cy.get('#saveItems').click();
+ cy.get('#addUsersToChannelModal').should('not.exist');
+ }
+});
+
+Cypress.Commands.add('uiInviteUsersToCurrentChannel', (usernameList) => {
+ if (usernameList.length) {
+ cy.get('#channelHeaderTitle').click();
+ cy.get('#channelMembers').click();
+ cy.uiGetButton('Add').click();
+ cy.get('#addUsersToChannelModal').should('be.visible');
+ usernameList.forEach((username) => {
+ cy.get('#selectItems input').typeWithForce(`@${username}{enter}`);
+ });
+ cy.get('#saveItems').click();
+ cy.get('#addUsersToChannelModal').should('not.exist');
+ }
+});
+
+Cypress.Commands.add('uiArchiveChannel', () => {
+ cy.get('#channelHeaderTitle').click();
+ cy.get('#channelArchiveChannel').click();
+ return cy.get('#deleteChannelModalDeleteButton').click();
+});
+
+Cypress.Commands.add('uiUnarchiveChannel', () => {
+ cy.get('#channelHeaderTitle').should('be.visible').click();
+ cy.get('#channelUnarchiveChannel').should('be.visible').click();
+ return cy.get('#unarchiveChannelModalDeleteButton').should('be.visible').click();
+});
+
+Cypress.Commands.add('uiLeaveChannel', (isPrivate = false) => {
+ cy.get('#channelHeaderTitle').click();
+
+ if (isPrivate) {
+ cy.get('#channelLeaveChannel').click();
+ return cy.get('#confirmModalButton').click();
+ }
+
+ return cy.get('#channelLeaveChannel').click();
+});
+
+Cypress.Commands.add('goToDm', (username) => {
+ cy.uiAddDirectMessage().click({force: true});
+
+ // # Start typing part of a username that matches previously created users
+ cy.get('#selectItems input').typeWithForce(username);
+ cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC);
+ cy.findByRole('combobox', {name: 'Search for people'}).
+ typeWithForce(username).
+ wait(TIMEOUTS.ONE_SEC).
+ typeWithForce('{enter}');
+
+ // # Save the selected item
+ return cy.get('#saveItems').click().wait(TIMEOUTS.HALF_SEC);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_header.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_header.d.ts
new file mode 100644
index 00000000000..1c544ad70e6
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_header.d.ts
@@ -0,0 +1,86 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiGetChannelFavoriteButton`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get channel header button.
+ *
+ * @example
+ * cy.uiGetChannelHeaderButton().click();
+ */
+ uiGetChannelHeaderButton(): Chainable;
+
+ /**
+ * Get favorite button from channel header.
+ *
+ * @example
+ * cy.uiGetChannelFavoriteButton().click();
+ */
+ uiGetChannelFavoriteButton(): Chainable;
+
+ /**
+ * Get mute button from channel header.
+ *
+ * @example
+ * cy.uiGetMuteButton().click();
+ */
+ uiGetMuteButton(): Chainable;
+
+ /**
+ * Get member button from channel header.
+ *
+ * @example
+ * cy.uiGetChannelMemberButton().click();
+ */
+ uiGetChannelMemberButton(): Chainable;
+
+ /**
+ * Get pin button from channel header.
+ *
+ * @example
+ * cy.uiGetChannelPinButton().click();
+ */
+ uiGetChannelPinButton(): Chainable;
+
+ /**
+ * Get files button from channel header.
+ *
+ * @example
+ * cy.uiGetChannelFileButton().click();
+ */
+ uiGetChannelFileButton(): Chainable;
+
+ /**
+ * Get channel menu
+ *
+ * @example
+ * cy.uiGetChannelMenu();
+ */
+ uiGetChannelMenu(): Chainable;
+
+ /**
+ * Open channel menu
+ * @param {string} [menu] - such as `'View Info'`, `'Notification Preferences'`, `'Team Settings'` and other items in the main menu.
+ * @return the channel menu
+ *
+ * @example
+ * cy.uiOpenChannelMenu();
+ */
+ uiOpenChannelMenu(menu?: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_header.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_header.js
new file mode 100644
index 00000000000..eb3661257c4
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_header.js
@@ -0,0 +1,57 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// Buttons
+
+Cypress.Commands.add('uiGetChannelHeaderButton', () => {
+ return cy.get('#channelHeaderDropdownButton').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetChannelFavoriteButton', () => {
+ return cy.get('#toggleFavorite').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetMuteButton', () => {
+ return cy.get('#toggleMute').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetChannelMemberButton', () => {
+ return cy.get('#member_rhs').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetChannelPinButton', () => {
+ return cy.get('#channelHeaderPinButton').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetChannelFileButton', () => {
+ return cy.get('#channelHeaderFilesButton').should('be.visible');
+});
+
+// Menus
+
+Cypress.Commands.add('uiGetChannelMenu', (options = {exist: true}) => {
+ if (options.exist) {
+ return cy.get('#channelHeaderDropdownMenu').
+ find('.dropdown-menu').
+ should('be.visible');
+ }
+
+ return cy.get('#channelHeaderDropdownMenu').should('not.exist');
+});
+
+Cypress.Commands.add('uiOpenChannelMenu', (item = '') => {
+ // # Click on channel header button
+ cy.uiGetChannelHeaderButton().click();
+
+ if (!item) {
+ // # Return the menu if no item is passed
+ return cy.uiGetChannelMenu();
+ }
+
+ // # Click on a particular item
+ return cy.uiGetChannelMenu().
+ findByText(item).
+ scrollIntoView().
+ should('be.visible').
+ click();
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_sidebar.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_sidebar.js
new file mode 100644
index 00000000000..930337ae81f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/channel_sidebar.js
@@ -0,0 +1,51 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getRandomId} from '../../utils';
+
+Cypress.Commands.add('uiCreateSidebarCategory', (categoryName = `category-${getRandomId()}`) => {
+ // # Click the New Category/Channel Dropdown button
+ cy.uiGetLHSAddChannelButton().click();
+
+ // # Click the Create new category dropdown item
+ cy.get('.AddChannelDropdown').should('be.visible').contains('.MenuItem', 'Create new category').click();
+
+ cy.findByRole('dialog', {name: 'Rename Category'}).should('be.visible').within(() => {
+ // # Fill in the category name and click 'Create'
+ cy.findByRole('textbox').should('be.visible').typeWithForce(categoryName).
+ invoke('val').should('equal', categoryName);
+ cy.findByRole('button', {name: 'Create'}).should('be.enabled').click();
+ });
+
+ // * Wait for the category to appear in the sidebar
+ cy.contains('.SidebarChannelGroup', categoryName, {matchCase: false});
+
+ return cy.wrap({displayName: categoryName});
+});
+
+Cypress.Commands.add('uiMoveChannelToCategory', (channelName, categoryName, newCategory = false, isChannelId = false) => {
+ // # Open the channel menu, select Move to
+ cy.uiGetChannelSidebarMenu(channelName, isChannelId).within(() => {
+ cy.findByText('Move to...').should('be.visible').trigger('mouseover');
+ });
+
+ // # Select the move to category
+ cy.findAllByRole('menu', {name: 'Move to submenu'}).should('be.visible').within(() => {
+ if (newCategory) {
+ cy.findByText('New Category').should('be.visible').click({force: true});
+ } else {
+ cy.findByText(categoryName).should('be.visible').click({force: true});
+ }
+ });
+
+ if (newCategory) {
+ cy.findByRole('dialog', {name: 'Rename Category'}).should('be.visible').within(() => {
+ // # Fill in the category name and click 'Create'
+ cy.findByRole('textbox').should('be.visible').typeWithForce(categoryName).
+ invoke('val').should('equal', categoryName);
+ cy.findByRole('button', {name: 'Create'}).should('be.enabled').click();
+ });
+ }
+
+ return cy.wrap({displayName: categoryName});
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/cloud_billing.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/cloud_billing.d.ts
new file mode 100644
index 00000000000..af61726d379
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/cloud_billing.d.ts
@@ -0,0 +1,27 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Iframe element in Stripe
+ *
+ * @example
+ * cy.getIframeBody();
+ */
+ uiGetPaymentCardInput(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/cloud_billing.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/cloud_billing.js
new file mode 100644
index 00000000000..3f4a7b00f05
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/cloud_billing.js
@@ -0,0 +1,9 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiGetPaymentCardInput', () => {
+ return cy.
+ get('.__PrivateStripeElement > iframe').
+ its('0.contentDocument.body').should('not.be.empty').
+ then(cy.wrap);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/common.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/common.d.ts
new file mode 100644
index 00000000000..91a453ee975
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/common.d.ts
@@ -0,0 +1,114 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiSave`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Click 'Save' button
+ *
+ * @example
+ * cy.uiSave();
+ */
+ uiSave(): Chainable;
+
+ /**
+ * Click 'Cancel' button
+ *
+ * @example
+ * cy.uiCancel();
+ */
+ uiCancel(): Chainable;
+
+ /**
+ * Click 'Close' button
+ *
+ * @example
+ * cy.uiClose();
+ */
+ uiClose(): Chainable;
+
+ /**
+ * Click Save then Close buttons
+ *
+ * @example
+ * cy.uiSaveAndClose();
+ */
+ uiSaveAndClose(): Chainable;
+
+ /**
+ * Get a button by its text using "cy.findByRole"
+ *
+ * @param {String} label - Button text
+ *
+ * @example
+ * cy.uiGetButton('Save');
+ */
+ uiGetButton(label: string): Chainable;
+
+ /**
+ * Get save button
+ *
+ * @example
+ * cy.uiSaveButton();
+ */
+ uiSaveButton(): Chainable;
+
+ /**
+ * Get cancel button
+ *
+ * @example
+ * cy.uiCancelButton();
+ */
+ uiCancelButton(): Chainable;
+
+ /**
+ * Get close button
+ *
+ * @example
+ * cy.uiCloseButton();
+ */
+ uiCloseButton(): Chainable;
+
+ /**
+ * Get a radio button by its text using "cy.findByRole"
+ *
+ * @example
+ * cy.uiGetRadioButton('Custom Theme');
+ */
+ uiGetRadioButton(): Chainable;
+
+ /**
+ * Get a heading by its text using "cy.findByRole"
+ *
+ * @param {string} headingText - Heading text
+ *
+ * @example
+ * cy.uiGetHeading('General Settings');
+ */
+ uiGetHeading(headingText: string): Chainable;
+
+ /**
+ * Get a textbox by its text using "cy.findByRole"
+ *
+ * @param {string} text - Textbox label
+ *
+ * @example
+ * cy.uiGetTextbox('Nickname');
+ */
+ uiGetTextbox(text: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/common.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/common.js
new file mode 100644
index 00000000000..ab8b357329b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/common.js
@@ -0,0 +1,55 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiSave', () => {
+ return cy.findByRole('button', {name: 'Save'}).scrollIntoView().click();
+});
+
+Cypress.Commands.add('uiCancel', () => {
+ return cy.findByRole('button', {name: 'Cancel'}).click();
+});
+
+Cypress.Commands.add('uiClose', () => {
+ return cy.findAllByRole('button', {name: 'Close'}).eq(0).click();
+});
+
+Cypress.Commands.add('uiSaveAndClose', () => {
+ cy.uiSave();
+ cy.uiClose();
+});
+
+Cypress.Commands.add('uiGetButton', (name) => {
+ return cy.findByRole('button', {name});
+});
+
+Cypress.Commands.add('uiSaveButton', () => {
+ return cy.uiGetButton('Save');
+});
+
+Cypress.Commands.add('uiCancelButton', () => {
+ return cy.uiGetButton('Cancel');
+});
+
+Cypress.Commands.add('uiCloseButton', () => {
+ return cy.uiGetButton('Close');
+});
+
+Cypress.Commands.add('uiGetRadioButton', (name) => {
+ return cy.findByRole('radio', {name}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetHeading', (name) => {
+ return cy.findByRole('heading', {name}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetTextbox', (name) => {
+ return cy.findByRole('textbox', {name}).should('be.visible');
+});
+
+Cypress.Commands.add('uiCloseOnboardingTaskList', () => {
+ cy.get('[data-cy=onboarding-task-list-action-button]').then(($btn) => {
+ if ($btn.find('i.icon-close').length) {
+ $btn.trigger('click');
+ }
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/compliance_export.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/compliance_export.d.ts
new file mode 100644
index 00000000000..ebefff61b04
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/compliance_export.d.ts
@@ -0,0 +1,39 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Select compliance export format
+ * @param {string} exportFormat - compliance export format
+ *
+ * @example
+ * const EXPORTFORMAT = "Actiance XML";
+ * cy.uiEnableComplianceExport(Compliance Export Format);
+ */
+ uiEnableComplianceExport(exportFormat: string): Chainable;
+
+ /**
+ * Go to Compliance Page
+ */
+ uiGoToCompliancePage(): Chainable;
+
+ /**
+ * Click Run Export Compliance and wait for Success status
+ */
+ uiExportCompliance(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/compliance_export.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/compliance_export.js
new file mode 100644
index 00000000000..f566dc4257b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/compliance_export.js
@@ -0,0 +1,53 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+Cypress.Commands.add('uiEnableComplianceExport', (exportFormat = 'csv') => {
+ // * Verify that the page is loaded
+ cy.findByText('Enable Compliance Export:').should('be.visible');
+ cy.findByText('Compliance Export time:').should('be.visible');
+ cy.findByText('Export Format:').should('be.visible');
+
+ // # Enable compliance export
+ cy.findByRole('radio', {name: /false/i}).click();
+ cy.findByRole('radio', {name: /true/i}).click();
+
+ // # Change export format
+ cy.findByTestId('exportFormatdropdown').should('be.visible').select(exportFormat);
+
+ // # Save settings
+ cy.uiSaveConfig({confirm: true});
+});
+
+Cypress.Commands.add('uiGoToCompliancePage', () => {
+ cy.visit('/admin_console/compliance/export');
+ cy.get('.admin-console__header', {timeout: TIMEOUTS.TWO_MIN}).should('be.visible').invoke('text').should('include', 'Compliance Export');
+});
+
+Cypress.Commands.add('uiExportCompliance', () => {
+ // # Click the export job button
+ cy.findByRole('button', {name: /run compliance export job now/i}).click();
+
+ // # Small wait to ensure new row is add
+ cy.wait(TIMEOUTS.THREE_SEC);
+
+ // # Get the first row
+ cy.get('.job-table__table').find('tbody > tr').eq(0).as('firstRow');
+
+ // # Get the first table header
+ cy.get('.job-table__table').find('thead > tr').as('firstheader');
+
+ // # Wait until export is finished
+ cy.waitUntil(() => {
+ return cy.get('@firstRow').find('td:eq(1)').then((el) => {
+ return el[0].innerText.trim() === 'Success';
+ });
+ },
+ {
+ timeout: TIMEOUTS.FIVE_MIN,
+ interval: TIMEOUTS.ONE_SEC,
+ errorMsg: 'Compliance export did not finish in time',
+ });
+});
+
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/data_retention.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/data_retention.d.ts
new file mode 100644
index 00000000000..39c70c69807
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/data_retention.d.ts
@@ -0,0 +1,86 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Go to Data Retention page
+ */
+ uiGoToDataRetentionPage(): Chainable;
+
+ /**
+ * Click create policy button
+ */
+ uiClickCreatePolicy(): Chainable;
+
+ /**
+ * Fill out custom policy form fields
+ * @param {string} name - policy name
+ * @param {string} durationDropdown - duration dropdown value (days, years, forever)
+ * @param {string?} durationText - duration text
+ */
+ uiFillOutCustomPolicyFields(name: string, durationDropdown: string, durationText?: string): Chainable;
+
+ /**
+ * Search and add teams to custom policy
+ * @param {string[]} teamNames - array of team names
+ */
+ uiAddTeamsToCustomPolicy(teamNames: string[]): Chainable;
+
+ /**
+ * Search and add channels to custom policy
+ * @param {string[]} channelNames - array of channel names
+ */
+ uiAddChannelsToCustomPolicy(channelNames: string[]): Chainable;
+
+ /**
+ * Add teams to a custom policy
+ * @param {number} numberOfTeams - number of teams to add to the policy
+ */
+ uiAddRandomTeamToCustomPolicy(numberOfTeams?: number): Chainable;
+
+ /**
+ * Add channels to a custom policy
+ * @param {number} numberOfTeams - number of teams to add to the policy
+ */
+ uiAddRandomChannelToCustomPolicy(numberOfChannels?: number): Chainable;
+
+ /**
+ * Verify custom policy UI information
+ * @param {string} policyId - Custom Policy ID
+ * @param {string} description - The name of the policy
+ * @param {string} duration - How long messages last in the policy
+ * @param {string} appliedTo - Teams and channels the policy apples to
+ */
+ uiVerifyCustomPolicyRow(policyId: string, description: string, duration: string, appliedTo: string): Chainable;
+
+ /**
+ * Click edit custom policy
+ * @param {string} policyId - Custom Policy ID
+ */
+ uiClickEditCustomPolicyRow(policyId: string): Chainable;
+
+ /**
+ * Verify custom create policy response
+ * @param body - Response body
+ * @param {number} teamCount - Number of teams the policy applies to
+ * @param {number} channelCount - Number of channels the policy applies to
+ * @param {number} duration - How long messages last in the policy
+ * @param {string} displayName - The name of the policy
+ */
+ uiVerifyPolicyResponse(body, teamCount: number, channelCount: number, duration: number, displayName: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/data_retention.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/data_retention.js
new file mode 100644
index 00000000000..0d8f23c87f2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/data_retention.js
@@ -0,0 +1,105 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+Cypress.Commands.add('uiGoToDataRetentionPage', () => {
+ cy.visit('/admin_console/compliance/data_retention_settings');
+ cy.get('.DataRetentionSettings .admin-console__header', {timeout: TIMEOUTS.TWO_MIN}).should('be.visible').invoke('text').should('include', 'Data Retention Policies');
+});
+
+Cypress.Commands.add('uiClickCreatePolicy', () => {
+ cy.uiGetButton('Add policy').click();
+ cy.get('.DataRetentionSettings .admin-console__header', {timeout: TIMEOUTS.TWO_MIN}).should('be.visible').invoke('text').should('include', 'Custom Retention Policy');
+});
+
+Cypress.Commands.add('uiFillOutCustomPolicyFields', (name, durationDropdown, durationText = '') => {
+ // # Type policy name
+ cy.uiGetTextbox('Policy name').clear().type(name);
+
+ // # Add message retention values
+ cy.get('.CustomPolicy__fields #DropdownInput_message_retention').should('be.visible').click();
+ cy.get(`.message_retention__menu .message_retention__option span.option_${durationDropdown}`).should('be.visible').click();
+ if (durationText) {
+ cy.get('.CustomPolicy__fields input#message_retention_input').clear().type(durationText);
+ }
+});
+
+Cypress.Commands.add('uiAddTeamsToCustomPolicy', (teamNames) => {
+ cy.uiGetButton('Add teams').click();
+ teamNames.forEach((teamName) => {
+ cy.findByRole('textbox', {name: 'Search and add teams'}).typeWithForce(teamName);
+ cy.get('.team-info-block').then((el) => {
+ el.click();
+ });
+ });
+ cy.uiGetButton('Add').click();
+});
+
+Cypress.Commands.add('uiAddChannelsToCustomPolicy', (channelNames) => {
+ cy.uiGetButton('Add channels').click();
+ channelNames.forEach((channelName) => {
+ cy.findByRole('textbox', {name: 'Search and add channels'}).typeWithForce(channelName);
+ cy.wait(TIMEOUTS.ONE_SEC);
+ cy.get('.channel-info-block').then((el) => {
+ el.click();
+ });
+ });
+ cy.uiGetButton('Add').click();
+});
+
+Cypress.Commands.add('uiAddRandomTeamToCustomPolicy', (numberOfTeams = 1) => {
+ cy.uiGetButton('Add teams').click();
+ for (let i = 0; i < numberOfTeams; i++) {
+ cy.get('.team-info-block').first().then((el) => {
+ el.click();
+ });
+ }
+ cy.uiGetButton('Add').click();
+});
+
+Cypress.Commands.add('uiAddRandomChannelToCustomPolicy', (numberOfChannels = 1) => {
+ cy.uiGetButton('Add channels').click();
+ for (let i = 0; i < numberOfChannels; i++) {
+ cy.get('.channel-info-block').first().then((el) => {
+ el.click();
+ });
+ }
+ cy.uiGetButton('Add').click();
+});
+
+Cypress.Commands.add('uiVerifyCustomPolicyRow', (policyId, description, duration, appliedTo) => {
+ // * Assert row has correct description
+ cy.get(`#customDescription-${policyId}`).should('include.text', description);
+
+ // * Assert row has correct duration
+ cy.get(`#customDuration-${policyId}`).should('include.text', duration);
+
+ // * Assert row has correct team/channel counts
+ cy.get(`#customAppliedTo-${policyId}`).should('include.text', appliedTo);
+});
+
+Cypress.Commands.add('uiClickEditCustomPolicyRow', (policyId) => {
+ cy.get(`#customWrapper-${policyId}`).trigger('mouseover').click();
+ cy.findByRole('button', {name: /edit/i}).should('be.visible').click();
+});
+
+Cypress.Commands.add('uiVerifyPolicyResponse', (body, teamCount, channelCount, duration, displayName) => {
+ // * Assert response body exists
+ assert.isNotNull(body);
+
+ // * Assert response body contains an ID
+ assert.isNotNull(body.id);
+
+ // * Assert response body team_count matches supplied value
+ expect(body.team_count).to.equal(teamCount);
+
+ // * Assert response body channel_count matches supplied value
+ expect(body.channel_count).to.equal(channelCount);
+
+ // * Assert response body duration matches supplied value
+ expect(body.post_duration).to.equal(duration);
+
+ // * Assert response body display_name matches supplied value
+ expect(body.display_name).to.equal(displayName);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/emoji.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/emoji.ts
new file mode 100644
index 00000000000..3855496514f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/emoji.ts
@@ -0,0 +1,53 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ChainableT} from 'tests/types';
+
+Cypress.Commands.add('uiGetEmojiPicker', (): ChainableT => {
+ return cy.get('#emojiPicker').should('be.visible');
+});
+
+Cypress.Commands.add('uiOpenEmojiPicker', (): ChainableT => {
+ cy.findByRole('button', {name: 'select an emoji'}).click();
+ return cy.get('#emojiPicker').should('be.visible');
+});
+
+Cypress.Commands.add('uiOpenCustomEmoji', () => {
+ cy.uiOpenEmojiPicker();
+ cy.findByText('Custom Emoji').should('be.visible').click();
+
+ cy.url().should('include', '/emoji');
+ cy.get('.backstage-header').should('be.visible').and('contain', 'Custom Emoji');
+});
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Open custom emoji
+ *
+ * @example
+ * cy.uiOpenCustomEmoji();
+ */
+ uiGetEmojiPicker(): Chainable;
+
+ /**
+ * Open custom emoji
+ *
+ * @example
+ * cy.uiOpenCustomEmoji();
+ */
+ uiOpenCustomEmoji(): Chainable;
+
+ /**
+ * Open emoji picker
+ *
+ * @example
+ * cy.uiOpenEmojiPicker();
+ */
+ uiOpenEmojiPicker(): Chainable;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/extend_testing_library.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/extend_testing_library.d.ts
new file mode 100644
index 00000000000..dd00a0ca368
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/extend_testing_library.d.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of the Testing Library commands
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Extends `findByRole` by matching case to `name` as insensitive but sensitive to `text` value
+ * @param {string} role - button, input, textbox, etc.
+ * @param {Object} option - text value of the target element
+ *
+ * @example
+ * cy.findByRoleExtended('button', {name: 'Advanced'}).should('be.visible').click();
+ */
+ findByRoleExtended(role: string, option: {name: string}): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/extend_testing_library.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/extend_testing_library.js
new file mode 100644
index 00000000000..1972b084596
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/extend_testing_library.js
@@ -0,0 +1,7 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('findByRoleExtended', (role, {name}) => {
+ const re = RegExp(name, 'i');
+ return cy.findByRole(role, {name: re}).should('have.text', name);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/file_preview.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/file_preview.d.ts
new file mode 100644
index 00000000000..21cfdc5ae8b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/file_preview.d.ts
@@ -0,0 +1,124 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiOpenFilePreviewModal`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get file thumbnail from a post
+ *
+ * @param {string} filename
+ *
+ * @example
+ * cy.uiGetFileThumbnail('image.png');
+ */
+ uiGetFileThumbnail(filename: string): Chainable;
+
+ /**
+ * Get file upload preview located below post textbox
+ *
+ * @example
+ * cy.uiGetFileUploadPreview();
+ */
+ uiGetFileUploadPreview(): Chainable;
+
+ /**
+ * Wait for file upload preview located below post textbox
+ *
+ * @example
+ * cy.uiGetFileUploadPreview();
+ */
+ uiGetFileUploadPreview(): Chainable;
+
+ /**
+ * Get file preview modal
+ *
+ * @param {bool} option.exist - Set to false to not verify if the element exists. Otherwise, true (default) to check existence.
+ *
+ * @example
+ * cy.uiGetFilePreviewModal();
+ */
+ uiGetFilePreviewModal(option: Record): Chainable;
+
+ /**
+ * Get Public Link
+ *
+ * @param {bool} option.exist - Set to false to not verify if the element exists. Otherwise, true (default) to check existence.
+ *
+ * @example
+ * cy.uiGetPublicLink();
+ */
+ uiGetPublicLink(option: Record): Chainable;
+
+ /**
+ * Open file preview modal
+ *
+ * @param {string} filename
+ *
+ * @example
+ * cy.uiOpenFilePreviewModal('image.png');
+ */
+ uiOpenFilePreviewModal(filename: string): Chainable;
+
+ /**
+ * Close file preview modal
+ *
+ * @example
+ * cy.uiCloseFilePreviewModal();
+ */
+ uiCloseFilePreviewModal(): Chainable;
+
+ /**
+ * Get main content of file preview modal
+ *
+ * @example
+ * cy.uiGetContentFilePreviewModal();
+ */
+ uiGetContentFilePreviewModal(): Chainable;
+
+ /**
+ * Get download link button from file preview modal
+ *
+ * @example
+ * cy.uiGetDownloadLinkFilePreviewModal();
+ */
+ uiGetDownloadLinkFilePreviewModal(): Chainable;
+
+ /**
+ * Get download button from file preview modal
+ *
+ * @example
+ * cy.uiGetDownloadFilePreviewModal();
+ */
+ uiGetDownloadFilePreviewModal(): Chainable;
+
+ /**
+ * Get arrow left button from file preview modal
+ *
+ * @example
+ * cy.uiGetArrowLeftFilePreviewModal();
+ */
+ uiGetArrowLeftFilePreviewModal(): Chainable;
+
+ /**
+ * Get arrow right button from file preview modal
+ *
+ * @example
+ * cy.uiGetArrowRightFilePreviewModal();
+ */
+ uiGetArrowRightFilePreviewModal(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/file_preview.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/file_preview.js
new file mode 100644
index 00000000000..0a58408cb6e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/file_preview.js
@@ -0,0 +1,67 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiGetFileThumbnail', (filename) => {
+ return cy.findByLabelText(`file thumbnail ${filename.toLowerCase()}`);
+});
+
+Cypress.Commands.add('uiGetFileUploadPreview', () => {
+ return cy.get('.file-preview__container');
+});
+
+Cypress.Commands.add('uiWaitForFileUploadPreview', () => {
+ cy.waitUntil(() => cy.uiGetFileUploadPreview().then((el) => {
+ return el.find('.post-image.normal').length > 0;
+ }));
+});
+
+Cypress.Commands.add('uiGetFilePreviewModal', (options = {exist: true}) => {
+ if (options.exist) {
+ return cy.get('.file-preview-modal').should('be.visible');
+ }
+
+ return cy.get('.file-preview-modal').should('not.exist');
+});
+
+Cypress.Commands.add('uiGetPublicLink', (options = {exist: true}) => {
+ if (options.exist) {
+ return cy.get('.icon-link-variant').should('be.visible');
+ }
+ return cy.get('.icon-link-variant').should('not.exist');
+});
+
+Cypress.Commands.add('uiGetHeaderFilePreviewModal', () => {
+ return cy.uiGetFilePreviewModal().find('.file-preview-modal-header').should('be.visible');
+});
+
+Cypress.Commands.add('uiOpenFilePreviewModal', (filename) => {
+ if (filename) {
+ cy.uiGetFileThumbnail(filename.toLowerCase()).click();
+ } else {
+ cy.findByTestId('fileAttachmentList').children().first().click();
+ }
+});
+
+Cypress.Commands.add('uiCloseFilePreviewModal', () => {
+ return cy.uiGetFilePreviewModal().find('.icon-close').click();
+});
+
+Cypress.Commands.add('uiGetContentFilePreviewModal', () => {
+ return cy.uiGetFilePreviewModal().find('.file-preview-modal__content');
+});
+
+Cypress.Commands.add('uiGetDownloadLinkFilePreviewModal', () => {
+ return cy.uiGetFilePreviewModal().find('.icon-link-variant').parent();
+});
+
+Cypress.Commands.add('uiGetDownloadFilePreviewModal', () => {
+ return cy.uiGetFilePreviewModal().find('.icon-download-outline').parent();
+});
+
+Cypress.Commands.add('uiGetArrowLeftFilePreviewModal', () => {
+ return cy.uiGetFilePreviewModal().find('.icon-chevron-left').parent();
+});
+
+Cypress.Commands.add('uiGetArrowRightFilePreviewModal', () => {
+ return cy.uiGetFilePreviewModal().find('.icon-chevron-right').parent();
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/global_header.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/global_header.d.ts
new file mode 100644
index 00000000000..e6348149824
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/global_header.d.ts
@@ -0,0 +1,189 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiGetProductMenuButton`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get product switch button
+ *
+ * @example
+ * cy.uiGetProductMenuButton().click();
+ */
+ uiGetProductMenuButton(): Chainable;
+
+ /**
+ * Get product switch menu
+ *
+ * @example
+ * cy.uiGetProductMenu().click();
+ */
+ uiGetProductMenu(): Chainable;
+
+ /**
+ * Open product switch menu
+ *
+ * @param {string} item - menu item ex. System Console, Integrations, etc.
+ *
+ * @example
+ * cy.uiOpenProductMenu().click();
+ */
+ uiOpenProductMenu(item: string): Chainable;
+
+ /**
+ * Get set status button
+ *
+ * @example
+ * cy.uiGetSetStatusButton().click();
+ */
+ uiGetSetStatusButton(): Chainable;
+
+ /**
+ * Get profile header
+ *
+ * @example
+ * cy.uiGetProfileHeader();
+ */
+ uiGetProfileHeader(): Chainable;
+
+ /**
+ * Get status menu container
+ *
+ * @param {bool} option.exist - Set to false to not verify if the element exists. Otherwise, true (default) to check existence.
+ * @example
+ * cy.uiGetStatusMenuContainer({exist: false});
+ */
+ uiGetStatusMenuContainer(option: Record): Chainable;
+
+ /**
+ * Get user menu
+ *
+ * @example
+ * cy.uiGetStatusMenu();
+ */
+ uiGetStatusMenu(): Chainable;
+
+ /**
+ * Open help menu
+ *
+ * @param {string} item - menu item ex. Ask the community, Help resources, etc.
+ *
+ * @example
+ * cy.uiOpenHelpMenu();
+ */
+ uiOpenHelpMenu(item: string): Chainable;
+
+ /**
+ * Get help button
+ *
+ * @example
+ * cy.uiGetHelpButton();
+ */
+ uiGetHelpButton(): Chainable;
+
+ /**
+ * Get help menu
+ *
+ * @example
+ * cy.uiGetHelpMenu();
+ */
+ uiGetHelpMenu(): Chainable;
+
+ /**
+ * Open user menu
+ *
+ * @param {string} [item] - menu item ex. Profile, Logout, etc.
+ *
+ * @example
+ * cy.uiOpenUserMenu();
+ */
+ uiOpenUserMenu(item?: string): Chainable;
+
+ /**
+ * Get search form container
+ *
+ * @example
+ * cy.uiGetSearchContainer();
+ */
+ uiGetSearchContainer(): Chainable;
+
+ /**
+ * Get search box
+ *
+ * @example
+ * cy.uiGetSearchBox();
+ */
+ uiGetSearchBox(): Chainable;
+
+ /**
+ * Get at-mention button
+ *
+ * @example
+ * cy.uiGetRecentMentionButton();
+ */
+ uiGetRecentMentionButton(): Chainable;
+
+ /**
+ * Get saved posts button
+ *
+ * @example
+ * cy.uiGetSavedPostButton();
+ */
+ uiGetSavedPostButton(): Chainable;
+
+ /**
+ * Get settings button
+ *
+ * @example
+ * cy.uiGetSettingsButton();
+ */
+ uiGetSettingsButton(): Chainable;
+
+ /**
+ * Get settings modal
+ *
+ * @example
+ * cy.uiGetSettingsModal();
+ */
+ uiGetSettingsModal(): Chainable;
+
+ /**
+ * Get channel info button
+ *
+ * @example
+ * cy.uiGetChannelInfoButton();
+ */
+ uiGetChannelInfoButton(): Chainable;
+
+ /**
+ * Open settings modal
+ *
+ * @param {string} section - ex. Display, Sidebar, etc.
+ *
+ * @example
+ * cy.uiOpenSettingsModal();
+ */
+ uiOpenSettingsModal(section: string): Chainable;
+
+ /**
+ * User log out via user menu
+ *
+ * @example
+ * cy.uiLogout();
+ */
+ uiLogout(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/global_header.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/global_header.js
new file mode 100644
index 00000000000..7618c5ab29f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/global_header.js
@@ -0,0 +1,155 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiGetProductMenuButton', () => {
+ return cy.findByRole('button', {name: 'Product switch menu'}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetProductMenu', () => {
+ return cy.get('.product-switcher-menu').should('be.visible');
+});
+
+Cypress.Commands.add('uiOpenProductMenu', (item = '') => {
+ // # Click on product switch button
+ cy.uiGetProductMenuButton().click();
+
+ if (!item) {
+ // # Return the menu if no item is passed
+ return cy.uiGetProductMenu();
+ }
+
+ // # Click on a particular item
+ return cy.uiGetProductMenu().
+ findByText(item).
+ scrollIntoView().
+ should('be.visible').
+ click();
+});
+
+Cypress.Commands.add('uiGetSetStatusButton', () => {
+ return cy.findByRole('button', {name: /Select to open profile and status menu\./i}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetProfileHeader', () => {
+ return cy.uiGetSetStatusButton().parent();
+});
+
+Cypress.Commands.add('uiGetStatusMenuContainer', (options = {exist: true}) => {
+ if (options.exist) {
+ return cy.findByRole('menu').should('exist');
+ }
+
+ return cy.findByRole('menu').should('not.exist');
+});
+
+Cypress.Commands.add('uiGetStatusMenu', (options = {visible: true}) => {
+ if (options.visible) {
+ return cy.uiGetStatusMenuContainer().
+ find('ul').
+ should('be.visible');
+ }
+
+ return cy.uiGetStatusMenuContainer().
+ find('ul').
+ should('not.be.visible');
+});
+
+Cypress.Commands.add('uiOpenHelpMenu', (item = '') => {
+ // # Click on help status button
+ cy.uiGetHelpButton().click();
+
+ if (!item) {
+ // # Return the menu if no item is passed
+ return cy.uiGetHelpMenu();
+ }
+
+ // # Click on a particular item
+ return cy.uiGetHelpMenu().
+ findByText(item).
+ scrollIntoView().
+ should('be.visible').
+ click();
+});
+
+Cypress.Commands.add('uiGetHelpButton', () => {
+ return cy.findByRole('button', {name: 'Help'}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetHelpMenu', (options = {visible: true}) => {
+ const dropdown = () => cy.get('#helpMenuPortal').find('.dropdown-menu');
+
+ if (options.visible) {
+ return dropdown().should('be.visible');
+ }
+
+ return dropdown().should('not.be.visible');
+});
+
+Cypress.Commands.add('uiOpenUserMenu', (item = '') => {
+ // # Click on user status button
+ cy.uiGetSetStatusButton().click();
+
+ if (!item) {
+ // # Return the menu if no item is passed
+ return cy.uiGetStatusMenu();
+ }
+
+ // # Click on a particular item
+ return cy.uiGetStatusMenu().
+ findByText(item).
+ scrollIntoView().
+ should('be.visible').
+ click();
+});
+
+Cypress.Commands.add('uiGetSearchContainer', () => {
+ return cy.get('#searchFormContainer').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetSearchBox', () => {
+ return cy.get('#searchBox').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetRecentMentionButton', () => {
+ return cy.findByRole('button', {name: 'Recent mentions'}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetSavedPostButton', () => {
+ return cy.findByRole('button', {name: 'Saved messages'}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetSettingsButton', () => {
+ return cy.findByRole('button', {name: 'Settings'}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetChannelInfoButton', () => {
+ return cy.findByRole('button', {name: 'View Info'}).should('be.visible');
+});
+
+Cypress.Commands.add('uiGetSettingsModal', () => {
+ // # Get settings modal
+ return cy.findByRole('dialog', {name: 'Settings'});
+});
+
+Cypress.Commands.add('uiOpenSettingsModal', (section = '') => {
+ // # Open settings modal
+ cy.uiGetSettingsButton().click();
+
+ if (!section) {
+ return cy.uiGetSettingsModal();
+ }
+
+ // # Click on a particular section
+ cy.findByRoleExtended('tab', {name: section}).should('be.visible').click();
+
+ return cy.uiGetSettingsModal();
+});
+
+Cypress.Commands.add('uiLogout', () => {
+ // # Click logout via user menu
+ cy.uiOpenUserMenu('Log Out');
+
+ cy.url().should('include', '/login');
+ cy.get('.login-body-message').should('be.visible');
+ cy.get('.login-body-card').should('be.visible');
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/index.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/index.js
new file mode 100644
index 00000000000..c841f133cd3
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/index.js
@@ -0,0 +1,31 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import './account_settings_modal';
+import './announcement_bar';
+import './boards';
+import './channel';
+import './channel_header';
+import './channel_sidebar';
+import './cloud_billing';
+import './common';
+import './compliance_export';
+import './data_retention';
+import './extend_testing_library';
+import './global_header';
+import './emoji';
+import './file_preview';
+import './login';
+import './menu';
+import './mfa';
+import './modal';
+import './playbooks';
+import './post';
+import './post_dropdown_menu';
+import './search';
+import './sidebar_left';
+import './sidebar_right';
+import './suggestion_list';
+import './system';
+import './team';
+import './tooltip';
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/login.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/login.d.ts
new file mode 100644
index 00000000000..c0720676d94
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/login.d.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiLogin`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Login vi UI at login page
+ *
+ * @param {UserProfile} user - user with username and password
+ *
+ * @example
+ * cy.uiLogin(user);
+ */
+ uiLogin(user: UserProfile): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/login.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/login.js
new file mode 100644
index 00000000000..a742fab5dbb
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/login.js
@@ -0,0 +1,11 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiLogin', (user = {}) => {
+ cy.url().should('include', '/login');
+
+ // # Type email and password, then Sign in
+ cy.get('#input_loginId').should('be.visible').type(user.email);
+ cy.get('#input_password-input').should('be.visible').type(user.password);
+ cy.get('#saveSetting').should('not.be.disabled').click();
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/menu.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/menu.d.ts
new file mode 100644
index 00000000000..235532d3b70
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/menu.d.ts
@@ -0,0 +1,46 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiOpenSystemConsoleMainMenu`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Open main menu at system console
+ * @param {string} item - such as `'Switch to [Team Name]'`, `'Administrator's Guide'`, `'Troubleshooting Forum'`, `'Commercial Support'`, `'About Mattermost'` and `'Log Out'`.
+ * @return the main menu
+ *
+ * @example
+ * cy.uiOpenSystemConsoleMainMenu();
+ */
+ uiOpenSystemConsoleMainMenu(): Chainable;
+
+ /**
+ * Close main menu at system console
+ *
+ * @example
+ * cy.uiCloseSystemConsoleMainMenu();
+ */
+ uiCloseSystemConsoleMainMenu(): Chainable;
+
+ /**
+ * Get main menu at system console
+ *
+ * @example
+ * cy.uiGetSystemConsoleMainMenu();
+ */
+ uiGetSystemConsoleMainMenu(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/menu.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/menu.js
new file mode 100644
index 00000000000..689f0e28165
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/menu.js
@@ -0,0 +1,46 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+const SYSTEM_CONSOLE_MAIN_MENU = 'Menu Icon';
+
+function openMenu(name, item) {
+ const menu = () => cy.findByRole('button', {name}).should('be.visible');
+
+ // # Open the menu
+ menu().should('be.visible').click();
+
+ if (!item) {
+ return menu();
+ }
+
+ // # Click on a particular item
+ return cy.findByRole('menu').findByText(item).scrollIntoView().should('be.visible').click();
+}
+
+function getMenu(name) {
+ return cy.findByRole('button', {name}).should('be.visible');
+}
+
+Cypress.Commands.add('uiOpenSystemConsoleMainMenu', (item = '') => {
+ return openMenu(SYSTEM_CONSOLE_MAIN_MENU, item);
+});
+
+Cypress.Commands.add('uiCloseSystemConsoleMainMenu', () => {
+ return cy.uiGetSystemConsoleMainMenu().click();
+});
+
+Cypress.Commands.add('uiGetSystemConsoleMainMenu', () => {
+ return getMenu(SYSTEM_CONSOLE_MAIN_MENU);
+});
+
+Cypress.Commands.add('uiOpenDndStatusSubMenu', () => {
+ cy.uiOpenUserMenu();
+
+ // # Wait for status menu to transition in
+ cy.get('.MenuWrapper.status-dropdown-menu .Menu__content.dropdown-menu').should('be.visible');
+
+ // # Hover over Do Not Disturb option
+ cy.get('.MenuWrapper.status-dropdown-menu .Menu__content.dropdown-menu li#status-menu-dnd_menuitem').trigger('mouseover');
+
+ return cy.get('#status-menu-dnd');
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/mfa.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/mfa.d.ts
new file mode 100644
index 00000000000..b45db855c5d
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/mfa.d.ts
@@ -0,0 +1,34 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiGetMFASecret`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get MFA secret of a given user
+ * @param {string} userId - ID of user
+ *
+ * @returns {string} `secret` - MFA secret
+ *
+ * @example
+ * const headerLabel = 'What\'s New';
+ * cy.uiGetMFASecret('user-id').then((secret) => {
+ * // do something with the secret
+ * });
+ */
+ uiGetMFASecret(userId: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/mfa.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/mfa.js
new file mode 100644
index 00000000000..0c350d69ecd
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/mfa.js
@@ -0,0 +1,32 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import authenticator from 'authenticator';
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+Cypress.Commands.add('uiGetMFASecret', (userId) => {
+ return cy.url().then((url) => {
+ if (url.includes('mfa/setup')) {
+ // # Complete MFA setup if we are on token setup page /mfa/setup
+ return cy.get('#mfa').wait(TIMEOUTS.HALF_SEC).find('.col-sm-12').then((p) => {
+ const secretp = p.text();
+ const secret = secretp.split(' ')[1];
+
+ const token = authenticator.generateToken(secret);
+ cy.findByPlaceholderText('MFA Code').type(token);
+ cy.findByText('Save').click();
+
+ cy.wait(TIMEOUTS.HALF_SEC);
+ cy.findByText('Okay').click();
+
+ return cy.wrap(secret);
+ });
+ }
+
+ // # If the user already has MFA enabled, reset the secret.
+ return cy.apiGenerateMfaSecret(userId).then((res) => {
+ return cy.wrap(res.code.secret);
+ });
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/modal.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/modal.d.ts
new file mode 100644
index 00000000000..3360cfecf4b
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/modal.d.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiCloseModal`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Close modal with header label
+ * @param {string} headerLabel - the header label
+ *
+ * @example
+ * const headerLabel = 'What\'s New';
+ * cy.uiCloseModal(headerLabel);
+ */
+ uiCloseModal(headerLabel: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/modal.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/modal.js
new file mode 100644
index 00000000000..1d2a3f1d645
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/modal.js
@@ -0,0 +1,9 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+Cypress.Commands.add('uiCloseModal', (headerLabel) => {
+ // # Close modal with modal label
+ cy.get('#genericModalLabel', {timeout: TIMEOUTS.HALF_MIN}).should('have.text', headerLabel).parents().find('.modal-dialog').findByLabelText('Close').click();
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/playbooks.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/playbooks.js
new file mode 100644
index 00000000000..4a2523367ab
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/playbooks.js
@@ -0,0 +1,278 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+const playbookRunStartCommand = '/playbook run';
+
+Cypress.Commands.add('startPlaybookRun', (playbookName, playbookRunName) => {
+ cy.get('#appsModal').should('exist').within(() => {
+ // # Select playbook
+ cy.selectPlaybookFromDropdown(playbookName);
+
+ // # Type playbook run name
+ cy.findByTestId('playbookRunNameinput').type(playbookRunName, {force: true});
+
+ // # Submit
+ cy.get('#appsModalSubmit').click();
+ });
+
+ cy.get('#appsModal').should('not.exist');
+});
+
+// Opens playbook run dialog using the `/playbook run` slash command
+Cypress.Commands.add('openPlaybookRunDialogFromSlashCommand', () => {
+ cy.uiPostMessageQuickly(playbookRunStartCommand);
+});
+
+// Starts playbook run with the `/playbook run` slash command
+Cypress.Commands.add('startPlaybookRunWithSlashCommand', (playbookName, playbookRunName) => {
+ cy.openPlaybookRunDialogFromSlashCommand();
+
+ cy.startPlaybookRun(playbookName, playbookRunName);
+});
+
+// Selects Playbooks icon in the App Bar
+Cypress.Commands.add('getPlaybooksAppBarIcon', () => {
+ cy.get('#channel_view').should('be.visible');
+
+ return cy.get('.app-bar').find('#app-bar-icon-playbooks');
+});
+
+// Starts playbook run from the playbook run RHS
+Cypress.Commands.add('startPlaybookRunFromRHS', (playbookName, playbookRunName) => {
+ cy.get('#channel-header').within(() => {
+ // open flagged posts to ensure playbook run RHS is closed
+ cy.get('#channelHeaderFlagButton').click();
+
+ // open the playbook run RHS
+ cy.getPlaybooksAppBarIcon().should('exist').click();
+ });
+
+ cy.get('#rhsContainer').should('exist').within(() => {
+ cy.findByText('Run playbook').click();
+ });
+
+ cy.startPlaybookRun(playbookName, playbookRunName);
+});
+
+// Create a new task from the RHS
+Cypress.Commands.add('addNewTaskFromRHS', (taskname) => {
+ // Click add new task
+ cy.findByTestId('add-new-task-0').click();
+
+ // Type a name
+ cy.findByTestId('checklist-item-textarea-title').type(taskname);
+
+ // Save task
+ cy.findByTestId('checklist-item-save-button').click();
+});
+
+// Starts playbook run from the post menu
+Cypress.Commands.add('startPlaybookRunFromPostMenu', (playbookName, playbookRunName) => {
+ // post a message as user to avoid system message
+ cy.findByTestId('post_textbox').clear().type('new message here{enter}');
+
+ // post a second message because cypress has trouble finding latest post when there's only one message
+ cy.findByTestId('post_textbox').clear().type('another new message here{enter}');
+ cy.clickPostActionsMenu();
+ cy.findByRole('menuitem', {name: 'Run playbook'}).click();
+ cy.startPlaybookRun(playbookName, playbookRunName);
+});
+
+// Create playbook
+Cypress.Commands.add('createPlaybook', (teamName, playbookName) => {
+ cy.visit('/playbooks/playbooks/new');
+
+ cy.findByTestId('save_playbook', {timeout: TIMEOUTS.HALF_MIN}).should('exist');
+
+ // # Type playbook name
+ cy.get('#playbook-name .editable-trigger').click();
+ cy.get('#playbook-name .editable-input').type(playbookName);
+ cy.get('#playbook-name .editable-input').type('{enter}');
+
+ // # Save playbook
+ cy.findByTestId('save_playbook', {timeout: TIMEOUTS.HALF_MIN}).should('not.be.disabled').click();
+ cy.wait(TIMEOUTS.TWO_SEC);
+ cy.findByTestId('save_playbook', {timeout: TIMEOUTS.HALF_MIN}).should('not.be.disabled').click();
+});
+
+// Select the playbook from the dropdown menu
+Cypress.Commands.add('selectPlaybookFromDropdown', (playbookName) => {
+ cy.findByTestId('playbookID').should('exist').within(() => {
+ cy.get('input').click().type(playbookName.toLowerCase(), {force: true});
+ });
+ cy.document().its('body').find('#react-select-2-listbox').contains(playbookName).click({force: true});
+});
+
+Cypress.Commands.add('createPost', (message) => {
+ // post a message as user to avoid system message
+ cy.findByTestId('post_textbox').clear().type(`${message}{enter}`);
+});
+
+Cypress.Commands.add('addPostToTimelineUsingPostMenu', (playbookRunName, summary, postId) => {
+ cy.clickPostDotMenu(postId);
+ cy.findByTestId('playbookRunAddToTimeline').click();
+
+ cy.get('#appsModal').should('exist').within(() => {
+ // # Select playbook run
+ cy.findByTestId('playbookID').should('exist').within(() => {
+ cy.get('input').click().type(playbookRunName);
+ });
+ cy.document().its('body').find('#react-select-2-listbox').contains(playbookRunName).click({force: true});
+
+ // # Type playbook run name
+ cy.findByTestId('summaryinput').clear().type(summary, {force: true});
+
+ // # Submit
+ cy.get('#appsModalSubmit').click();
+ });
+
+ cy.get('#appsModal').should('not.exist');
+});
+
+Cypress.Commands.add('openSelector', () => {
+ cy.findByText('Search for people').click({force: true});
+});
+
+Cypress.Commands.add('addInvitedUser', (userName) => {
+ cy.get('.invite-users-selector__menu').within(() => {
+ cy.findByText(userName).click({force: true});
+ });
+});
+
+Cypress.Commands.add('selectOwner', (userName) => {
+ cy.get('.assign-owner-selector__menu').within(() => {
+ cy.findByText(userName).click({force: true});
+ });
+});
+
+Cypress.Commands.add('selectChannel', (channelName) => {
+ cy.get('#playbook-automation-broadcast .playbooks-rselect__menu').within(() => {
+ cy.findByText(channelName).click({force: true});
+ });
+});
+
+Cypress.Commands.add('openReminderSelector', () => {
+ cy.get('#reminder_timer_datetime input').click({force: true});
+});
+
+Cypress.Commands.add('selectReminderTime', (timeText) => {
+ cy.get('#reminder_timer_datetime .playbooks-rselect__menu').within(() => {
+ cy.findByText(timeText).click({force: true});
+ });
+});
+
+/**
+ * Update the status of the current playbook run through the slash command.
+ */
+Cypress.Commands.add('updateStatus', (message, reminderQuery) => {
+ // # Run the slash command to update status.
+ cy.uiPostMessageQuickly('/playbook update');
+
+ // # Get the interactive dialog modal.
+ cy.getStatusUpdateDialog().within(() => {
+ cy.wait(3 * TIMEOUTS.ONE_HUNDRED_MILLIS);
+
+ // # remove what's there if applicable, and type the new update in the textbox.
+ cy.findByTestId('update_run_status_textbox').clear().focus().realType(message);
+
+ cy.wait(TIMEOUTS.ONE_HUNDRED_MILLIS);
+
+ if (reminderQuery) {
+ cy.get('#reminder_timer_datetime').within(() => {
+ cy.get('#react-select-2-input').focus().realType(reminderQuery).wait(TIMEOUTS.ONE_SEC);
+ cy.get('#react-select-2-input').focus().type('{enter}');
+ });
+ }
+
+ // # Submit the dialog.
+ cy.get('button.confirm').click();
+ });
+
+ // * Verify that the interactive dialog has gone.
+ cy.getStatusUpdateDialog().should('not.exist');
+
+ // # Return the post ID of the status update.
+ return cy.getLastPostId();
+});
+
+/**
+ * Edit a post through the post dot menu.
+ * @param {String} postId - ID of the post to delete.
+ * @param {String} newMessage - New content of the post.
+ */
+Cypress.Commands.add('editPost', (postId, newMessage) => {
+ // # Open the post dot menu.
+ cy.clickPostDotMenu(postId);
+
+ // # Click on the Edit menu option.
+ cy.get(`#edit_post_${postId}`).click();
+
+ // # Overwrite the post content with the new message provided.
+ cy.get('#edit_textbox').clear().type(newMessage);
+
+ // # Confirm the edit in the dialog.
+ cy.get('#editButton').click();
+});
+
+Cypress.Commands.add('getStatusUpdateDialog', () => {
+ return cy.findByRole('dialog', {name: /post update/i});
+});
+
+Cypress.Commands.add('getStyledComponent', (className) => {
+ cy.get(`[class^="${className}-"]`);
+});
+
+/**
+ * Get the provided pseudo-class from the previous element and return the property passed as argument
+ * @param {String} pseudoClass - CSS pseudo class to get.
+ * @param {String} property - Property that will be returned.
+ *
+ * Stolen from https://stackoverflow.com/questions/55516990/cypress-testing-pseudo-css-class-before
+ */
+Cypress.Commands.add('cssPseudoClass', {prevSubject: 'element'}, (el, pseudoClass, property) => {
+ const win = el[0].ownerDocument.defaultView;
+ const pseudoElem = win.getComputedStyle(el[0], pseudoClass);
+ return pseudoElem.getPropertyValue(property).replace(/(^")|("$)/g, '');
+});
+
+/**
+ * Get the :before pseudo-class from the previous element and return the property passed as argument
+ * @param {String} property - Property that will be returned.
+ */
+Cypress.Commands.add('before', {prevSubject: 'element'}, (el, property) => {
+ return cy.wrap(el).cssPseudoClass('before', property);
+});
+
+/**
+ * Get the :after pseudo-class from the previous element and return the property passed as argument
+ * @param {String} property - Property that will be returned.
+ */
+Cypress.Commands.add('after', {prevSubject: 'element'}, (el, property) => {
+ return cy.wrap(el).cssPseudoClass('after', property);
+});
+
+function waitUntilPermanentPost() {
+ cy.get('#postListContent').should('exist');
+ cy.waitUntil(() => cy.findAllByTestId('postView').last().then((el) => !(el[0].id.includes(':'))));
+}
+
+Cypress.Commands.add('getFirstPostId', () => {
+ waitUntilPermanentPost();
+
+ cy.findAllByTestId('postView').first().should('have.attr', 'id').and('not.include', ':').
+ invoke('replace', 'post_', '');
+});
+
+Cypress.Commands.add('assertRunDetailsPageRenderComplete', (expectedRunOwner) => {
+ // LHS uses position:fixed — use 'exist' to avoid Cypress 15 strict visibility checks
+ cy.findByTestId('lhs-navigation').should('exist').within(() => {
+ cy.contains('Playbooks').should('exist');
+ cy.contains('Runs').should('exist');
+ });
+ cy.get('#playbooks-sidebar-right').should('be.visible').within(() => {
+ cy.findByTestId('assignee-profile-selector').should('contain', expectedRunOwner);
+ cy.findAllByTestId('timeline-item', {exact: false}).should('have.length.of.at.least', 1);
+ cy.findAllByTestId('profile-option', {exact: false}).should('have.length.of.at.least', 1);
+ });
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post.ts
new file mode 100644
index 00000000000..ff1a8f014fa
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post.ts
@@ -0,0 +1,258 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ChainableT} from '../../types';
+
+function uiGetPostTextBox(option = {exist: true}): ChainableT {
+ if (option.exist) {
+ return cy.get('#post_textbox').should('be.visible');
+ }
+
+ return cy.get('#post_textbox').should('not.exist');
+}
+Cypress.Commands.add('uiGetPostTextBox', uiGetPostTextBox);
+
+function uiGetReplyTextBox(option = {exist: true}): ChainableT {
+ if (option.exist) {
+ return cy.get('#reply_textbox').should('be.visible');
+ }
+
+ return cy.get('#reply_textbox').should('not.exist');
+}
+Cypress.Commands.add('uiGetReplyTextBox', uiGetReplyTextBox);
+
+function uiGetPostProfileImage(postId: string): ChainableT {
+ return getPost(postId).within(() => {
+ return cy.get('.post__img').should('be.visible');
+ });
+}
+Cypress.Commands.add('uiGetPostProfileImage', uiGetPostProfileImage);
+
+function uiGetPostHeader(postId: string): ChainableT {
+ return getPost(postId).within(() => {
+ return cy.get('.post__header').should('be.visible');
+ });
+}
+Cypress.Commands.add('uiGetPostHeader', uiGetPostHeader);
+
+function uiGetPostBody(postId: string): ChainableT {
+ return getPost(postId).within(() => {
+ return cy.get('.post__body').should('be.visible');
+ });
+}
+Cypress.Commands.add('uiGetPostBody', uiGetPostBody);
+
+function uiGetPostThreadFooter(postId: string): ChainableT {
+ return getPost(postId).find('.ThreadFooter');
+}
+Cypress.Commands.add('uiGetPostThreadFooter', uiGetPostThreadFooter);
+
+function uiGetPostEmbedContainer(postId: string): ChainableT {
+ return cy.uiGetPostBody(postId).
+ find('.file-preview__button').
+ should('be.visible');
+}
+Cypress.Commands.add('uiGetPostEmbedContainer', uiGetPostEmbedContainer);
+
+function getPost(postId: string): ChainableT {
+ if (postId) {
+ return cy.get(`#post_${postId}`).should('be.visible');
+ }
+
+ return cy.getLastPost();
+}
+Cypress.Commands.add('getPost', getPost);
+
+export function verifySavedPost(postId, message) {
+ // * Check that the center save icon has been updated correctly
+ cy.get(`#post_${postId}`).trigger('mouseover', {force: true});
+ cy.get(`#CENTER_flagIcon_${postId}`).
+ should('have.class', 'post-menu__item').
+ and('have.attr', 'aria-label', 'remove from saved');
+
+ // # Open the post-dotmenu
+ cy.clickPostDotMenu(postId, 'CENTER');
+
+ // * Check that the dotmenu item is changed accordingly
+ cy.findAllByTestId(`post-menu-${postId}`).eq(0).should('be.visible');
+ cy.findByText('Remove from Saved').scrollIntoView().should('be.visible');
+ cy.get(`#CENTER_dropdown_${postId}`).should('be.visible').type('{esc}');
+
+ cy.get('#postListContent').within(() => {
+ // * Check that the post is highlighted
+ cy.get(`#post_${postId}`).should('have.class', 'post--pinned-or-flagged');
+
+ // * Check that the post pre-header is visible
+ cy.get('div.post-pre-header').should('be.visible');
+
+ // * Check that the post pre-header has the saved icon
+ cy.get('span.icon--post-pre-header').
+ should('be.visible').
+ within(() => {
+ cy.get('svg').should('have.attr', 'aria-label', 'Saved Icon');
+ });
+
+ // * Check that the post pre-header has the saved post link
+ cy.get('div.post-pre-header__text-container').
+ should('be.visible').
+ and('have.text', 'Saved').
+ within(() => {
+ cy.get('a').as('savedLink').should('be.visible');
+ });
+ });
+
+ // * Check that the saved posts list is not open in RHS before clicking the link in the post pre-header
+ cy.get('#searchContainer').should('not.exist');
+
+ // # Click the link
+ cy.get('@savedLink').click();
+
+ // * Check that the saved posts list is open in RHS
+ cy.get('#searchContainer').should('be.visible').within(() => {
+ cy.get('.sidebar--right__title').
+ should('be.visible').
+ and('have.text', 'Saved Messages');
+
+ // * Check that the post pre-header is not shown for the saved message in RHS
+ cy.get(`#searchResult_${postId}`).within(() => {
+ cy.get(`#rhsPostMessageText_${postId}`).contains(message);
+ cy.get('div.post-pre-header').should('not.exist');
+ });
+ });
+
+ // # Close the RHS
+ cy.get('#searchResultsCloseButton').should('be.visible').click();
+}
+
+export function verifyUnsavedPost(postId) {
+ // * Check that the center save icon has been updated correctly
+ cy.get(`#post_${postId}`).trigger('mouseover', {force: true});
+ cy.get(`#CENTER_flagIcon_${postId}`).
+ should('have.class', 'post-menu__item').
+ and('have.attr', 'aria-label', 'save');
+
+ // # Open the post-dotmenu
+ cy.clickPostDotMenu(postId, 'CENTER');
+
+ // * Check that the dotmenu item is changed accordingly
+ cy.findAllByTestId(`post-menu-${postId}`).eq(0).should('be.visible');
+ cy.findByText('Save').scrollIntoView().should('be.visible');
+ cy.get(`#CENTER_dropdown_${postId}`).should('be.visible').type('{esc}');
+
+ cy.get('#postListContent').within(() => {
+ // * Check that the post is not highlighted
+ cy.get(`#post_${postId}`).should('not.have.class', 'post--pinned-or-flagged');
+
+ // * Check that the post pre-header is not visible
+ cy.get('div.post-pre-header').should('not.exist');
+
+ // * Check that the post pre-header does not have the saved icon
+ cy.get('span.icon--post-pre-header').
+ should('not.exist');
+
+ // * Check that the post pre-header does not have the saved post link
+ cy.get('div.post-pre-header__text-container').
+ should('not.exist');
+ });
+
+ // * Check that the saved posts list is not open in RHS before clicking the link in the post pre-header
+ cy.get('#searchContainer').should('not.exist');
+
+ // # Click the link
+ cy.uiGetSavedPostButton().click();
+
+ // * Check that the saved posts list is open in RHS
+ cy.get('#searchContainer').should('be.visible').within(() => {
+ cy.get('.sidebar--right__title').
+ should('be.visible').
+ and('have.text', 'Saved Messages');
+
+ // * Check that the post pre-header is not shown for the saved message in RHS
+ cy.get('#search-items-container').within(() => {
+ cy.get(`#rhsPostMessageText_${postId}`).should('not.exist');
+ });
+ });
+
+ // # Close the RHS
+ cy.get('#searchResultsCloseButton').should('be.visible').click();
+}
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get post profile image of a given post ID or the last post if post ID is not given
+ *
+ * @param {string} - postId (optional)
+ *
+ * @example
+ * cy.uiGetPostProfileImage();
+ */
+ uiGetPostProfileImage: typeof uiGetPostProfileImage;
+
+ /**
+ * Get post header of a given post ID or the last post if post ID is not given
+ *
+ * @param {string} - postId (optional)
+ *
+ * @example
+ * cy.uiGetPostHeader();
+ */
+ uiGetPostHeader: typeof uiGetPostHeader;
+
+ /**
+ * Get post body of a given post ID or the last post if post ID is not given
+ *
+ * @param {string} - postId (optional)
+ *
+ * @example
+ * cy.uiGetPostBody();
+ */
+ uiGetPostBody: typeof uiGetPostBody;
+
+ /**
+ * Get post thread footer of a given post ID or the last post if post ID is not given
+ *
+ * @param {string} - postId (optional)
+ *
+ * @example
+ * cy.uiGetPostThreadFooter();
+ */
+ uiGetPostThreadFooter: typeof uiGetPostThreadFooter;
+
+ /**
+ * Get post embed container of a given post ID or the last post if post ID is not given
+ *
+ * @param {string} - postId (optional)
+ *
+ * @example
+ * cy.uiGetPostEmbedContainer();
+ */
+ uiGetPostEmbedContainer: typeof uiGetPostEmbedContainer;
+
+ /**
+ * Get post textbox
+ *
+ * @param {bool} option.exist - Set to false to check whether element should not exist. Otherwise, true (default) to check visibility.
+ *
+ * @example
+ * cy.uiGetPostTextBox();
+ */
+ uiGetPostTextBox: typeof uiGetPostTextBox;
+
+ /**
+ * Get reply textbox
+ *
+ * @param {bool} option.exist - Set to false to check whether element should not exist. Otherwise, true (default) to check visibility.
+ *
+ * @example
+ * cy.uiGetReplyTextBox();
+ */
+ uiGetReplyTextBox: typeof uiGetReplyTextBox;
+
+ getPost: typeof getPost;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post_dropdown_menu.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post_dropdown_menu.d.ts
new file mode 100644
index 00000000000..3d8348f9fb9
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post_dropdown_menu.d.ts
@@ -0,0 +1,39 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiClickCopyLink`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Click on "Copy Link" of post dropdown menu and verifies if the link is copied into the clipboard
+ * Created user has an option to log in after all are setup.
+ * @param {string} permalink - permalink to verify if copied into the clipboard
+ *
+ * @example
+ * const permalink = 'http://localhost:8065/team-name/pl/post-id';
+ * cy.uiClickCopyLink(permalink);
+ */
+ uiClickCopyLink(permalink: string, postId: string): Chainable;
+
+ /**
+ * Click dropdown menu of a post by post ID.
+ * @param {String} postId - post ID
+ * @param {String} menuItem - e.g. "Pin to channel"
+ * @param {String} location - 'CENTER' (default), 'SEARCH', RHS_ROOT, RHS_COMMENT
+ */
+ uiClickPostDropdownMenu(postId: string, menuItem: string, location?: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post_dropdown_menu.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post_dropdown_menu.js
new file mode 100644
index 00000000000..02d41fed664
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/post_dropdown_menu.js
@@ -0,0 +1,32 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {stubClipboard} from '../../utils';
+
+Cypress.Commands.add('uiClickCopyLink', (permalink, postId) => {
+ stubClipboard().as('clipboard');
+
+ // * Verify initial state
+ cy.get('@clipboard').its('contents').should('eq', '');
+
+ // # Click on "Copy Link"
+ cy.get(`#CENTER_dropdown_${postId}`).should('be.visible').within(() => {
+ cy.findByText('Copy Link').scrollIntoView().should('be.visible').click();
+
+ // * Verify if it's called with correct link value
+ cy.get('@clipboard').its('wasCalled').should('eq', true);
+ cy.get('@clipboard').its('contents').should('eq', permalink);
+ });
+});
+
+Cypress.Commands.add('uiClickPostDropdownMenu', (postId, menuItem, location = 'CENTER') => {
+ cy.clickPostDotMenu(postId, location);
+ cy.findAllByTestId(`post-menu-${postId}`).eq(0).should('be.visible');
+ cy.findByText(menuItem).scrollIntoView().should('be.visible').click({force: true});
+});
+
+Cypress.Commands.add('uiPostDropdownMenuShortcut', (postId, menuText, shortcutKey, location = 'CENTER') => {
+ cy.clickPostDotMenu(postId, location);
+ cy.findByText(menuText).scrollIntoView().should('be.visible');
+ cy.get('body').type(shortcutKey);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/search.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/search.js
new file mode 100644
index 00000000000..83423799549
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/search.js
@@ -0,0 +1,22 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiSearchPosts', (searchTerm) => {
+ // # Enter the search terms and hit enter to start the search
+ cy.get('#searchBox').clear().type(searchTerm).type('{enter}');
+
+ // * Wait for the RHS to open and the search results to appear
+ cy.contains('.sidebar--right__header', 'Search Results').should('be.visible');
+ cy.get('#searchContainer .LoadingSpinner').should('not.exist');
+});
+
+Cypress.Commands.add('uiJumpToSearchResult', (postId) => {
+ // # Find the post in the search results and click Jump
+ cy.get(`#searchResult_${postId}`).contains('a', 'Jump').click();
+
+ // * Verify the URL changes to the permalink URL
+ cy.url().should((url) => url.endsWith(`/${postId}`));
+
+ // * Verify that the permalinked post is highlighted in the center channel
+ cy.get(`#post_${postId}.post--highlight`).should('be.visible');
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_left.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_left.ts
new file mode 100644
index 00000000000..683c172b918
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_left.ts
@@ -0,0 +1,273 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ChainableT} from '../../types';
+
+Cypress.Commands.add('uiGetLHS', () => {
+ return cy.get('#SidebarContainer').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetLHSHeader', () => {
+ return cy.uiGetLHS().
+ find('.SidebarHeaderMenuWrapper').
+ should('be.visible');
+});
+
+Cypress.Commands.add('uiOpenTeamMenu', (item = '') => {
+ // # Click on LHS header
+ cy.uiGetLHSHeader().click();
+
+ if (!item) {
+ // # Return the menu if no item is passed
+ return cy.uiGetLHSTeamMenu();
+ }
+
+ // # Click on a particular item
+ return cy.uiGetLHSTeamMenu().
+ findByText(item).
+ scrollIntoView().
+ should('be.visible').
+ click();
+});
+
+Cypress.Commands.add('uiGetLHSAddChannelButton', () => {
+ return cy.uiGetLHS().
+ find('.AddChannelDropdown_dropdownButton');
+});
+
+Cypress.Commands.add('uiGetLHSTeamMenu', () => {
+ return cy.uiGetLHS().find('#sidebarDropdownMenu');
+});
+
+function uiOpenSystemConsoleMenu(item = ''): ChainableT {
+ // # Click on LHS header button
+ cy.uiGetSystemConsoleButton().click();
+
+ if (!item) {
+ // # Return the menu if no item is passed
+ return cy.uiGetSystemConsoleMenu();
+ }
+
+ // # Click on a particular item
+ return cy.uiGetSystemConsoleMenu().
+ findByText(item).
+ scrollIntoView().
+ should('be.visible').
+ click();
+}
+
+Cypress.Commands.add('uiOpenSystemConsoleMenu', uiOpenSystemConsoleMenu);
+
+function uiGetSystemConsoleButton(): ChainableT {
+ return cy.get('.admin-sidebar').
+ findByRole('button', {name: 'Menu Icon'});
+}
+
+Cypress.Commands.add('uiGetSystemConsoleButton', uiGetSystemConsoleButton);
+
+function uiGetSystemConsoleMenu(): ChainableT {
+ return cy.get('.admin-sidebar').
+ find('.dropdown-menu').
+ should('be.visible');
+}
+
+Cypress.Commands.add('uiGetSystemConsoleMenu', uiGetSystemConsoleMenu);
+
+Cypress.Commands.add('uiGetLhsSection', (section) => {
+ if (section === 'UNREADS') {
+ return cy.findByText(section).
+ parent().
+ parent().
+ parent();
+ }
+
+ return cy.findAllByRole('button', {name: section}).
+ first().
+ parent().
+ parent().
+ parent();
+});
+
+Cypress.Commands.add('uiBrowseOrCreateChannel', (item) => {
+ cy.get('.AddChannelDropdown_dropdownButton').
+ should('be.visible').
+ click();
+ cy.get('.dropdown-menu').should('be.visible');
+
+ if (item) {
+ cy.findByRole('menuitem', {name: item});
+ }
+});
+
+Cypress.Commands.add('uiAddDirectMessage', () => {
+ return cy.findByRole('button', {name: 'Write a direct message'});
+});
+
+Cypress.Commands.add('uiGetFindChannels', () => {
+ return cy.get('#lhsNavigator').findByRole('button', {name: 'Find Channels'});
+});
+
+Cypress.Commands.add('uiOpenFindChannels', () => {
+ cy.uiGetFindChannels().click();
+});
+
+function uiGetSidebarThreadsButton(): ChainableT {
+ return cy.get('#sidebar-threads-button').should('be.visible');
+}
+Cypress.Commands.add('uiGetSidebarThreadsButton', uiGetSidebarThreadsButton);
+
+Cypress.Commands.add('uiGetChannelSidebarMenu', (channelName, isChannelId = false) => {
+ cy.uiGetLHS().within(() => {
+ if (isChannelId) {
+ cy.get(`#sidebarItem_${channelName}`).should('be.visible').find('button').should('exist').click({force: true});
+ } else {
+ cy.findByText(channelName).should('be.visible').parents('a').find('button').should('exist').click({force: true});
+ }
+ });
+
+ return cy.findByRole('menu', {name: 'Edit channel menu'}).should('be.visible');
+});
+
+Cypress.Commands.add('uiClickSidebarItem', (name) => {
+ cy.uiGetSidebarItem(name).click({force: true});
+
+ if (name === 'threads') {
+ cy.get('body').then((body) => {
+ if (body.find('#genericModalLabel').length > 0) {
+ cy.uiCloseModal('A new way to view and follow threads');
+ }
+ });
+ cy.findByRole('heading', {name: 'Followed threads'});
+ } else {
+ cy.findAllByTestId('postView').should('be.visible');
+ }
+});
+
+Cypress.Commands.add('uiGetSidebarItem', (channelName) => {
+ return cy.get(`#sidebarItem_${channelName}`);
+});
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get LHS
+ *
+ * @example
+ * cy.uiGetLHS();
+ */
+ uiGetLHS(): Chainable;
+
+ /**
+ * Get LHS header
+ *
+ * @example
+ * cy.uiGetLHSHeader().click();
+ */
+ uiGetLHSHeader(): Chainable;
+
+ /**
+ * Open team menu
+ *
+ * @param {string} item - ex. 'Invite People', 'Team Settings', etc.
+ *
+ * @example
+ * cy.uiOpenTeamMenu();
+ */
+ uiOpenTeamMenu(item?: string): Chainable;
+
+ /**
+ * Get LHS add channel button
+ *
+ * @example
+ * cy.uiGetLHSAddChannelButton().click();
+ */
+ uiGetLHSAddChannelButton(): Chainable;
+
+ /**
+ * Get LHS team menu
+ *
+ * @example
+ * cy.uiGetLHSTeamMenu().should('not.exist);
+ */
+ uiGetLHSTeamMenu(): Chainable;
+
+ /**
+ * Get LHS section
+ * @param {string} section - section such as UNREADS, CHANNELS, FAVORITES, DIRECT MESSAGES and other custom category
+ *
+ * @example
+ * cy.uiGetLhsSection('CHANNELS');
+ */
+ uiGetLhsSection(section: string): Chainable;
+
+ /**
+ * Open menu to browse or create channel
+ * @param {string} item - dropdown menu. If set, it will do click action.
+ *
+ * @example
+ * cy.uiBrowseOrCreateChannel('Browse channels');
+ */
+ uiBrowseOrCreateChannel(item: string): Chainable;
+
+ /**
+ * Get "+" button to write a direct message
+ * @example
+ * cy.uiAddDirectMessage();
+ */
+ uiAddDirectMessage(): Chainable;
+
+ /**
+ * Get find channels button
+ * @example
+ * cy.uiGetFindChannels();
+ */
+ uiGetFindChannels(): Chainable;
+
+ /**
+ * Open find channels
+ * @example
+ * cy.uiOpenFindChannels();
+ */
+ uiOpenFindChannels(): Chainable;
+
+ /**
+ * Open menu of a channel in the sidebar
+ * @param {string} channelName - name of channel, ex. 'town-square'
+ * @param {boolean} isChannelId - default false. If true, it will use channel id instead of channel name
+ * @example
+ * cy.uiGetChannelSidebarMenu('Town Square');
+ * cy.uiGetChannelSidebarMenu('user1212__user333', true);
+ */
+ uiGetChannelSidebarMenu(channelName: string, isChannelId?: boolean): Chainable;
+
+ /**
+ * Click sidebar item by channel or thread name
+ * @param {string} name - channel name for channels, and threads for Global Threads
+ *
+ * @example
+ * cy.uiClickSidebarItem('town-square');
+ */
+ uiClickSidebarItem(name: string): Chainable;
+
+ /**
+ * Get sidebar item by channel or thread name
+ * @param {string} name - channel name for channels, and threads for Global Threads
+ *
+ * @example
+ * cy.uiGetSidebarItem('town-square').find('.badge').should('be.visible');
+ */
+ uiGetSidebarItem(name: string): Chainable;
+
+ uiOpenSystemConsoleMenu: typeof uiOpenSystemConsoleMenu;
+
+ uiGetSystemConsoleButton: typeof uiGetSystemConsoleButton;
+
+ uiGetSystemConsoleMenu: typeof uiGetSystemConsoleMenu;
+
+ uiGetSidebarThreadsButton: typeof uiGetSidebarThreadsButton;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_right.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_right.d.ts
new file mode 100644
index 00000000000..8544122914f
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_right.d.ts
@@ -0,0 +1,108 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiGetRHS`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get RHS container
+ *
+ * @param {bool} option.visible - Set to false to check whether RHS is not visible. Otherwise, true (default) to check visibility.
+ *
+ * @example
+ * cy.uiGetRHS();
+ */
+ uiGetRHS(option?: Record): Chainable;
+
+ /**
+ * Close RHS
+ *
+ * @example
+ * cy.uiCloseRHS();
+ */
+ uiCloseRHS(): Chainable;
+
+ /**
+ * Expand RHS
+ *
+ * @example
+ * cy.uiExpandRHS();
+ */
+ uiExpandRHS(): Chainable;
+
+ /**
+ * Verify if RHS is expanded
+ *
+ * @example
+ * cy.uiGetRHS().isExpanded();
+ */
+ isExpanded(): Chainable;
+
+ /**
+ * Get "Reply" button
+ *
+ * @example
+ * cy.uiGetReply();
+ */
+ uiGetReply(): Chainable;
+
+ /**
+ * Reply by clicking "Reply" button
+ *
+ * @example
+ * cy.uiReply();
+ */
+ uiReply(): Chainable;
+
+ /**
+ * Get RHS container
+ *
+ * @param {bool} option.visible - Set to false to check whether Search container at RHS is not visible. Otherwise, true (default) to check visibility.
+ *
+ * @example
+ * cy.uiGetRHSSearchContainer();
+ */
+ uiGetRHSSearchContainer(option: Record): Chainable;
+
+ /**
+ * Get file filter button from RHS.
+ *
+ * @example
+ * cy.uiGetFileFilterButton().click();
+ */
+ uiGetFileFilterButton(): Chainable;
+
+ /**
+ * Get file filter menu from RHS
+ *
+ * @param {bool} option.exist - Set to false to check whether file filter menu should not exist at RHS. Otherwise, true (default) to check visibility.
+ *
+ * @example
+ * cy.uiGetFileFilterMenu();
+ */
+ uiGetFileFilterMenu(): Chainable;
+
+ /**
+ * Open file filter menu from RHS
+ * @param {string} item - such as `'Documents'`, `'Spreadsheets'`, `'Presentations'`, `'Code'`, `'Images'`, `'Audio'` and `'Videos'`.
+ * @return the file filter menu
+ *
+ * @example
+ * cy.uiOpenFileFilterMenu();
+ */
+ uiOpenFileFilterMenu(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_right.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_right.js
new file mode 100644
index 00000000000..9ba60991a97
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/sidebar_right.js
@@ -0,0 +1,74 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiGetRHS', (options = {visible: true}) => {
+ if (options.visible) {
+ return cy.get('#sidebar-right').should('be.visible');
+ }
+
+ return cy.get('#sidebar-right').should('not.be.exist');
+});
+
+Cypress.Commands.add('uiCloseRHS', () => {
+ cy.findByLabelText('Close Sidebar Icon').click();
+});
+
+Cypress.Commands.add('uiExpandRHS', () => {
+ cy.findByLabelText('Expand').click();
+});
+
+Cypress.Commands.add('isExpanded', {prevSubject: true}, (subject) => {
+ return cy.get(subject).should('have.class', 'sidebar--right--expanded');
+});
+
+Cypress.Commands.add('uiGetReply', () => {
+ return cy.get('#sidebar-right').findByTestId('SendMessageButton');
+});
+
+Cypress.Commands.add('uiReply', () => {
+ cy.uiGetReply().click();
+});
+
+// Sidebar search container
+
+Cypress.Commands.add('uiGetRHSSearchContainer', (options = {visible: true}) => {
+ if (options.visible) {
+ return cy.get('#searchContainer').should('be.visible');
+ }
+
+ return cy.get('#searchContainer').should('not.exist');
+});
+
+// Sidebar files search
+
+Cypress.Commands.add('uiGetFileFilterButton', () => {
+ return cy.get('.FilesFilterMenu').should('be.visible');
+});
+
+Cypress.Commands.add('uiGetFileFilterMenu', (option = {exist: true}) => {
+ if (option.exist) {
+ return cy.get('.FilesFilterMenu').
+ find('.dropdown-menu').
+ should('be.visible');
+ }
+
+ return cy.get('.FilesFilterMenu').
+ find('.dropdown-menu').
+ should('not.exist');
+});
+
+Cypress.Commands.add('uiOpenFileFilterMenu', (item = '') => {
+ // # Click on file filter button
+ cy.uiGetFileFilterButton().click();
+
+ if (!item) {
+ // # Return the menu if no item is passed
+ return cy.uiGetFileFilterMenu();
+ }
+
+ // # Click on a particular item
+ return cy.uiGetFileFilterMenu().
+ findByText(item).
+ should('be.visible').
+ click();
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/suggestion_list.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/suggestion_list.d.ts
new file mode 100644
index 00000000000..74aade48304
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/suggestion_list.d.ts
@@ -0,0 +1,41 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiCheckLicenseExists`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Verify user's at-mention in the suggestion list
+ * @param {UserProfile} user - user object
+ * @param {boolean} isSelected - check if user is selected with false as default
+ * @param {string} sectionDividerName - name of the section in suggestion list, ex. "Channel Members"
+ *
+ * @example
+ * cy.uiVerifyAtMentionInSuggestionList(user, true, 'Channel Members');
+ */
+ uiVerifyAtMentionInSuggestionList(user: UserProfile, isSelected: boolean, sectionDividerName?: string): Chainable;
+
+ /**
+ * Verify user's at-mention suggestion
+ * @param {UserProfile} user - user object
+ * @param {boolean} isSelected - check if user is selected with false as default
+ *
+ * @example
+ * cy.uiVerifyAtMentionSuggestion(user, true);
+ */
+ uiVerifyAtMentionSuggestion(user: UserProfile, isSelected?: boolean): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/suggestion_list.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/suggestion_list.js
new file mode 100644
index 00000000000..c187e14d417
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/suggestion_list.js
@@ -0,0 +1,36 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiVerifyAtMentionInSuggestionList', (user, isSelected = false, sectionDividerName = null) => {
+ // * Verify that the suggestion list is open and visible
+ return cy.get('#suggestionList').should('be.visible').within(() => {
+ if (sectionDividerName) {
+ // * Verify the section name is as expected
+ cy.get('.suggestion-list__divider').findByText(sectionDividerName).should('be.visible');
+ cy.get('.suggestion-list__divider').next().findByTestId(`mentionSuggestion_${user.username}`).should('be.visible');
+ }
+
+ // * Verify that the user is selected
+ return cy.uiVerifyAtMentionSuggestion(user, isSelected);
+ });
+});
+
+Cypress.Commands.add('uiVerifyAtMentionSuggestion', (user, isSelected = false) => {
+ const {
+ username,
+ first_name: firstName,
+ last_name: lastName,
+ nickname,
+ } = user;
+
+ // * Verify that the user is selected
+ cy.findByTestId(`mentionSuggestion_${username}`).as('selectedMentionSuggestion').should('be.visible');
+ if (isSelected) {
+ cy.get('@selectedMentionSuggestion').should('have.class', 'suggestion--selected');
+ }
+
+ cy.get('@selectedMentionSuggestion').findByText(`@${username}`).should('be.visible');
+ cy.get('@selectedMentionSuggestion').findByText(`${firstName} ${lastName} (${nickname})`).should('be.visible');
+
+ return cy.findByTestId(`mentionSuggestion_${username}`);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/system.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/system.d.ts
new file mode 100644
index 00000000000..abf351f3fa3
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/system.d.ts
@@ -0,0 +1,44 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiCheckLicenseExists`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Verify license exists via admin console.
+ *
+ * @example
+ * cy.uiCheckLicenseExists();
+ */
+ uiCheckLicenseExists(): Chainable;
+
+ /**
+ * Reset system scheme permissions via System Console
+ *
+ * @example
+ * cy.uiResetPermissionsToDefault();
+ */
+ uiResetPermissionsToDefault(): Chainable;
+
+ /**
+ * Save settings located in System Console
+ *
+ * @example
+ * cy.uiSaveConfig();
+ */
+ uiSaveConfig(): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/system.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/system.js
new file mode 100644
index 00000000000..118453015b5
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/system.js
@@ -0,0 +1,40 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import * as TIMEOUTS from '../../fixtures/timeouts';
+
+Cypress.Commands.add('uiCheckLicenseExists', () => {
+ // # Go to system admin then verify admin console URL, header, and content
+ cy.visit('/admin_console/about/license');
+ cy.url().should('include', '/admin_console/about/license');
+ cy.get('.admin-console', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').within(() => {
+ cy.get('.admin-console__header').should('be.visible').and('have.text', 'Edition and License');
+ cy.get('.admin-console__content').should('be.visible').and('not.contain', 'undefined').and('not.contain', 'Invalid');
+ cy.get('#remove-button').should('be.visible');
+ });
+});
+
+Cypress.Commands.add('uiResetPermissionsToDefault', () => {
+ // # Navigate to system scheme page
+ cy.visit('/admin_console/user_management/permissions/system_scheme');
+
+ // # Click reset to defaults and confirm
+ cy.findByTestId('resetPermissionsToDefault', {timeout: TIMEOUTS.HALF_MIN}).click();
+ cy.get('#confirmModalButton').click();
+ cy.uiSaveConfig();
+});
+
+Cypress.Commands.add('uiSaveConfig', ({confirm = true} = {}) => {
+ // # Save settings
+ cy.get('#saveSetting').should('be.enabled').click();
+ cy.wait(TIMEOUTS.HALF_SEC);
+
+ if (confirm) {
+ // # Wait until the UI shows the saving is done and revert the text to "Save"
+ cy.waitUntil(() => cy.get('#saveSetting').then((el) => {
+ return el[0].innerText === 'Save';
+ }));
+ } else {
+ cy.wait(TIMEOUTS.HALF_SEC);
+ }
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/team.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/team.js
new file mode 100644
index 00000000000..3354972f3c2
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/team.js
@@ -0,0 +1,26 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiInviteMemberToCurrentTeam', (username) => {
+ // # Open member invite screen
+ cy.uiOpenTeamMenu('Invite People');
+
+ // # Open members section if licensed for guest accounts
+ cy.findByTestId('invitationModal').
+ then((container) => container.find('[data-testid="inviteMembersLink"]')).
+ then((link) => link && link.click());
+
+ // # Enter bot username and submit
+ cy.get('.users-emails-input__control input').typeWithForce(username).as('input');
+ cy.get('.users-emails-input__option ').contains(`@${username}`);
+ cy.get('@input').typeWithForce('{enter}');
+ cy.findByTestId('inviteButton').click();
+
+ // * Verify user invited to team
+ cy.get('.invitation-modal-confirm--sent .InviteResultRow').
+ should('contain.text', `@${username}`).
+ and('contain.text', 'This member has been added to the team.');
+
+ // # Close, return
+ cy.findByTestId('confirm-done').click();
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/tooltip.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/tooltip.d.ts
new file mode 100644
index 00000000000..59c05f64ce8
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/tooltip.d.ts
@@ -0,0 +1,30 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// Custom command should follow naming convention of having `ui` prefix, e.g. `uiGetToolTip`.
+// ***************************************************************
+
+declare namespace Cypress {
+ interface Chainable {
+
+ /**
+ * Get tooltip
+ *
+ * @param {string} text of the tooltip
+ *
+ * @example
+ * cy.uiGetToolTip('text');
+ */
+ uiGetToolTip(text: string): Chainable;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/tooltip.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/tooltip.js
new file mode 100644
index 00000000000..d74768d3a8e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui/tooltip.js
@@ -0,0 +1,6 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+Cypress.Commands.add('uiGetToolTip', (text) => {
+ cy.findByRole('tooltip').should('contain', text);
+});
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui_commands.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui_commands.ts
new file mode 100644
index 00000000000..e4a7098234e
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/ui_commands.ts
@@ -0,0 +1,807 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import localforage from 'localforage';
+
+import * as TIMEOUTS from '../fixtures/timeouts';
+import {isMac} from '../utils';
+
+import {ChainableT} from '../types';
+
+// ***********************************************************
+// Read more: https://on.cypress.io/custom-commands
+// ***********************************************************
+
+function logout(): ChainableT {
+ return cy.get('#logout').click({force: true});
+}
+Cypress.Commands.add('logout', logout);
+
+function getCurrentUserId(): ChainableT> {
+ return cy.wrap(new Promise((resolve) => {
+ cy.getCookie('MMUSERID').then((cookie) => {
+ resolve(cookie.value);
+ });
+ }));
+}
+Cypress.Commands.add('getCurrentUserId', getCurrentUserId);
+
+// ***********************************************************
+// Key Press
+// ***********************************************************
+
+// Type Cmd or Ctrl depending on OS
+function typeCmdOrCtrl(): ChainableT {
+ return typeCmdOrCtrlInt('#post_textbox');
+}
+Cypress.Commands.add('typeCmdOrCtrl', typeCmdOrCtrl);
+
+function typeCmdOrCtrlForEdit(): ChainableT {
+ return typeCmdOrCtrlInt('#edit_textbox');
+}
+Cypress.Commands.add('typeCmdOrCtrlForEdit', typeCmdOrCtrlForEdit);
+
+function typeCmdOrCtrlInt(textboxSelector: string) {
+ let cmdOrCtrl: string;
+ if (isMac()) {
+ cmdOrCtrl = '{cmd}';
+ } else {
+ cmdOrCtrl = '{ctrl}';
+ }
+
+ return cy.get(textboxSelector).type(cmdOrCtrl, {release: false});
+}
+
+function cmdOrCtrlShortcut(subject: string, text?: string): ChainableT {
+ const cmdOrCtrl = isMac() ? '{cmd}' : '{ctrl}';
+ return cy.get(subject).type(`${cmdOrCtrl}${text}`);
+}
+Cypress.Commands.add('cmdOrCtrlShortcut', {prevSubject: true}, cmdOrCtrlShortcut);
+
+// ***********************************************************
+// Post
+// ***********************************************************
+
+function postMessage(message: string): ChainableT {
+ cy.get('#postListContent').should('be.visible');
+ return postMessageAndWait('#post_textbox', message);
+}
+Cypress.Commands.add('postMessage', postMessage);
+
+function postMessageReplyInRHS(message: string): ChainableT {
+ cy.get('#sidebar-right').should('be.visible');
+ return postMessageAndWait('#reply_textbox', message, true);
+}
+Cypress.Commands.add('postMessageReplyInRHS', postMessageReplyInRHS);
+
+Cypress.Commands.add('uiPostMessageQuickly', (message) => {
+ cy.uiGetPostTextBox().should('be.visible').clear().
+ invoke('val', message).wait(TIMEOUTS.HALF_SEC).type(' {backspace}{meta+enter}');
+ cy.waitUntil(() => {
+ return cy.uiGetPostTextBox().then((el) => {
+ return el[0].textContent === '';
+ });
+ });
+});
+
+function postMessageAndWait(textboxSelector: string, message: string, isComment = false) {
+ // Add explicit wait to let the page load freely since `cy.get` seemed to block
+ // some operation which caused to prolong complete page loading.
+ cy.wait(TIMEOUTS.HALF_SEC);
+ cy.get(textboxSelector, {timeout: TIMEOUTS.HALF_MIN}).should('be.visible');
+
+ // # Type then wait for a while for the draft to be saved (async) into the local storage
+ cy.get(textboxSelector).clear().type(message).wait(TIMEOUTS.ONE_SEC);
+
+ // If posting a comment, wait for comment draft from localforage before hitting enter
+ if (isComment) {
+ waitForCommentDraft(message);
+ }
+
+ cy.get(textboxSelector).should('have.value', message).focus().type('{enter}').wait(TIMEOUTS.HALF_SEC);
+
+ cy.get(textboxSelector).invoke('val').then((value: string) => {
+ if (value.length > 0 && value === message) {
+ cy.get(textboxSelector).type('{enter}').wait(TIMEOUTS.HALF_SEC);
+ }
+ });
+ return cy.waitUntil(() => {
+ return cy.get(textboxSelector).then((el) => {
+ return el[0].textContent === '';
+ });
+ });
+}
+
+interface Draft {
+ value?: {
+ message?: string;
+ };
+}
+
+// Wait until comment message is saved as draft from the localforage
+function waitForCommentDraft(message: string) {
+ const draftPrefix = 'comment_draft_';
+
+ cy.waitUntil(async () => {
+ // Get all keys from localforage
+ const keys = await localforage.keys();
+
+ // Get all draft comments matching the predefined prefix
+ const draftPromises = keys.
+ filter((key) => key.includes(draftPrefix)).
+ map((key) => localforage.getItem(key));
+ const draftItems = await Promise.all(draftPromises) as string[];
+
+ // Get the exact draft comment
+ const commentDraft = draftItems.filter((item) => {
+ const draft: Draft = JSON.parse(item);
+
+ if (draft && draft.value && draft.value.message) {
+ return draft.value.message === message;
+ }
+
+ return false;
+ });
+
+ return Boolean(commentDraft);
+ });
+}
+
+function waitUntilPermanentPost() {
+ // Add explicit wait to let the page load freely since `cy.get` seemed to block
+ // some operation which caused to prolong complete page loading.
+ cy.wait(TIMEOUTS.HALF_SEC);
+ cy.get('#postListContent', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
+ return cy.waitUntil(() => cy.findAllByTestId('postView').last().then((el) => !(el[0].id.includes(':'))));
+}
+
+function getLastPost(): ChainableT {
+ waitUntilPermanentPost();
+
+ return cy.findAllByTestId('postView').last();
+}
+Cypress.Commands.add('getLastPost', getLastPost);
+
+function getLastPostId(): ChainableT {
+ waitUntilPermanentPost();
+
+ return cy.findAllByTestId('postView').last().should('have.attr', 'id').and('not.include', ':').
+ invoke('replace', /^[^_]*_/, '');
+}
+Cypress.Commands.add('getLastPostId', getLastPostId);
+
+function uiWaitUntilMessagePostedIncludes(message: string): ChainableT {
+ const checkFn = () => {
+ return cy.getLastPost().then((el) => {
+ const postedMessageEl = el.find('.post-message__text')[0];
+ return Boolean(postedMessageEl && postedMessageEl.textContent.includes(message));
+ });
+ };
+
+ // Wait for 5 seconds with 500ms check interval
+ const options = {
+ timeout: TIMEOUTS.FIVE_SEC,
+ interval: TIMEOUTS.HALF_SEC,
+ errorMsg: `Expected "${message}" to be in the last message posted but not found.`,
+ };
+
+ return cy.waitUntil(checkFn, options);
+}
+Cypress.Commands.add('uiWaitUntilMessagePostedIncludes', uiWaitUntilMessagePostedIncludes);
+
+function getNthPostId(index = 0): ChainableT {
+ waitUntilPermanentPost();
+
+ return cy.findAllByTestId('postView').eq(index).should('have.attr', 'id').and('not.include', ':').
+ invoke('replace', /^[^_]*_/, '');
+}
+Cypress.Commands.add('getNthPostId', getNthPostId);
+
+function uiGetNthPost(index: number): ChainableT {
+ waitUntilPermanentPost();
+
+ return cy.findAllByTestId('postView').eq(index);
+}
+Cypress.Commands.add('uiGetNthPost', uiGetNthPost);
+
+function postMessageFromFile(file: string, target = '#post_textbox'): ChainableT {
+ return cy.fixture(file, 'utf-8').then((text) => {
+ return cy.get(target).clear().invoke('val', text).wait(TIMEOUTS.HALF_SEC).type(' {backspace}{enter}').should('have.text', '');
+ });
+}
+Cypress.Commands.add('postMessageFromFile', postMessageFromFile);
+
+function compareLastPostHTMLContentFromFile(file: string, timeout = TIMEOUTS.TEN_SEC): ChainableT {
+ // * Verify that HTML Content is correct
+ return cy.getLastPostId().then((postId) => {
+ const postMessageTextId = `#postMessageText_${postId}`;
+
+ return cy.fixture(file, 'utf-8').then((expectedHtml) => {
+ cy.get(postMessageTextId, {timeout}).should('have.html', expectedHtml.replace(/\n$/, ''));
+ });
+ });
+}
+Cypress.Commands.add('compareLastPostHTMLContentFromFile', compareLastPostHTMLContentFromFile);
+
+// ***********************************************************
+// DM
+// ***********************************************************
+
+export interface User {
+ username: string;
+}
+
+function uiGotoDirectMessageWithUser(user: User) {
+ // # Open a new direct message with firstDMUser
+ cy.uiAddDirectMessage().click().wait(TIMEOUTS.ONE_SEC);
+ cy.findByRole('dialog', {name: 'Direct Messages'}).should('be.visible').wait(TIMEOUTS.ONE_SEC);
+
+ // # Type username
+ cy.findByRole('textbox', {name: 'Search for people'}).click({force: true}).
+ type(user.username, {force: true}).wait(TIMEOUTS.ONE_SEC);
+
+ // * Expect user count in the list to be 1
+ cy.get('#multiSelectList').
+ should('be.visible').
+ children().
+ should('have.length', 1);
+
+ // # Select first user in the list
+ cy.get('body').
+ type('{downArrow}').
+ type('{enter}');
+
+ // # Click on "Go" in the group message's dialog to begin the conversation
+ cy.get('#saveItems').click();
+
+ // * Expect the channel title to be the user's username
+ // In the channel header, it seems there is a space after the username, justifying the use of contains.text instead of have.text
+ cy.get('#channelHeaderTitle').should('be.visible').and('contain.text', user.username);
+}
+Cypress.Commands.add('uiGotoDirectMessageWithUser', uiGotoDirectMessageWithUser);
+
+function sendDirectMessageToUser(user: User, message: string) {
+ cy.uiGotoDirectMessageWithUser(user);
+
+ // # Type message and send it to the user
+ cy.postMessage(message);
+}
+Cypress.Commands.add('sendDirectMessageToUser', sendDirectMessageToUser);
+
+function sendDirectMessageToUsers(users: User[], message: string) {
+ // # Open a new direct message
+ cy.uiAddDirectMessage().click();
+
+ users.forEach((user: User) => {
+ // # Type username
+ cy.get('#selectItems input').should('be.enabled').type(`@${user.username}`, {force: true});
+
+ // * Expect user count in the list to be 1
+ cy.get('#multiSelectList').
+ should('be.visible').
+ children().
+ should('have.length', 1);
+
+ // # Select first user in the list
+ cy.get('body').
+ type('{downArrow}').
+ type('{enter}');
+ });
+
+ // # Click on "Go" in the group message's dialog to begin the conversation
+ cy.get('#saveItems').click();
+
+ // * Expect the channel title to be the user's username
+ // In the channel header, it seems there is a space after the username, justifying the use of contains.text instead of have.text
+ users.forEach((user) => {
+ cy.get('#channelHeaderTitle').should('be.visible').and('contain.text', user.username);
+ });
+
+ // # Type message and send it to the user
+ cy.postMessage(message);
+}
+Cypress.Commands.add('sendDirectMessageToUsers', sendDirectMessageToUsers);
+
+// ***********************************************************
+// Post header
+// ***********************************************************
+
+function clickPostHeaderItem(postId: string, location: string, item: string) {
+ let idPrefix: string;
+ switch (location) {
+ case 'CENTER':
+ idPrefix = 'post';
+ break;
+ case 'RHS_ROOT':
+ case 'RHS_COMMENT':
+ idPrefix = 'rhsPost';
+ break;
+ case 'SEARCH':
+ idPrefix = 'searchResult';
+ break;
+
+ default:
+ idPrefix = 'post';
+ }
+
+ if (postId) {
+ cy.get(`#${idPrefix}_${postId}`).trigger('mouseover', {force: true}).
+ get(`#${location}_${item}_${postId}`).scrollIntoView().trigger('mouseenter', {force: true}).click({force: true});
+ } else {
+ cy.getLastPostId().then((lastPostId) => {
+ cy.get(`#${idPrefix}_${lastPostId}`).trigger('mouseover', {force: true}).
+ get(`#${location}_${item}_${lastPostId}`).scrollIntoView().trigger('mouseenter', {force: true}).click({force: true});
+ });
+ }
+}
+
+function clickPostTime(postId: string, location = 'CENTER') {
+ clickPostHeaderItem(postId, location, 'time');
+}
+Cypress.Commands.add('clickPostTime', clickPostTime);
+
+function clickPostSaveIcon(postId: string, location = 'CENTER') {
+ clickPostHeaderItem(postId, location, 'flagIcon');
+}
+Cypress.Commands.add('clickPostSaveIcon', clickPostSaveIcon);
+
+function clickPostDotMenu(postId: string, location = 'CENTER') {
+ clickPostHeaderItem(postId, location, 'button');
+}
+Cypress.Commands.add('clickPostDotMenu', clickPostDotMenu);
+
+function clickPostReactionIcon(postId: string, location = 'CENTER') {
+ clickPostHeaderItem(postId, location, 'reaction');
+}
+Cypress.Commands.add('clickPostReactionIcon', clickPostReactionIcon);
+
+function clickPostCommentIcon(postId: string, location = 'CENTER') {
+ clickPostHeaderItem(postId, location, 'commentIcon');
+}
+Cypress.Commands.add('clickPostCommentIcon', clickPostCommentIcon);
+
+function clickPostActionsMenu(postId: string, location = 'CENTER') {
+ clickPostHeaderItem(postId, location, 'actions_button');
+}
+Cypress.Commands.add('clickPostActionsMenu', clickPostActionsMenu);
+
+// ***********************************************************
+// Teams
+// ***********************************************************
+
+function createNewTeam(teamName: string, teamURL: string) {
+ cy.visit('/create_team');
+ cy.get('#teamNameInput').type(teamName).type('{enter}');
+ cy.get('#teamURLInput').type(teamURL).type('{enter}');
+ cy.visit(`/${teamURL}`);
+}
+Cypress.Commands.add('createNewTeam', createNewTeam);
+
+function getCurrentTeamURL(siteURL: string): ChainableT {
+ let path: string;
+
+ // siteURL can be provided for cases where subpath is being tested
+ if (siteURL) {
+ path = window.location.href.substring(siteURL.length);
+ } else {
+ path = window.location.pathname;
+ }
+
+ const result = path.split('/', 2);
+ return cy.wrap(`/${(result[0] ? result[0] : result[1])}`); // sometimes the first element is empty if path starts with '/'
+}
+Cypress.Commands.add('getCurrentTeamURL', getCurrentTeamURL);
+
+function leaveTeam() {
+ // # Open team menu and click "Leave Team"
+ cy.uiOpenTeamMenu('Leave Team');
+
+ // * Check that the "leave team modal" opened up
+ cy.get('#leaveTeamModal').should('be.visible');
+
+ // # click on yes
+ cy.get('#leaveTeamYes').click();
+
+ // * Check that the "leave team modal" closed
+ cy.get('#leaveTeamModal').should('not.exist');
+}
+Cypress.Commands.add('leaveTeam', leaveTeam);
+
+// ***********************************************************
+// Text Box
+// ***********************************************************
+
+function clearPostTextbox(channelName = 'town-square') {
+ cy.get(`#sidebarItem_${channelName}`).click({force: true});
+ cy.uiGetPostTextBox().clear();
+}
+Cypress.Commands.add('clearPostTextbox', clearPostTextbox);
+
+// ***********************************************************
+// Min Setting View
+// ************************************************************
+
+function minDisplaySettings() {
+ cy.get('#themeTitle').should('be.visible', 'contain', 'Theme');
+ cy.get('#themeEdit').should('be.visible', 'contain', 'Edit');
+
+ cy.get('#clockTitle').should('be.visible', 'contain', 'Clock Display');
+ cy.get('#clockEdit').should('be.visible', 'contain', 'Edit');
+
+ cy.get('#name_formatTitle').should('be.visible', 'contain', 'Teammate Name Display');
+ cy.get('#name_formatEdit').should('be.visible', 'contain', 'Edit');
+
+ cy.get('#collapseTitle').should('be.visible', 'contain', 'Default appearance of image previews');
+ cy.get('#collapseEdit').should('be.visible', 'contain', 'Edit');
+
+ cy.get('#message_displayTitle').scrollIntoView().should('be.visible', 'contain', 'Message Display');
+ cy.get('#message_displayEdit').should('be.visible', 'contain', 'Edit');
+
+ cy.get('#languagesTitle').scrollIntoView().should('be.visible', 'contain', 'Language');
+ cy.get('#languagesEdit').should('be.visible', 'contain', 'Edit');
+}
+Cypress.Commands.add('minDisplaySettings', minDisplaySettings);
+
+// ***********************************************************
+// Change User Status
+// ************************************************************
+
+function userStatus(statusInt: number) {
+ cy.get('.status-wrapper.status-selector').click();
+ cy.get('.MenuItem').eq(statusInt).click();
+}
+Cypress.Commands.add('userStatus', userStatus);
+
+// ***********************************************************
+// Channel
+// ************************************************************
+
+function getCurrentChannelId(): ChainableT {
+ return cy.get('#channel-header', {timeout: TIMEOUTS.HALF_MIN}).invoke('attr', 'data-channelid');
+}
+Cypress.Commands.add('getCurrentChannelId', getCurrentChannelId);
+
+function updateChannelHeader(text: string) {
+ cy.get('#channelHeaderDropdownIcon').
+ should('be.visible').
+ click();
+ cy.get('.Menu__content').
+ should('be.visible').
+ find('#channelEditHeader').
+ click();
+ cy.get('#edit_textbox').
+ clear().
+ type(text).
+ type('{enter}').
+ wait(TIMEOUTS.HALF_SEC);
+}
+
+Cypress.Commands.add('updateChannelHeader', updateChannelHeader);
+
+function checkRunLDAPSync(): ChainableT {
+ return cy.apiGetLDAPSync().then((response) => {
+ const jobs = response.body;
+ const currentTime = new Date();
+
+ // # Run LDAP Sync if no job exists (or) last status is an error (or) last run time is more than 1 day old
+ if (jobs.length === 0 || jobs[0].status === 'error' || ((currentTime.getTime() - (new Date(jobs[0].last_activity_at)).getTime()) > 8640000)) {
+ // # Go to system admin LDAP page and run the group sync
+ cy.visit('/admin_console/authentication/ldap');
+
+ // # Click on AD/LDAP Synchronize Now button and verify if succesful
+ cy.findByText('AD/LDAP Test').click();
+ cy.findByText('AD/LDAP Test Successful').should('be.visible');
+
+ // # Click on AD/LDAP Synchronize Now button
+ cy.findByText('AD/LDAP Synchronize Now').click().wait(TIMEOUTS.ONE_SEC);
+
+ // * Get the First row
+ cy.findByTestId('jobTable').
+ find('tbody > tr').
+ eq(0).
+ as('firstRow');
+
+ // * Wait until first row updates to say Success
+ cy.waitUntil(() => {
+ return cy.get('@firstRow').then((el) => {
+ return el.find('.status-icon-success').length > 0;
+ });
+ }
+ , {
+ timeout: TIMEOUTS.FIVE_MIN,
+ interval: TIMEOUTS.TWO_SEC,
+ errorMsg: 'AD/LDAP Sync Job did not finish',
+ });
+ }
+ });
+}
+Cypress.Commands.add('checkRunLDAPSync', checkRunLDAPSync);
+
+function clickEmojiInEmojiPicker(emojiName: string) {
+ cy.get('#emojiPicker').should('exist').and('be.visible').within(() => {
+ // # Mouse over the emoji to get it selected
+ cy.findAllByTestId(emojiName).eq(0).trigger('mouseover', {force: true});
+
+ // * Verify that preview shows the emoji selected
+ cy.findAllByTestId('emoji_picker_preview').eq(0).should('exist').and('be.visible').contains(emojiName, {matchCase: false});
+
+ // # Click on the emoji
+ cy.findAllByTestId(emojiName).eq(0).click({force: true});
+ });
+}
+Cypress.Commands.add('clickEmojiInEmojiPicker', clickEmojiInEmojiPicker);
+
+function verifyPostedMessage(message) {
+ cy.wait(TIMEOUTS.HALF_SEC).getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ cy.get(`#postMessageText_${postId}`).contains(message);
+ });
+ });
+}
+Cypress.Commands.add('verifyPostedMessage', verifyPostedMessage);
+
+function verifyEphemeralMessage(message, isCompactMode, needsToScroll) {
+ if (needsToScroll) {
+ // # Scroll the ephemeral message into view
+ cy.get('#postListContent').within(() => {
+ cy.get('.post-list__dynamic').scrollTo('bottom', {ensureScrollable: false});
+ });
+ }
+
+ // # Checking if we got the ephemeral message with the selection we made
+ cy.wait(TIMEOUTS.HALF_SEC).getLastPostId().then((postId) => {
+ cy.get(`#post_${postId}`).within(() => {
+ if (isCompactMode) {
+ // * Check if Bot message only visible to you and has requisite message.
+ cy.get(`#postMessageText_${postId}`).contains(message);
+ cy.get(`#postMessageText_${postId}`).contains('(Only visible to you)');
+ } else {
+ // * Check if Bot message only visible to you
+ cy.get('.post__visibility').last().should('exist').and('have.text', '(Only visible to you)');
+
+ // * Check if we got ephemeral message of our selection
+ cy.get(`#postMessageText_${postId}`).contains(message);
+ }
+ });
+ });
+}
+Cypress.Commands.add('verifyEphemeralMessage', verifyEphemeralMessage);
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace Cypress {
+ interface Chainable {
+
+ /**
+ * log out user
+ *
+ * @example
+ * cy.logout();
+ */
+ logout: typeof logout;
+
+ /**
+ * Wait for a message to get posted as the last post.
+ * @returns {string} returns true if found or fail a test if not.
+ *
+ * @example
+ * cy.getCurrentUserId().then((id) => {
+ */
+ getCurrentUserId: typeof getCurrentUserId;
+
+ /**
+ * Types `{cmd}` mac / `{ctrl}` windows into post textbox
+ */
+ typeCmdOrCtrl: typeof typeCmdOrCtrl;
+
+ /**
+ * Types `{cmd}` mac / `{ctrl}` windows into edit post textbox
+ */
+ typeCmdOrCtrlForEdit: typeof typeCmdOrCtrlForEdit;
+
+ cmdOrCtrlShortcut: typeof cmdOrCtrlShortcut;
+
+ postMessage: typeof postMessage;
+
+ postMessageReplyInRHS: typeof postMessageReplyInRHS;
+
+ /**
+ * Wait for a message to get posted as the last post.
+ * @param {string} message - message to check if includes in the last post
+ * @returns {boolean} returns true if found or fail a test if not.
+ *
+ * @example
+ * const message = 'message';
+ * cy.postMessage(message);
+ * cy.uiWaitUntilMessagePostedIncludes(message);
+ */
+ uiWaitUntilMessagePostedIncludes: typeof uiWaitUntilMessagePostedIncludes;
+
+ /**
+ * Get nth post from the post list
+ * @param {number} index - an identifier of a post
+ * - zero (0) : oldest post
+ * - positive number : from old to latest post
+ * - negative number : from new to oldest post
+ * @returns {JQuery} response: Cypress-chainable JQuery
+ *
+ * @example
+ * cy.uiGetNthPost(-1);
+ */
+ uiGetNthPost: typeof uiGetNthPost;
+
+ /**
+ * Post message via center textbox by directly injected in the textbox
+ * @param {string} message - message to be posted
+ * @returns void
+ *
+ * @example
+ * cy.uiPostMessageQuickly('Hello world')
+ */
+ uiPostMessageQuickly(message: string): void;
+
+ /**
+ * Clicks on a visible emoji in the emoji picker.
+ * For emojis further down the page, search for that emoji in search bar and then use this command to click on it.
+ * @param {string} emojiName - The name of emoji to click. For emojis with multiple names concat with ','. eg. slightly_frowning_face
+ * @returns void
+ *
+ * @example
+ * cy.uiClickSystemEmoji('slightly_frowning_face');
+ * cy.uiClickSystemEmoji('star-struck,grinning_face_with_star_eyes');
+ */
+ clickEmojiInEmojiPicker(emojiName: string): ChainableT;
+
+ /**
+ * Get nth post from the post list
+ * @returns {JQuery} response: Cypress-chainable JQuery
+ *
+ * @example
+ * cy.getLastPost().then((el: Element) => {;
+ */
+ getLastPost: typeof getLastPost;
+
+ /**
+ * Get nth post from the post list
+ * @returns {string} response: Cypress-chainable string
+ *
+ * @example
+ * cy.getLastPostId().then((postId) => {
+ */
+ getLastPostId: typeof getLastPostId;
+
+ /**
+ * Get post ID based on index of post list
+ * zero (0) : oldest post
+ * positive number : from old to latest post
+ * negative number : from new to oldest post
+ */
+ getNthPostId: typeof getNthPostId;
+
+ /**
+ * Post message from a file instantly post a message in a textbox
+ * instead of typing into it which takes longer period of time.
+ */
+ postMessageFromFile: typeof postMessageFromFile;
+
+ /**
+ * Compares HTML content of a last post against the given file
+ * instead of typing into it which takes longer period of time.
+ */
+ compareLastPostHTMLContentFromFile: typeof compareLastPostHTMLContentFromFile;
+
+ /**
+ * Go to a DM channel with a given user
+ * @param {User} user - the user that should get the message
+ * @example
+ * const user = {username: 'bob'};
+ * cy.uiGotoDirectMessageWithUser(user);
+ */
+ uiGotoDirectMessageWithUser(user: User): ChainableT;
+
+ /**
+ * Sends a DM to a given user
+ * @param {User} user - the user that should get the message
+ * @param {String} message - the message to send
+ */
+ sendDirectMessageToUser: typeof sendDirectMessageToUser;
+
+ /**
+ * Sends a GM to a given user list
+ * @param {User[]} users - the users that should get the message
+ * @param {String} message - the message to send
+ */
+ sendDirectMessageToUsers(users: User[], message: string): ChainableT;
+
+ /**
+ * Click post time
+ * @param {String} postId - Post ID
+ * @param {String} location - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH'
+ */
+ clickPostTime(postId: string, location: string): ChainableT;
+
+ /**
+ * Click save icon by post ID or to most recent post (if post ID is not provided)
+ * @param {String} postId - Post ID
+ * @param {String} [location] - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH'
+ */
+ clickPostSaveIcon(postId: string, location?: string): ChainableT;
+
+ /**
+ * Click dot menu by post ID or to most recent post (if post ID is not provided)
+ * @param {String} [postId] - Post ID
+ * @param {String} [location] - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT', 'SEARCH'
+ */
+ clickPostDotMenu(postId?: string, location?: string): ChainableT;
+
+ /**
+ * Click post reaction icon
+ * @param {String} postId - Post ID
+ * @param {String} [location] - as 'CENTER', 'RHS_ROOT', 'RHS_COMMENT'
+ */
+ clickPostReactionIcon(postId?: string, location?: string): ChainableT;
+
+ /**
+ * Click comment icon by post ID or to most recent post (if post ID is not provided)
+ * This open up the RHS
+ * @param {String} postId - Post ID
+ * @param {String} [location] - as 'CENTER', 'SEARCH'
+ */
+ clickPostCommentIcon(postId: string, location?: string): ChainableT;
+
+ /**
+ * Click actions menu by post ID or to most recent post (if post ID is not provided)
+ * @param {String} postId - Post ID
+ * @param {String} location - as 'CENTER', 'SEARCH'
+ */
+ clickPostActionsMenu(postId: string, location?: string): ChainableT;
+
+ createNewTeam(teamName: string, teamURL: string): ChainableT;
+
+ getCurrentTeamURL: typeof getCurrentTeamURL;
+
+ leaveTeam(): ChainableT;
+
+ clearPostTextbox(channelName: string): ChainableT;
+
+ /**
+ * Checking min setting view for display
+ */
+ minDisplaySettings(): ChainableT;
+
+ /**
+ * Set the user's status
+ * Need to be in main channel view for this to work
+ * 0 = Online
+ * 1 = Away
+ * 2 = Do Not Disturb
+ * 3 = Offline
+ */
+ userStatus(statusInt: number): ChainableT;
+
+ getCurrentChannelId: typeof getCurrentChannelId;
+
+ /**
+ * Update channel header
+ * @param {String} text - Text to set the header to
+ */
+ updateChannelHeader(text: string): ChainableT;
+
+ /**
+ * Navigate to system console-PluginManagement from profile settings
+ */
+ checkRunLDAPSync: typeof checkRunLDAPSync;
+
+ /**
+ * verifyPostedMessage verifies the receipt of a post containing the given message substring.
+ */
+ verifyPostedMessage: typeof verifyPostedMessage;
+
+ /**
+ * verifyEphemeralMessage verifies the receipt of an ephemeral message containing the given
+ * message substring. An exact match is avoided to simplify tests.
+ */
+ verifyEphemeralMessage: typeof verifyEphemeralMessage;
+ }
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/win.d.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/win.d.ts
new file mode 100644
index 00000000000..737d7687470
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/support/win.d.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+// ***************************************************************
+// Each command should be properly documented using JSDoc.
+// See https://jsdoc.app/index.html for reference.
+// Basic requirements for documentation are the following:
+// - Meaningful description
+// - Each parameter with `@params`
+// - Return value with `@returns`
+// - Example usage with `@example`
+// ***************************************************************
+
+declare namespace Cypress {
+ interface ApplicationWindow {
+
+ /**
+ * Reset all tracked selectors
+ * @example
+ * win.resetTrackedSelectors();
+ */
+ resetTrackedSelectors(): void;
+ }
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/types/index.ts b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/types/index.ts
new file mode 100644
index 00000000000..966617bde7a
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/types/index.ts
@@ -0,0 +1,5 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export type ChainableT = Cypress.Chainable;
+export type ResponseT = ChainableT>;
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/admin_console.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/admin_console.js
new file mode 100644
index 00000000000..34a435b134c
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/admin_console.js
@@ -0,0 +1,367 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export const adminConsoleNavigation = [
+ {
+ type: ['team', 'e20'],
+ header: 'Edition and License',
+ sidebar: 'Edition and License',
+ url: 'admin_console/about/license',
+ },
+ {
+ type: ['cloud_enterprise'],
+ header: 'Subscription',
+ sidebar: 'Subscription',
+ url: 'admin_console/billing/subscription',
+ },
+ {
+ type: ['cloud_enterprise', 'e20'],
+ header: 'Billing History',
+ sidebar: 'Billing History',
+ url: 'admin_console/billing/billing_history',
+ },
+ {
+ type: ['cloud_enterprise'],
+ header: 'Company Information',
+ sidebar: 'Company Information',
+ url: 'admin_console/billing/company_info',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'System Statistics',
+ sidebar: 'Site Statistics',
+ url: '/admin_console/reporting/system_analytics',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Team Statistics',
+ sidebar: 'Team Statistics',
+ url: '/admin_console/reporting/team_statistics',
+ headerContains: true,
+ },
+ {
+ type: ['team', 'e20'],
+ header: 'Server Logs',
+ sidebar: 'Server Logs',
+ url: '/admin_console/reporting/server_logs',
+ headerContains: true,
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Mattermost Users',
+ sidebar: 'Users',
+ url: 'admin_console/user_management/users',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Groups',
+ sidebar: 'Groups',
+ url: 'admin_console/user_management/groups',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Mattermost Teams',
+ sidebar: 'Teams',
+ url: 'admin_console/user_management/teams',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Mattermost Channels',
+ team_header: 'Channels',
+ sidebar: 'Channels',
+ url: 'admin_console/user_management/channels',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Permission Schemes',
+ sidebar: 'Permissions',
+ url: 'admin_console/user_management/permissions',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'System Roles',
+ sidebar: 'System Roles',
+ url: 'admin_console/user_management/system_roles',
+ },
+ {
+ type: ['team', 'e20'],
+ header: 'Web Server',
+ sidebar: 'Web Server',
+ url: 'admin_console/environment/web_server',
+ },
+ {
+ type: ['team', 'e20'],
+ header: 'Database',
+ sidebar: 'Database',
+ url: 'admin_console/environment/database',
+ },
+ {
+ type: ['e20'],
+ section: 'Environment',
+ header: 'Elasticsearch',
+ sidebar: 'Elasticsearch',
+ url: 'admin_console/environment/elasticsearch',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'File Storage',
+ sidebar: 'File Storage',
+ url: 'admin_console/environment/file_storage',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'Image Proxy',
+ sidebar: 'Image Proxy',
+ url: 'admin_console/environment/image_proxy',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'SMTP',
+ sidebar: 'SMTP',
+ url: 'admin_console/environment/smtp',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'Push Notification Server',
+ sidebar: 'Push Notification Server',
+ url: 'admin_console/environment/push_notification_server',
+ },
+ {
+ type: ['e20'],
+ section: 'Environment',
+ header: 'High Availability',
+ sidebar: 'High Availability',
+ url: 'admin_console/environment/high_availability',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'Rate Limiting',
+ sidebar: 'Rate Limiting',
+ url: 'admin_console/environment/rate_limiting',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'Logging',
+ sidebar: 'Logging',
+ url: 'admin_console/environment/logging',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'Session Lengths',
+ sidebar: 'Session Lengths',
+ url: 'admin_console/environment/session_lengths',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'Performance Monitoring',
+ sidebar: 'Performance Monitoring',
+ url: 'admin_console/environment/performance_monitoring',
+ },
+ {
+ type: ['team', 'e20'],
+ section: 'Environment',
+ header: 'Developer Settings',
+ sidebar: 'Developer',
+ url: 'admin_console/environment/developer',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Customization',
+ sidebar: 'Customization',
+ url: 'admin_console/site_config/customization',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Localization',
+ sidebar: 'Localization',
+ url: 'admin_console/site_config/localization',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Users and Teams',
+ sidebar: 'Users and Teams',
+ url: 'admin_console/site_config/users_and_teams',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Notifications',
+ sidebar: 'Notifications',
+ url: 'admin_console/environment/notifications',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Announcement Banner',
+ sidebar: 'Announcement Banner',
+ url: 'admin_console/site_config/announcement_banner',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Emoji',
+ sidebar: 'Emoji',
+ url: 'admin_console/site_config/emoji',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Posts',
+ sidebar: 'Posts',
+ url: 'admin_console/site_config/posts',
+ },
+ {
+ type: ['team', 'e20'],
+ header: 'File Sharing and Downloads',
+ sidebar: 'File Sharing and Downloads',
+ url: 'admin_console/site_config/file_sharing_downloads',
+ },
+ {
+ type: ['team', 'e20'],
+ header: 'Public Links',
+ sidebar: 'Public Links',
+ url: 'admin_console/site_config/public_links',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Notices',
+ sidebar: 'Notices',
+ url: 'admin_console/site_config/notices',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Signup',
+ sidebar: 'Signup',
+ url: 'admin_console/authentication/signup',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Email Authentication',
+ sidebar: 'Email',
+ url: 'admin_console/authentication/email',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Password',
+ sidebar: 'Password',
+ url: 'admin_console/authentication/password',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Multi-factor Authentication',
+ sidebar: 'MFA',
+ url: 'admin_console/authentication/mfa',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'AD/LDAP',
+ sidebar: 'AD/LDAP',
+ url: 'admin_console/authentication/ldap',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'SAML 2.0',
+ sidebar: 'SAML 2.0',
+ url: 'admin_console/authentication/saml',
+ },
+ {
+ type: ['team'],
+ header: 'GitLab',
+ sidebar: 'GitLab',
+ url: 'admin_console/authentication/gitlab',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'OpenID Connect',
+ sidebar: 'OpenID Connect',
+ url: 'admin_console/authentication/openid',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Guest Access',
+ sidebar: 'Guest Access',
+ url: 'admin_console/authentication/guest_access',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Plugin Management',
+ sidebar: 'Plugin Management',
+ url: 'admin_console/plugins/plugin_management',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Integration Management',
+ sidebar: 'Integration Management',
+ url: 'admin_console/integrations/integration_management',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Bot Accounts',
+ sidebar: 'Bot Accounts',
+ url: 'admin_console/integrations/bot_accounts',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'GIF (Beta)',
+ sidebar: 'GIF (Beta)',
+ url: 'admin_console/integrations/gif',
+ },
+ {
+ type: ['team', 'e20'],
+ header: 'CORS',
+ sidebar: 'CORS',
+ url: 'admin_console/integrations/cors',
+ },
+ {
+ type: ['e20', 'cloud_enterprise'],
+ header: 'Data Retention Policies',
+ sidebar: 'Data Retention Policies',
+ url: 'admin_console/compliance/data_retention_settings',
+ },
+ {
+ type: ['team'],
+ header: 'Data Retention Policy',
+ sidebar: 'Data Retention Policy',
+ url: 'admin_console/compliance/data_retention',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Compliance Export',
+ sidebar: 'Compliance Export',
+ url: 'admin_console/compliance/export',
+ },
+ {
+ type: ['e20', 'cloud_enterprise'],
+ header: 'Compliance Monitoring',
+ sidebar: 'Compliance Monitoring',
+ url: 'admin_console/compliance/monitoring',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Custom Terms of Service',
+ sidebar: 'Custom Terms of Service',
+ url: 'admin_console/compliance/custom_terms_of_service',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Experimental Features',
+ sidebar: 'Features',
+ url: 'admin_console/experimental/features',
+ },
+ {
+ type: ['team', 'e20', 'cloud_enterprise'],
+ header: 'Feature Flags',
+ sidebar: 'Feature Flags',
+ url: 'admin_console/experimental/feature_flags',
+ },
+ {
+ type: ['team', 'e20'],
+ header: 'Bleve',
+ sidebar: 'Bleve',
+ url: 'admin_console/experimental/blevesearch',
+ },
+];
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/benchmark.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/benchmark.js
new file mode 100644
index 00000000000..b0305894d32
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/benchmark.js
@@ -0,0 +1,29 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export function reportBenchmarkResults(cy, win) {
+ const testName = getTestName();
+ const selectors = win.getSortedTrackedSelectors();
+ win.dumpTrackedSelectorsStatistics();
+ cy.log(selectors.length);
+ cy.writeFile(`tests/integration/benchmark/__benchmarks__/${testName}.json`, JSON.stringify(selectors));
+}
+
+// From https://github.com/cypress-io/cypress/issues/2972#issuecomment-577072392
+function getTestName() {
+ const cypressContext = Cypress.mocha.getRunner().suite.ctx.test;
+ const testTitles = [];
+
+ function extractTitles(obj) {
+ if (obj.hasOwnProperty('parent')) {
+ testTitles.push(obj.title);
+ const nextObj = obj.parent;
+ extractTitles(nextObj);
+ }
+ }
+
+ extractTitles(cypressContext);
+ const orderedTitles = testTitles.reverse();
+ const fileName = orderedTitles.join(' -- ');
+ return fileName;
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/config.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/config.js
new file mode 100644
index 00000000000..4f98359e789
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/config.js
@@ -0,0 +1,34 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export function getKeycloakServerSettings() {
+ const baseUrl = Cypress.config('baseUrl');
+ const {keycloakBaseUrl, keycloakAppName} = Cypress.env();
+ const idpDescriptorUrl = `${keycloakBaseUrl}/auth/realms/${keycloakAppName}`;
+ const idpUrl = `${idpDescriptorUrl}/protocol/saml`;
+
+ return {
+ SamlSettings: {
+ Enable: true,
+ Encrypt: false,
+ IdpURL: idpUrl,
+ IdpDescriptorURL: idpDescriptorUrl,
+ ServiceProviderIdentifier: `${baseUrl}/login/sso/saml`,
+ AssertionConsumerServiceURL: `${baseUrl}/login/sso/saml`,
+ SignatureAlgorithm: 'RSAwithSHA256',
+ PublicCertificateFile: '',
+ PrivateKeyFile: '',
+ FirstNameAttribute: 'firstName',
+ LastNameAttribute: 'lastName',
+ EmailAttribute: 'email',
+ UsernameAttribute: 'username',
+ EnableSyncWithLdap: true,
+ EnableSyncWithLdapIncludeAuth: true,
+ IdAttribute: 'username',
+ },
+ LdapSettings: {
+ EnableSync: true,
+ BaseDN: 'ou=e2etest,dc=mm,dc=test,dc=com',
+ },
+ };
+}
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/constants.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/constants.js
new file mode 100644
index 00000000000..ee31d320a53
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/constants.js
@@ -0,0 +1,57 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export const FEEDBACK_EMAIL = 'test@example.com';
+export const ABOUT_LINK = 'https://docs.mattermost.com/about/product.html';
+export const ASK_COMMUNITY_LINK = 'https://mattermost.com/pl/default-ask-mattermost-community/';
+export const HELP_LINK = 'https://mattermost.com/default-help/';
+export const PRIVACY_POLICY_LINK = 'https://mattermost.com/privacy-policy/';
+export const REPORT_A_PROBLEM_LINK = 'https://mattermost.com/default-report-a-problem/';
+export const TERMS_OF_SERVICE_LINK = 'https://mattermost.com/terms-of-use/';
+
+export const CLOUD = 'Cloud';
+export const E20 = 'E20';
+export const TEAM = 'Team';
+
+export const FixedPublicLinks = {
+ TermsOfService: 'https://mattermost.com/terms-of-use/',
+ PrivacyPolicy: 'https://mattermost.com/privacy-policy/',
+};
+
+export const SupportSettings = {
+ ABOUT_LINK,
+ ASK_COMMUNITY_LINK,
+ HELP_LINK,
+ PRIVACY_POLICY_LINK,
+ REPORT_A_PROBLEM_LINK,
+ TERMS_OF_SERVICE_LINK,
+};
+export const FixedCloudConfig = {
+ EmailSettings: {
+ FEEDBACK_EMAIL,
+ },
+ SupportSettings,
+};
+
+export const ServerEdition = {
+ CLOUD,
+ E20,
+ TEAM,
+};
+
+export const Constants = {
+ FixedCloudConfig,
+ ServerEdition,
+};
+
+export const CustomStatusDuration = {
+ DONT_CLEAR: '',
+ THIRTY_MINUTES: 'thirty_minutes',
+ ONE_HOUR: 'one_hour',
+ FOUR_HOURS: 'four_hours',
+ TODAY: 'today',
+ THIS_WEEK: 'this_week',
+ DATE_AND_TIME: 'date_and_time',
+};
+
+export default Constants;
diff --git a/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/email.js b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/email.js
new file mode 100644
index 00000000000..5d5ad6ddcd3
--- /dev/null
+++ b/core-plugins/mattermost-plugin-playbooks/e2e-tests/tests/utils/email.js
@@ -0,0 +1,148 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export function getEmailUrl() {
+ const smtpUrl = Cypress.env('smtpUrl') || 'http://localhost:9001';
+
+ return `${smtpUrl}/api/v1/mailbox`;
+}
+
+export function splitEmailBodyText(text) {
+ return text.split('\n').map((d) => d.trim());
+}
+
+export function getEmailResetEmailTemplate(userEmail) {
+ return [
+ '----------------------',
+ 'You updated your email',
+ '----------------------',
+ '',
+ `Your email address for Mattermost has been changed to ${userEmail}.`,
+ 'If you did not make this change, please contact the system administrator.',
+ '',
+ 'To change your notification preferences, log in to your team site and go to Settings > Notifications.',
+ ];
+}
+
+export function getJoinEmailTemplate(sender, userEmail, team, isGuest = false) {
+ const baseUrl = Cypress.config('baseUrl');
+
+ return [
+ `${sender} invited you to join the ${team.display_name} team.`,
+ `${isGuest ? 'You were invited as a guest to collaborate with the team' : 'Start collaborating with your team on Mattermost'}`,
+ '',
+ ` Join now ( ${baseUrl}/signup_user_complete/?d=${encodeURIComponent(JSON.stringify({display_name: team.display_name.replace(' ', '+'), email: userEmail, name: team.name}))}&t= )`,
+ '',
+ 'What is Mattermost?',
+ 'Mattermost is a flexible, open source messaging platform that enables secure team collaboration.',
+ 'Learn more ( mattermost.com )',
+ '',
+ '© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301',
+ ];
+}
+
+export function getMentionEmailTemplate(sender, message, postId, siteName, teamName, channelDisplayName) {
+ const baseUrl = Cypress.config('baseUrl');
+
+ return [
+ `@${sender} mentioned you in a message`,
+ `While you were away, @${sender} mentioned you in the ${channelDisplayName} channel.`,
+ '',
+ `Reply in Mattermost ( ${baseUrl}/landing#/${teamName}/pl/${postId} )`,
+ '',
+ `@${sender}`,
+ '',
+ channelDisplayName,
+ '',
+ message,
+ '',
+ 'Want to change your notifications settings?',
+ `Login to ${siteName} ( ${baseUrl} ) and go to Settings > Notifications`,
+ '',
+ '© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301',
+ ];
+}
+
+export function getPasswordResetEmailTemplate() {
+ const baseUrl = Cypress.config('baseUrl');
+
+ return [
+ 'Reset Your Password',
+ 'Click the button below to reset your password. If you didn’t request this, you can safely ignore this email.',
+ '',
+ ` Reset Password ( http://${baseUrl}/reset_password_complete?token=