mattermost-mobile-upstream/detox/e2e/support/server_api/plugin.ts
Scott Bishel c6b9170d4f
Add date/datetime field support with timezone features for Apps Forms (#9521)
* Add multiselect, dynamic select, multiform, and field refresh support for Interactive Dialogs

This PR adds comprehensive support for advanced interactive dialog features through the DialogRouter component, which routes between legacy InteractiveDialog and modern AppsForm implementations based on the FeatureFlagInteractiveDialogAppsForm feature flag.

Features Added:
- Multiselect support for select fields (users, channels, static options)
- Dynamic select fields with server-side lookup and search
- Multiform/multistep dialogs with state preservation across steps
- Field refresh functionality to update form fields based on selections

Core Components:
- DialogRouter: Routes between legacy InteractiveDialog and AppsForm based on feature flag
- InteractiveDialogAdapter: Converts between dialog formats and handles submission/lookup
- dialog_conversion utilities: Convert DialogElement to AppField format
- Enhanced autocomplete_selector with multiselect support
- Integration selector improvements for multiselect workflows

Technical Implementation:
- Follows webapp DialogRouter pattern (PR #31821)
- Uses WeakMap caching for expensive form conversions
- Proper error handling and validation for all field types
- Comprehensive unit test coverage for conversion logic

Bug Fixes:
- Fix dynamic select to fetch initial options with empty search input
- Fix multiselect value handling in dialog submission
- Add proper multiselect support in dialog-to-form conversion

Note: Changes only affect AppsForm path. Legacy InteractiveDialog remains unchanged and will only support basic features (no multiselect/dynamic/multiform).

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add E2E tests and test infrastructure for interactive dialog features

E2E Tests Added (18 tests, all passing in ~10 minutes):
- MM-T4976: Multiselect fields (users, channels, options)
- MM-T4977: Dynamic select fields with search
- MM-T4980: Multistep dialog progression
- MM-T4981: Multistep dialog cancellation
- MM-T4983: Field refresh basic interaction
- MM-T4986: Field refresh changes and cancellation
- Plus 12 existing tests for basic dialogs, text fields, boolean fields

Test Infrastructure:
- Add interactive_dialog_test_helper with setup utilities
- Add webhook server support for dynamic plugin installation
- Enhanced integration_selector screen helpers for multiselect testing
- Add helper functions for user/channel selection

Test Optimizations:
- Replace arbitrary 2s waits with waitFor() for dynamic search results
- Reduce buffer waits from 500ms to 300ms where appropriate
- Add swipe gesture in ensureDialogClosed() to reveal posts hidden behind keyboard
- Tests optimized for speed while maintaining reliability

Bug Fixes in Tests:
- Fix MM-T4976 to use selectUser() helper instead of incorrect test data
- Remove multiform code incorrectly merged into MM-T4977 dynamic select test
- Add proper cleanup between tests to prevent state accumulation

All tests verified passing multiple times with proper synchronization.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Fix integration selector tap handling on Android and update E2E tests

Bug Fix - Integration Selector:
- Replace KeyboardAwareFlatList with regular FlatList to fix tap handling on Android API 34+
- Add platform-specific keyboard dismiss behavior (onScrollBeginDrag for Android, on-drag for iOS)
- Follows the same pattern as invite/selection and user_list components

Issue: KeyboardAwareFlatList prevents taps on search results after typing in the search field on Android API 34+. Users could not select dynamic options after searching, making dynamic select fields unusable on Android.

E2E Test Updates:
- Add waitFor() with 3s timeout instead of immediate expect for dynamic field buttons
- Add 300ms buffer wait after selecting first dynamic option before checking for second field
- Ensures proper timing for dialog re-render after integration selector closes

Testing:
- All 18 E2E tests passing locally on Android Pixel 4 XL API 34 (~8.4 minutes)
- Verified dynamic select, multiselect, multiform, and field refresh functionality

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Address code review feedback for interactive dialog improvements

- Move API call to client layer: Added lookupInteractiveDialog method to client mixin following ClientMix pattern
- Extract error message function: Created getSubmissionErrorMessage helper to improve readability
- Simplify nullish coalescing: Use ?? operator instead of ternary for notify_on_cancel
- Add error logging: Added logDebug in autocomplete selector and performLookupCall catch blocks
- Improve field.name handling: Added early return check and removed unnecessary fallback
- Remove type cast: Use object spread instead of 'as any' for refresh submission
- Simplify refresh logic: Removed verbose debug logs from dialog conversion
- Revert PROFILE_CHUNK_SIZE: Changed back from 10 to 100 (unintentional change)
- Revert test config: Changed serverOneUrl back to localhost (unintentional change)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>

* Add date/datetime field support with timezone features

  Core Features:
  - Add DATE and DATETIME field types for Apps Forms
  - User timezone and military time preferences via database observables
  - Relative date support (today, tomorrow, +5d, -2w, +1m)
  - Location timezone override with visual indicator (🌍 Times in GMT/PST)
  - Min/max date constraints with relative date resolution
  - Configurable time intervals (iOS: 1-30 min, fallback 60→30)
  - Required field validation prevents empty submission
  - allowPastDates logic (defaults to false, auto-calculated from min_date)

  Implementation:
  - DateTimeSelector component with timezone-aware display
  - parseDateInTimezone utility for proper date parsing
  - Dialog to AppForm conversion preserves datetime_config
  - Proper UTC conversion regardless of display timezone
  - Fix date off-by-one errors from timezone conversion
  - Fix field label overlap with flexbox layout

  Type Definitions:
  - Add min_date, max_date, time_interval, datetime_config to AppField
  - Define DateTimeConfig (time_interval, location_timezone, is_range, etc)
  - Update DialogElement and InteractiveDialogElementType

  Testing:
  - 52 unit tests passing (date utilities, validation, integration)
  - 7 E2E tests passing (field display, validation, timezone, UTC conversion)
  - Comprehensive coverage for relative dates, timezone parsing, validation

  Architecture:
  - withObservables HOC pattern for reactive user preferences
  - Only modifies apps_form (not deprecated interactive_dialog)
  - Clean separation of utilities, components, and observables
  - Follows existing patterns from custom_status/scheduled_posts

* move performDynamicLookup and use currentConfig

* Fix code review findings for datetime field support

- Hoist useMemo/hook calls out of switch-case (Rules of Hooks fix)
- Wrap AppsFormFieldComponent with React.memo
- Extract dateTimeStyles to makeStyleSheetFromTheme at module scope
- i18n: replace hardcoded "Times in", "Select Date", "Select Time" strings
- Remove spurious handleChange calls from picker toggle buttons
- Add .clone() to prevent moment mutations in resolveRelativeDate and getDateValue
- Fix test: remove private _d field access, assert no-call on toggle
- Fix getStyleSheet return syntax in DateTimeSelector

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

* Align mobile date/datetime with webapp and migrate E2E tests

Date/DateTime parity with webapp:
- Change default time interval from 30 to 60 minutes
- Add time offset support (+H, +M, +S) to resolveRelativeDate
- Add manual time entry with parseTimeString for allow_manual_time_entry
- Auto-populate required datetime fields with rounded current time
- Remove unused range fields from DateTimeConfig type
- Extract DEFAULT_TIME_INTERVAL_MINUTES to shared constant
- Use effectiveInterval for consistent iOS picker/rounding alignment
- Wrap handlers in useCallback, use typography() and changeOpacity()
- Add logWarning for +M disambiguation and null date parse failures
- Internationalize date-time separator string

E2E test infrastructure:
- Migrate all dialog tests from demo plugin to test plugin
- Replace DemoPlugin with TestPlugin constant
- Remove duplicate MM-T4986 test and demo-plugin activation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix line spacing nit

* remove file, update alphabetically

* Fix code review findings for datetime field support

Bug fixes:
- Fix relative time offsets (+2H, +30M) being overwritten with current
  time in getDateValue — skip time replacement for HMS offsets
- Handle Android picker dismiss (event.type === 'dismissed') to prevent
  silently filling fields when user cancels
- Parse minDate/maxDate with field timezone via parseDateInTimezone
  to avoid off-by-one at timezone day boundaries
- Use field's location_timezone in initValues for correct auto-defaults
- Fix parseDateInTimezone to use timezone for date-only strings

Improvements:
- Add database teardown in DateTimeSelector test (afterAll cleanup)
- Fail fast in apiGetLatestPluginVersion instead of wrong fallback
- Stronger E2E assertions: validation error text in MM-T2530B,
  ISO datetime pattern check in MM-T2530F
- Extract ISO_DATETIME_PATTERN constant for E2E test reuse
- Use typography() in apps_form_field.tsx stylesheets
- Internationalize hardcoded 'at' date-time separator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix by.text() RegExp usage in MM-T2530G timezone test

Detox's by.text() only accepts strings, not RegExp. Use API to fetch
the submission post and validate ISO datetime pattern in JavaScript,
matching the approach used in MM-T2530F.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address CodeRabbit R2 findings in E2E tests

- Enforce UTC suffix (Z or ±HH:MM) in ISO_DATETIME_PATTERN
- Make required-field validation assertion mandatory (remove try/catch)
- Fail fast when field refresh element not found (remove silent fallback)
- Remove commented-out pluginInstallAndEnable dead code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add unit tests for datetime business logic

Cover parseTimeString, initValues datetime auto-population,
getDateValue/isTimeOffset, and datetime_config preservation in
dialog_conversion — areas flagged as uncovered in PR test check.
Export the four helpers from their modules so tests can exercise
them directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pin demo plugin install via fixture upload + add datetime E2E tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auto-download demo plugin tarball into fixtures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Configure demo plugin user/channel/lastname for fresh-install path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add datetime E2E tests and isolate iOS 26 keyboard contamination

Adds MM-T2530A-H covering date/datetime field rendering, validation,
picker interaction, defaults, UTC submit, timezone indicator, and manual
time entry. Manual entry placed last (T2530H) since the TextInput
keyboard cycle leaves animation state on iOS 26 +
react-native-keyboard-controller that breaks subsequent dialog tests.

Skips T4983 with TODO referencing the same iOS 26 keyboard contamination
(field-refresh dialog with text inputs poisons RCTRefreshControl with
progressViewOffset NaN). Refines selectUser/selectChannel helpers to
target by entity ID for when the iOS 26 + Detox UserItem TouchableOpacity
hit-test regression is resolved upstream and skipped tests can be
re-enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix lint and update tests for updates to main

* fix bad import

* updates for code review, move/rename files

* Restore UserItem testID on the touchable (from reverted #9790)

  #9790 (Rainforest→Detox migration) was reverted in #9834, which dropped
  this fix. On iOS 26, Detox synthetic taps don't propagate from the inner
  testID-bearing View to the parent TouchableOpacity, so integration-selector
  user rows weren't selectable in E2E. Move the testID onto the TouchableOpacity
  (as CustomListRow already does) to make the row tappable again.

* Update user_list snapshot for UserItem testID move

* updates from main merge, review fixes

* fix e2etests

* fix bad merge

* fix failing test caused by bad merge

---------

Co-authored-by: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-07-16 08:09:47 -06:00

535 lines
21 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
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';
}
};
// Agents Plugin Constants
export const AgentsPlugin = {
id: 'mattermost-ai',
} as const;
// Calls Plugin Constants
export const CallsPlugin = {
id: 'com.mattermost.calls',
url: 'https://github.com/mattermost/mattermost-plugin-calls/releases/download/v1.5.0/com.mattermost.calls-1.5.0.tar.gz',
} as const;
// 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`;
},
} 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
* @param {boolean} force - overwrite an existing plugin install
* @return {Object} returns response on success or {error, status} on error
*/
export const apiUploadPlugin = async (baseUrl: string, filename: string, force = false): Promise<any> => {
try {
const absFilePath = path.resolve(__dirname, `../../support/fixtures/${filename}`);
const forceQuery = force ? '?force=true' : '';
return await apiUploadFile('plugin', absFilePath, {url: `${baseUrl}/api/v4/plugins${forceQuery}`, 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> => {
const allPluginsResult = await apiGetAllPlugins(baseUrl);
if (allPluginsResult.error) {
return allPluginsResult;
}
const {plugins} = allPluginsResult;
if (!plugins) {
return {isInstalled: false, isActive: false};
}
try {
// 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 from a local fixture when available, otherwise from GitHub.
export const apiUploadAndEnablePlugin = async (options: {
baseUrl: string;
version?: string;
force?: boolean;
filename?: string;
}): Promise<any> => {
const {baseUrl, version, force = false, filename} = options;
const id = DemoPlugin.id;
if (filename) {
const absFilePath = path.resolve(__dirname, `../../support/fixtures/${filename}`);
if (fs.existsSync(absFilePath)) {
const uploadResult = await apiUploadPlugin(baseUrl, filename, force);
if (uploadResult.error) {
return uploadResult;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
const enableResult = await apiEnablePluginById(baseUrl, id);
if (enableResult.error) {
return enableResult;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
return apiGetPluginStatus(baseUrl, id, version);
}
}
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);
}
};
/**
* Install a plugin from the Marketplace.
* See https://api.mattermost.com/#operation/InstallMarketplacePlugin
* @param {string} baseUrl - the base server URL
* @param {string} pluginId - the plugin ID to install from Marketplace
* @return {Object} returns {plugin} on success or {error, status} on error
*/
export const apiInstallPluginFromMarketplace = async (baseUrl: string, pluginId: string): Promise<any> => {
try {
const response = await client.post(`${baseUrl}/api/v4/plugins/marketplace`, {id: pluginId});
return {plugin: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Ensure a plugin is installed and active, installing from Marketplace if needed.
* @param {string} baseUrl - the base server URL
* @param {string} pluginId - the plugin ID
* @return {Object} returns {isInstalled, isActive} status
*/
export const apiEnsurePluginInstalled = async (baseUrl: string, pluginId: string): Promise<any> => {
// Check if already installed
const status = await apiGetPluginStatus(baseUrl, pluginId);
if (status.isActive) {
return status;
}
if (status.isInstalled && !status.isActive) {
// eslint-disable-next-line no-console
console.log(`[apiEnsurePluginInstalled] Plugin ${pluginId} installed but inactive, enabling...`);
await apiEnablePluginById(baseUrl, pluginId);
await new Promise((resolve) => setTimeout(resolve, 2000));
return apiGetPluginStatus(baseUrl, pluginId);
}
// Not installed — try Marketplace
// eslint-disable-next-line no-console
console.log(`[apiEnsurePluginInstalled] Plugin ${pluginId} not installed, installing from Marketplace...`);
const installResult = await apiInstallPluginFromMarketplace(baseUrl, pluginId);
if (installResult.error) {
// eslint-disable-next-line no-console
console.warn(`[apiEnsurePluginInstalled] Marketplace install failed for ${pluginId}:`, installResult.error.message || installResult.error);
return {isInstalled: false, isActive: false};
}
// Enable after install
await apiEnablePluginById(baseUrl, pluginId);
await new Promise((resolve) => setTimeout(resolve, 2000));
const finalStatus = await apiGetPluginStatus(baseUrl, pluginId);
// eslint-disable-next-line no-console
console.log(`[apiEnsurePluginInstalled] Plugin ${pluginId}: installed=${finalStatus.isInstalled}, active=${finalStatus.isActive}`);
return finalStatus;
};
export const Plugin = {
apiDisableNonPrepackagedPlugins,
apiDisablePluginById,
apiEnablePluginById,
apiEnsurePluginInstalled,
apiGetAllPlugins,
apiGetLatestPluginVersion,
apiGetPluginStatus,
apiInstallPluginFromMarketplace,
apiInstallPluginFromUrl,
apiRemovePluginById,
apiUploadPlugin,
apiUploadAndEnablePlugin,
};
export default Plugin;