mattermost-mobile/detox/e2e/support/server_api/plugin.ts
Scott Bishel ca117805be
Add DialogRouter for Interactive Dialog to Apps Form migration (#9114)
* feat: Add DialogRouter for Interactive Dialog to Apps Form migration

Implements feature flag-controlled routing between legacy InteractiveDialog and modern AppsForm components to enable gradual migration.

Key Features:
- DialogRouter component with React.memo optimization for performance
- InteractiveDialogAdapter with WeakMap caching for form conversion
- Complete dialog/AppForm conversion utilities with validation
- Graceful fallback to legacy InteractiveDialog on conversion errors
- Type-safe implementation with optional subtype field support

Architecture:
- Feature flag controlled: FeatureFlagInteractiveDialogAppsForm
- Performance optimized: WeakMap cache, useMemo, useCallback patterns
- Error resilient: try/catch with fallback handling
- Mobile-first: Designed for React Native Navigation

Testing:
- 94 comprehensive unit tests covering all scenarios
- Cache behavior, error handling, edge cases
- Mock implementations for component integration
- Full TypeScript coverage with proper error cases

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* i18n-extract

* Add interactive dialog e2e tests and fix testID support for mobile form elements

This commit adds comprehensive e2e testing for interactive dialogs and fixes critical testID issues that were preventing form element interactions in mobile tests.

Key changes:
- Fix UserList component to use dynamic testID prop instead of hardcoded 'create_direct_message.user_list.user_item'
- Add testID support to BoolSetting and RadioSetting components for AppsForm elements
- Add testID support to ChannelListRow in IntegrationSelector for consistent channel selection
- Remove problematic disabled send button validation that was causing test failures
- Add comprehensive interactive dialog test suite with text, select, multiselect, and boolean field tests
- Implement wildcard testID discovery for dynamic user/channel element finding
- Add webhook server health check functionality matching webapp patterns
- Add Command and Webhook server API modules for test infrastructure
- Enable MM_FEATUREFLAGS_InteractiveDialogAppsForm feature flag in Detox config
- Optimize test performance by reducing unnecessary wait times
- Correct testID patterns from InteractiveDialogElement.* to AppFormElement.*

TestID chain fixes enable proper element discovery:
IntegrationSelector → ServerUserList → UserList → UserListRow → UserItem
All components now properly pass through and construct testID chains for e2e testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix ESLint errors in interactive dialog tests and components

- Remove console.log statements and replace with comments
- Replace for-await loops with sequential try-catch blocks to avoid no-await-in-loop violations
- Add missing dependency 'testID' to RadioSetting useMemo hook
- Fix UserList useCallback dependencies to match actual usage
- Add missing newlines at end of files
- Remove duplicate getBooleanDialog function definition in webhook_utils.js
- Fix trailing spaces and formatting issues

All lint and TypeScript checks now pass.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix TypeScript errors in interactive dialog test files

- Add null checks for array access in boolean_fields.e2e.ts and text_fields.e2e.ts to prevent 'string | undefined' errors
- Replace RegExp patterns with string patterns in select_fields.e2e.ts since Detox by.id() expects strings
- Add proper null checking for array elements before using them in element selection
- All test files now compile without TypeScript errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix useCallback dependencies in UserList component

- Remove unnecessary 'style' dependency from renderNoResults useCallback
- Fix React hooks/exhaustive-deps ESLint warning
- Ensures proper dependency tracking for useCallback optimization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* cleanup

* cleanup

* fix tests

* Replace webhook-based interactive dialog tests with plugin-based versions

- Remove webhook server dependency from dialog tests
- Add plugin-based test files using /dialog commands
- Tests now use mattermost-plugin-demo instead of webhook_server.js
- Remove webhook and command support utilities
- Update screen and server API support for plugin-based testing
- Clean up debug logging and unnecessary comments

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update text_fields_plugin.e2e.ts

* Update webhook_utils.js

* add lf to eof

* Implement dynamic plugin installation and CSRF token handling for Detox tests

- Add DemoPlugin constants with dynamic version fetching from GitHub releases API
- Implement CSRF token handling in HTTP client following Cypress pattern
- Add server configuration functions (apiUpdateConfig, shouldHavePluginUploadEnabled)
- Update interactive dialog tests to use plugin-based approach with shared constants
- Fix naming conventions to follow established PascalCase/camelCase patterns

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix bad merge

* updates for new build name

* fix spacing lint issue

* Security: Fix information disclosure and SSRF vulnerabilities

- Prevent exposure of internal error messages in interactive dialog submissions
- Remove form structure enumeration in apps form error handling
- Add URL validation to prevent SSRF in plugin installation (test code)

Addresses Dryrun security scan findings.

* Update i18n strings for security fixes

* Improve plugin installation verification in interactive dialog tests

- Add error checking after plugin installation to fail fast with clear errors
- Verify plugin is actually active before running tests
- Add alert dismissal in afterEach to prevent cascading test failures
- Increase wait time for plugin initialization to 2 seconds

This fixes CI failures where the plugin installation silently failed,
causing tests to proceed and fail with "Plugin for /dialog is not working"
error alerts that blocked subsequent tests.

* Use linux-amd64 plugin build and add environment logging

  - Change demo plugin download to use linux-amd64 variant instead of generic tar.gz
    This is more reliable for CI environments running on Linux x86_64
  - Add environment logging (platform, arch, Node version, test server) at test start
  - Add specific error message for Cloudflare 524 timeouts with actionable solutions

  These changes help debug CI failures and should reduce plugin installation timeouts.

* Improve plugin installation with version checking and debugging

- Add comprehensive debug logging to track plugin installation status
  - Log target version, download URL, and current plugin state
  - Log installation actions taken (enable, remove, install)
  - Log final plugin status with version verification
- Fix plugin installation logic to handle version mismatches
  - Now removes old plugin versions before installing new ones
  - Only reactivates inactive plugins if version matches
  - Requires version parameter for proper version checking
- Add version validation to ensure correct plugin version is active
- Add detailed status messages for all installation paths

This helps diagnose plugin installation issues including architecture
mismatches and version upgrade problems.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Change plugin version mismatch from error to warning

Allow tests to continue even when plugin version doesn't match expected
version. This helps determine if the plugin commands work correctly
despite version metadata discrepancies.

The test will now log a warning but continue execution to verify if
/dialog commands are registered and functional.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add plugin enable logging and fix SSRF vulnerability in test code

Plugin API improvements:
- Add detailed logging for enable plugin API response (status, data, errors)
- Add immediate status check after enable to verify activation
- Switch back to linux-amd64 architecture for plugin downloads
- Fix SSRF vulnerability by removing arbitrary URL parameter
- Make apiUploadAndEnablePlugin DemoPlugin-specific

Test file updates:
- Update all test files to use simplified API without url/id params
- Remove unused DemoPlugin imports from test files
- Only basic_dialog_plugin.e2e.ts retains detailed debug logging

This will help diagnose why plugins are not activating after the enable
API call succeeds.

* Add robust plugin installation with fallback and 524 timeout handling

- Try to activate existing plugin first before downloading new version
- If download/install fails, fall back to activating existing plugin
- Handle 524 Cloudflare timeout errors by checking if plugin activated anyway
- Never remove old plugin before successful new installation (maintain fallback)
- Add comprehensive logging at each step for debugging
- Return proper error format with message field for test detection

This handles the CI infrastructure issue where Cloudflare times out during
plugin enable (HTTP 524) by checking if the plugin is actually active
despite the timeout, and falling back to existing plugins when downloads fail.

* Add server config logging to debug plugin activation timeouts

Check and log EnableGifPicker and EnablePublicLink settings before
plugin activation to determine if missing config is causing the
2+ minute activation timeouts (524 errors).

* minor fixes

* couple tweeks

* Combine dialog router tests in 1 spec to avoid race condition. (#9269)

* Simplify plugin disabling condition

* dont depend on secondary status check

* fix merge issues, add config setting

* disable plugin after tests, cleanup

* lint fixes

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
2026-01-26 08:57:56 -07:00

447 lines
17 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'path';
import client from './client';
import {apiUploadFile, getResponseFromError} from './common';
// ****************************************************************
// Plugins
// https://api.mattermost.com/#tag/plugins
//
// Exported API function should have the following:
// - documented using JSDoc
// - meaningful description
// - match the referenced API endpoints
// - parameter/s defined by `@param`
// - return value defined by `@return`
// ****************************************************************
const prepackagedPlugins = new Set([
'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.calls',
'com.mattermost.nps',
'com.mattermost.welcomebot',
'zoom',
]);
/**
* Get the latest release version from GitHub releases
* @param {string} repo - GitHub repository in format 'owner/repo'
* @return {Promise<string>} returns latest version string without 'v' prefix
*/
export const apiGetLatestPluginVersion = async (repo: string): Promise<string> => {
try {
const response = await client.get(`https://api.github.com/repos/${repo}/releases/latest`);
const tagName = response.data.tag_name;
// Remove 'v' prefix if present (e.g., 'v0.10.2' -> '0.10.2')
return tagName.startsWith('v') ? tagName.substring(1) : tagName;
} catch (err) {
// Fallback to hardcoded version if API fails
return '0.10.3';
}
};
// Demo Plugin Constants
export const DemoPlugin = {
id: 'com.mattermost.demo-plugin',
repo: 'mattermost/mattermost-plugin-demo',
// Get download URL for latest version (linux-amd64 for CI compatibility)
async getLatestDownloadUrl() {
const latestVersion = await apiGetLatestPluginVersion(this.repo);
// return `https://github.com/${this.repo}/releases/download/v${latestVersion}/mattermost-plugin-demo-v${latestVersion}.tar.gz`;
return `https://github.com/${this.repo}/releases/download/v${latestVersion}/mattermost-plugin-demo-v${latestVersion}-linux-amd64.tar.gz`;
},
} as const;
/**
* Disable non-prepackaged plugins.
* @param {string} baseUrl - the base server URL
*/
export const apiDisableNonPrepackagedPlugins = async (baseUrl: string): Promise<any> => {
const {plugins} = await apiGetAllPlugins(baseUrl);
if (!plugins) {
return;
}
plugins.active.forEach(async (plugin: any) => {
if (plugin.id !== DemoPlugin.id && !prepackagedPlugins.has(plugin.id)) {
await apiDisablePluginById(baseUrl, plugin.id);
}
});
};
/**
* Disable plugin.
* See https://api.mattermost.com/#operation/DisablePlugin
* @param {string} baseUrl - the base server URL
* @param {string} pluginId - the plugin ID
* @return {Object} returns response on success or {error, status} on error
*/
export const apiDisablePluginById = async (baseUrl: string, pluginId: string): Promise<any> => {
try {
return await client.post(`${baseUrl}/api/v4/plugins/${encodeURIComponent(pluginId)}/disable`);
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Enable plugin.
* See https://api.mattermost.com/#operation/EnablePlugin
* @param {string} baseUrl - the base server URL
* @param {string} pluginId - the plugin ID
* @return {Object} returns response on success or {error, status} on error
*/
export const apiEnablePluginById = async (baseUrl: string, pluginId: string): Promise<any> => {
try {
return await client.post(`${baseUrl}/api/v4/plugins/${encodeURIComponent(pluginId)}/enable`);
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Get plugins.
* See https://api.mattermost.com/#operation/GetPlugins
* @param {string} baseUrl - the base server URL
* @return {Object} returns {plugins} on success or {error, status} on error
*/
export const apiGetAllPlugins = async (baseUrl: string): Promise<any> => {
try {
const response = await client.get(`${baseUrl}/api/v4/plugins`);
return {plugins: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Install plugin from URL.
* See https://api.mattermost.com/#operation/InstallPluginFromUrl
* @param {string} baseUrl - the base server URL
* @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
* @return {Object} returns {plugin} on success or {error, status} on error
*/
export const apiInstallPluginFromUrl = async (baseUrl: string, pluginDownloadUrl: string, force = false): Promise<any> => {
try {
const response = await client.post(`${baseUrl}/api/v4/plugins/install_from_url?plugin_download_url=${encodeURIComponent(pluginDownloadUrl)}&force=${force}`);
return {plugin: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Remove plugin.
* See https://api.mattermost.com/#operation/RemovePlugin
* @param {string} baseUrl - the base server URL
* @param {string} pluginId - the plugin ID
* @return {Object} returns response on success or {error, status} on error
*/
export const apiRemovePluginById = async (baseUrl: string, pluginId: string): Promise<any> => {
try {
return await client.delete(`${baseUrl}/api/v4/plugins/${encodeURIComponent(pluginId)}`);
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Upload plugin.
* See https://api.mattermost.com/#operation/UploadPlugin
* @param {string} baseUrl - the base server URL
* @param {string} filename - the filename of plugin to be uploaded
* @return {Object} returns response on success or {error, status} on error
*/
export const apiUploadPlugin = async (baseUrl: string, filename: string): Promise<any> => {
try {
const absFilePath = path.resolve(__dirname, `../../support/fixtures/${filename}`);
return await apiUploadFile('plugin', absFilePath, {url: `${baseUrl}/api/v4/plugins`, method: 'POST'});
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Get plugin status - whether it's installed and/or active.
* @param {string} baseUrl - the base server URL
* @param {string} pluginId - the plugin ID
* @param {string} version - the expected plugin version
* @return {Object} returns {isInstalled, isActive, plugin} on success or {error, status} on error
*/
export const apiGetPluginStatus = async (baseUrl: string, pluginId: string, version?: string): Promise<any> => {
try {
const {plugins} = await apiGetAllPlugins(baseUrl);
if (!plugins) {
return {isInstalled: false, isActive: false};
}
// Check if plugin is installed (in either active or inactive list)
let plugin = plugins.active?.find((p: any) => p.id === pluginId);
if (plugin) {
const isVersionMatch = !version || plugin.version === version;
return {
isInstalled: true,
isActive: true,
plugin,
isVersionMatch,
};
}
plugin = plugins.inactive?.find((p: any) => p.id === pluginId);
if (plugin) {
const isVersionMatch = !version || plugin.version === version;
return {
isInstalled: true,
isActive: false,
plugin,
isVersionMatch,
};
}
return {isInstalled: false, isActive: false};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Upload and enable demo plugin, handling various states.
* Uses DemoPlugin.getLatestDownloadUrl() internally to avoid SSRF concerns.
* @param {Object} options - configuration object
* @param {string} options.baseUrl - the base server URL
* @param {string} options.version - expected plugin version
* @param {boolean} options.force - whether to force install if already exists
* @return {Object} returns plugin data on success or {error, status} on error
*/
export const apiUploadAndEnablePlugin = async (options: {
baseUrl: string;
version?: string;
force?: boolean;
}): Promise<any> => {
const {baseUrl, version, force = false} = options;
const id = DemoPlugin.id;
try {
// Check current plugin status
const statusResult = await apiGetPluginStatus(baseUrl, id, version);
if (statusResult.error) {
return statusResult;
}
// If already active with correct version, return early
if (statusResult.isActive && version && statusResult.isVersionMatch) {
return {plugin: statusResult.plugin, message: 'Plugin is already active with correct version'};
}
// If installed but inactive, try to enable it first (regardless of version)
if (statusResult.isInstalled && !statusResult.isActive) {
// eslint-disable-next-line no-console
console.log(`Found existing plugin version ${statusResult.plugin?.version} (inactive). Attempting to activate it...`);
const enableResult = await apiEnablePluginById(baseUrl, id);
// eslint-disable-next-line no-console
console.log('Enable existing plugin API response:', {
status: enableResult.status,
error: enableResult.error,
});
if (enableResult.error) {
// eslint-disable-next-line no-console
console.log('Failed to activate existing plugin. Will try to install new version.');
} else {
// Wait and verify activation
await new Promise((resolve) => setTimeout(resolve, 2000));
const verifyStatus = await apiGetPluginStatus(baseUrl, id);
// eslint-disable-next-line no-console
console.log('Existing plugin activation verification:', {
isActive: verifyStatus.isActive,
version: verifyStatus.plugin?.version,
});
return {plugin: verifyStatus.plugin, message: 'Plugin was inactive with correct version, now enabled'};
}
}
// Store the existing version before attempting installation
const existingVersion = statusResult.isInstalled ? statusResult.plugin?.version : null;
// Plugin needs to be installed - get URL from DemoPlugin
const url = await DemoPlugin.getLatestDownloadUrl();
// eslint-disable-next-line no-console
console.log(`Attempting to install plugin from: ${url}`);
const installResult = await apiInstallPluginFromUrl(baseUrl, url, force);
if (installResult.error) {
// eslint-disable-next-line no-console
console.log('Plugin installation failed:', {
error: installResult.error,
status: installResult.status,
});
// Check if there's an existing plugin we can try to activate as fallback
const fallbackStatusCheck = await apiGetPluginStatus(baseUrl, id);
if (fallbackStatusCheck.isInstalled) {
// eslint-disable-next-line no-console
console.log(`Installation failed, but found existing plugin version ${fallbackStatusCheck.plugin?.version}. Attempting to activate it as fallback...`);
const fallbackEnableResult = await apiEnablePluginById(baseUrl, id);
// eslint-disable-next-line no-console
console.log('Fallback enable plugin API response:', {
status: fallbackEnableResult.status,
statusText: fallbackEnableResult.statusText,
data: fallbackEnableResult.data,
error: fallbackEnableResult.error,
});
if (fallbackEnableResult.error) {
// eslint-disable-next-line no-console
console.log('Fallback activation also failed. Returning original installation error.');
return {
error: installResult.error,
status: installResult.status,
message: `Plugin installation failed (HTTP ${installResult.status}) and fallback activation also failed`,
};
}
// Wait for activation
await new Promise((resolve) => setTimeout(resolve, 2000));
// Verify fallback activation worked
const fallbackVerifyStatus = await apiGetPluginStatus(baseUrl, id);
// eslint-disable-next-line no-console
console.log('Fallback activation verification:', {
isInstalled: fallbackVerifyStatus.isInstalled,
isActive: fallbackVerifyStatus.isActive,
version: fallbackVerifyStatus.plugin?.version,
});
if (fallbackVerifyStatus.isActive) {
return {
plugin: fallbackVerifyStatus.plugin,
message: `Installation failed but activated existing plugin version ${fallbackVerifyStatus.plugin?.version} as fallback`,
};
}
// eslint-disable-next-line no-console
console.log('Fallback activation succeeded but plugin is not active. Returning error.');
return {
error: installResult.error,
status: installResult.status,
message: `Plugin installation failed (HTTP ${installResult.status}), fallback activation attempted but plugin not active`,
};
}
// No existing plugin to fall back to
// eslint-disable-next-line no-console
console.log('Installation failed and no existing plugin found for fallback.');
return installResult;
}
// eslint-disable-next-line no-console
console.log('Plugin installation succeeded');
// Wait a moment for installation to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
// Enable the newly installed plugin
// eslint-disable-next-line no-console
console.log('Attempting to enable newly installed plugin...');
const enableResult = await apiEnablePluginById(baseUrl, id);
// Log the enable API response for debugging
// eslint-disable-next-line no-console
console.log('Enable plugin API response:', {
status: enableResult.status,
statusText: enableResult.statusText,
data: enableResult.data,
error: enableResult.error,
});
if (enableResult.error) {
// eslint-disable-next-line no-console
console.log(`Enable failed with HTTP ${enableResult.status}. Checking if plugin is actually active...`);
// Check if plugin is actually active despite the error
const verifyStatusAfterError = await apiGetPluginStatus(baseUrl, id);
// eslint-disable-next-line no-console
console.log('Plugin status after enable error:', {
isInstalled: verifyStatusAfterError.isInstalled,
isActive: verifyStatusAfterError.isActive,
version: verifyStatusAfterError.plugin?.version,
});
if (verifyStatusAfterError.isActive) {
// eslint-disable-next-line no-console
console.log('Plugin is actually active despite enable error. Treating as success.');
return {
plugin: verifyStatusAfterError.plugin,
message: `Plugin enabled successfully (despite HTTP ${enableResult.status} timeout)`,
};
}
// Return error with consistent format
return {
error: enableResult.error,
status: enableResult.status,
message: `Failed to enable plugin: HTTP ${enableResult.status}`,
};
}
// Wait a moment for enablement to complete
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check plugin status immediately after enable to verify it activated
const enableStatusCheck = await apiGetPluginStatus(baseUrl, id);
// eslint-disable-next-line no-console
console.log('Plugin status immediately after enable:', {
isInstalled: enableStatusCheck.isInstalled,
isActive: enableStatusCheck.isActive,
version: enableStatusCheck.plugin?.version,
});
const message = existingVersion? `Installed version ${enableStatusCheck.plugin?.version || 'unknown'} over existing version ${existingVersion}`: 'Plugin uploaded and enabled successfully';
return {
plugin: enableStatusCheck.plugin,
message,
};
} catch (err) {
return getResponseFromError(err);
}
};
export const Plugin = {
apiDisableNonPrepackagedPlugins,
apiDisablePluginById,
apiEnablePluginById,
apiGetAllPlugins,
apiGetLatestPluginVersion,
apiGetPluginStatus,
apiInstallPluginFromUrl,
apiRemovePluginById,
apiUploadPlugin,
apiUploadAndEnablePlugin,
};
export default Plugin;