diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 25ab9e5..ba3875e 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -80,6 +80,24 @@ jobs: version: v2.11.4 args: --tests=false --timeout=5m + goland-extension: + name: GoLand Extension + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + - name: Build and test plugin + working-directory: goland-extension + run: ./gradlew test buildPlugin verifyPlugin --no-daemon --no-configuration-cache + update-coverage: name: Update coverage report needs: test diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb96a3..85303a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Added a GoLand extension for running and debugging individual Testo suite tests from editor gutter actions. + ## [1.5.1] - 2026-06-17 ### Fixed @@ -89,6 +95,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial stable version. +[Unreleased]: https://github.com/ozontech/testo/compare/v1.5.1...HEAD [1.5.1]: https://github.com/ozontech/testo/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/ozontech/testo/compare/v1.4.0...v1.5.0 [1.4.0]: https://github.com/ozontech/testo/compare/v1.3.2...v1.4.0 diff --git a/goland-extension/.gitignore b/goland-extension/.gitignore new file mode 100644 index 0000000..02611af --- /dev/null +++ b/goland-extension/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +.intellijPlatform/ +build/ + diff --git a/goland-extension/README.md b/goland-extension/README.md new file mode 100644 index 0000000..e6df248 --- /dev/null +++ b/goland-extension/README.md @@ -0,0 +1,42 @@ +# GoLand Testo + +GoLand extension for Go testing framework testo. + +It provides gutter actions to run (and debug) either specific suite test or whole suite. + +It automatically determines a suite caller (a regular test which calls `testo.RunSuite`) +and runs it using GoLand's native Go Test run configuration. + +Whole-suite actions are shown on `testo.RunSuite(...)` calls. Individual suite test actions are +shown on suite test methods like `func (s *Suite) TestSomething(...)`. + +The extension supports suite callers with default, aliased and dot imports of `github.com/ozontech/testo`. +It also detects direct suite arguments like `Suite{}`, `&Suite{}` and `new(Suite)`. + +## Install from source + +Ensure you have installed: + +- [GoLand itself](https://www.jetbrains.com/go/download/) +- [JDK 21](https://adoptium.net/temurin/releases/) + +Then, do the rest: + +```bash +# Clone the repository +git clone https://github.com/ozontech/testo.git +cd testo/goland-extension + +# Build extension +./gradlew buildPlugin +``` + +You will get a `testo-goland-extension-0.1.0.zip` file in `build/distributions`. + +You can install it using the "Install Plugin from Disk..." action in GoLand: + +```text +Settings | Plugins | Gear icon | Install Plugin from Disk... +``` + +Point GoLand to the `.zip` file and restart the IDE when prompted. diff --git a/goland-extension/build.gradle.kts b/goland-extension/build.gradle.kts new file mode 100644 index 0000000..3d73f77 --- /dev/null +++ b/goland-extension/build.gradle.kts @@ -0,0 +1,36 @@ +import org.jetbrains.intellij.platform.gradle.TestFrameworkType + +plugins { + id("java") + id("org.jetbrains.intellij.platform") +} + +group = "com.ozontech.testo" +version = "0.1.0" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + options.release = 17 +} + +dependencies { + intellijPlatform { + goland("2026.1.4") + bundledPlugin("org.jetbrains.plugins.go") + testFramework(TestFrameworkType.Platform) + } + + testImplementation("junit:junit:4.13.2") +} + +intellijPlatform { + pluginConfiguration { + ideaVersion { + sinceBuild = "261" + } + } +} diff --git a/goland-extension/gradle.properties b/goland-extension/gradle.properties new file mode 100644 index 0000000..8db8141 --- /dev/null +++ b/goland-extension/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.configuration-cache=true + diff --git a/goland-extension/gradle/wrapper/gradle-wrapper.jar b/goland-extension/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/goland-extension/gradle/wrapper/gradle-wrapper.jar differ diff --git a/goland-extension/gradle/wrapper/gradle-wrapper.properties b/goland-extension/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/goland-extension/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/goland-extension/gradlew b/goland-extension/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/goland-extension/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# 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. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# 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\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# 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" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/goland-extension/gradlew.bat b/goland-extension/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/goland-extension/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/goland-extension/settings.gradle.kts b/goland-extension/settings.gradle.kts new file mode 100644 index 0000000..269739f --- /dev/null +++ b/goland-extension/settings.gradle.kts @@ -0,0 +1,25 @@ +import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +plugins { + id("org.jetbrains.intellij.platform.settings") version "2.18.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } + } +} + +rootProject.name = "testo-goland-extension" diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/TestoPsiUtil.java b/goland-extension/src/main/java/com/ozontech/testo/goland/TestoPsiUtil.java new file mode 100644 index 0000000..e355815 --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/TestoPsiUtil.java @@ -0,0 +1,15 @@ +package com.ozontech.testo.goland; + +import com.intellij.psi.PsiElement; +import com.ozontech.testo.goland.discovery.TestoTargetResolver; +import org.jetbrains.annotations.Nullable; + +public final class TestoPsiUtil { + private TestoPsiUtil() { + } + + @Nullable + public static TestoTarget targetAt(PsiElement element) { + return TestoTargetResolver.targetAt(element); + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/TestoTarget.java b/goland-extension/src/main/java/com/ozontech/testo/goland/TestoTarget.java new file mode 100644 index 0000000..48227cc --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/TestoTarget.java @@ -0,0 +1,82 @@ +package com.ozontech.testo.goland; + +import java.util.Objects; + +public final class TestoTarget { + private final String packageDir; + private final String packageImportPath; + private final String buildFlags; + private final String suiteCallerTest; + private final String suiteName; + private final String testName; + + public TestoTarget(String packageDir, String suiteCallerTest, String suiteName, String testName) { + this(packageDir, "", "", suiteCallerTest, suiteName, testName); + } + + public TestoTarget(String packageDir, String packageImportPath, String buildFlags, String suiteCallerTest, String suiteName, String testName) { + this.packageDir = packageDir; + this.packageImportPath = packageImportPath; + this.buildFlags = buildFlags; + this.suiteCallerTest = suiteCallerTest; + this.suiteName = suiteName; + this.testName = testName; + } + + public String getPackageDir() { + return packageDir; + } + + public String getPackageImportPath() { + return packageImportPath; + } + + public String getBuildFlags() { + return buildFlags; + } + + public String getSuiteCallerTest() { + return suiteCallerTest; + } + + public String getSuiteName() { + return suiteName; + } + + public String getTestName() { + return testName; + } + + public boolean isSuite() { + return testName == null || testName.isBlank(); + } + + public String configurationName() { + if (isSuite()) { + return "Testo " + suiteName; + } + + return "Testo " + suiteName + "." + testName; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TestoTarget that)) { + return false; + } + return Objects.equals(packageDir, that.packageDir) + && Objects.equals(packageImportPath, that.packageImportPath) + && Objects.equals(buildFlags, that.buildFlags) + && Objects.equals(suiteCallerTest, that.suiteCallerTest) + && Objects.equals(suiteName, that.suiteName) + && Objects.equals(testName, that.testName); + } + + @Override + public int hashCode() { + return Objects.hash(packageDir, packageImportPath, buildFlags, suiteCallerTest, suiteName, testName); + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoBuildTags.java b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoBuildTags.java new file mode 100644 index 0000000..f7beda6 --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoBuildTags.java @@ -0,0 +1,32 @@ +package com.ozontech.testo.goland.discovery; + +import com.goide.psi.GoFile; +import com.intellij.psi.PsiComment; + +final class GoBuildTags { + private GoBuildTags() { + } + + static String buildFlags(GoFile goFile) { + String buildFlags = goFile.getBuildFlags(); + if (buildFlags != null && !buildFlags.isBlank()) { + return buildFlags; + } + + PsiComment directive = goFile.getGoBuildDirectiveElement(); + if (directive == null) { + return ""; + } + + return simpleGoBuildTag(directive.getText()); + } + + static String simpleGoBuildTag(String directiveText) { + if (directiveText == null) { + return ""; + } + + String expression = directiveText.replaceFirst("^\\s*//go:build\\s+", "").trim(); + return expression.matches("[A-Za-z0-9_][A-Za-z0-9_\\-.]*") ? expression : ""; + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoTestNames.java b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoTestNames.java new file mode 100644 index 0000000..9212314 --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoTestNames.java @@ -0,0 +1,30 @@ +package com.ozontech.testo.goland.discovery; + +final class GoTestNames { + private GoTestNames() { + } + + static boolean isGoTestCaller(String name) { + return isGoTestName(name, "Test") || isGoTestName(name, "Example"); + } + + static boolean isGoTestMethod(String name) { + return isGoTestName(name, "Test"); + } + + private static boolean isGoTestName(String name, String prefix) { + if (name == null) { + return false; + } + + if (name.equals(prefix)) { + return true; + } + + if (!name.startsWith(prefix) || name.length() == prefix.length()) { + return false; + } + + return !Character.isLowerCase(name.charAt(prefix.length())); + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoTypeNames.java b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoTypeNames.java new file mode 100644 index 0000000..7958c3b --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/GoTypeNames.java @@ -0,0 +1,52 @@ +package com.ozontech.testo.goland.discovery; + +import com.goide.psi.GoMethodDeclaration; +import com.goide.psi.GoType; +import com.goide.psi.GoTypeSpec; + +import java.util.Objects; + +final class GoTypeNames { + private GoTypeNames() { + } + + static boolean sameTypeSpec(GoTypeSpec left, GoTypeSpec right) { + return left.isEquivalentTo(right) || Objects.equals(left.getQualifiedName(), right.getQualifiedName()); + } + + static boolean receiverMatchesSuiteName(GoMethodDeclaration method, GoTypeSpec suite) { + GoType receiverType = method.getReceiverType(); + return receiverType != null && simpleTypeName(receiverType.getText()).equals(suite.getName()); + } + + static String simpleTypeName(String text) { + if (text == null || text.isBlank()) { + return ""; + } + + String result = text.trim(); + while (result.endsWith(".")) { + result = result.substring(0, result.length() - 1).trim(); + } + + while (result.startsWith("*") || result.startsWith("&")) { + result = result.substring(1).trim(); + } + + if (result.endsWith("{}")) { + result = result.substring(0, result.length() - 2).trim(); + } + + int lastDot = result.lastIndexOf('.'); + if (lastDot >= 0) { + result = result.substring(lastDot + 1); + } + + int typeArgs = result.indexOf('['); + if (typeArgs >= 0) { + result = result.substring(0, typeArgs); + } + + return result; + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoPackageIndex.java b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoPackageIndex.java new file mode 100644 index 0000000..6733ac5 --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoPackageIndex.java @@ -0,0 +1,106 @@ +package com.ozontech.testo.goland.discovery; + +import com.goide.psi.GoFile; +import com.goide.psi.GoMethodDeclaration; +import com.goide.psi.GoTypeSpec; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.psi.util.CachedValueProvider; +import com.intellij.psi.util.CachedValuesManager; +import com.intellij.psi.util.PsiModificationTracker; +import com.intellij.psi.util.PsiTreeUtil; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +final class TestoPackageIndex { + private final List files; + + private TestoPackageIndex(List files) { + this.files = files; + } + + @Nullable + static TestoPackageIndex forElement(PsiElement element) { + if (element == null) { + return null; + } + + PsiFile containingFile = element.getContainingFile(); + if (!(containingFile instanceof GoFile goFile)) { + return null; + } + + return forFile(goFile); + } + + static TestoPackageIndex forFile(GoFile goFile) { + return CachedValuesManager.getCachedValue(goFile, () -> + CachedValueProvider.Result.create(compute(goFile), PsiModificationTracker.MODIFICATION_COUNT) + ); + } + + List files() { + return files; + } + + @Nullable + GoTypeSpec findType(String name) { + if (name == null || name.isBlank()) { + return null; + } + + for (GoFile goFile : files) { + for (GoTypeSpec typeSpec : PsiTreeUtil.findChildrenOfType(goFile, GoTypeSpec.class)) { + if (name.equals(typeSpec.getName())) { + return typeSpec; + } + } + } + + return null; + } + + boolean hasSuiteTest(GoTypeSpec suite) { + for (GoFile goFile : files) { + for (GoMethodDeclaration method : goFile.getMethods()) { + if (!GoTestNames.isGoTestMethod(method.getName())) { + continue; + } + + GoTypeSpec receiverType = method.resolveTypeSpec(); + if ((receiverType != null && GoTypeNames.sameTypeSpec(receiverType, suite)) + || GoTypeNames.receiverMatchesSuiteName(method, suite)) { + return true; + } + } + } + + return false; + } + + private static TestoPackageIndex compute(GoFile goFile) { + VirtualFile dir = goFile.getVirtualFile() == null ? null : goFile.getVirtualFile().getParent(); + if (dir == null) { + return new TestoPackageIndex(List.of(goFile)); + } + + PsiManager psiManager = PsiManager.getInstance(goFile.getProject()); + List packageFiles = Arrays.stream(dir.getChildren()) + .filter(TestoPackageIndex::isGoFile) + .map(psiManager::findFile) + .filter(GoFile.class::isInstance) + .map(GoFile.class::cast) + .filter(candidate -> Objects.equals(candidate.getPackageName(), goFile.getPackageName())) + .toList(); + return new TestoPackageIndex(packageFiles); + } + + private static boolean isGoFile(VirtualFile file) { + return file != null && file.getName().endsWith(".go"); + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoRunSuiteCallMatcher.java b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoRunSuiteCallMatcher.java new file mode 100644 index 0000000..540af73 --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoRunSuiteCallMatcher.java @@ -0,0 +1,196 @@ +package com.ozontech.testo.goland.discovery; + +import com.goide.psi.GoArgumentList; +import com.goide.psi.GoBuiltinArgumentList; +import com.goide.psi.GoBuiltinCallExpr; +import com.goide.psi.GoCallExpr; +import com.goide.psi.GoCompositeLit; +import com.goide.psi.GoExpression; +import com.goide.psi.GoFile; +import com.goide.psi.GoImportSpec; +import com.goide.psi.GoParenthesesExpr; +import com.goide.psi.GoReferenceExpression; +import com.goide.psi.GoType; +import com.goide.psi.GoTypeReferenceExpression; +import com.goide.psi.GoTypeSpec; +import com.goide.psi.GoUnaryExpr; +import com.intellij.psi.PsiElement; +import com.intellij.psi.ResolveState; +import com.intellij.psi.util.PsiTreeUtil; +import org.jetbrains.annotations.Nullable; + +final class TestoRunSuiteCallMatcher { + private static final String TESTO_IMPORT_PATH = "github.com/ozontech/testo"; + private static final String TESTO_DEFAULT_IMPORT_NAME = "testo"; + + private TestoRunSuiteCallMatcher() { + } + + @Nullable + static GoTypeSpec suiteFromRunSuiteCall(GoCallExpr call, GoFile goFile, TestoPackageIndex packageIndex) { + if (!isTestoRunSuiteCall(call, goFile)) { + return null; + } + + GoArgumentList argumentList = call.getArgumentList(); + if (argumentList == null) { + return null; + } + + for (GoExpression argument : argumentList.getExpressionList()) { + GoTypeSpec argumentType = suiteTypeFromExpression(argument); + if (argumentType != null) { + return argumentType; + } + + GoTypeSpec fallbackType = packageIndex.findType(suiteNameFromExpression(argument)); + if (fallbackType != null) { + return fallbackType; + } + } + + return null; + } + + private static boolean isTestoRunSuiteCall(GoCallExpr call, GoFile goFile) { + GoExpression expression = call.getExpression(); + if (!(expression instanceof GoReferenceExpression reference)) { + return false; + } + + PsiElement identifier = reference.getIdentifier(); + if (identifier == null || !"RunSuite".equals(identifier.getText())) { + return false; + } + + PsiElement qualifier = reference.getQualifier(); + if (qualifier == null) { + return hasDotTestoImport(goFile); + } + + String qualifierName = GoTypeNames.simpleTypeName(qualifier.getText()); + String importName = testoImportName(goFile); + return qualifierName.equals(importName) || (TESTO_DEFAULT_IMPORT_NAME.equals(qualifierName) && hasTestoImport(goFile)); + } + + @Nullable + private static GoTypeSpec suiteTypeFromExpression(GoExpression expression) { + GoExpression unwrapped = unwrapExpression(expression); + + if (unwrapped instanceof GoCompositeLit compositeLit) { + GoTypeReferenceExpression typeReferenceExpression = compositeLit.getTypeReferenceExpression(); + if (typeReferenceExpression != null) { + PsiElement resolved = typeReferenceExpression.getReference().resolve(); + return resolved instanceof GoTypeSpec typeSpec ? typeSpec : null; + } + + return resolveType(compositeLit.getType()); + } + + if (unwrapped instanceof GoBuiltinCallExpr builtinCall + && builtinCall.getExpression() != null + && "new".equals(builtinCall.getExpression().getText())) { + GoBuiltinArgumentList argumentList = builtinCall.getBuiltinArgumentList(); + if (argumentList != null) { + return resolveType(argumentList.getType()); + } + } + + GoType goType = unwrapped.getGoType(ResolveState.initial()); + return resolveType(goType); + } + + @Nullable + private static GoTypeSpec resolveType(@Nullable GoType goType) { + if (goType == null) { + return null; + } + + PsiElement resolved = goType.getTypeReferenceExpression() == null + ? null + : goType.getTypeReferenceExpression().getReference().resolve(); + return resolved instanceof GoTypeSpec typeSpec ? typeSpec : null; + } + + private static GoExpression unwrapExpression(GoExpression expression) { + GoExpression current = expression; + while (true) { + if (current instanceof GoParenthesesExpr parentheses && parentheses.getExpression() != null) { + current = parentheses.getExpression(); + continue; + } + + if (current instanceof GoUnaryExpr unary && isAddressOfExpression(unary) && unary.getExpression() != null) { + current = unary.getExpression(); + continue; + } + + return current; + } + } + + private static boolean isAddressOfExpression(GoUnaryExpr unary) { + PsiElement operator = unary.getOperator(); + if (operator != null && "&".equals(operator.getText())) { + return true; + } + + return unary.getText().startsWith("&"); + } + + private static String suiteNameFromExpression(GoExpression expression) { + GoExpression unwrapped = unwrapExpression(expression); + + if (unwrapped instanceof GoCompositeLit compositeLit) { + GoTypeReferenceExpression typeReferenceExpression = compositeLit.getTypeReferenceExpression(); + if (typeReferenceExpression != null) { + return GoTypeNames.simpleTypeName(typeReferenceExpression.getText()); + } + + return GoTypeNames.simpleTypeName(compositeLit.getType() == null ? "" : compositeLit.getType().getText()); + } + + if (unwrapped instanceof GoBuiltinCallExpr builtinCall + && builtinCall.getExpression() != null + && "new".equals(builtinCall.getExpression().getText())) { + GoBuiltinArgumentList argumentList = builtinCall.getBuiltinArgumentList(); + return GoTypeNames.simpleTypeName(argumentList == null || argumentList.getType() == null ? "" : argumentList.getType().getText()); + } + + return GoTypeNames.simpleTypeName(unwrapped.getText()); + } + + @Nullable + private static String testoImportName(GoFile goFile) { + for (GoImportSpec importSpec : PsiTreeUtil.findChildrenOfType(goFile, GoImportSpec.class)) { + if (!TESTO_IMPORT_PATH.equals(importSpec.getPath()) || importSpec.isDot()) { + continue; + } + + String alias = importSpec.getAlias(); + return alias == null || alias.isBlank() ? TESTO_DEFAULT_IMPORT_NAME : alias; + } + + return null; + } + + private static boolean hasDotTestoImport(GoFile goFile) { + for (GoImportSpec importSpec : PsiTreeUtil.findChildrenOfType(goFile, GoImportSpec.class)) { + if (TESTO_IMPORT_PATH.equals(importSpec.getPath()) && importSpec.isDot()) { + return true; + } + } + + return false; + } + + private static boolean hasTestoImport(GoFile goFile) { + for (GoImportSpec importSpec : PsiTreeUtil.findChildrenOfType(goFile, GoImportSpec.class)) { + if (TESTO_IMPORT_PATH.equals(importSpec.getPath())) { + return true; + } + } + + return false; + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoTargetResolver.java b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoTargetResolver.java new file mode 100644 index 0000000..8d86c3c --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/discovery/TestoTargetResolver.java @@ -0,0 +1,148 @@ +package com.ozontech.testo.goland.discovery; + +import com.goide.psi.GoCallExpr; +import com.goide.psi.GoFile; +import com.goide.psi.GoFunctionDeclaration; +import com.goide.psi.GoMethodDeclaration; +import com.goide.psi.GoReferenceExpression; +import com.goide.psi.GoTypeSpec; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.util.PsiTreeUtil; +import com.ozontech.testo.goland.TestoTarget; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; + +public final class TestoTargetResolver { + private TestoTargetResolver() { + } + + @Nullable + public static TestoTarget targetAt(PsiElement element) { + if (element == null) { + return null; + } + + TestoPackageIndex packageIndex = TestoPackageIndex.forElement(element); + if (packageIndex == null) { + return null; + } + + TestoTarget runSuiteTarget = runSuiteCallTarget(element, packageIndex); + if (runSuiteTarget != null) { + return runSuiteTarget; + } + + GoMethodDeclaration method = PsiTreeUtil.getParentOfType(element, GoMethodDeclaration.class, false); + if (method != null && isDeclarationIdentifier(element, method.getIdentifier())) { + return methodTarget(method, packageIndex); + } + + return null; + } + + @Nullable + private static TestoTarget methodTarget(GoMethodDeclaration method, TestoPackageIndex packageIndex) { + String testName = method.getName(); + if (!GoTestNames.isGoTestMethod(testName)) { + return null; + } + + GoTypeSpec suite = method.resolveTypeSpec(); + if (suite == null) { + String receiverName = GoTypeNames.simpleTypeName(method.getReceiverType() == null ? "" : method.getReceiverType().getText()); + suite = packageIndex.findType(receiverName); + } + if (suite == null || suite.getName() == null) { + return null; + } + + SuiteCaller caller = findSuiteCaller(suite, packageIndex); + return caller == null ? null : targetFor(caller, suite, testName); + } + + @Nullable + private static TestoTarget runSuiteCallTarget(PsiElement element, TestoPackageIndex packageIndex) { + GoReferenceExpression reference = PsiTreeUtil.getParentOfType(element, GoReferenceExpression.class, false); + if (reference == null || !isDeclarationIdentifier(element, reference.getIdentifier())) { + return null; + } + + GoCallExpr call = PsiTreeUtil.getParentOfType(reference, GoCallExpr.class, false); + if (call == null || call.getExpression() != reference) { + return null; + } + + GoFunctionDeclaration function = PsiTreeUtil.getParentOfType(call, GoFunctionDeclaration.class); + if (function == null || !GoTestNames.isGoTestCaller(function.getName())) { + return null; + } + + PsiFile containingFile = function.getContainingFile(); + if (!(containingFile instanceof GoFile goFile) || !isGoTestFile(goFile)) { + return null; + } + + GoTypeSpec suite = TestoRunSuiteCallMatcher.suiteFromRunSuiteCall(call, goFile, packageIndex); + if (suite == null || suite.getName() == null || !packageIndex.hasSuiteTest(suite)) { + return null; + } + + SuiteCaller caller = suiteCaller(function, goFile); + return caller == null ? null : targetFor(caller, suite, null); + } + + @Nullable + private static SuiteCaller findSuiteCaller(GoTypeSpec suite, TestoPackageIndex packageIndex) { + for (GoFile goFile : packageIndex.files()) { + if (!isGoTestFile(goFile)) { + continue; + } + + for (GoFunctionDeclaration function : goFile.getFunctions()) { + if (!GoTestNames.isGoTestCaller(function.getName())) { + continue; + } + + Collection calls = PsiTreeUtil.findChildrenOfType(function.getBlock(), GoCallExpr.class); + for (GoCallExpr call : calls) { + GoTypeSpec callSuite = TestoRunSuiteCallMatcher.suiteFromRunSuiteCall(call, goFile, packageIndex); + if (callSuite != null && GoTypeNames.sameTypeSpec(callSuite, suite)) { + return suiteCaller(function, goFile); + } + } + } + } + + return null; + } + + @Nullable + private static SuiteCaller suiteCaller(GoFunctionDeclaration function, GoFile goFile) { + VirtualFile dir = goFile.getVirtualFile() == null ? null : goFile.getVirtualFile().getParent(); + if (dir == null) { + return null; + } + + return new SuiteCaller(dir.getPath(), goFile.getImportPath(false), GoBuildTags.buildFlags(goFile), function.getName()); + } + + private static TestoTarget targetFor(SuiteCaller caller, GoTypeSpec suite, @Nullable String testName) { + return new TestoTarget(caller.packageDir(), caller.packageImportPath(), caller.buildFlags(), caller.name(), suite.getName(), testName); + } + + private static boolean isDeclarationIdentifier(PsiElement element, @Nullable PsiElement identifier) { + return identifier != null + && (element == identifier || element.getParent() == identifier || identifier.getTextRange().contains(element.getTextRange())); + } + + private static boolean isGoTestFile(GoFile file) { + VirtualFile virtualFile = file.getVirtualFile(); + return virtualFile != null && virtualFile.getName().endsWith("_test.go"); + } + + private record SuiteCaller(String packageDir, String packageImportPath, String buildFlags, String name) { + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/marker/TestoRunLineMarkerContributor.java b/goland-extension/src/main/java/com/ozontech/testo/goland/marker/TestoRunLineMarkerContributor.java new file mode 100644 index 0000000..e25deb8 --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/marker/TestoRunLineMarkerContributor.java @@ -0,0 +1,26 @@ +package com.ozontech.testo.goland.marker; + +import com.intellij.execution.lineMarker.ExecutorAction; +import com.intellij.execution.lineMarker.RunLineMarkerContributor; +import com.intellij.icons.AllIcons; +import com.intellij.psi.PsiElement; +import com.ozontech.testo.goland.TestoPsiUtil; +import com.ozontech.testo.goland.TestoTarget; +import org.jetbrains.annotations.Nullable; + +public final class TestoRunLineMarkerContributor extends RunLineMarkerContributor { + @Nullable + @Override + public Info getInfo(PsiElement element) { + TestoTarget target = TestoPsiUtil.targetAt(element); + if (target == null) { + return null; + } + + String tooltip = target.isSuite() + ? "Run Testo suite " + target.getSuiteName() + : "Run Testo test " + target.getSuiteName() + "." + target.getTestName(); + + return new Info(AllIcons.RunConfigurations.TestState.Run, ExecutorAction.getActions(0), ignored -> tooltip); + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/run/TestoGoTestConfiguration.java b/goland-extension/src/main/java/com/ozontech/testo/goland/run/TestoGoTestConfiguration.java new file mode 100644 index 0000000..aa9e34a --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/run/TestoGoTestConfiguration.java @@ -0,0 +1,63 @@ +package com.ozontech.testo.goland.run; + +import com.goide.execution.GoBuildingRunConfiguration; +import com.goide.execution.testing.GoTestRunConfiguration; +import com.goide.execution.testing.frameworks.gotest.GotestFramework; +import com.ozontech.testo.goland.TestoTarget; + +import java.util.Objects; + +public final class TestoGoTestConfiguration { + private TestoGoTestConfiguration() { + } + + public static void applyTo(GoTestRunConfiguration configuration, TestoTarget target) { + configuration.setName(target.configurationName()); + if (target.getPackageImportPath() == null || target.getPackageImportPath().isBlank()) { + configuration.setKind(GoBuildingRunConfiguration.Kind.DIRECTORY); + configuration.setDirectoryPath(target.getPackageDir()); + } else { + configuration.setKind(GoBuildingRunConfiguration.Kind.PACKAGE); + configuration.setPackage(target.getPackageImportPath()); + } + configuration.setDirectoryPath(target.getPackageDir()); + configuration.setWorkingDirectory(target.getPackageDir()); + configuration.setGoToolParams(goToolParamsFor(target)); + configuration.setPattern(patternFor(target)); + configuration.setParams(paramsFor(target)); + configuration.setTestFramework(GotestFramework.INSTANCE); + } + + public static boolean isForTarget(GoTestRunConfiguration configuration, TestoTarget target) { + GoBuildingRunConfiguration.Kind expectedKind = target.getPackageImportPath() == null || target.getPackageImportPath().isBlank() + ? GoBuildingRunConfiguration.Kind.DIRECTORY + : GoBuildingRunConfiguration.Kind.PACKAGE; + + return configuration.getKind() == expectedKind + && Objects.equals(configuration.getDirectoryPath(), target.getPackageDir()) + && Objects.equals(configuration.getPackage(), target.getPackageImportPath()) + && Objects.equals(configuration.getGoToolParams(), goToolParamsFor(target)) + && Objects.equals(configuration.getPattern(), patternFor(target)) + && Objects.equals(configuration.getParams(), paramsFor(target)); + } + + public static String patternFor(TestoTarget target) { + return "^" + target.getSuiteCallerTest() + "$/^" + target.getSuiteName() + "$"; + } + + public static String paramsFor(TestoTarget target) { + if (target.isSuite()) { + return ""; + } + + return "-testo.m ^" + target.getTestName() + "$"; + } + + public static String goToolParamsFor(TestoTarget target) { + if (target.getBuildFlags() == null || target.getBuildFlags().isBlank()) { + return ""; + } + + return "-tags " + target.getBuildFlags(); + } +} diff --git a/goland-extension/src/main/java/com/ozontech/testo/goland/run/TestoRunConfigurationProducer.java b/goland-extension/src/main/java/com/ozontech/testo/goland/run/TestoRunConfigurationProducer.java new file mode 100644 index 0000000..d4a60b1 --- /dev/null +++ b/goland-extension/src/main/java/com/ozontech/testo/goland/run/TestoRunConfigurationProducer.java @@ -0,0 +1,45 @@ +package com.ozontech.testo.goland.run; + +import com.goide.execution.testing.GoTestRunConfiguration; +import com.goide.execution.testing.GoTestRunConfigurationType; +import com.intellij.execution.actions.ConfigurationContext; +import com.intellij.execution.actions.LazyRunConfigurationProducer; +import com.intellij.execution.configurations.ConfigurationFactory; +import com.intellij.openapi.util.Ref; +import com.intellij.psi.PsiElement; +import com.ozontech.testo.goland.TestoPsiUtil; +import com.ozontech.testo.goland.TestoTarget; +import org.jetbrains.annotations.NotNull; + +public final class TestoRunConfigurationProducer extends LazyRunConfigurationProducer { + @Override + public @NotNull ConfigurationFactory getConfigurationFactory() { + return GoTestRunConfigurationType.getInstance().getFactory(); + } + + @Override + protected boolean setupConfigurationFromContext( + @NotNull GoTestRunConfiguration configuration, + @NotNull ConfigurationContext context, + @NotNull Ref sourceElement + ) { + PsiElement element = context.getPsiLocation(); + TestoTarget target = TestoPsiUtil.targetAt(element); + if (target == null) { + return false; + } + + TestoGoTestConfiguration.applyTo(configuration, target); + sourceElement.set(element); + return true; + } + + @Override + public boolean isConfigurationFromContext( + @NotNull GoTestRunConfiguration configuration, + @NotNull ConfigurationContext context + ) { + TestoTarget target = TestoPsiUtil.targetAt(context.getPsiLocation()); + return target != null && TestoGoTestConfiguration.isForTarget(configuration, target); + } +} diff --git a/goland-extension/src/main/resources/META-INF/plugin.xml b/goland-extension/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..1fa3463 --- /dev/null +++ b/goland-extension/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,16 @@ + + com.ozontech.testo.helper + Testo Framework Helper + Ozon Tech + + + com.intellij.modules.platform + org.jetbrains.plugins.go + + + + + + diff --git a/goland-extension/src/test/java/com/ozontech/testo/goland/TestoPsiUtilTest.java b/goland-extension/src/test/java/com/ozontech/testo/goland/TestoPsiUtilTest.java new file mode 100644 index 0000000..079cd2c --- /dev/null +++ b/goland-extension/src/test/java/com/ozontech/testo/goland/TestoPsiUtilTest.java @@ -0,0 +1,163 @@ +package com.ozontech.testo.goland; + +import com.intellij.psi.PsiElement; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; + +public final class TestoPsiUtilTest extends BasePlatformTestCase { + public void testFindsSuiteTestWithAliasedTestoImport() { + myFixture.configureByText("suite_test.go", """ + //go:build example + + package sample + + import ( + "testing" + tst "github.com/ozontech/testo" + ) + + type UserSuite struct{} + + func TestUserSuite(t *testing.T) { + tst.RunSuite(t, &UserSuite{}) + } + + func (s *UserSuite) TestCreatesUser() {} + """); + + TestoTarget target = targetAtCaret(); + + assertNotNull(target); + assertEquals("TestUserSuite", target.getSuiteCallerTest()); + assertEquals("UserSuite", target.getSuiteName()); + assertEquals("TestCreatesUser", target.getTestName()); + assertEquals("example", target.getBuildFlags()); + } + + public void testFindsSuiteRunSuiteCallWithDotTestoImport() { + myFixture.configureByText("suite_test.go", """ + package sample + + import ( + "testing" + . "github.com/ozontech/testo" + ) + + type PaymentSuite struct{} + + func TestPaymentSuite(t *testing.T) { + RunSuite(t, new(PaymentSuite)) + } + + func (s *PaymentSuite) TestChargesCard() {} + """); + + TestoTarget target = targetAtCaret(); + + assertNotNull(target); + assertEquals("TestPaymentSuite", target.getSuiteCallerTest()); + assertEquals("PaymentSuite", target.getSuiteName()); + assertNull(target.getTestName()); + } + + public void testFindsRunSuiteCallWhenSuiteDeclarationIsOutsideTestFile() { + myFixture.addFileToProject("suite.go", """ + package sample + + type InventorySuite struct{} + """); + + myFixture.configureByText("suite_test.go", """ + package sample + + import ( + "testing" + "github.com/ozontech/testo" + ) + + func TestInventorySuite(t *testing.T) { + testo.RunSuite(t, InventorySuite{}) + } + + func (s *InventorySuite) TestListsItems() {} + """); + + TestoTarget target = targetAtCaret(); + + assertNotNull(target); + assertEquals("TestInventorySuite", target.getSuiteCallerTest()); + assertEquals("InventorySuite", target.getSuiteName()); + assertNull(target.getTestName()); + } + + public void testIgnoresSuiteCallerFunctionToAvoidGoLandGutterConflicts() { + myFixture.configureByText("suite_test.go", """ + package sample + + import ( + "testing" + "github.com/ozontech/testo" + ) + + type InventorySuite struct{} + + func TestInventorySuite(t *testing.T) { + testo.RunSuite(t, InventorySuite{}) + } + + func (s *InventorySuite) TestListsItems() {} + """); + + assertNull(targetAtCaret()); + } + + public void testIgnoresSuiteDeclarationToAvoidGoLandGutterConflicts() { + myFixture.addFileToProject("suite_test.go", """ + package sample + + import ( + "testing" + "github.com/ozontech/testo" + ) + + func TestInventorySuite(t *testing.T) { + testo.RunSuite(t, InventorySuite{}) + } + + func (s *InventorySuite) TestListsItems() {} + """); + + myFixture.configureByText("suite.go", """ + package sample + + type InventorySuite struct{} + """); + + assertNull(targetAtCaret()); + } + + public void testIgnoresNonTestMethods() { + myFixture.configureByText("suite_test.go", """ + package sample + + import ( + "testing" + "github.com/ozontech/testo" + ) + + type UserSuite struct{} + + func TestUserSuite(t *testing.T) { + testo.RunSuite(t, &UserSuite{}) + } + + func (s *UserSuite) Helper() {} + """); + + assertNull(targetAtCaret()); + } + + private TestoTarget targetAtCaret() { + PsiElement element = myFixture.getFile().findElementAt(myFixture.getCaretOffset()); + return TestoPsiUtil.targetAt(element); + } +} diff --git a/goland-extension/src/test/java/com/ozontech/testo/goland/run/TestoGoTestConfigurationTest.java b/goland-extension/src/test/java/com/ozontech/testo/goland/run/TestoGoTestConfigurationTest.java new file mode 100644 index 0000000..92bd340 --- /dev/null +++ b/goland-extension/src/test/java/com/ozontech/testo/goland/run/TestoGoTestConfigurationTest.java @@ -0,0 +1,33 @@ +package com.ozontech.testo.goland.run; + +import com.ozontech.testo.goland.TestoTarget; +import junit.framework.TestCase; + +public final class TestoGoTestConfigurationTest extends TestCase { + public void testBuildsSuitePatternWithoutExtraParams() { + TestoTarget target = new TestoTarget("/repo/pkg", "TestUserSuite", "UserSuite", null); + + assertEquals("^TestUserSuite$/^UserSuite$", TestoGoTestConfiguration.patternFor(target)); + assertEquals("", TestoGoTestConfiguration.paramsFor(target)); + } + + public void testBuildsSuiteTestPatternAndTestoMethodFilter() { + TestoTarget target = new TestoTarget("/repo/pkg", "TestUserSuite", "UserSuite", "TestCreatesUser"); + + assertEquals("^TestUserSuite$/^UserSuite$", TestoGoTestConfiguration.patternFor(target)); + assertEquals("-testo.m ^TestCreatesUser$", TestoGoTestConfiguration.paramsFor(target)); + } + + public void testBuildsGoToolParamsForBuildFlags() { + TestoTarget target = new TestoTarget( + "/repo/examples/01_suite", + "github.com/ozontech/testo/examples/01_suite", + "example", + "Test", + "Suite", + "TestMath" + ); + + assertEquals("-tags example", TestoGoTestConfiguration.goToolParamsFor(target)); + } +}