From b0b820a089543a2786304d1b63d7e902ed1e5ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 2 Jan 2026 17:37:45 -0800 Subject: [PATCH 1/3] Enhance project view decorations and add 100% test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project view now shows richer venv metadata: Python implementation (CPython, PyPy), system site-packages indicator, and creator tool with version (uv, virtualenv). A new VenvInfo data class captures all this information, and the cache stores full venv info instead of just version strings. The settings page uses a single decoration format field with placeholders: $version, $implementation, $system, $creator. Added comprehensive unit tests achieving 100% line coverage with CI enforcement via Kover's koverVerify task. Tests cover utility functions, version cache, action classes, and settings persistence. Updated UI tests for PyCharm 2025.1 and RemoteRobot compatibility. Simplified CI verification to only Python-bundled IDEs (PyCharm Community and Professional) since this plugin requires com.intellij.modules.python as a mandatory dependency. Added CONTRIBUTING.md covering development, testing, and the release process. Added require-restart="false" to plugin.xml for dynamic loading and searchable options XML for settings search. Signed-off-by: Bernát Gábor --- .github/workflows/check.yaml | 57 ++- .github/workflows/release.yaml | 2 +- CONTRIBUTING.md | 107 +++++ README.md | 22 +- build.gradle.kts | 82 +++- gradle.properties | 8 +- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 43504 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 12 +- gradlew.bat | 3 +- .../VenvProjectViewNodeDecorator.kt | 26 +- .../com/github/pyvenvmanage/VenvUtils.kt | 34 ++ .../github/pyvenvmanage/VenvVersionCache.kt | 22 +- .../actions/ConfigurePythonActionModule.kt | 2 +- .../actions/ConfigurePythonActionProject.kt | 2 +- .../settings/PyVenvManageConfigurable.kt | 220 ++++++++- .../settings/PyVenvManageSettings.kt | 64 ++- src/main/resources/META-INF/plugin.xml | 2 +- .../search/PyVenvManageConfigurable.xml | 20 + .../kotlin/com/github/pyvenvmanage/UITest.kt | 64 ++- .../VenvProjectViewNodeDecoratorTest.kt | 47 +- .../com/github/pyvenvmanage/VenvUtilsTest.kt | 271 +++++++++++ .../pyvenvmanage/VenvVersionCacheTest.kt | 265 +++++++++++ .../ConfigurePythonActionAbstractTest.kt | 359 +++++++++++++++ .../ConfigurePythonActionModuleTest.kt | 86 ++++ .../ConfigurePythonActionProjectTest.kt | 62 +++ .../pyvenvmanage/pages/ActionMenuFixture.kt | 32 +- .../github/pyvenvmanage/pages/IdeaFrame.kt | 65 +-- .../github/pyvenvmanage/pages/WelcomeFrame.kt | 10 +- .../settings/PyVenvManageConfigurableTest.kt | 421 ++++++++++++++++++ .../settings/PyVenvManageSettingsTest.kt | 190 ++++++++ 32 files changed, 2327 insertions(+), 234 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 src/main/resources/search/PyVenvManageConfigurable.xml create mode 100644 src/test/kotlin/com/github/pyvenvmanage/VenvVersionCacheTest.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModuleTest.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProjectTest.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt create mode 100644 src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettingsTest.kt diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index cd735ed..c7bf4dc 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 - name: Build plugin @@ -68,7 +68,7 @@ jobs: strategy: fail-fast: false matrix: - ide: [PC, PY, IC, IU, GO, CL, RR] + ide: [PC, PY] steps: - name: Free disk space uses: jlumbroso/free-disk-space@main @@ -83,19 +83,21 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 with: validate-wrappers: false - cache-read-only: true - - name: Set up verifier cache + cache-read-only: false + - name: Cache plugin verifier IDEs uses: actions/cache@v5 with: - path: ${{ needs.build.outputs.pluginVerifierHomeDir }}/ides - key: plugin-verifier-${{ matrix.ide }}-${{ needs.build.outputs.platformVersion }} + path: ~/.gradle/caches/*/transforms/*/transformed/ + key: plugin-verifier-ides-${{ matrix.ide }}-${{ needs.build.outputs.platformVersion }} + restore-keys: | + plugin-verifier-ides-${{ matrix.ide }}- - name: Run verification - run: ./gradlew verifyPlugin -PverifyIde=${{ matrix.ide }} -Dplugin.verifier.home.dir=${{ needs.build.outputs.pluginVerifierHomeDir }} + run: ./gradlew verifyPlugin -PverifyIde=${{ matrix.ide }} - name: Collect verification result if: ${{ always() }} uses: actions/upload-artifact@v6 @@ -103,6 +105,25 @@ jobs: name: pluginVerifier-result-${{ matrix.ide }} path: ${{ github.workspace }}/build/reports/pluginVerifier + lint: + name: Lint + needs: [build] + runs-on: ubuntu-latest + steps: + - name: Checkout git repository + uses: actions/checkout@v6 + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: 21 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: true + - name: Run linter + run: ./gradlew ktlintCheck + test: name: Run tests needs: [build] @@ -114,19 +135,31 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 with: validate-wrappers: false cache-read-only: true - - name: Run linter - run: ./gradlew ktlintCheck + - name: Run unit tests + run: ./gradlew test + - name: Verify 100% coverage + run: ./gradlew koverVerify + - name: Run UI tests + run: | + export DISPLAY=:99.0 + Xvfb :99 -screen 0 1920x1080x24 & + ./gradlew runIdeForUiTests & + echo "Waiting for IDE to start..." + timeout 180 bash -c 'until curl -s http://127.0.0.1:8082 > /dev/null 2>&1; do sleep 2; done' || { echo "IDE failed to start"; exit 1; } + echo "IDE is ready" + ./gradlew uiTest + kill %1 %2 || true releaseDraft: name: Create release draft if: github.event_name != 'pull_request' - needs: [build, test, verify] + needs: [build, lint, test, verify] runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ff0ada5..d3f981b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -27,7 +27,7 @@ jobs: uses: actions/setup-java@v5 with: distribution: zulu - java-version: 17 + java-version: 21 - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 - name: Extract plugin properties diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a629496 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +# Contributing to PyVenvManage + +## Development Setup + +You'll need JDK 21 and Python 3.10+ (for creating test virtual environments). Build the plugin with: + +```bash +./gradlew buildPlugin +``` + +## Testing + +The project uses two complementary testing strategies: fast unit tests that mock IntelliJ platform dependencies, and +end-to-end UI tests that interact with a running IDE. + +### Unit Tests + +Unit tests cover business logic, action update logic, and error paths. They run quickly and don't require a running +IDE: + +```bash +./gradlew test +``` + +### UI Tests + +UI tests validate full user workflows by interacting with a running PyCharm instance via RemoteRobot. Start the IDE +with robot-server in one terminal: + +```bash +./gradlew runIdeForUiTests +``` + +Wait for the IDE to fully start and the robot-server to be ready at http://localhost:8082, then run the tests in +another terminal: + +```bash +./gradlew uiTest +``` + +### Coverage + +Unit tests achieve full line coverage. The CI enforces this with `./gradlew test koverVerify`. UI tests are excluded +from coverage collection since they test end-to-end workflows already covered by unit tests. + +To generate an HTML coverage report showing overall percentage, package breakdown, and line-by-line highlighting: + +```bash +./gradlew test koverHtmlReport +open build/reports/kover/html/index.html +``` + +For per-test coverage analysis (which test covered which line), generate a binary report with +`./gradlew test koverBinaryReport`, then in IntelliJ IDEA go to **Run → Show Coverage Data**, click **+**, select +`build/kover/bin-reports/test.ic`, and click **Show selected**. Right-click any covered line and choose **Show Covering +Tests** to see which tests hit it. + +## Code Quality + +Check code style with `./gradlew ktlintCheck` or auto-fix issues with `./gradlew ktlintFormat`. Run all checks together +(lint, unit tests, coverage verification) with `./gradlew check`. + +## Continuous Integration + +The CI pipeline in `.github/workflows/check.yaml` builds the plugin, runs linting, executes unit tests with coverage +verification followed by UI tests, verifies the plugin against PyCharm Community and PyCharm Professional, and creates +a release draft on the main branch. + +The test job runs unit tests with `koverVerify`, then starts Xvfb and the IDE with robot-server, and finally runs UI +tests for end-to-end validation. + +## Making Code Changes + +Before committing, run `./gradlew ktlintFormat` to fix style issues, then `./gradlew test koverVerify` to ensure tests +pass with full coverage. If you modified action classes, run UI tests for end-to-end validation by starting +`./gradlew runIdeForUiTests` in one terminal and `./gradlew uiTest` in another. + +Follow conventional commit style: use `feat:` for new features, `fix:` for bug fixes, `refactor:` for code +refactoring, `test:` for test changes, `docs:` for documentation, and `chore:` for maintenance tasks. + +## Troubleshooting + +If UI tests timeout or fail to connect, ensure no other IDE instance is using port 8082. Kill any running IDE processes +with `pkill -f runIdeForUiTests`, delete old test projects with `rm -rf ~/projects/ui-test*`, then restart the IDE and +wait for full initialization before running tests. + +If `koverVerify` fails due to coverage below 100%, generate the HTML report with `./gradlew koverHtmlReport` and open +`build/reports/kover/html/index.html` to see which lines are uncovered. Add unit tests for those code paths, or if the +code requires IntelliJ platform services that can't be mocked, add UI test coverage instead. + +## Releasing + +The plugin version is defined in `gradle.properties` as `pluginVersion`. To release, update the version in that file +and merge your PR to main. The CI automatically creates a draft release on GitHub with the version from +`gradle.properties`. + +Review the draft release on the [Releases page](https://github.com/pyvenvmanage/PyVenvManage/releases) and edit the +release notes if needed. Click "Publish release" (not pre-release) to trigger the release workflow, which builds and +signs the plugin, publishes to [JetBrains Marketplace](https://plugins.jetbrains.com/plugin/20536-pyvenv-manage-2), +uploads the plugin ZIP to the GitHub release, and creates a PR to update CHANGELOG.md. Merge that changelog PR after +the release workflow completes. + +The release workflow requires repository secrets configured by maintainers: `PUBLISH_TOKEN` for JetBrains Marketplace +upload, and `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, and `PRIVATE_KEY_PASSWORD` for plugin signing. + +Follow [semantic versioning](https://semver.org/): increment MAJOR for breaking changes, MINOR for new backward +compatible features, and PATCH for backward compatible bug fixes. diff --git a/README.md b/README.md index bd10376..8f6dd2b 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ streamlines this by enabling quick interpreter selection directly from the proje - **Quick interpreter switching**: Right-click any virtual environment folder to set it as your project or module interpreter instantly -- **Visual identification**: Virtual environment folders display with a distinctive icon and Python version badge - (e.g., `venv [3.11.5]`) in the project view +- **Visual identification**: Virtual environment folders display with a distinctive icon and customizable decoration + (e.g., `.venv [3.11.5 - CPython]`) in the project view +- **Customizable decorations**: Configure which fields to show (Python version, implementation, system site-packages, + creator tool), their order, and the format via Settings - **Multi-IDE support**: Works with PyCharm (Community and Professional), IntelliJ IDEA, GoLand, CLion, and RustRover - **Smart detection**: Automatically detects Python virtual environments by recognizing `pyvenv.cfg` files - **Cached version display**: Python version information is cached for performance and automatically refreshed when @@ -30,6 +32,8 @@ streamlines this by enabling quick interpreter selection directly from the proje ## Supported IDEs +Version 2025.1 or later of: + - PyCharm (Community and Professional) - IntelliJ IDEA (Community and Ultimate) - GoLand @@ -51,6 +55,20 @@ The official plugin page is at https://plugins.jetbrains.com/plugin/20536-pyvenv 3. Select **Set as Project Interpreter** or **Set as Module Interpreter** 4. The interpreter is configured instantly with a confirmation notification +## Settings + +Open **Settings** -> **PyVenv Manage** to customize the virtual environment decoration display: + +- **Prefix/Suffix**: Characters surrounding the decoration (default: ` [` and `]`) +- **Separator**: Text between fields (default: ` - `) +- **Fields**: Enable, disable, and reorder which information to display: + - Python version (e.g., `3.14.2`) + - Python implementation (e.g., `CPython`) + - System site-packages indicator (`SYSTEM`) + - Virtual environment creator (e.g., `uv@0.9.21`) + +A live preview updates as you modify settings. + ## License This project is licensed under the BSD-3-Clause license - see the diff --git a/build.gradle.kts b/build.gradle.kts index 929834b..1979def 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ val platformVersion = providers.gradleProperty("platformVersion").get() kotlin { jvmToolchain(17) } + repositories { mavenCentral() intellijPlatform { @@ -108,11 +109,6 @@ intellijPlatform { listOf( IntelliJPlatformType.PyCharmCommunity, IntelliJPlatformType.PyCharmProfessional, - IntelliJPlatformType.IntellijIdeaCommunity, - IntelliJPlatformType.IntellijIdeaUltimate, - IntelliJPlatformType.GoLand, - IntelliJPlatformType.CLion, - IntelliJPlatformType.RustRover, ) } ideTypes.forEach { create(it, platformVersion) } @@ -125,12 +121,25 @@ changelog { repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") } kover { + currentProject { + sources { + excludeJava = true + } + instrumentation { + disabledForTestTasks.add("uiTest") + } + } reports { total { xml { onCheck = true } } + verify { + rule { + minBound(100) + } + } } } @@ -144,8 +153,45 @@ tasks { buildSearchableOptions { enabled = false } + prepareJarSearchableOptions { + enabled = false + } + verifyPlugin { + System.getProperty("http.proxyHost")?.let { host -> + jvmArgs("-Dhttp.proxyHost=$host") + System.getProperty("http.proxyPort")?.let { jvmArgs("-Dhttp.proxyPort=$it") } + } + System.getProperty("https.proxyHost")?.let { host -> + jvmArgs("-Dhttps.proxyHost=$host") + System.getProperty("https.proxyPort")?.let { jvmArgs("-Dhttps.proxyPort=$it") } + } + System.getProperty("javax.net.ssl.trustStore")?.let { jvmArgs("-Djavax.net.ssl.trustStore=$it") } + System + .getProperty( + "javax.net.ssl.trustStorePassword", + )?.let { jvmArgs("-Djavax.net.ssl.trustStorePassword=$it") } + } test { useJUnitPlatform() + exclude("**/UITest.class") + } + val uiTest = + register("uiTest") { + description = "Runs UI tests (requires runIdeForUiTests to be running)" + group = "verification" + useJUnitPlatform() + include("**/UITest.class") + testClassesDirs = sourceSets["test"].output.classesDirs + classpath = sourceSets["test"].runtimeClasspath + shouldRunAfter(test) + jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED", + ) + } + + runIde { + jvmArgs("-XX:+UnlockDiagnosticVMOptions") } } @@ -153,15 +199,23 @@ val runIdeForUiTests by intellijPlatformTesting.runIde.registering { task { jvmArgumentProviders += CommandLineArgumentProvider { - listOf( - "-Drobot-server.port=8082", - "-Dide.mac.message.dialogs.as.sheets=false", - "-Djb.privacy.policy.text=", - "-Djb.consents.confirmation.enabled=false", - "-Didea.trust.all.projects=true", - "-Dide.mac.file.chooser.native=false", - "-Dide.show.tips.on.startup.default.value=false", - ) + buildList { + add("-Drobot-server.port=8082") + add("-Djb.privacy.policy.text=") + add("-Djb.consents.confirmation.enabled=false") + add("-Didea.trust.all.projects=true") + add("-Dide.show.tips.on.startup.default.value=false") + val isMac = + org.gradle.internal.os.OperatingSystem + .current() + .isMacOsX + if (isMac) { + add("-Dide.mac.message.dialogs.as.sheets=false") + add("-Dide.mac.file.chooser.native=false") + add("-DjbScreenMenuBar.enabled=false") + add("-Dapple.laf.useScreenMenuBar=false") + } + } } } diff --git a/gradle.properties b/gradle.properties index a46f599..0ab8920 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,12 +1,12 @@ -gradleVersion=8.9 +gradleVersion=9.2.1 kotlin.stdlib.default.dependency=false nl.littlerobots.vcu.resolver=true org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.welcome=never -platformVersion=2024.1 +platformVersion=2025.1 pluginGroup=com.github.pyvenvmanage pluginName=PyVenv Manage 2 pluginRepositoryUrl=https://github.com/pyvenvmanage/PyVenvManage -pluginSinceBuild=241 -pluginVersion=2.1.2 +pluginSinceBuild=251 +pluginVersion=2.2.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a21e660..974c584 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ changelog = "2.5.0" intelliJPlatform = "2.10.5" junitPlatformLauncher= "6.0.0" jupiter = "6.0.1" -kotlin = "2.3.0" +kotlin = "2.2.21" mockk = "1.14.7" kover = "0.9.4" ktlint = "14.0.1" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c3521197d7c4586c843d1d3e9090525f1898cde..8bdaf60c75ab801e22807dde59e12a8735a34077 100644 GIT binary patch delta 37340 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 UX##k1IU5Uz9RMyC?XF1w2hzEis{jB1 delta 35347 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e11132..4eac4a8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6..adff685 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -173,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -206,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9b42019..e509b2d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt index 2fa3307..fd33733 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecorator.kt @@ -3,6 +3,7 @@ package com.github.pyvenvmanage import com.intellij.ide.projectView.PresentationData import com.intellij.ide.projectView.ProjectViewNode import com.intellij.ide.projectView.ProjectViewNodeDecorator +import com.intellij.openapi.diagnostic.thisLogger import com.intellij.ui.SimpleTextAttributes import com.jetbrains.python.icons.PythonIcons.Python.Virtualenv @@ -14,18 +15,23 @@ class VenvProjectViewNodeDecorator : ProjectViewNodeDecorator { node: ProjectViewNode<*>, data: PresentationData, ) { + node.virtualFile?.let { vf -> + thisLogger().debug("Checking node: ${vf.path}, isDir: ${vf.isDirectory}") + } VenvUtils.getPyVenvCfg(node.virtualFile)?.let { pyVenvCfgPath -> + thisLogger().debug("Found pyvenv.cfg at: $pyVenvCfgPath") val settings = PyVenvManageSettings.getInstance() - if (settings.showVersionInProjectView) { - val pythonVersion = VenvVersionCache.getInstance().getVersion(pyVenvCfgPath.toString()) - pythonVersion?.let { version -> - data.presentableText?.let { fileName -> - data.clearText() - data.addText(fileName, SimpleTextAttributes.REGULAR_ATTRIBUTES) - data.addText(settings.formatVersion(version), SimpleTextAttributes.GRAY_ATTRIBUTES) - } - } - } + val venvInfo = VenvVersionCache.getInstance().getInfo(pyVenvCfgPath.toString()) + thisLogger().debug("VenvInfo from cache: $venvInfo") + venvInfo?.let { info -> + data.presentableText?.let { fileName -> + val decoration = settings.formatDecoration(info) + thisLogger().debug("Decorating $fileName with: $decoration") + data.clearText() + data.addText(fileName, SimpleTextAttributes.REGULAR_ATTRIBUTES) + data.addText(decoration, SimpleTextAttributes.GRAY_ATTRIBUTES) + } ?: thisLogger().debug("No presentableText for decoration") + } ?: thisLogger().debug("No venvInfo found for $pyVenvCfgPath") data.setIcon(Virtualenv) } } diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt index 8e1238f..7db2d2c 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvUtils.kt @@ -9,6 +9,13 @@ import com.intellij.openapi.vfs.VirtualFile import com.jetbrains.python.sdk.PythonSdkUtil +data class VenvInfo( + val version: String, + val implementation: String? = null, + val includeSystemSitePackages: Boolean = false, + val creator: String? = null, +) + object VenvUtils { fun getPyVenvCfg(file: VirtualFile?): Path? = file @@ -25,4 +32,31 @@ object VenvUtils { }.getProperty("version") ?.trim() }.getOrNull() + + fun getVenvInfo(pyvenvCfgPath: Path): VenvInfo? = + runCatching { + val props = + Properties().apply { + Files.newBufferedReader(pyvenvCfgPath, StandardCharsets.UTF_8).use { load(it) } + } + val version = props.getProperty("version") ?: props.getProperty("version_info") + if (version == null) { + com.intellij.openapi.diagnostic.Logger + .getInstance(VenvUtils::class.java) + .debug("No version found in $pyvenvCfgPath") + return@runCatching null + } + val implementation = props.getProperty("implementation") + val includeSystemSitePackages = + props.getProperty("include-system-site-packages")?.toBoolean() ?: false + val creator = + props.getProperty("uv")?.let { " - uv@$it" } + ?: props.getProperty("virtualenv")?.let { " - virtualenv@$it" } + com.intellij.openapi.diagnostic.Logger + .getInstance(VenvUtils::class.java) + .debug( + "Found venv info: version=$version, implementation=$implementation, systemSitePackages=$includeSystemSitePackages, creator=$creator from $pyvenvCfgPath", + ) + VenvInfo(version, implementation, includeSystemSitePackages, creator) + }.getOrNull() } diff --git a/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt b/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt index 333aa25..4c3b303 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/VenvVersionCache.kt @@ -1,5 +1,6 @@ package com.github.pyvenvmanage +import java.util.Optional import java.util.concurrent.ConcurrentHashMap import com.intellij.openapi.Disposable @@ -12,7 +13,7 @@ import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent @Service(Service.Level.APP) class VenvVersionCache : Disposable { - private val cache = ConcurrentHashMap() + private val cache = ConcurrentHashMap>() init { VirtualFileManager.getInstance().addAsyncFileListener( @@ -39,13 +40,18 @@ class VenvVersionCache : Disposable { ) } - fun getVersion(pyvenvCfgPath: String): String? = - cache.computeIfAbsent(pyvenvCfgPath) { - VenvUtils.getPythonVersionFromPyVenv( - java.nio.file.Path - .of(it), - ) - } + fun getInfo(pyvenvCfgPath: String): VenvInfo? = + cache + .computeIfAbsent(pyvenvCfgPath) { + Optional.ofNullable( + VenvUtils.getVenvInfo( + java.nio.file.Path + .of(it), + ), + ) + }.orElse(null) + + fun getVersion(pyvenvCfgPath: String): String? = getInfo(pyvenvCfgPath)?.version fun invalidate(path: String) { cache.remove(path) diff --git a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt index 394885d..bd666b1 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModule.kt @@ -6,7 +6,7 @@ import com.intellij.openapi.roots.ModuleRootModificationUtil import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.openapi.vfs.VirtualFile -class ConfigurePythonActionModule : ConfigurePythonActionAbstract() { +open class ConfigurePythonActionModule : ConfigurePythonActionAbstract() { override fun setSdk( project: Project, selectedPath: VirtualFile, diff --git a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt index 06eb39c..9a9de35 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProject.kt @@ -5,7 +5,7 @@ import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil import com.intellij.openapi.vfs.VirtualFile -class ConfigurePythonActionProject : ConfigurePythonActionAbstract() { +open class ConfigurePythonActionProject : ConfigurePythonActionAbstract() { override fun setSdk( project: Project, selectedPath: VirtualFile, diff --git a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt index 85c70fc..6b70747 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurable.kt @@ -1,55 +1,241 @@ package com.github.pyvenvmanage.settings -import javax.swing.JCheckBox +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.FlowLayout +import javax.swing.JButton import javax.swing.JComponent import javax.swing.JPanel import javax.swing.JTextField +import javax.swing.ListSelectionModel +import javax.swing.event.DocumentEvent +import javax.swing.event.DocumentListener import com.intellij.openapi.options.Configurable +import com.intellij.ui.CheckBoxList import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextField import com.intellij.util.ui.FormBuilder +import com.github.pyvenvmanage.VenvInfo + class PyVenvManageConfigurable : Configurable { - private var showVersionCheckBox: JCheckBox? = null - private var versionFormatField: JTextField? = null + internal var prefixField: JTextField? = null + internal var suffixField: JTextField? = null + internal var separatorField: JTextField? = null + internal var fieldsList: CheckBoxList? = null + internal var previewField: JBTextField? = null override fun getDisplayName(): String = "PyVenv Manage" override fun createComponent(): JComponent { - showVersionCheckBox = JCheckBox("Show Python version in project view") - versionFormatField = - JTextField().apply { - toolTipText = "Use \$version as placeholder for the version number" + prefixField = JTextField().apply { toolTipText = "Text before the decoration (e.g., ' [')" } + suffixField = JTextField().apply { toolTipText = "Text after the decoration (e.g., ']')" } + separatorField = JTextField().apply { toolTipText = "Text between fields (e.g., ' - ')" } + + fieldsList = + object : CheckBoxList() { + override fun getSecondaryText(index: Int): String? = getFieldExample(getItemAt(index)) + }.apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + setCheckBoxListListener { _, _ -> + updatePreview() + } + } + + previewField = + JBTextField().apply { + isEditable = false + toolTipText = "Preview of what the decoration will look like" + } + + val textChangeListener = + object : DocumentListener { + override fun insertUpdate(e: DocumentEvent?) = updatePreview() + + override fun removeUpdate(e: DocumentEvent?) = updatePreview() + + override fun changedUpdate(e: DocumentEvent?) = updatePreview() + } + + prefixField?.document?.addDocumentListener(textChangeListener) + suffixField?.document?.addDocumentListener(textChangeListener) + separatorField?.document?.addDocumentListener(textChangeListener) + + val moveUpButton = + JButton("↑").apply { + toolTipText = "Move selected field up" + addActionListener { + moveSelectedField(-1) + updatePreview() + } + } + val moveDownButton = + JButton("↓").apply { + toolTipText = "Move selected field down" + addActionListener { + moveSelectedField(1) + updatePreview() + } + } + + val buttonsPanel = + JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply { + add(moveUpButton) + add(moveDownButton) + } + + val scrollPane = + JBScrollPane(fieldsList).apply { + preferredSize = Dimension(0, 120) + } + + val listPanel = + JPanel(BorderLayout()).apply { + add(scrollPane, BorderLayout.CENTER) + add(buttonsPanel, BorderLayout.SOUTH) + } + + reset() + + val fieldsLabel = + JBLabel("Fields:").apply { + toolTipText = "Check to enable, use arrows to reorder" } return FormBuilder .createFormBuilder() - .addComponent(showVersionCheckBox!!) - .addLabeledComponent(JBLabel("Version format:"), versionFormatField!!) + .addSeparator() + .addComponent(JBLabel("Python Interpreter Decoration")) + .addLabeledComponent(JBLabel("Prefix:"), prefixField!!) + .addLabeledComponent(JBLabel("Suffix:"), suffixField!!) + .addLabeledComponent(JBLabel("Separator:"), separatorField!!) + .addLabeledComponent(fieldsLabel, listPanel) + .addLabeledComponent(JBLabel("Preview:"), previewField!!) .addComponentFillVertically(JPanel(), 0) .panel } + internal fun updatePreview() { + val list = fieldsList ?: return + val preview = previewField ?: return + + val enabledFields = + (0 until list.itemsCount) + .filter { list.isItemSelected(it) } + .mapNotNull { list.getItemAt(it) } + + val sampleInfo = VenvInfo("3.14.2", "CPython", true, " - uv@0.9.21") + val values = + enabledFields.mapNotNull { field -> + when (field) { + DecorationField.VERSION -> sampleInfo.version + DecorationField.IMPLEMENTATION -> sampleInfo.implementation + DecorationField.SYSTEM -> if (sampleInfo.includeSystemSitePackages) "SYSTEM" else null + DecorationField.CREATOR -> sampleInfo.creator?.removePrefix(" - ") + } + } + + val prefix = prefixField?.text ?: "" + val suffix = suffixField?.text ?: "" + val separator = separatorField?.text ?: "" + + val decoration = if (values.isEmpty()) "" else prefix + values.joinToString(separator) + suffix + preview.text = ".venv$decoration" + } + + internal fun moveSelectedField(direction: Int) { + val list = fieldsList ?: return + val selectedIndex = list.selectedIndex + if (selectedIndex < 0) return + + val newIndex = selectedIndex + direction + if (newIndex < 0 || newIndex >= list.itemsCount) return + + val currentItem = list.getItemAt(selectedIndex) ?: return + val swapItem = list.getItemAt(newIndex) ?: return + val currentChecked = list.isItemSelected(selectedIndex) + val swapChecked = list.isItemSelected(newIndex) + + val items = getAllFieldsOrdered() + val checkedStates = (0 until list.itemsCount).associate { list.getItemAt(it) to list.isItemSelected(it) } + + items[selectedIndex] = swapItem + items[newIndex] = currentItem + + list.clear() + items.forEach { field -> + val wasChecked = + when (field) { + currentItem -> currentChecked + swapItem -> swapChecked + else -> checkedStates[field] ?: false + } + list.addItem(field, field.displayName, wasChecked) + } + list.selectedIndex = newIndex + } + + private fun getAllFieldsOrdered(): MutableList { + val list = fieldsList ?: return mutableListOf() + return (0 until list.itemsCount).mapNotNull { list.getItemAt(it) }.toMutableList() + } + override fun isModified(): Boolean { val settings = PyVenvManageSettings.getInstance() - return showVersionCheckBox?.isSelected != settings.showVersionInProjectView || - versionFormatField?.text != settings.versionFormat + return prefixField?.text != settings.prefix || + suffixField?.text != settings.suffix || + separatorField?.text != settings.separator || + getEnabledFields() != settings.fields + } + + private fun getEnabledFields(): List { + val list = fieldsList ?: return emptyList() + return (0 until list.itemsCount) + .filter { list.isItemSelected(it) } + .mapNotNull { list.getItemAt(it) } } override fun apply() { val settings = PyVenvManageSettings.getInstance() - showVersionCheckBox?.isSelected?.let { settings.showVersionInProjectView = it } - versionFormatField?.text?.let { settings.versionFormat = it } + prefixField?.text?.let { settings.prefix = it } + suffixField?.text?.let { settings.suffix = it } + separatorField?.text?.let { settings.separator = it } + settings.fields = getEnabledFields() } override fun reset() { val settings = PyVenvManageSettings.getInstance() - showVersionCheckBox?.isSelected = settings.showVersionInProjectView - versionFormatField?.text = settings.versionFormat + prefixField?.text = settings.prefix + suffixField?.text = settings.suffix + separatorField?.text = settings.separator + + val list = fieldsList ?: return + list.clear() + + val enabledFields = settings.fields.toSet() + val orderedFields = settings.fields + DecorationField.entries.filter { it !in enabledFields } + orderedFields.forEach { field -> + list.addItem(field, field.displayName, field in enabledFields) + } + updatePreview() } override fun disposeUIResources() { - showVersionCheckBox = null - versionFormatField = null + prefixField = null + suffixField = null + separatorField = null + fieldsList = null + previewField = null } } + +internal fun getFieldExample(field: DecorationField?): String? = + when (field) { + DecorationField.VERSION -> "e.g., 3.14.2" + DecorationField.IMPLEMENTATION -> "e.g., CPython" + DecorationField.SYSTEM -> "shows SYSTEM" + DecorationField.CREATOR -> "e.g., uv@0.9.21" + null -> null + } diff --git a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt index 15232fe..e1f0845 100644 --- a/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt +++ b/src/main/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettings.kt @@ -6,38 +6,74 @@ import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service +import com.github.pyvenvmanage.VenvInfo + +enum class DecorationField( + val displayName: String, +) { + VERSION("Python version"), + IMPLEMENTATION("Python implementation"), + SYSTEM("Is a system site package"), + CREATOR("Virtual environment creator"), +} + @Service(Service.Level.APP) @State( name = "PyVenvManageSettings", storages = [Storage("PyVenvManageSettings.xml")], ) -class PyVenvManageSettings : PersistentStateComponent { - private var state = State() +class PyVenvManageSettings : PersistentStateComponent { + private var state = SettingsState() - data class State( - var showVersionInProjectView: Boolean = true, - var versionFormat: String = " [\$version]", + data class SettingsState( + var prefix: String = " [", + var suffix: String = "]", + var separator: String = " - ", + var fields: List = DecorationField.entries.map { it.name }, ) - override fun getState(): State = state + override fun getState(): SettingsState = state - override fun loadState(state: State) { + override fun loadState(state: SettingsState) { this.state = state } - var showVersionInProjectView: Boolean - get() = state.showVersionInProjectView + var prefix: String + get() = state.prefix + set(value) { + state.prefix = value + } + + var suffix: String + get() = state.suffix set(value) { - state.showVersionInProjectView = value + state.suffix = value } - var versionFormat: String - get() = state.versionFormat + var separator: String + get() = state.separator set(value) { - state.versionFormat = value + state.separator = value } - fun formatVersion(version: String): String = versionFormat.replace("\$version", version) + var fields: List + get() = state.fields.mapNotNull { name -> DecorationField.entries.find { it.name == name } } + set(value) { + state.fields = value.map { it.name } + } + + fun formatDecoration(info: VenvInfo): String { + val values = + fields.mapNotNull { field -> + when (field) { + DecorationField.VERSION -> info.version + DecorationField.IMPLEMENTATION -> info.implementation + DecorationField.SYSTEM -> if (info.includeSystemSitePackages) "SYSTEM" else null + DecorationField.CREATOR -> info.creator?.removePrefix(" - ") + } + } + return if (values.isEmpty()) "" else prefix + values.joinToString(separator) + suffix + } companion object { fun getInstance(): PyVenvManageSettings = service() diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a65f18e..4841586 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,4 +1,4 @@ - + com.github.pyvenvmanage.pyvenv PyVenv Manage 2 pyvenvmanage diff --git a/src/main/resources/search/PyVenvManageConfigurable.xml b/src/main/resources/search/PyVenvManageConfigurable.xml new file mode 100644 index 0000000..2a0451e --- /dev/null +++ b/src/main/resources/search/PyVenvManageConfigurable.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt index 26d01b1..5010223 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/UITest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/UITest.kt @@ -9,7 +9,6 @@ import java.time.Duration.ofSeconds import java.util.concurrent.TimeUnit import javax.imageio.ImageIO -import kotlin.io.path.name import org.assertj.swing.core.MouseButton import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll @@ -20,15 +19,17 @@ import org.junit.jupiter.api.extension.ExtensionContext import org.junit.jupiter.api.extension.TestWatcher import com.intellij.remoterobot.RemoteRobot -import com.intellij.remoterobot.fixtures.JTreeFixture import com.intellij.remoterobot.search.locators.byXpath import com.intellij.remoterobot.stepsProcessing.StepLogger import com.intellij.remoterobot.stepsProcessing.StepWorker import com.intellij.remoterobot.utils.waitFor +import com.github.pyvenvmanage.pages.IdeaFrame import com.github.pyvenvmanage.pages.actionMenuItem import com.github.pyvenvmanage.pages.dialog +import com.github.pyvenvmanage.pages.hasActionMenuItem import com.github.pyvenvmanage.pages.idea +import com.github.pyvenvmanage.pages.pressEscape import com.github.pyvenvmanage.pages.welcomeFrame @ExtendWith(UITest.IdeTestWatcher::class) @@ -56,6 +57,9 @@ class UITest { fun startIdea() { val base = Path.of(System.getProperty("user.home"), "projects") Files.createDirectories(base) + Files.list(base).filter { it.fileName.toString().startsWith("ui-test") }.forEach { + it.toFile().deleteRecursively() + } tmpDir = Files.createTempDirectory(base, "ui-test") // create test project val demo = Paths.get(tmpDir.toString(), "demo") @@ -67,30 +71,23 @@ class UITest { val process = ProcessBuilder("python", "-m", "venv", venv, "--without-pip") assert(process.start().waitFor() == 0) - // ./gradlew runIdeForUiTests requires already running, so just wait to connect StepWorker.registerProcessor(StepLogger()) - remoteRobot = RemoteRobot("http://localhost:8082") - waitFor(ofSeconds(20), ofSeconds(5)) { - runCatching { - remoteRobot.callJs("true") - }.getOrDefault(false) - } - // open test project + remoteRobot = RemoteRobot("http://127.0.0.1:8082") + Thread.sleep(10000) remoteRobot.welcomeFrame { - openLink.click() + openButton.click() dialog("Open File or Project") { - button(byXpath("//div[@myicon='refresh.svg']")).click() + val pathField = textField(byXpath("//div[@class='BorderlessTextField']")) + pathField.click() + Thread.sleep(500) + pathField.runJs("component.setText('${demo.toString().replace("'", "\\'")}')") Thread.sleep(500) - val tree = find(byXpath("//div[@class='Tree']")) - tree.expand(tree.getValueAtRow(0), *demo.map { it.name }.toTypedArray()) - tree.clickPath(tree.getValueAtRow(0), *demo.map { it.name }.toTypedArray(), fullMatch = true) button("OK").click() } } - Thread.sleep(1000) - // wait for indexing to finish - remoteRobot.idea { - waitFor(ofMinutes(1)) { isDumbMode().not() } + Thread.sleep(5000) + remoteRobot.find(timeout = ofMinutes(2)).apply { + waitFor(ofMinutes(2)) { isDumbMode().not() } } } @@ -102,12 +99,11 @@ class UITest { @Test fun testSetProjectInterpreter() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { findText("ve").click(MouseButton.RIGHT_BUTTON) remoteRobot.actionMenuItem("Set as Project Interpreter").click() - findText("Updated SDK for project demo to:") - // wait for indexing to finish waitFor(ofMinutes(1)) { isDumbMode().not() } } } @@ -115,12 +111,11 @@ class UITest { @Test fun testSetModuleInterpreter() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { findText("ve").click(MouseButton.RIGHT_BUTTON) remoteRobot.actionMenuItem("Set as Module Interpreter").click() - findText("Updated SDK for module demo to:") - // wait for indexing to finish waitFor(ofMinutes(1)) { isDumbMode().not() } } } @@ -152,33 +147,30 @@ class UITest { @Test fun testContextMenuOnNonVenvDirectory() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { - // Right-click on a non-venv directory should not show interpreter options findText("demo").click(MouseButton.RIGHT_BUTTON) - waitFor(ofSeconds(2)) { - // The action menu should be visible but interpreter options should not be enabled - runCatching { - remoteRobot.actionMenuItem("Set as Project Interpreter") - false // If found, test should handle it - }.getOrDefault(true) // If not found, that's expected + Thread.sleep(500) + assert(!remoteRobot.hasActionMenuItem("Set as Project Interpreter")) { + "Non-venv directory should not show 'Set as Project Interpreter'" } + remoteRobot.pressEscape() } } } @Test fun testContextMenuOnPythonFile() { + remoteRobot.pressEscape() remoteRobot.idea { with(projectViewTree) { - // Right-click on a Python file should not show interpreter options findText("main.py").click(MouseButton.RIGHT_BUTTON) - waitFor(ofSeconds(2)) { - runCatching { - remoteRobot.actionMenuItem("Set as Project Interpreter") - false - }.getOrDefault(true) + Thread.sleep(500) + assert(!remoteRobot.hasActionMenuItem("Set as Project Interpreter")) { + "Python file should not show 'Set as Project Interpreter'" } + remoteRobot.pressEscape() } } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt index c59e822..8aca198 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvProjectViewNodeDecoratorTest.kt @@ -51,8 +51,15 @@ class VenvProjectViewNodeDecoratorTest { settings = mockk(relaxed = true) mockkObject(PyVenvManageSettings.Companion) every { PyVenvManageSettings.getInstance() } returns settings - every { settings.showVersionInProjectView } returns true - every { settings.formatVersion(any()) } answers { " [${firstArg()}]" } + every { settings.formatDecoration(any()) } answers + { + val info = firstArg() + val parts = mutableListOf(info.version) + info.implementation?.let { parts.add(it) } + if (info.includeSystemSitePackages) parts.add("SYSTEM") + info.creator?.removePrefix(" - ")?.let { parts.add(it) } + " [${parts.joinToString(" - ")}]" + } } @AfterEach @@ -87,10 +94,10 @@ class VenvProjectViewNodeDecoratorTest { @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) every { data.presentableText } returns "venv" decorator.decorate(node, data) @@ -99,32 +106,32 @@ class VenvProjectViewNodeDecoratorTest { } @Test - fun `adds version text when version available`( + fun `adds version and implementation text when info available`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) every { data.presentableText } returns "venv" decorator.decorate(node, data) verify { data.clearText() } verify { data.addText("venv", SimpleTextAttributes.REGULAR_ATTRIBUTES) } - verify { data.addText(" [3.11.0]", SimpleTextAttributes.GRAY_ATTRIBUTES) } + verify { data.addText(" [3.11.0 - CPython]", SimpleTextAttributes.GRAY_ATTRIBUTES) } } @Test - fun `does not add version text when version unavailable`( + fun `does not add text when info unavailable`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") Files.writeString(pyvenvCfgPath, "home = /usr/bin") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns null + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns null every { data.presentableText } returns "venv" decorator.decorate(node, data) @@ -135,40 +142,38 @@ class VenvProjectViewNodeDecoratorTest { } @Test - fun `uses cache for version lookup`( + fun `uses cache for info lookup`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { versionCache.getVersion(pyvenvCfgPath.toString()) } returns "3.11.0" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) every { data.presentableText } returns "venv" - // Call twice decorator.decorate(node, data) decorator.decorate(node, data) - // Version cache should be called twice (caching is handled by the cache service) - verify(exactly = 2) { versionCache.getVersion(pyvenvCfgPath.toString()) } + verify(exactly = 2) { versionCache.getInfo(pyvenvCfgPath.toString()) } } @Test - fun `respects showVersionInProjectView setting`( + fun `does not modify text when presentableText is null`( @TempDir tempDir: Path, ) { val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") - Files.writeString(pyvenvCfgPath, "version = 3.11.0") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") - every { settings.showVersionInProjectView } returns false every { VenvUtils.getPyVenvCfg(virtualFile) } returns pyvenvCfgPath - every { data.presentableText } returns "venv" + every { versionCache.getInfo(pyvenvCfgPath.toString()) } returns VenvInfo("3.11.0", "CPython", false, null) + every { data.presentableText } returns null decorator.decorate(node, data) verify(exactly = 0) { data.clearText() } verify(exactly = 0) { data.addText(any(), any()) } - verify { data.setIcon(any()) } // Icon is still set + verify { data.setIcon(any()) } } } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt index 2e9b0b4..1b55c27 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvUtilsTest.kt @@ -3,14 +3,94 @@ package com.github.pyvenvmanage import java.nio.file.Files import java.nio.file.Path +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import com.intellij.openapi.vfs.VirtualFile + +import com.jetbrains.python.sdk.PythonSdkUtil + class VenvUtilsTest { + @Nested + inner class GetPyVenvCfgTest { + private lateinit var virtualFile: VirtualFile + private lateinit var pyvenvCfgFile: VirtualFile + + @BeforeEach + fun setUp() { + mockkStatic(PythonSdkUtil::class) + virtualFile = mockk(relaxed = true) + pyvenvCfgFile = mockk(relaxed = true) + } + + @AfterEach + fun tearDown() { + unmockkStatic(PythonSdkUtil::class) + } + + @Test + fun `returns null when file is null`() { + val result = VenvUtils.getPyVenvCfg(null) + assertNull(result) + } + + @Test + fun `returns null when file is not a directory`() { + every { virtualFile.isDirectory } returns false + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNull(result) + } + + @Test + fun `returns null when no Python executable found`() { + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/path" + every { PythonSdkUtil.getPythonExecutable("/some/path") } returns null + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNull(result) + } + + @Test + fun `returns null when pyvenv cfg not found`() { + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { virtualFile.findChild("pyvenv.cfg") } returns null + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNull(result) + } + + @Test + fun `returns path when venv directory with pyvenv cfg`() { + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { virtualFile.findChild("pyvenv.cfg") } returns pyvenvCfgFile + every { pyvenvCfgFile.path } returns "/some/venv/pyvenv.cfg" + + val result = VenvUtils.getPyVenvCfg(virtualFile) + + assertNotNull(result) + assertEquals(Path.of("/some/venv/pyvenv.cfg"), result) + } + } + @Nested inner class GetPythonVersionFromPyVenvTest { @TempDir @@ -116,4 +196,195 @@ class VenvUtilsTest { assertEquals("3.9.0", result) } } + + @Nested + inner class GetVenvInfoTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var pyvenvCfgPath: Path + + @BeforeEach + fun setUp() { + pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + } + + @Test + fun `returns info when version and implementation present`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version = 3.11.5 + implementation = CPython + include-system-site-packages = false + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(false, result?.includeSystemSitePackages) + assertNull(result?.creator) + } + + @Test + fun `returns info with null implementation when implementation missing`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version = 3.11.5 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertNull(result?.implementation) + assertEquals(false, result?.includeSystemSitePackages) + assertNull(result?.creator) + } + + @Test + fun `returns info with system site packages enabled`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version = 3.11.5 + implementation = CPython + include-system-site-packages = true + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(true, result?.includeSystemSitePackages) + assertNull(result?.creator) + } + + @Test + fun `uses version_info when version missing`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + version_info = 3.13.10.final.0 + implementation = CPython + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.13.10.final.0", result?.version) + assertEquals("CPython", result?.implementation) + assertNull(result?.creator) + } + + @Test + fun `returns null when version missing`() { + Files.writeString( + pyvenvCfgPath, + """ + home = /usr/bin + implementation = CPython + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNull(result) + } + + @Test + fun `returns null when file does not exist`() { + val nonExistentPath = tempDir.resolve("nonexistent.cfg") + + val result = VenvUtils.getVenvInfo(nonExistentPath) + + assertNull(result) + } + + @Test + fun `trims version and implementation`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.12.0 + implementation = PyPy + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.12.0", result?.version) + assertEquals("PyPy", result?.implementation) + assertNull(result?.creator) + } + + @Test + fun `returns creator when uv present`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.11.5 + implementation = CPython + uv = 0.9.18 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(" - uv@0.9.18", result?.creator) + } + + @Test + fun `returns creator when virtualenv present`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.11.5 + implementation = CPython + virtualenv = 20.35.4 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals("CPython", result?.implementation) + assertEquals(" - virtualenv@20.35.4", result?.creator) + } + + @Test + fun `prefers uv over virtualenv when both present`() { + Files.writeString( + pyvenvCfgPath, + """ + version = 3.11.5 + uv = 0.9.18 + virtualenv = 20.35.4 + """.trimIndent(), + ) + + val result = VenvUtils.getVenvInfo(pyvenvCfgPath) + + assertNotNull(result) + assertEquals("3.11.5", result?.version) + assertEquals(" - uv@0.9.18", result?.creator) + } + } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/VenvVersionCacheTest.kt b/src/test/kotlin/com/github/pyvenvmanage/VenvVersionCacheTest.kt new file mode 100644 index 0000000..ecc9554 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/VenvVersionCacheTest.kt @@ -0,0 +1,265 @@ +package com.github.pyvenvmanage + +import java.nio.file.Files +import java.nio.file.Path + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.vfs.AsyncFileListener +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent + +class VenvVersionCacheTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var virtualFileManager: VirtualFileManager + private var capturedListener: AsyncFileListener? = null + + @BeforeEach + fun setUp() { + mockkObject(VenvUtils) + mockkStatic(VirtualFileManager::class) + + virtualFileManager = mockk(relaxed = true) + every { VirtualFileManager.getInstance() } returns virtualFileManager + + val listenerSlot = slot() + every { + virtualFileManager.addAsyncFileListener(capture(listenerSlot), any()) + } answers { + capturedListener = listenerSlot.captured + } + } + + @AfterEach + fun tearDown() { + unmockkObject(VenvUtils) + unmockkStatic(VirtualFileManager::class) + capturedListener = null + } + + @Test + fun `getVersion returns cached version on second call`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) + + val cache = VenvVersionCache() + + val firstResult = cache.getVersion(pyvenvCfgPath.toString()) + val secondResult = cache.getVersion(pyvenvCfgPath.toString()) + + assertEquals("3.11.0", firstResult) + assertEquals("3.11.0", secondResult) + verify(exactly = 1) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + + @Test + fun `getVersion returns null when version not found`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "home = /usr/bin") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns null + + val cache = VenvVersionCache() + val result = cache.getVersion(pyvenvCfgPath.toString()) + + assertNull(result) + } + + @Test + fun `getInfo returns cached info on second call`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0\nimplementation = CPython") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) + + val cache = VenvVersionCache() + + val firstResult = cache.getInfo(pyvenvCfgPath.toString()) + val secondResult = cache.getInfo(pyvenvCfgPath.toString()) + + assertEquals(VenvInfo("3.11.0", "CPython"), firstResult) + assertEquals(VenvInfo("3.11.0", "CPython"), secondResult) + verify(exactly = 1) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + + @Test + fun `getInfo returns null when info not found`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "home = /usr/bin") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns null + + val cache = VenvVersionCache() + val result = cache.getInfo(pyvenvCfgPath.toString()) + + assertNull(result) + } + + @Test + fun `invalidate removes entry from cache`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen + VenvInfo("3.12.0", "CPython", false) + + val cache = VenvVersionCache() + + cache.getVersion(pyvenvCfgPath.toString()) + cache.invalidate(pyvenvCfgPath.toString()) + val afterInvalidate = cache.getVersion(pyvenvCfgPath.toString()) + + assertEquals("3.12.0", afterInvalidate) + verify(exactly = 2) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + + @Test + fun `clear removes all entries from cache`() { + val pyvenvCfgPath1 = tempDir.resolve("venv1/pyvenv.cfg") + val pyvenvCfgPath2 = tempDir.resolve("venv2/pyvenv.cfg") + Files.createDirectories(pyvenvCfgPath1.parent) + Files.createDirectories(pyvenvCfgPath2.parent) + Files.writeString(pyvenvCfgPath1, "version = 3.11.0") + Files.writeString(pyvenvCfgPath2, "version = 3.12.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath1) } returns VenvInfo("3.11.0", "CPython") andThen + VenvInfo("3.11.1", "CPython", false) + every { VenvUtils.getVenvInfo(pyvenvCfgPath2) } returns VenvInfo("3.12.0", "CPython", false) andThen + VenvInfo("3.12.1", "CPython", false) + + val cache = VenvVersionCache() + + cache.getVersion(pyvenvCfgPath1.toString()) + cache.getVersion(pyvenvCfgPath2.toString()) + cache.clear() + val after1 = cache.getVersion(pyvenvCfgPath1.toString()) + val after2 = cache.getVersion(pyvenvCfgPath2.toString()) + + assertEquals("3.11.1", after1) + assertEquals("3.12.1", after2) + } + + @Test + fun `dispose clears the cache`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen + VenvInfo("3.12.0", "CPython", false) + + val cache = VenvVersionCache() + + cache.getVersion(pyvenvCfgPath.toString()) + cache.dispose() + val afterDispose = cache.getVersion(pyvenvCfgPath.toString()) + + assertEquals("3.12.0", afterDispose) + } + + @Test + fun `registers async file listener on init`() { + VenvVersionCache() + + verify { virtualFileManager.addAsyncFileListener(any(), any()) } + } + + @Nested + inner class FileListenerTest { + @Test + fun `file listener invalidates cache on pyvenv cfg content change`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen + VenvInfo("3.12.0", "CPython", false) + + val cache = VenvVersionCache() + cache.getVersion(pyvenvCfgPath.toString()) + + val event: VFileContentChangeEvent = mockk(relaxed = true) + every { event.path } returns pyvenvCfgPath.toString() + + val changeApplier = capturedListener?.prepareChange(listOf(event)) + changeApplier?.afterVfsChange() + + val afterChange = cache.getVersion(pyvenvCfgPath.toString()) + assertEquals("3.12.0", afterChange) + } + + @Test + fun `file listener invalidates cache on pyvenv cfg delete`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) andThen null + + val cache = VenvVersionCache() + cache.getVersion(pyvenvCfgPath.toString()) + + val event: VFileDeleteEvent = mockk(relaxed = true) + every { event.path } returns pyvenvCfgPath.toString() + + val changeApplier = capturedListener?.prepareChange(listOf(event)) + changeApplier?.afterVfsChange() + + val afterDelete = cache.getVersion(pyvenvCfgPath.toString()) + assertNull(afterDelete) + } + + @Test + fun `file listener ignores non-pyvenv cfg files`() { + val pyvenvCfgPath = tempDir.resolve("pyvenv.cfg") + Files.writeString(pyvenvCfgPath, "version = 3.11.0") + + every { VenvUtils.getVenvInfo(pyvenvCfgPath) } returns VenvInfo("3.11.0", "CPython", false) + + val cache = VenvVersionCache() + cache.getVersion(pyvenvCfgPath.toString()) + + val event: VFileContentChangeEvent = mockk(relaxed = true) + every { event.path } returns "/some/other/file.txt" + + val changeApplier = capturedListener?.prepareChange(listOf(event)) + + assertNull(changeApplier) + verify(exactly = 1) { VenvUtils.getVenvInfo(pyvenvCfgPath) } + } + } + + @Test + fun `getInstance returns cache instance`() { + val application: Application = mockk(relaxed = true) + val mockCache: VenvVersionCache = mockk(relaxed = true) + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(VenvVersionCache::class.java) } returns mockCache + + val instance = VenvVersionCache.getInstance() + + assertEquals(mockCache, instance) + unmockkStatic(ApplicationManager::class) + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt new file mode 100644 index 0000000..c70f8c7 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionAbstractTest.kt @@ -0,0 +1,359 @@ +package com.github.pyvenvmanage.actions + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationGroup +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager + +import com.jetbrains.python.configuration.PyConfigurableInterpreterList +import com.jetbrains.python.sdk.PythonSdkType +import com.jetbrains.python.sdk.PythonSdkUtil + +class ConfigurePythonActionAbstractTest { + private lateinit var action: TestableConfigurePythonAction + private lateinit var event: AnActionEvent + private lateinit var presentation: Presentation + private lateinit var virtualFile: VirtualFile + private lateinit var project: Project + + @BeforeEach + fun setUp() { + action = TestableConfigurePythonAction() + event = mockk(relaxed = true) + presentation = mockk(relaxed = true) + virtualFile = mockk(relaxed = true) + project = mockk(relaxed = true) + + every { event.presentation } returns presentation + every { event.project } returns project + } + + @Test + fun `getActionUpdateThread returns BGT`() { + assertEquals(ActionUpdateThread.BGT, action.actionUpdateThread) + } + + @Nested + inner class UpdateTest { + @BeforeEach + fun setUpMocks() { + mockkStatic(PythonSdkUtil::class) + } + + @AfterEach + fun tearDown() { + unmockkStatic(PythonSdkUtil::class) + } + + @Test + fun `disables action when no file selected`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns null + + action.update(event) + + verify { presentation.isEnabledAndVisible = false } + } + + @Test + fun `enables action for directory with Python executable`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + + action.update(event) + + verify { presentation.isEnabledAndVisible = true } + } + + @Test + fun `disables action for directory without Python executable`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/dir" + every { PythonSdkUtil.getPythonExecutable("/some/dir") } returns null + + action.update(event) + + verify { presentation.isEnabledAndVisible = false } + } + + @Test + fun `enables action for file in virtual env`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns false + every { virtualFile.path } returns "/some/venv/bin/python" + every { PythonSdkUtil.isVirtualEnv("/some/venv/bin/python") } returns true + + action.update(event) + + verify { presentation.isEnabledAndVisible = true } + } + + @Test + fun `disables action for file not in virtual env`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns false + every { virtualFile.path } returns "/usr/bin/python" + every { PythonSdkUtil.isVirtualEnv("/usr/bin/python") } returns false + + action.update(event) + + verify { presentation.isEnabledAndVisible = false } + } + } + + @Nested + inner class ActionPerformedTest { + private lateinit var parentDir: VirtualFile + private lateinit var interpreterList: PyConfigurableInterpreterList + private lateinit var notificationGroupManager: NotificationGroupManager + private lateinit var notificationGroup: NotificationGroup + private lateinit var notification: Notification + + @BeforeEach + fun setUpMocks() { + parentDir = mockk(relaxed = true) + interpreterList = mockk(relaxed = true) + notificationGroupManager = mockk(relaxed = true) + notificationGroup = mockk(relaxed = true) + notification = mockk(relaxed = true) + + mockkStatic(PythonSdkUtil::class) + mockkStatic(PyConfigurableInterpreterList::class) + mockkStatic(SdkConfigurationUtil::class) + mockkStatic(NotificationGroupManager::class) + mockkStatic(PythonSdkType::class) + + every { NotificationGroupManager.getInstance() } returns notificationGroupManager + every { notificationGroupManager.getNotificationGroup(any()) } returns notificationGroup + every { + notificationGroup.createNotification(any(), any(), any()) + } returns notification + every { notification.notify(any()) } just Runs + } + + @AfterEach + fun tearDown() { + unmockkStatic(PythonSdkUtil::class) + unmockkStatic(PyConfigurableInterpreterList::class) + unmockkStatic(SdkConfigurationUtil::class) + unmockkStatic(NotificationGroupManager::class) + unmockkStatic(PythonSdkType::class) + } + + @Test + fun `returns early when project is null`() { + every { event.project } returns null + + action.actionPerformed(event) + + verify(exactly = 0) { event.getData(CommonDataKeys.VIRTUAL_FILE) } + } + + @Test + fun `returns early when no file selected`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns null + + action.actionPerformed(event) + + verify(exactly = 0) { PythonSdkUtil.getPythonExecutable(any()) } + } + + @Test + fun `uses parent directory when file is not a directory`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns false + every { virtualFile.parent } returns parentDir + every { parentDir.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns null + + action.actionPerformed(event) + + verify { PythonSdkUtil.getPythonExecutable("/some/venv") } + } + + @Test + fun `shows error when no Python executable found`() { + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/dir" + every { virtualFile.name } returns "dir" + every { PythonSdkUtil.getPythonExecutable("/some/dir") } returns null + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + "Python SDK Error", + "No Python executable found in dir", + NotificationType.ERROR, + ) + } + } + + @Test + fun `shows error when SDK creation fails`() { + val pythonSdkType: PythonSdkType = mockk(relaxed = true) + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf() + every { PythonSdkType.getInstance() } returns pythonSdkType + every { SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) } returns null + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + "Python SDK Error", + "Failed to create SDK from /some/venv/bin/python", + NotificationType.ERROR, + ) + } + } + + @Test + fun `creates new SDK when not found in existing SDKs`() { + val newSdk: Sdk = mockk(relaxed = true) + val pythonSdkType: PythonSdkType = mockk(relaxed = true) + val application: Application = mockk(relaxed = true) + val virtualFileManager: VirtualFileManager = mockk(relaxed = true) + val messageSlot = slot() + + action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Success("module") + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(any>()) } returns mockk(relaxed = true) + every { application.getService(VirtualFileManager::class.java) } returns virtualFileManager + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf() + every { PythonSdkType.getInstance() } returns pythonSdkType + every { SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) } returns newSdk + every { newSdk.name } returns "Python 3.11 (venv)" + + action.actionPerformed(event) + + verify { + SdkConfigurationUtil.createAndAddSDK("/some/venv/bin/python", pythonSdkType) + } + verify { + notificationGroup.createNotification( + eq("Python SDK Updated"), + capture(messageSlot), + eq(NotificationType.INFORMATION), + ) + } + assert(messageSlot.captured.startsWith("Updated SDK for module to:")) + + unmockkStatic(ApplicationManager::class) + } + + @Test + fun `shows error notification on setSdk error`() { + val existingSdk: Sdk = mockk(relaxed = true) + + action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Error("Module not found") + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf(existingSdk) + every { existingSdk.homePath } returns "/some/venv/bin/python" + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + "Python SDK Error", + "Module not found", + NotificationType.ERROR, + ) + } + } + + @Test + fun `shows success notification on setSdk success`() { + val existingSdk: Sdk = mockk(relaxed = true) + val application: Application = mockk(relaxed = true) + val virtualFileManager: VirtualFileManager = mockk(relaxed = true) + val messageSlot = slot() + + action.lastSetSdkResult = ConfigurePythonActionAbstract.SetSdkResult.Success("module") + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(any>()) } returns mockk(relaxed = true) + every { application.getService(VirtualFileManager::class.java) } returns virtualFileManager + + every { event.getData(CommonDataKeys.VIRTUAL_FILE) } returns virtualFile + every { virtualFile.isDirectory } returns true + every { virtualFile.path } returns "/some/venv" + every { PythonSdkUtil.getPythonExecutable("/some/venv") } returns "/some/venv/bin/python" + every { PyConfigurableInterpreterList.getInstance(project) } returns interpreterList + every { interpreterList.model.projectSdks.values } returns mutableListOf(existingSdk) + every { existingSdk.homePath } returns "/some/venv/bin/python" + every { existingSdk.name } returns "Python 3.11 (venv)" + + action.actionPerformed(event) + + verify { + notificationGroup.createNotification( + eq("Python SDK Updated"), + capture(messageSlot), + eq(NotificationType.INFORMATION), + ) + } + assert(messageSlot.captured.startsWith("Updated SDK for module to:")) + assert(messageSlot.captured.contains("Python 3.11 (venv)")) + + unmockkStatic(ApplicationManager::class) + } + } + + class TestableConfigurePythonAction : ConfigurePythonActionAbstract() { + var lastSetSdkResult: SetSdkResult = SetSdkResult.Success("test") + + override fun setSdk( + project: Project, + selectedPath: VirtualFile, + sdk: Sdk, + ): SetSdkResult = lastSetSdkResult + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModuleTest.kt b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModuleTest.kt new file mode 100644 index 0000000..9806472 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionModuleTest.kt @@ -0,0 +1,86 @@ +package com.github.pyvenvmanage.actions + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.roots.ModuleRootModificationUtil +import com.intellij.openapi.roots.ProjectFileIndex +import com.intellij.openapi.vfs.VirtualFile + +class ConfigurePythonActionModuleTest { + private lateinit var action: TestableConfigurePythonActionModule + private lateinit var project: Project + private lateinit var selectedPath: VirtualFile + private lateinit var sdk: Sdk + private lateinit var projectFileIndex: ProjectFileIndex + private lateinit var module: Module + + @BeforeEach + fun setUp() { + action = TestableConfigurePythonActionModule() + project = mockk(relaxed = true) + selectedPath = mockk(relaxed = true) + sdk = mockk(relaxed = true) + projectFileIndex = mockk(relaxed = true) + module = mockk(relaxed = true) + + mockkStatic(ProjectFileIndex::class) + mockkStatic(ModuleRootModificationUtil::class) + every { ProjectFileIndex.getInstance(project) } returns projectFileIndex + every { selectedPath.name } returns "venv" + } + + @AfterEach + fun tearDown() { + unmockkStatic(ProjectFileIndex::class) + unmockkStatic(ModuleRootModificationUtil::class) + } + + @Test + fun `setSdk returns error when no module found`() { + every { projectFileIndex.getModuleForFile(selectedPath, false) } returns null + + val result = action.testSetSdk(project, selectedPath, sdk) + + assertTrue(result is ConfigurePythonActionAbstract.SetSdkResult.Error) + assertEquals( + "No module found for venv", + (result as ConfigurePythonActionAbstract.SetSdkResult.Error).message, + ) + } + + @Test + fun `setSdk sets module SDK and returns success`() { + every { projectFileIndex.getModuleForFile(selectedPath, false) } returns module + every { module.name } returns "my-module" + every { ModuleRootModificationUtil.setModuleSdk(module, sdk) } returns Unit + + val result = action.testSetSdk(project, selectedPath, sdk) + + assertTrue(result is ConfigurePythonActionAbstract.SetSdkResult.Success) + assertEquals( + "module my-module", + (result as ConfigurePythonActionAbstract.SetSdkResult.Success).target, + ) + verify { ModuleRootModificationUtil.setModuleSdk(module, sdk) } + } + + class TestableConfigurePythonActionModule : ConfigurePythonActionModule() { + fun testSetSdk( + project: Project, + selectedPath: VirtualFile, + sdk: Sdk, + ): SetSdkResult = setSdk(project, selectedPath, sdk) + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProjectTest.kt b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProjectTest.kt new file mode 100644 index 0000000..4b91f6b --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/actions/ConfigurePythonActionProjectTest.kt @@ -0,0 +1,62 @@ +package com.github.pyvenvmanage.actions + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.vfs.VirtualFile + +class ConfigurePythonActionProjectTest { + private lateinit var action: TestableConfigurePythonActionProject + private lateinit var project: Project + private lateinit var selectedPath: VirtualFile + private lateinit var sdk: Sdk + + @BeforeEach + fun setUp() { + action = TestableConfigurePythonActionProject() + project = mockk(relaxed = true) + selectedPath = mockk(relaxed = true) + sdk = mockk(relaxed = true) + + mockkStatic(SdkConfigurationUtil::class) + every { project.name } returns "my-project" + } + + @AfterEach + fun tearDown() { + unmockkStatic(SdkConfigurationUtil::class) + } + + @Test + fun `setSdk sets project SDK and returns success`() { + every { SdkConfigurationUtil.setDirectoryProjectSdk(project, sdk) } returns Unit + + val result = action.testSetSdk(project, selectedPath, sdk) + + assertTrue(result is ConfigurePythonActionAbstract.SetSdkResult.Success) + assertEquals( + "project my-project", + (result as ConfigurePythonActionAbstract.SetSdkResult.Success).target, + ) + verify { SdkConfigurationUtil.setDirectoryProjectSdk(project, sdk) } + } + + class TestableConfigurePythonActionProject : ConfigurePythonActionProject() { + fun testSetSdk( + project: Project, + selectedPath: VirtualFile, + sdk: Sdk, + ): SetSdkResult = setSdk(project, selectedPath, sdk) + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt index 40f2889..c8f57fb 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/pages/ActionMenuFixture.kt @@ -1,4 +1,8 @@ package com.github.pyvenvmanage.pages + +import java.time.Duration +import java.time.Duration.ofSeconds + import com.intellij.remoterobot.RemoteRobot import com.intellij.remoterobot.data.RemoteComponent import com.intellij.remoterobot.fixtures.ComponentFixture @@ -6,27 +10,25 @@ import com.intellij.remoterobot.fixtures.FixtureName import com.intellij.remoterobot.search.locators.byXpath import com.intellij.remoterobot.utils.waitFor -fun RemoteRobot.actionMenu(text: String): ActionMenuFixture { - val xpath = byXpath("text '$text'", "//div[@class='ActionMenu' and @text='$text']") - waitFor { - findAll(xpath).isNotEmpty() - } - return findAll(xpath).first() -} - -fun RemoteRobot.actionMenuItem(text: String): ActionMenuItemFixture { +fun RemoteRobot.actionMenuItem( + text: String, + timeout: Duration = ofSeconds(30), +): ActionMenuItemFixture { val xpath = byXpath("text '$text'", "//div[@class='ActionMenuItem' and @text='$text']") - waitFor { + waitFor(timeout) { findAll(xpath).isNotEmpty() } return findAll(xpath).first() } -@FixtureName("ActionMenu") -class ActionMenuFixture( - remoteRobot: RemoteRobot, - remoteComponent: RemoteComponent, -) : ComponentFixture(remoteRobot, remoteComponent) +fun RemoteRobot.hasActionMenuItem(text: String): Boolean { + val xpath = byXpath("text '$text'", "//div[@class='ActionMenuItem' and @text='$text']") + return findAll(xpath).isNotEmpty() +} + +fun RemoteRobot.pressEscape() { + runJs("robot.pressAndReleaseKey(java.awt.event.KeyEvent.VK_ESCAPE)") +} @FixtureName("ActionMenuItem") class ActionMenuItemFixture( diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt index 5b4d468..1e6174d 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/pages/IdeaFrame.kt @@ -8,10 +8,7 @@ import com.intellij.remoterobot.fixtures.CommonContainerFixture import com.intellij.remoterobot.fixtures.ContainerFixture import com.intellij.remoterobot.fixtures.DefaultXpath import com.intellij.remoterobot.fixtures.FixtureName -import com.intellij.remoterobot.fixtures.JMenuBarFixture import com.intellij.remoterobot.search.locators.byXpath -import com.intellij.remoterobot.stepsProcessing.step -import com.intellij.remoterobot.utils.waitFor fun RemoteRobot.idea(function: IdeaFrame.() -> Unit) { find(timeout = Duration.ofSeconds(10)).apply(function) @@ -24,34 +21,11 @@ class IdeaFrame( remoteComponent: RemoteComponent, ) : CommonContainerFixture(remoteRobot, remoteComponent) { val projectViewTree - get() = find(byXpath("ProjectViewTree", "//div[@class='ProjectViewTree']")) - - val projectName - get() = step("Get project name") { return@step callJs("component.getProject().getName()") } - - val menuBar: JMenuBarFixture get() = - step("Menu...") { - return@step remoteRobot.find(JMenuBarFixture::class.java, JMenuBarFixture.byType()) - } - - @JvmOverloads - fun dumbAware( - timeout: Duration = Duration.ofMinutes(5), - function: () -> Unit, - ) { - step("Wait for smart mode") { - waitFor(duration = timeout, interval = Duration.ofSeconds(5)) { - runCatching { isDumbMode().not() }.getOrDefault(false) - } - function() - step("..wait for smart mode again") { - waitFor(duration = timeout, interval = Duration.ofSeconds(5)) { - isDumbMode().not() - } - } - } - } + find( + byXpath("ProjectViewTree", "//div[@class='MyProjectViewTree']"), + Duration.ofSeconds(30), + ) fun isDumbMode(): Boolean = callJs( @@ -66,35 +40,4 @@ class IdeaFrame( """, true, ) - - fun openFile(path: String) { - runJs( - """ - importPackage(com.intellij.openapi.fileEditor) - importPackage(com.intellij.openapi.vfs) - importPackage(com.intellij.openapi.wm.impl) - importClass(com.intellij.openapi.application.ApplicationManager) - - const path = '$path' - const frameHelper = ProjectFrameHelper.getFrameHelper(component) - if (frameHelper) { - const project = frameHelper.getProject() - const projectPath = project.getBasePath() - const file = LocalFileSystem.getInstance().findFileByPath(projectPath + '/' + path) - const openFileFunction = new Runnable({ - run: function() { - FileEditorManager.getInstance(project).openTextEditor( - new OpenFileDescriptor( - project, - file - ), true - ) - } - }) - ApplicationManager.getApplication().invokeLater(openFileFunction) - } - """, - true, - ) - } } diff --git a/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt b/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt index 3d1b66a..cb36824 100644 --- a/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt +++ b/src/test/kotlin/com/github/pyvenvmanage/pages/WelcomeFrame.kt @@ -4,8 +4,8 @@ import java.time.Duration import com.intellij.remoterobot.RemoteRobot import com.intellij.remoterobot.data.RemoteComponent -import com.intellij.remoterobot.fixtures.ActionLinkFixture import com.intellij.remoterobot.fixtures.CommonContainerFixture +import com.intellij.remoterobot.fixtures.ComponentFixture import com.intellij.remoterobot.fixtures.DefaultXpath import com.intellij.remoterobot.fixtures.FixtureName import com.intellij.remoterobot.search.locators.byXpath @@ -20,7 +20,9 @@ class WelcomeFrame( remoteRobot: RemoteRobot, remoteComponent: RemoteComponent, ) : CommonContainerFixture(remoteRobot, remoteComponent) { - private val xpath = "//div[@myiconbutton='createNewProjectTabSelected.svg']" - val createNewProjectLink: ActionLinkFixture get() = actionLink(byXpath("New Project", xpath)) - val openLink: ActionLinkFixture get() = actionLink(byXpath("Open", "//div[@myiconbutton='openSelected.svg']")) + val openButton: ComponentFixture + get() = + find( + byXpath("//div[@class='LargeIconWithTextPanel']//div[@class='JButton' and @accessiblename='Open']"), + ) } diff --git a/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt new file mode 100644 index 0000000..a278f58 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageConfigurableTest.kt @@ -0,0 +1,421 @@ +package com.github.pyvenvmanage.settings + +import java.awt.BorderLayout +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.event.DocumentListener + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.CoroutineSupport +import com.intellij.ui.components.JBScrollPane + +class PyVenvManageConfigurableTest { + private lateinit var configurable: PyVenvManageConfigurable + private lateinit var settings: PyVenvManageSettings + private lateinit var application: Application + + @BeforeEach + fun setUp() { + application = mockk(relaxed = true) + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(CoroutineSupport::class.java) } returns mockk(relaxed = true) + + settings = mockk(relaxed = true) + mockkObject(PyVenvManageSettings.Companion) + every { PyVenvManageSettings.getInstance() } returns settings + every { settings.prefix } returns " [" + every { settings.suffix } returns "]" + every { settings.separator } returns " - " + every { settings.fields } returns DecorationField.entries + + configurable = PyVenvManageConfigurable() + } + + @AfterEach + fun tearDown() { + unmockkObject(PyVenvManageSettings.Companion) + unmockkStatic(ApplicationManager::class) + } + + @Test + fun `getDisplayName returns correct name`() { + assertEquals("PyVenv Manage", configurable.displayName) + } + + @Test + fun `createComponent returns non-null panel`() { + val component = configurable.createComponent() + assertNotNull(component) + } + + @Test + fun `isModified returns false when no changes`() { + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `isModified returns true when prefix changed`() { + configurable.createComponent() + + every { settings.prefix } returns "(" + + assertTrue(configurable.isModified) + } + + @Test + fun `isModified returns true when suffix changed`() { + configurable.createComponent() + + every { settings.suffix } returns ")" + + assertTrue(configurable.isModified) + } + + @Test + fun `isModified returns true when separator changed`() { + configurable.createComponent() + + every { settings.separator } returns " | " + + assertTrue(configurable.isModified) + } + + @Test + fun `isModified returns true when fields changed`() { + configurable.createComponent() + + every { settings.fields } returns listOf(DecorationField.VERSION) + + assertTrue(configurable.isModified) + } + + @Test + fun `apply updates settings`() { + configurable.createComponent() + + configurable.apply() + + verify { settings.prefix = any() } + verify { settings.suffix = any() } + verify { settings.separator = any() } + verify { settings.fields = any() } + } + + @Test + fun `reset loads settings into UI`() { + every { settings.prefix } returns "(" + every { settings.suffix } returns ")" + every { settings.separator } returns ":" + every { settings.fields } returns listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION) + + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `reset with partial fields shows all fields with correct enabled state`() { + every { settings.fields } returns listOf(DecorationField.VERSION) + + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `disposeUIResources clears references`() { + configurable.createComponent() + configurable.disposeUIResources() + + configurable.apply() + configurable.reset() + } + + @Test + fun `isModified returns false after reset`() { + configurable.createComponent() + configurable.reset() + + assertFalse(configurable.isModified) + } + + @Test + fun `apply then isModified returns false`() { + configurable.createComponent() + configurable.apply() + + assertFalse(configurable.isModified) + } + + @Test + fun `reset with empty fields list`() { + every { settings.fields } returns emptyList() + + configurable.createComponent() + + assertFalse(configurable.isModified) + } + + @Test + fun `multiple createComponent calls work`() { + configurable.createComponent() + configurable.disposeUIResources() + val component = configurable.createComponent() + + assertNotNull(component) + } + + @Test + fun `updatePreview shows sample decoration`() { + configurable.createComponent() + + assertNotNull(configurable.previewField?.text) + assertTrue(configurable.previewField?.text?.contains(".venv") == true) + } + + @Test + fun `updatePreview with no fields shows just folder name`() { + every { settings.fields } returns emptyList() + configurable.createComponent() + + assertEquals(".venv", configurable.previewField?.text) + } + + @Test + fun `moveSelectedField does nothing when no selection`() { + configurable.createComponent() + configurable.fieldsList?.clearSelection() + + configurable.moveSelectedField(-1) + } + + @Test + fun `moveSelectedField does nothing when at boundary`() { + configurable.createComponent() + configurable.fieldsList?.selectedIndex = 0 + + configurable.moveSelectedField(-1) + } + + @Test + fun `moveSelectedField swaps items`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = 1 + + configurable.moveSelectedField(-1) + + assertEquals(0, list.selectedIndex) + } + + @Test + fun `moveSelectedField down at last position does nothing`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = list.itemsCount - 1 + + configurable.moveSelectedField(1) + + assertEquals(list.itemsCount - 1, list.selectedIndex) + } + + @Test + fun `updatePreview before createComponent does nothing`() { + configurable.updatePreview() + } + + @Test + fun `moveSelectedField before createComponent does nothing`() { + configurable.moveSelectedField(1) + } + + @Test + fun `document insert triggers preview update`() { + configurable.createComponent() + + configurable.prefixField?.document?.insertString(0, "x", null) + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `document remove triggers preview update`() { + configurable.createComponent() + + configurable.prefixField?.document?.remove(0, 1) + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `checkbox toggle triggers preview update`() { + configurable.createComponent() + val list = configurable.fieldsList!! + + list.setItemSelected(list.getItemAt(0), false) + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `checkbox list shows secondary text for each field`() { + configurable.createComponent() + val list = configurable.fieldsList!! + + for (i in 0 until list.itemsCount) { + val item = list.getItemAt(i) + assertNotNull(item) + } + } + + @Test + fun `getFieldExample returns example for VERSION`() { + assertEquals("e.g., 3.14.2", getFieldExample(DecorationField.VERSION)) + } + + @Test + fun `getFieldExample returns example for IMPLEMENTATION`() { + assertEquals("e.g., CPython", getFieldExample(DecorationField.IMPLEMENTATION)) + } + + @Test + fun `getFieldExample returns example for SYSTEM`() { + assertEquals("shows SYSTEM", getFieldExample(DecorationField.SYSTEM)) + } + + @Test + fun `getFieldExample returns example for CREATOR`() { + assertEquals("e.g., uv@0.9.21", getFieldExample(DecorationField.CREATOR)) + } + + @Test + fun `getFieldExample returns null for null`() { + assertEquals(null, getFieldExample(null)) + } + + @Test + fun `move up button click triggers move and preview update`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = 1 + + val component = configurable.createComponent() + val listPanel = findListPanel(component) + val buttonsPanel = listPanel.getComponent(1) as JPanel + val moveUpButton = buttonsPanel.getComponent(0) as JButton + + moveUpButton.doClick() + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `move down button click triggers move and preview update`() { + configurable.createComponent() + val list = configurable.fieldsList!! + list.selectedIndex = 0 + + val component = configurable.createComponent() + val listPanel = findListPanel(component) + val buttonsPanel = listPanel.getComponent(1) as JPanel + val moveDownButton = buttonsPanel.getComponent(1) as JButton + + moveDownButton.doClick() + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `checkbox list listener triggers preview update on toggle`() { + configurable.createComponent() + val list = configurable.fieldsList!! + + var listenerFieldName: String? = null + var targetClass: Class<*>? = list.javaClass + while (targetClass != null && listenerFieldName == null) { + for (field in targetClass.declaredFields) { + if (field.type == com.intellij.ui.CheckBoxListListener::class.java) { + listenerFieldName = field.name + break + } + } + targetClass = targetClass.superclass + } + + if (listenerFieldName != null) { + val listenerField = list.javaClass.superclass.getDeclaredField(listenerFieldName) + listenerField.isAccessible = true + val listener = listenerField.get(list) as? com.intellij.ui.CheckBoxListListener + listener?.checkBoxSelectionChanged(0, false) + } + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `changedUpdate triggers preview update`() { + configurable.createComponent() + + configurable.prefixField?.document?.let { doc -> + val listenerList = (doc as javax.swing.text.AbstractDocument).documentListeners + listenerList.filterIsInstance().forEach { + val mockEvent = mockk(relaxed = true) + it.changedUpdate(mockEvent) + } + } + + assertNotNull(configurable.previewField?.text) + } + + @Test + fun `suffixField is accessible after createComponent`() { + configurable.createComponent() + + assertNotNull(configurable.suffixField) + configurable.suffixField?.text = "test" + assertEquals("test", configurable.suffixField?.text) + } + + @Test + fun `separatorField is accessible after createComponent`() { + configurable.createComponent() + + assertNotNull(configurable.separatorField) + configurable.separatorField?.text = "test" + assertEquals("test", configurable.separatorField?.text) + } + + private fun findListPanel(component: JComponent): JPanel { + val formPanel = component as JPanel + for (i in 0 until formPanel.componentCount) { + val child = formPanel.getComponent(i) + if (child is JPanel && child.layout is BorderLayout) { + val center = (child.layout as BorderLayout).getLayoutComponent(child, BorderLayout.CENTER) + if (center is JBScrollPane) { + return child + } + } + } + throw IllegalStateException("List panel not found") + } +} diff --git a/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettingsTest.kt b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettingsTest.kt new file mode 100644 index 0000000..78b7855 --- /dev/null +++ b/src/test/kotlin/com/github/pyvenvmanage/settings/PyVenvManageSettingsTest.kt @@ -0,0 +1,190 @@ +package com.github.pyvenvmanage.settings + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager + +import com.github.pyvenvmanage.VenvInfo + +class PyVenvManageSettingsTest { + private lateinit var settings: PyVenvManageSettings + + @BeforeEach + fun setUp() { + settings = PyVenvManageSettings() + } + + @Test + fun `default prefix is space bracket`() { + assertEquals(" [", settings.prefix) + } + + @Test + fun `default suffix is close bracket`() { + assertEquals("]", settings.suffix) + } + + @Test + fun `default separator is dash with spaces`() { + assertEquals(" - ", settings.separator) + } + + @Test + fun `default fields include all decoration fields in order`() { + assertEquals(DecorationField.entries, settings.fields) + } + + @Test + fun `prefix can be set`() { + settings.prefix = "(" + assertEquals("(", settings.prefix) + } + + @Test + fun `suffix can be set`() { + settings.suffix = ")" + assertEquals(")", settings.suffix) + } + + @Test + fun `separator can be set`() { + settings.separator = " | " + assertEquals(" | ", settings.separator) + } + + @Test + fun `fields can be set`() { + settings.fields = listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION) + assertEquals(listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION), settings.fields) + } + + @Test + fun `formatDecoration with all fields`() { + val info = VenvInfo("3.11.0", "CPython", false, " - uv@0.9.18") + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0 - CPython - uv@0.9.18]", result) + } + + @Test + fun `formatDecoration with version only`() { + settings.fields = listOf(DecorationField.VERSION) + val info = VenvInfo("3.11.0", "CPython", true, " - uv@0.9.18") + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0]", result) + } + + @Test + fun `formatDecoration with null implementation skips it`() { + val info = VenvInfo("3.11.0", null, false, null) + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0]", result) + } + + @Test + fun `formatDecoration with system site packages`() { + val info = VenvInfo("3.11.0", "CPython", true, null) + val result = settings.formatDecoration(info) + assertEquals(" [3.11.0 - CPython - SYSTEM]", result) + } + + @Test + fun `formatDecoration with custom prefix suffix separator`() { + settings.prefix = "(" + settings.suffix = ")" + settings.separator = " | " + val info = VenvInfo("3.12.0", "PyPy", false, null) + val result = settings.formatDecoration(info) + assertEquals("(3.12.0 | PyPy)", result) + } + + @Test + fun `formatDecoration with reordered fields`() { + settings.fields = listOf(DecorationField.IMPLEMENTATION, DecorationField.VERSION) + val info = VenvInfo("3.11.0", "CPython", false, null) + val result = settings.formatDecoration(info) + assertEquals(" [CPython - 3.11.0]", result) + } + + @Test + fun `formatDecoration with empty fields returns empty string`() { + settings.fields = emptyList() + val info = VenvInfo("3.11.0", "CPython", true, " - uv@0.9.18") + val result = settings.formatDecoration(info) + assertEquals("", result) + } + + @Test + fun `formatDecoration strips creator prefix`() { + settings.fields = listOf(DecorationField.CREATOR) + val info = VenvInfo("3.11.0", null, false, " - virtualenv@20.35.4") + val result = settings.formatDecoration(info) + assertEquals(" [virtualenv@20.35.4]", result) + } + + @Test + fun `getState returns current state`() { + settings.prefix = "[" + settings.suffix = "]" + settings.separator = ":" + settings.fields = listOf(DecorationField.VERSION) + + val state = settings.state + + assertEquals("[", state.prefix) + assertEquals("]", state.suffix) + assertEquals(":", state.separator) + assertEquals(listOf("VERSION"), state.fields) + } + + @Test + fun `loadState updates settings`() { + val newState = + PyVenvManageSettings.SettingsState( + prefix = "<<", + suffix = ">>", + separator = " / ", + fields = listOf("IMPLEMENTATION", "VERSION"), + ) + + settings.loadState(newState) + + assertEquals("<<", settings.prefix) + assertEquals(">>", settings.suffix) + assertEquals(" / ", settings.separator) + assertEquals(listOf(DecorationField.IMPLEMENTATION, DecorationField.VERSION), settings.fields) + } + + @Test + fun `fields getter ignores invalid field names`() { + val state = + PyVenvManageSettings.SettingsState( + fields = listOf("VERSION", "INVALID_FIELD", "IMPLEMENTATION"), + ) + settings.loadState(state) + + assertEquals(listOf(DecorationField.VERSION, DecorationField.IMPLEMENTATION), settings.fields) + } + + @Test + fun `getInstance returns settings instance`() { + val application: Application = mockk(relaxed = true) + val mockSettings: PyVenvManageSettings = mockk(relaxed = true) + + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns application + every { application.getService(PyVenvManageSettings::class.java) } returns mockSettings + + val instance = PyVenvManageSettings.getInstance() + + assertNotNull(instance) + unmockkStatic(ApplicationManager::class) + } +} From 406d81d32946de3ee5a7c6b8424b59d26717ab4d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 05:43:38 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f6dd2b..4936a78 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ The official plugin page is at https://plugins.jetbrains.com/plugin/20536-pyvenv Open **Settings** -> **PyVenv Manage** to customize the virtual environment decoration display: - **Prefix/Suffix**: Characters surrounding the decoration (default: ` [` and `]`) -- **Separator**: Text between fields (default: ` - `) +- **Separator**: Text between fields (default: `-`) - **Fields**: Enable, disable, and reorder which information to display: - Python version (e.g., `3.14.2`) - Python implementation (e.g., `CPython`) From df5fc88be609fa5e2803664e68c7ada048832e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Sat, 3 Jan 2026 21:53:43 -0800 Subject: [PATCH 3/3] Fix Gradle transforms cache corruption in CI - Change plugin verifier cache path from transforms to ~/.pluginVerifier/ides - Add step to clean corrupted transform directories missing metadata.bin --- .github/workflows/check.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index c7bf4dc..510730b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -92,10 +92,18 @@ jobs: - name: Cache plugin verifier IDEs uses: actions/cache@v5 with: - path: ~/.gradle/caches/*/transforms/*/transformed/ + path: ~/.pluginVerifier/ides key: plugin-verifier-ides-${{ matrix.ide }}-${{ needs.build.outputs.platformVersion }} - restore-keys: | - plugin-verifier-ides-${{ matrix.ide }}- + - name: Clean corrupted Gradle transforms + run: | + find ~/.gradle/caches -type d -name "transforms" -exec sh -c ' + for dir in "$1"/*/; do + if [ -d "$dir" ] && [ ! -f "${dir}metadata.bin" ]; then + echo "Removing corrupted transform: $dir" + rm -rf "$dir" + fi + done + ' _ {} \; 2>/dev/null || true - name: Run verification run: ./gradlew verifyPlugin -PverifyIde=${{ matrix.ide }} - name: Collect verification result