From 0625244fe52e4c081274ae83ffcb12f62a1522b0 Mon Sep 17 00:00:00 2001 From: Kevin Mas Ruiz Date: Wed, 12 Jun 2024 16:47:02 +0200 Subject: [PATCH] feat(telemetry): allow to disable telemetry through settings INTELLIJ-12 (#8) Co-authored-by: Anna Henningsen --- .github/workflows/quality-check.yaml | 2 +- .gitignore | 4 +- CHANGELOG.md | 1 + gradle/diktat.yml | 2 + .../ActivatePluginPostStartupActivity.kt | 48 ++++++++++ .../mongodb/jbplugin/i18n/SettingsMessages.kt | 19 ++++ .../jbplugin/i18n/TelemetryMessages.kt | 19 ++++ .../observability/TelemetryService.kt | 52 ++++++---- .../jbplugin/settings/PluginSettings.kt | 46 +++++++++ .../settings/PluginSettingsConfigurable.kt | 73 ++++++++++++++ .../src/main/resources/META-INF/plugin.xml | 10 ++ .../messages/SettingsBundle.properties | 1 + .../messages/TelemetryBundle.properties | 8 ++ .../jbplugin/fixtures/FixtureExtensions.kt | 63 ++++++++++-- .../fixtures/IntegrationTestExtensions.kt | 57 +++++++---- .../jbplugin/fixtures/UiTestExtensions.kt | 95 +++++++++++++------ .../components/BrowserSettingsFixture.kt | 71 ++++++++++++++ .../components/MongoDbSettingsFixture.kt | 65 +++++++++++++ .../jbplugin/i18n/SettingsMessagesTest.kt | 12 +++ .../jbplugin/i18n/TelemetryMessagesTest.kt | 12 +++ .../observability/TelemetryServiceTest.kt | 72 ++++++++++---- .../jbplugin/settings/SettingsUiTest.kt | 49 ++++++++++ .../src/test/resources/fake-browser.sh | 9 ++ 23 files changed, 695 insertions(+), 95 deletions(-) create mode 100644 packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/SettingsMessages.kt create mode 100644 packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessages.kt create mode 100644 packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettings.kt create mode 100644 packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettingsConfigurable.kt create mode 100644 packages/jetbrains-plugin/src/main/resources/messages/SettingsBundle.properties create mode 100644 packages/jetbrains-plugin/src/main/resources/messages/TelemetryBundle.properties create mode 100644 packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/BrowserSettingsFixture.kt create mode 100644 packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/MongoDbSettingsFixture.kt create mode 100644 packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/SettingsMessagesTest.kt create mode 100644 packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessagesTest.kt create mode 100644 packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/settings/SettingsUiTest.kt create mode 100755 packages/jetbrains-plugin/src/test/resources/fake-browser.sh diff --git a/.github/workflows/quality-check.yaml b/.github/workflows/quality-check.yaml index cca38767..dfed3297 100644 --- a/.github/workflows/quality-check.yaml +++ b/.github/workflows/quality-check.yaml @@ -191,7 +191,7 @@ jobs: report_paths: '**/build/test-results/test/TEST-*.xml' - name: Generate Coverage Report run: | - ./gradlew --quiet --console=plain "jacocoTestReport" + ./gradlew "jacocoTestReport" $(./gradlew "jacocoTestReport" --dry-run | awk '/^:/ { print "-x" $1 }' | sed '$ d') - uses: actions/upload-artifact@v4 name: Upload Functional Test Coverage with: diff --git a/.gitignore b/.gitignore index 07d9be7f..dac423da 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ replay_pid* # This file is generated at compile time. packages/jetbrains-plugin/src/main/resources/build.properties /**/video -/**/.DS_Store \ No newline at end of file +/**/.DS_Store + +FAKE_BROWSER_OUTPUT \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e7314f0d..dfe18999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ MongoDB plugin for IntelliJ IDEA. ## [Unreleased] ### Added +* [INTELLIJ-12](https://jira.mongodb.org/browse/INTELLIJ-11): Notify users about telemetry, and allow them to disable it. * [INTELLIJ-11](https://jira.mongodb.org/browse/INTELLIJ-11): Flush pending analytics events before closing the IDE. ### Changed diff --git a/gradle/diktat.yml b/gradle/diktat.yml index eca884eb..d4251db8 100644 --- a/gradle/diktat.yml +++ b/gradle/diktat.yml @@ -23,4 +23,6 @@ - name: FILE_NAME_INCORRECT # File name does not need to match class name inside enabled: false - name: LOCAL_VARIABLE_EARLY_DECLARATION # Allow declaring variables at the beginning of a function if they are mutable + enabled: false +- name: USE_DATA_CLASS # Do not force to use data classes, some intellij components won't work if they are data classes enabled: false \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt index bec0c1fd..916c5096 100644 --- a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/ActivatePluginPostStartupActivity.kt @@ -1,13 +1,44 @@ +/** + * These classes implement the balloon that shows the first time that the plugin is activated. + */ + package com.mongodb.jbplugin +import com.intellij.ide.BrowserUtil +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.startup.StartupActivity +import com.mongodb.jbplugin.i18n.TelemetryMessages import com.mongodb.jbplugin.observability.probe.PluginActivatedProbe +import com.mongodb.jbplugin.settings.PluginSettingsConfigurable +import com.mongodb.jbplugin.settings.useSettings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +/** + * Class that represents the link that opens the settings page for MongoDB. + */ +class OpenMongoDbPluginSettingsAction : AnAction(TelemetryMessages.message("action.disable-telemetry")) { + override fun actionPerformed(event: AnActionEvent) { + ShowSettingsUtil.getInstance().showSettingsDialog(event.project, PluginSettingsConfigurable::class.java) + } +} + +/** + * Class that represents the link that opens the privacy policy page + */ +class OpenPrivacyPolicyPage : AnAction(TelemetryMessages.message("action.view-privacy-policy")) { + override fun actionPerformed(event: AnActionEvent) { + BrowserUtil.browse(TelemetryMessages.message("settings.telemetry-privacy-policy")) + } +} + /** * This notifies that the plugin has been activated. * @@ -18,6 +49,23 @@ class ActivatePluginPostStartupActivity(private val cs: CoroutineScope) : Startu cs.launch { val pluginActivated = ApplicationManager.getApplication().getService(PluginActivatedProbe::class.java) pluginActivated.pluginActivated() + + val settings = useSettings() + if (!settings.hasTelemetryOptOutputNotificationBeenShown) { + NotificationGroupManager.getInstance() + .getNotificationGroup("com.mongodb.jbplugin.notifications.Telemetry") + .createNotification( + TelemetryMessages.message("notification.title"), + TelemetryMessages.message("notification.message"), + NotificationType.INFORMATION, + ) + .setImportant(true) + .addAction(OpenPrivacyPolicyPage()) + .addAction(OpenMongoDbPluginSettingsAction()) + .notify(project) + + settings.hasTelemetryOptOutputNotificationBeenShown = true + } } } } diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/SettingsMessages.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/SettingsMessages.kt new file mode 100644 index 00000000..47052c71 --- /dev/null +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/SettingsMessages.kt @@ -0,0 +1,19 @@ +package com.mongodb.jbplugin.i18n + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object SettingsMessages { + @NonNls + private const val BUNDLE = "messages.SettingsBundle" + private val instance = DynamicBundle(SettingsMessages::class.java, BUNDLE) + + fun message( + key: + @PropertyKey(resourceBundle = BUNDLE) + String, + vararg params: Any, + ): @Nls String = instance.getMessage(key, *params) +} diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessages.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessages.kt new file mode 100644 index 00000000..79dba4fa --- /dev/null +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessages.kt @@ -0,0 +1,19 @@ +package com.mongodb.jbplugin.i18n + +import com.intellij.DynamicBundle +import org.jetbrains.annotations.Nls +import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.PropertyKey + +object TelemetryMessages { + @NonNls + private const val BUNDLE = "messages.TelemetryBundle" + private val instance = DynamicBundle(TelemetryMessages::class.java, BUNDLE) + + fun message( + key: + @PropertyKey(resourceBundle = BUNDLE) + String, + vararg params: Any, + ): @Nls String = instance.getMessage(key, *params) +} diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt index b6b5ba62..aa0ff75d 100644 --- a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/observability/TelemetryService.kt @@ -6,6 +6,7 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.logger import com.mongodb.jbplugin.meta.BuildInformation +import com.mongodb.jbplugin.settings.useSettings import com.segment.analytics.Analytics import com.segment.analytics.messages.IdentifyMessage import com.segment.analytics.messages.TrackMessage @@ -18,9 +19,10 @@ private val logger: Logger = logger() */ @Service internal class TelemetryService : AppLifecycleListener { - internal var analytics: Analytics = Analytics - .builder(BuildInformation.segmentApiKey) - .build() + internal var analytics: Analytics = + Analytics + .builder(BuildInformation.segmentApiKey) + .build() init { ApplicationManager.getApplication() @@ -28,38 +30,50 @@ internal class TelemetryService : AppLifecycleListener { .connect() .subscribe( AppLifecycleListener.TOPIC, - this + this, ) } fun sendEvent(event: TelemetryEvent) { - val runtimeInformationService = ApplicationManager.getApplication().getService( - RuntimeInformationService::class.java - ) - val runtimeInfo = runtimeInformationService.get() + if (!useSettings().isTelemetryEnabled) { + return + } - val message = when (event) { - is TelemetryEvent.PluginActivated -> IdentifyMessage.builder().userId(runtimeInfo.userId) - else -> - TrackMessage.builder(event.name).userId(runtimeInfo.userId) - .properties(event.properties.entries.associate { - it.key.publicName to it.value - }) + val runtimeInformationService = + ApplicationManager.getApplication().getService( + RuntimeInformationService::class.java, + ) + val runtimeInfo = runtimeInformationService.get() - } + val message = + when (event) { + is TelemetryEvent.PluginActivated -> IdentifyMessage.builder().userId(runtimeInfo.userId) + else -> + TrackMessage.builder(event.name).userId(runtimeInfo.userId) + .properties( + event.properties.entries.associate { + it.key.publicName to it.value + }, + ) + } analytics.enqueue(message) } override fun appWillBeClosed(isRestart: Boolean) { + val telemetryEnabled = useSettings().isTelemetryEnabled val logMessage = ApplicationManager.getApplication().getService(LogMessage::class.java) logger.info( - logMessage.message("Flushing Segment analytics because the IDE is closing.") + logMessage.message("Shutting down Segment analytics because the IDE is closing.") .put("isRestart", isRestart) - .build() + .put("telemetryEnabled", telemetryEnabled) + .build(), ) - analytics.flush() + if (telemetryEnabled) { + analytics.flush() + } + analytics.shutdown() } } diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettings.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettings.kt new file mode 100644 index 00000000..aa3cc5ca --- /dev/null +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettings.kt @@ -0,0 +1,46 @@ +/** + * Settings state for the plugin. These classes are responsible for the persistence of the plugin + * settings. + */ + +package com.mongodb.jbplugin.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.* +import java.io.Serializable + +/** + * The state component represents the persisting state. Don't use directly, this is only necessary + * for the state to be persisted. Use PluginSettings instead. + * + * @see PluginSettings + */ +@Service +@State( + name = "com.mongodb.jbplugin.settings.PluginSettings", + storages = [Storage(value = "MongoDBPluginSettings.xml")], +) +class PluginSettingsStateComponent : SimplePersistentStateComponent(PluginSettings()) + +/** + * The settings themselves. They are tracked, so any change on the settings properties will be eventually + * persisted by IntelliJ. To access the settings, use the useSettings provider. + * + * @see useSettings + */ +class PluginSettings : BaseState(), Serializable { + var isTelemetryEnabled by property(true) + var hasTelemetryOptOutputNotificationBeenShown by property(false) +} + +/** + * Function that provides a reference to the current PluginSettings. + * + * @see PluginSettings + * + * @return + */ +fun useSettings(): PluginSettings = + ApplicationManager.getApplication().getService( + PluginSettingsStateComponent::class.java, + ).state diff --git a/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettingsConfigurable.kt b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettingsConfigurable.kt new file mode 100644 index 00000000..2c694537 --- /dev/null +++ b/packages/jetbrains-plugin/src/main/kotlin/com/mongodb/jbplugin/settings/PluginSettingsConfigurable.kt @@ -0,0 +1,73 @@ +/** + * These classes represent the settings modal. + */ + +package com.mongodb.jbplugin.settings + +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.options.Configurable +import com.intellij.ui.components.JBCheckBox +import com.intellij.util.ui.FormBuilder +import com.mongodb.jbplugin.i18n.SettingsMessages +import com.mongodb.jbplugin.i18n.TelemetryMessages +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * This class represents a section in the settings modal. The UI will be implemented by + * PluginSettingsComponent. + */ +class PluginSettingsConfigurable : Configurable { + private lateinit var settingsComponent: PluginSettingsComponent + + override fun createComponent(): JComponent { + settingsComponent = PluginSettingsComponent() + return settingsComponent.root + } + + override fun isModified(): Boolean { + val savedSettings = useSettings() + return settingsComponent.isTelemetryEnabledCheckBox.isSelected != savedSettings.isTelemetryEnabled + } + + override fun apply() { + val savedSettings = + useSettings().apply { + isTelemetryEnabled = settingsComponent.isTelemetryEnabledCheckBox.isSelected + } + } + + override fun reset() { + val savedSettings = useSettings() + settingsComponent.isTelemetryEnabledCheckBox.isSelected = savedSettings.isTelemetryEnabled + } + + override fun getDisplayName() = SettingsMessages.message("settings.display-name") +} + +/** + * The panel that is shown in the settings section for MongoDB. + */ +private class PluginSettingsComponent { + val root: JPanel + val isTelemetryEnabledCheckBox = JBCheckBox(TelemetryMessages.message("settings.telemetry-collection-checkbox")) + val privacyPolicyButton = JButton(TelemetryMessages.message("action.view-privacy-policy")) + + init { + privacyPolicyButton.addActionListener { + BrowserUtil.browse(TelemetryMessages.message("settings.telemetry-privacy-policy")) + } + + root = + FormBuilder.createFormBuilder() + .addComponent(isTelemetryEnabledCheckBox) + .addTooltip(TelemetryMessages.message("settings.telemetry-collection-tooltip")) + .addComponent(privacyPolicyButton) + .addComponentFillVertically(JPanel(), 0) + .panel + + root.accessibleContext.accessibleName = "MongoDB Settings" + isTelemetryEnabledCheckBox.accessibleContext.accessibleName = "MongoDB Enable Telemetry" + } +} diff --git a/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml b/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml index 98d97504..855f9d32 100644 --- a/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml +++ b/packages/jetbrains-plugin/src/main/resources/META-INF/plugin.xml @@ -11,6 +11,16 @@ + + diff --git a/packages/jetbrains-plugin/src/main/resources/messages/SettingsBundle.properties b/packages/jetbrains-plugin/src/main/resources/messages/SettingsBundle.properties new file mode 100644 index 00000000..2fdfdfeb --- /dev/null +++ b/packages/jetbrains-plugin/src/main/resources/messages/SettingsBundle.properties @@ -0,0 +1 @@ +settings.display-name=MongoDB \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/main/resources/messages/TelemetryBundle.properties b/packages/jetbrains-plugin/src/main/resources/messages/TelemetryBundle.properties new file mode 100644 index 00000000..5dc27671 --- /dev/null +++ b/packages/jetbrains-plugin/src/main/resources/messages/TelemetryBundle.properties @@ -0,0 +1,8 @@ +notification.group.name=MongoDB telemetry +notification.title=MongoDB plugin telemetry +notification.message=Anonymous telemetry is enabled by default because it helps us improve the plugin. +action.disable-telemetry=Disable Telemetry +action.view-privacy-policy=View Privacy Policy +settings.telemetry-collection-tooltip=Allow the collection of anonymous diagnostics and usage telemetry data to help improve the product. +settings.telemetry-collection-checkbox=Enable telemetry +settings.telemetry-privacy-policy=https://www.mongodb.com/legal/privacy/privacy-policy \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/FixtureExtensions.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/FixtureExtensions.kt index ea326065..1eef0b3c 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/FixtureExtensions.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/FixtureExtensions.kt @@ -7,6 +7,7 @@ package com.mongodb.jbplugin.fixtures import com.intellij.remoterobot.RemoteRobot import com.intellij.remoterobot.fixtures.Fixture +import com.intellij.remoterobot.search.locators.Locator import com.intellij.remoterobot.utils.waitFor import java.time.Duration @@ -16,15 +17,65 @@ import java.time.Duration * @param timeout * @return */ -inline fun RemoteRobot.findVisible(timeout: Duration = Duration.ofMinutes(1)) = run { +inline fun RemoteRobot.findVisible(timeout: Duration = Duration.ofMinutes(1)) = + run { + waitFor( + timeout, + Duration.ofMillis(100), + errorMessage = "Could not find component of class ${T::class.java.canonicalName}", + ) { + runCatching { + find(T::class.java, Duration.ofMillis(100)).callJs("true") + }.getOrDefault(false) + } + + find(T::class.java) + } + +/** + * Returns a fixture by the locator. + * + * @param timeout + * @param locator + * @return + */ +inline fun RemoteRobot.findVisible( + locator: Locator, + timeout: Duration = Duration.ofMinutes(1), +) = run { waitFor( - timeout, Duration.ofMillis(100), - errorMessage = "Could not find component of class ${T::class.java.canonicalName}" + timeout, + Duration.ofMillis(100), + errorMessage = "Could not find component of class ${T::class.java.canonicalName}", ) { runCatching { - find(T::class.java, Duration.ofMillis(100)).callJs("true") + find(T::class.java, locator, Duration.ofMillis(100)).callJs("true") }.getOrDefault(false) } - find(T::class.java) -} \ No newline at end of file + find(T::class.java, locator) +} + +/** + * Opens the IntelliJ settings modal at the specific section. The section is the name of the + * group in the left sidebar. So for example, if you want to open the MongoDB section, you + * would specify "MongoDB". + * + * @param section + */ +fun RemoteRobot.openSettingsAtSection(section: String) { + this.runJs( + """ + importClass(com.intellij.openapi.application.ApplicationManager) + const runAction = new Runnable({ + run: function() { + com.intellij.openapi.options.ShowSettingsUtil.getInstance().showSettingsDialog( + null, + "$section", + ) + } + }) + ApplicationManager.getApplication().invokeLater(runAction) + """.trimIndent(), + ) +} diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt index 4f3eefb9..0a325be6 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/IntegrationTestExtensions.kt @@ -17,6 +17,8 @@ import com.mongodb.jbplugin.observability.LogMessage import com.mongodb.jbplugin.observability.LogMessageBuilder import com.mongodb.jbplugin.observability.RuntimeInformation import com.mongodb.jbplugin.observability.RuntimeInformationService +import com.mongodb.jbplugin.settings.PluginSettings +import com.mongodb.jbplugin.settings.PluginSettingsStateComponent import org.junit.jupiter.api.extension.* import org.mockito.Mockito.`when` import org.mockito.Mockito.mock @@ -34,44 +36,60 @@ annotation class IntegrationTest /** * Extension class, should not be used directly. */ -private class IntegrationTestExtension : BeforeTestExecutionCallback, +private class IntegrationTestExtension : + BeforeTestExecutionCallback, AfterTestExecutionCallback, ParameterResolver { private lateinit var application: Application + private lateinit var settings: PluginSettingsStateComponent private lateinit var messageBus: MessageBus private lateinit var project: Project override fun beforeTestExecution(context: ExtensionContext?) { application = mock() project = mock() + settings = PluginSettingsStateComponent() - messageBus = RootBus(mock().apply { - `when`(this.isDisposed()).thenReturn(false) - `when`(this.isParentLazyListenersIgnored()).thenReturn(false) - `when`(this.createListener(any())).then { - val descriptor = it.arguments[0] as ListenerDescriptor - Class.forName(descriptor.listenerClassName).getConstructor().newInstance() - } - }) + messageBus = + RootBus( + mock().apply { + `when`(this.isDisposed()).thenReturn(false) + `when`(this.isParentLazyListenersIgnored()).thenReturn(false) + `when`(this.createListener(any())).then { + val descriptor = it.arguments[0] as ListenerDescriptor + Class.forName(descriptor.listenerClassName).getConstructor().newInstance() + } + }, + ) `when`(application.getMessageBus()).thenReturn(messageBus) `when`(project.getMessageBus()).thenReturn(messageBus) + application.withMockedService(settings) ApplicationManager.setApplication(application) {} } override fun afterTestExecution(context: ExtensionContext?) { } - override fun supportsParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Boolean = + override fun supportsParameter( + parameterContext: ParameterContext?, + extensionContext: ExtensionContext?, + ): Boolean = parameterContext?.parameter?.type?.run { - equals(Application::class.java) || equals(Project::class.java) + equals(Application::class.java) || + equals(Project::class.java) || + equals(PluginSettings::class.java) } ?: false - override fun resolveParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Any = + override fun resolveParameter( + parameterContext: ParameterContext?, + extensionContext: ExtensionContext?, + ): Any = when (parameterContext?.parameter?.type) { Application::class.java -> application Project::class.java -> project + PluginSettings::class.java -> settings.state else -> TODO() } } @@ -119,7 +137,7 @@ internal fun mockRuntimeInformationService( jvmVendor: String = "Obelisk", jvmVersion: String = "42", buildVersion: String = "2024.2", - applicationName: String = "Cool IDE" + applicationName: String = "Cool IDE", ) = mock().also { service -> `when`(service.get()).thenReturn( RuntimeInformation( @@ -129,8 +147,8 @@ internal fun mockRuntimeInformationService( jvmVendor = jvmVendor, jvmVersion = jvmVersion, buildVersion = buildVersion, - applicationName = applicationName - ) + applicationName = applicationName, + ), ) } @@ -146,8 +164,9 @@ internal fun mockRuntimeInformationService( * * @return A new mocked LogMessage */ -internal fun mockLogMessage() = mock().also { logMessage -> - `when`(logMessage.message(any())).then { message -> - LogMessageBuilder(Gson(), message.arguments[0].toString()) +internal fun mockLogMessage() = + mock().also { logMessage -> + `when`(logMessage.message(any())).then { message -> + LogMessageBuilder(Gson(), message.arguments[0].toString()) + } } -} \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/UiTestExtensions.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/UiTestExtensions.kt index 42986b2c..5342ceca 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/UiTestExtensions.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/UiTestExtensions.kt @@ -45,32 +45,61 @@ annotation class RequiresProject(val value: String) * * @see UiTest */ -private class UiTestExtension : BeforeAllCallback, +private class UiTestExtension : + BeforeAllCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback, ParameterResolver { private val remoteRobotUrl: String = "http://localhost:8082" private lateinit var remoteRobot: RemoteRobot - override fun supportsParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Boolean = - parameterContext?.parameter?.type?.equals(RemoteRobot::class.java) ?: false + override fun supportsParameter( + parameterContext: ParameterContext?, + extensionContext: ExtensionContext?, + ): Boolean = parameterContext?.parameter?.type?.equals(RemoteRobot::class.java) ?: false - override fun resolveParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Any = - remoteRobot + override fun resolveParameter( + parameterContext: ParameterContext?, + extensionContext: ExtensionContext?, + ): Any = remoteRobot override fun beforeAll(context: ExtensionContext?) { remoteRobot = RemoteRobot(remoteRobotUrl) } override fun beforeTestExecution(context: ExtensionContext?) { - val requiresProject = context?.requiredTestMethod?.annotations - ?.find { annotation -> annotation.annotationClass == RequiresProject::class } as RequiresProject? + val requiresProject = + context?.requiredTestMethod?.annotations + ?.find { annotation -> annotation.annotationClass == RequiresProject::class } as RequiresProject? CommonSteps(remoteRobot).closeProject() + + remoteRobot.runJs( + """ + importClass(com.intellij.openapi.application.ApplicationManager) + importClass(com.intellij.ide.plugins.PluginManager) + importClass(com.intellij.openapi.extensions.PluginId) + + global.put('loadPlugin', function () { + const pluginManager = PluginManager.getInstance(); + const pluginID = PluginId.findId("com.mongodb.jbplugin"); + return pluginManager.findEnabledPlugin(pluginID); + }); + + global.put('loadPluginClass', function (className) { + return global.get('loadPlugin')().getPluginClassLoader().loadClass(className); + }); + + global.put('loadPluginService', function (className) { + return ApplicationManager.getApplication().getService(global.get("loadPluginClass")(className)); + }); + """.trimIndent(), + ) + requiresProject?.let { // If we have the @RequireProject annotation, load that project on startup CommonSteps(remoteRobot).openProject( - Path("src/test/resources/project-fixtures/${requiresProject.value}").toAbsolutePath().toString() + Path("src/test/resources/project-fixtures/${requiresProject.value}").toAbsolutePath().toString(), ) } } @@ -101,7 +130,7 @@ private class UiTestExtension : BeforeAllCallback, private fun String.saveFile( url: String, - name: String + name: String, ): File { val response = client.newCall(Request.Builder().url(url).build()).execute() return File(this).apply { @@ -112,10 +141,11 @@ private class UiTestExtension : BeforeAllCallback, } private fun BufferedImage.save(name: String) { - val bytes = ByteArrayOutputStream().use { bos -> - ImageIO.write(this, "png", bos) - bos.toByteArray() - } + val bytes = + ByteArrayOutputStream().use { bos -> + ImageIO.write(this, "png", bos) + bos.toByteArray() + } File("build/reports").apply { mkdirs() }.resolve("$name.png").writeBytes(bytes) } @@ -123,9 +153,10 @@ private class UiTestExtension : BeforeAllCallback, private fun saveIdeaFrames(testName: String) { remoteRobot.findAll(byXpath("//div[@class='IdeFrameImpl']")) .forEachIndexed { index, frame -> - val pic = try { - frame.callJs( - """ + val pic = + try { + frame.callJs( + """ importPackage(java.io) importPackage(javax.imageio) importPackage(java.awt.image) @@ -144,20 +175,22 @@ private class UiTestExtension : BeforeAllCallback, baos.close(); } pictureBytes; - """, true - ) - } catch (e: Throwable) { - e.printStackTrace() - throw e - } + """, + true, + ) + } catch (e: Throwable) { + e.printStackTrace() + throw e + } pic.inputStream().use { ImageIO.read(it) }.save(testName + "_" + index) } } - private fun fetchScreenShot(): BufferedImage = remoteRobot.callJs( - """ + private fun fetchScreenShot(): BufferedImage = + remoteRobot.callJs( + """ importPackage(java.io) importPackage(javax.imageio) const screenShot = new java.awt.Robot().createScreenCapture( @@ -172,12 +205,12 @@ private class UiTestExtension : BeforeAllCallback, baos.close(); } pictureBytes; - """ - ) - .inputStream() - .use { - ImageIO.read(it) - } + """, + ) + .inputStream() + .use { + ImageIO.read(it) + } } /** @@ -185,4 +218,4 @@ private class UiTestExtension : BeforeAllCallback, * * @return */ -fun String.stripHtml(): String = Jsoup.clean(this, Safelist.none()) \ No newline at end of file +fun String.stripHtml(): String = Jsoup.clean(this, Safelist.none()) diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/BrowserSettingsFixture.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/BrowserSettingsFixture.kt new file mode 100644 index 00000000..1077680a --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/BrowserSettingsFixture.kt @@ -0,0 +1,71 @@ +/** + * Fixture that represents the browser settings page. It's used for tests that depend on opening a browser. + * + */ + +package com.mongodb.jbplugin.fixtures.components + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.* +import com.intellij.remoterobot.search.locators.XpathLocator +import com.intellij.remoterobot.search.locators.byXpath +import com.intellij.remoterobot.utils.keyboard +import com.mongodb.jbplugin.fixtures.findVisible +import com.mongodb.jbplugin.fixtures.openSettingsAtSection +import java.nio.file.Files +import kotlin.io.path.Path + +/** + * Component that represents the settings page. + * + * @param remoteRobot + * @param remoteComponent + */ +@DefaultXpath(by = "hierarchy", xpath = "//div[@class='DialogPanel']//div[@class='JPanel']") +@FixtureName("BrowserSettingsFixture") +class BrowserSettingsFixture(remoteRobot: RemoteRobot, remoteComponent: RemoteComponent) : ContainerFixture( + remoteRobot, + remoteComponent, +) { + val ok by lazy { + remoteRobot.findAll().find { it.text == "OK" } ?: throw NoSuchElementException() + } + fun useFakeBrowser() { + val selector = remoteRobot.find(byXpath( +"//div[@accessiblename='Default Browser:' and @class='ComboBox']" +)) + selector.selectItem("Custom path") + + val commandInput = remoteRobot.find(XpathLocator("type", + "//div[@class='TextFieldWithBrowseButton']")) + commandInput.click() + + remoteRobot.keyboard { + selectAll() + enterText(Path("src", "test", "resources", "fake-browser.sh").toAbsolutePath().toString(), 5) + } + } + + fun lastBrowserUrl(): String { + val pathToOutput = Path("src", "test", "resources", "FAKE_BROWSER_OUTPUT").toAbsolutePath() + return Files.readString(pathToOutput).trim() + } + + fun useSystemBrowser() { + val selector = remoteRobot.find(byXpath( +"//div[@accessiblename='Default Browser:' and @class='ComboBox']" +)) + selector.selectItem("System default") + } +} + +/** + * Opens the settings dialog and returns a fixture, so it can be interacted. + * + * @return + */ +fun RemoteRobot.openBrowserSettings(): BrowserSettingsFixture { + openSettingsAtSection("Web Browsers and Preview") + return findVisible() +} diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/MongoDbSettingsFixture.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/MongoDbSettingsFixture.kt new file mode 100644 index 00000000..bf25b4c5 --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/fixtures/components/MongoDbSettingsFixture.kt @@ -0,0 +1,65 @@ +/** + * Fixture that represents the plugin settings page. If you add new settings, + * you should add new properties here. + * + * For usage in tests that work with settings, you can use openSettings, that will open the UI, or + * remoteRobot.useSetting(name), that will give you the value for that specific setting. + */ + +package com.mongodb.jbplugin.fixtures.components + +import com.intellij.remoterobot.RemoteRobot +import com.intellij.remoterobot.data.RemoteComponent +import com.intellij.remoterobot.fixtures.* +import com.mongodb.jbplugin.fixtures.findVisible +import com.mongodb.jbplugin.fixtures.openSettingsAtSection + +/** + * Component that represents the settings page. + * + * @param remoteRobot + * @param remoteComponent + */ +@DefaultXpath(by = "accessible name", xpath = "//div[@accessiblename='MongoDB Settings']") +@FixtureName("MongoDBSettings") +class MongoDbSettingsFixture(remoteRobot: RemoteRobot, remoteComponent: RemoteComponent) : ContainerFixture( + remoteRobot, + remoteComponent, +) { + val enableTelemetry by lazy { + findAll().find { it.text == "Enable telemetry" } ?: throw NoSuchElementException() + } + val privacyPolicyButton by lazy { + findAll().find { it.text == "View Privacy Policy" } ?: throw NoSuchElementException() + } + val ok by lazy { + remoteRobot.findAll().find { it.text == "OK" } ?: throw NoSuchElementException() + } +} + +/** + * Opens the settings dialog and returns a fixture, so it can be interacted. + * + * @return + */ +fun RemoteRobot.openSettings(): MongoDbSettingsFixture { + openSettingsAtSection("MongoDB") + return findVisible() +} + +/** + * Returns a specific setting value from the plugin settings. + * + * @see com.mongodb.jbplugin.settings.PluginSettings for the possible values. + * + * @param name + * @return + */ +inline fun RemoteRobot.useSetting(name: String): T = + callJs( + """ + global.get('loadPluginService')( + 'com.mongodb.jbplugin.settings.PluginSettingsStateComponent' + ).getState().$name() + """.trimIndent(), + ) diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/SettingsMessagesTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/SettingsMessagesTest.kt new file mode 100644 index 00000000..36f1b3bd --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/SettingsMessagesTest.kt @@ -0,0 +1,12 @@ +package com.mongodb.jbplugin.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SettingsMessagesTest { + @Test + fun `loads messages from the resource bundle`() { + val messages = SettingsMessages.message("settings.display-name") + assertEquals("MongoDB", messages) + } +} \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessagesTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessagesTest.kt new file mode 100644 index 00000000..109460a0 --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/i18n/TelemetryMessagesTest.kt @@ -0,0 +1,12 @@ +package com.mongodb.jbplugin.i18n + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TelemetryMessagesTest { + @Test + fun `loads messages from the resource bundle`() { + val messages = TelemetryMessages.message("notification.group.name") + assertEquals("MongoDB telemetry", messages) + } +} \ No newline at end of file diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt index 7dc84a5e..720585ef 100644 --- a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/observability/TelemetryServiceTest.kt @@ -6,11 +6,10 @@ import com.mongodb.jbplugin.fixtures.IntegrationTest import com.mongodb.jbplugin.fixtures.mockLogMessage import com.mongodb.jbplugin.fixtures.mockRuntimeInformationService import com.mongodb.jbplugin.fixtures.withMockedService +import com.mongodb.jbplugin.settings.PluginSettings import com.segment.analytics.Analytics import org.junit.jupiter.api.Test -import org.mockito.kotlin.argThat -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify +import org.mockito.kotlin.* @IntegrationTest internal class TelemetryServiceTest { @@ -18,18 +17,19 @@ internal class TelemetryServiceTest { fun `sends an identify event when a PluginActivated event is sent`(application: Application) { application.withMockedService(mockRuntimeInformationService(userId = "654321")) - val service = TelemetryService().apply { - analytics = mock() - } + val service = + TelemetryService().apply { + analytics = mock() + } service.sendEvent(TelemetryEvent.PluginActivated) verify(service.analytics).enqueue( argThat { build().let { it.userId() == "654321" && - it.type().name == "identify" + it.type().name == "identify" } - } + }, ) } @@ -37,9 +37,10 @@ internal class TelemetryServiceTest { fun `sends a new connection event as a tracking event`(application: Application) { application.withMockedService(mockRuntimeInformationService(userId = "654321")) - val service = TelemetryService().apply { - analytics = mock() - } + val service = + TelemetryService().apply { + analytics = mock() + } service.sendEvent( TelemetryEvent.NewConnection( @@ -49,17 +50,17 @@ internal class TelemetryServiceTest { isGenuine = true, nonGenuineServerName = null, serverOsFamily = null, - version = "8.0" - ) + version = "8.0", + ), ) verify(service.analytics).enqueue( argThat { build().let { it.userId() == "654321" && - it.type().name == "track" + it.type().name == "track" } - } + }, ) } @@ -68,9 +69,10 @@ internal class TelemetryServiceTest { application.withMockedService(mockRuntimeInformationService(userId = "654321")) application.withMockedService(mockLogMessage()) - val service = TelemetryService().apply { - analytics = mock() - } + val service = + TelemetryService().apply { + analytics = mock() + } val publisher = application.messageBus.syncPublisher(AppLifecycleListener.TOPIC) publisher.appWillBeClosed(true) @@ -78,4 +80,38 @@ internal class TelemetryServiceTest { verify(service.analytics).flush() verify(service.analytics).shutdown() } + + @Test + fun `does not send telemetry events when telemetry is disabled`(settings: PluginSettings) { + settings.isTelemetryEnabled = false + + val service = + TelemetryService().apply { + analytics = mock() + } + + service.sendEvent(TelemetryEvent.PluginActivated) + + verify(service.analytics, never()).enqueue(any()) + } + + @Test + fun `does not flush events, but shuts down, when telemetry is disabled`( + application: Application, + settings: PluginSettings, + ) { + settings.isTelemetryEnabled = false + application.withMockedService(mockLogMessage()) + + val service = + TelemetryService().apply { + analytics = mock() + } + + val publisher = application.messageBus.syncPublisher(AppLifecycleListener.TOPIC) + publisher.appWillBeClosed(true) + + verify(service.analytics, never()).flush() + verify(service.analytics).shutdown() + } } diff --git a/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/settings/SettingsUiTest.kt b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/settings/SettingsUiTest.kt new file mode 100644 index 00000000..c5c30f0c --- /dev/null +++ b/packages/jetbrains-plugin/src/test/kotlin/com/mongodb/jbplugin/settings/SettingsUiTest.kt @@ -0,0 +1,49 @@ +package com.mongodb.jbplugin.settings + +import com.intellij.remoterobot.RemoteRobot +import com.mongodb.jbplugin.fixtures.RequiresProject +import com.mongodb.jbplugin.fixtures.UiTest +import com.mongodb.jbplugin.fixtures.components.openBrowserSettings +import com.mongodb.jbplugin.fixtures.components.openSettings +import com.mongodb.jbplugin.fixtures.components.useSetting +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +@UiTest +class SettingsUiTest { + @Test + @RequiresProject("basic-java-project-with-mongodb") + fun `allows toggling the telemetry`(remoteRobot: RemoteRobot) { + val telemetryBeforeTest = remoteRobot.useSetting("isTelemetryEnabled") + + val settings = remoteRobot.openSettings() + settings.enableTelemetry.click() + settings.ok.click() + + val telemetryAfterTest = remoteRobot.useSetting("isTelemetryEnabled") + assertNotEquals(telemetryBeforeTest, telemetryAfterTest) + } + + @Test + @RequiresProject("basic-java-project-with-mongodb") + fun `allows opening the privacy policy in a browser`(remoteRobot: RemoteRobot) { + remoteRobot.openBrowserSettings().run { + useFakeBrowser() + ok.click() + } + + val settings = remoteRobot.openSettings() + settings.privacyPolicyButton.click() + settings.ok.click() + + val lastBrowserUrl = + remoteRobot.openBrowserSettings().run { + useSystemBrowser() + ok.click() + lastBrowserUrl() + } + + assertEquals("https://www.mongodb.com/legal/privacy/privacy-policy", lastBrowserUrl) + } +} diff --git a/packages/jetbrains-plugin/src/test/resources/fake-browser.sh b/packages/jetbrains-plugin/src/test/resources/fake-browser.sh new file mode 100755 index 00000000..7d80143b --- /dev/null +++ b/packages/jetbrains-plugin/src/test/resources/fake-browser.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +FAKE_BROWSER_OUTPUT="${SCRIPT_DIR}/FAKE_BROWSER_OUTPUT" + +echo $FAKE_BROWSER_OUTPUT + +rm -f "$FAKE_BROWSER_OUTPUT" +echo "$@" > "$FAKE_BROWSER_OUTPUT" \ No newline at end of file