diff --git a/.gitignore b/.gitignore index 3ca746c1..e4c47b56 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ Plugins/Gradle/gradle/wrapper/ Plugins/Gradle/gradlew Plugins/Gradle/gradlew.bat .intellijPlatform/ +Plugins/IntelliJ/.kotlin \ No newline at end of file diff --git a/Plugins/IntelliJ/CHANGELOG.md b/Plugins/IntelliJ/CHANGELOG.md index c4bfe973..697fabe6 100644 --- a/Plugins/IntelliJ/CHANGELOG.md +++ b/Plugins/IntelliJ/CHANGELOG.md @@ -2,6 +2,8 @@ # Android Testify - IntelliJ Platform Plugin - Change Log +## [Unreleased] + ## [3.0.0] - Added support for Support Android Studio Narwhal | 2025.1.1 Canary 9 | 251.+ diff --git a/Plugins/IntelliJ/gradle.properties b/Plugins/IntelliJ/gradle.properties index b088e262..dc752f95 100644 --- a/Plugins/IntelliJ/gradle.properties +++ b/Plugins/IntelliJ/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = dev.testify pluginName = Android Testify - Screenshot Instrumentation Tests pluginRepositoryUrl = https://github.com/ndtp/android-testify/tree/main/Plugins/IntelliJ # SemVer format -> https://semver.org -pluginVersion = 3.0.0 +pluginVersion = 4.0.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 242 @@ -13,8 +13,8 @@ pluginUntilBuild = 252.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = AI -# Narwhal 2025.1.1 Canary 9 -platformVersion = 2025.1.1.9 +# Otter 2025.2.1 +platformVersion = 2025.2.1.1 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt new file mode 100644 index 00000000..447dbe25 --- /dev/null +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt @@ -0,0 +1,72 @@ +package dev.testify + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.search.PsiShortNamesCache + +fun findClassByName(className: String, project: Project, packageName: String? = null): PsiClass? { + val psiShortNamesCache = PsiShortNamesCache.getInstance(project) + val classes = psiShortNamesCache.getClassesByName(className, GlobalSearchScope.projectScope(project)) + return if (packageName != null) { + classes.firstOrNull { psiClass -> + val pkg = psiClass.qualifiedName?.substringBeforeLast(".") + packageName == pkg + } + } else { + classes.firstOrNull() + } +} + +fun findMethod(methodName: String, psiClass: PsiClass): PsiMethod? { + return psiClass.findMethodsByName(methodName, false).firstOrNull() +} + +fun findTestifyMethod(imageFile: VirtualFile, project: Project): PsiMethod? { + if (imageFile.path.contains("/androidTest").not()) return null + imageFile.nameWithoutExtension.let { imageName -> + val parts = imageName.split("_") + if (parts.size == 2) { + val (className, methodName) = parts + findClassByName(className, project)?.let { psiClass -> + return findMethod(methodName, psiClass) + } + } + } + return null +} + +/** + * Input: /Users/danjette/dev/android-testify/Samples/Paparazzi/src/test/snapshots/images/dev.testify.samples.paparazzi.ui.common.composables_CastMemberScreenshotTest_b.png + */ +fun findPaparazziMethod(imageFile: VirtualFile, project: Project): PsiMethod? { + if (imageFile.path.contains("/test").not()) return null + imageFile.nameWithoutExtension.let { imageName -> + // imageName = composables_CastMemberScreenshotTest_default + val parts = imageName.split("_") + if (parts.size == 3) { + val (packageName, className, methodName) = parts + // _ = composables + // className = CastMemberScreenshotTest + // methodName = default + findClassByName(className, project, packageName)?.let { psiClass -> + return findMethod(methodName, psiClass) + } + } + } + return null +} + +fun findPreviewMethod(imageFile: VirtualFile, project: Project): PsiMethod? { + if (imageFile.path.contains("/screenshotTest").not()) return null + val className = imageFile.parent.name + imageFile.nameWithoutExtension.let { imageName -> + val methodName = imageName.split("_").first() + findClassByName(className, project)?.let { psiClass -> + return findMethod(methodName, psiClass) + } + } + return null +} diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 1e926f36..14081da2 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -34,10 +34,13 @@ import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION_LEGACY +import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.annotations.KaAnnotation import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.name import org.jetbrains.kotlin.idea.util.projectStructure.module +import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile @@ -45,7 +48,6 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.psiUtil.parents import java.util.concurrent.Callable -private const val ANDROID_TEST_MODULE = ".androidTest" private const val PROJECT_FORMAT = "%1s." val AnActionEvent.moduleName: String @@ -56,7 +58,12 @@ val AnActionEvent.moduleName: String val moduleName = ktFile?.module?.name ?: "" val modules = moduleName.removePrefix(PROJECT_FORMAT.format(projectName)) - val psiModule = modules.removeSuffix(ANDROID_TEST_MODULE) + + val suffix = TestFlavor.entries.find { flavor -> + modules.endsWith(flavor.moduleFilter) + }?.moduleFilter.orEmpty() + val psiModule = modules.removeSuffix(suffix) + val gradleModule = psiModule.replace(".", ":") println("$modules $psiModule $gradleModule") @@ -85,20 +92,19 @@ val PsiElement.methodName: String return methodName ?: "unknown" } -val KtNamedFunction.testifyMethodInvocationPath: String - get() { - return ApplicationManager.getApplication().executeOnPooledThread(Callable { - ReadAction.compute { - analyze(this@testifyMethodInvocationPath) { - val functionSymbol = this@testifyMethodInvocationPath.symbol - val className = - (functionSymbol.containingSymbol as? KaClassSymbol)?.classId?.asSingleFqName()?.asString() - val methodName = functionSymbol.name?.asString() - "$className#$methodName" - } +fun KtNamedFunction.testifyMethodInvocationPath(testFlavor: TestFlavor): String { + return ApplicationManager.getApplication().executeOnPooledThread(Callable { + ReadAction.compute { + analyze(this@testifyMethodInvocationPath) { + val functionSymbol = this@testifyMethodInvocationPath.symbol + val className = + (functionSymbol.containingSymbol as? KaClassSymbol)?.classId?.asSingleFqName()?.asString() + val methodName = functionSymbol.name?.asString() + testFlavor.methodInvocationPath(className, methodName) } - }).get() ?: "unknown" - } + } + }).get() ?: "unknown" +} val KtClass.testifyClassInvocationPath: String get() { @@ -113,22 +119,27 @@ val KtClass.testifyClassInvocationPath: String } val KtNamedFunction.hasScreenshotAnnotation: Boolean - get() { - return ApplicationManager.getApplication().executeOnPooledThread(Callable { - ReadAction.compute { - analyze(this@hasScreenshotAnnotation) { - this@hasScreenshotAnnotation.symbol - .annotations - .any { - it.classId?.asSingleFqName()?.asString() in listOf( - SCREENSHOT_INSTRUMENTATION, - SCREENSHOT_INSTRUMENTATION_LEGACY - ) - } - } + get() = hasQualifyingAnnotation(setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY)) + +fun KtNamedFunction.hasQualifyingAnnotation(annotationClassIds: Set): Boolean { + return ApplicationManager.getApplication().executeOnPooledThread(Callable { + ReadAction.compute { + analyze(this@hasQualifyingAnnotation) { + this@hasQualifyingAnnotation.symbol + .annotations + .any { + it.classId?.asSingleFqName()?.asString() in annotationClassIds + } } - }).get() ?: false + } + }).get() ?: false +} + +fun KaSession.getQualifyingAnnotation(function: KtNamedFunction, annotationClassIds: Set): KaAnnotation? { + return function.symbol.annotations.firstOrNull { annotation -> + annotationClassIds.any { FqName(it) == annotation.classId?.asSingleFqName() } } +} fun AnActionEvent.findScreenshotAnnotatedFunction(): KtNamedFunction? { val psiFile = this.getData(PlatformDataKeys.PSI_FILE) ?: return null diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt new file mode 100644 index 00000000..e972b38d --- /dev/null +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -0,0 +1,97 @@ +package dev.testify + +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMethod +import dev.testify.extensions.PAPARAZZI_ANNOTATION +import dev.testify.extensions.PREVIEW_ANNOTATION +import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION +import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION_LEGACY +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtNamedFunction + +typealias FindSourceMethod = (imageFile: VirtualFile, project: Project) -> PsiMethod? + +data class GradleCommand( + val argumentFlag: String, + val classCommand: String, + val methodCommand: String +) + +enum class TestFlavor( + val srcRoot: String, + val moduleFilter: String, + val qualifyingAnnotations: Set, + val isClassEligible: Boolean, + val methodInvocationPath: (className: String?, methodName: String?) -> String, + val testGradleCommands: GradleCommand, + val recordGradleCommands: GradleCommand, + val findSourceMethod: FindSourceMethod +) { + Testify( + srcRoot = "androidTest", + moduleFilter = ".androidTest", + qualifyingAnnotations = setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY), + isClassEligible = true, + methodInvocationPath = { className, methodName -> "$className#$methodName" }, + testGradleCommands = GradleCommand( + argumentFlag = "-PtestClass=$1", + classCommand = "screenshotTest", + methodCommand = "screenshotTest" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "-PtestClass=$1", + classCommand = "screenshotRecord", + methodCommand = "screenshotRecord" + ), + findSourceMethod = ::findTestifyMethod + ), + + Paparazzi( + srcRoot = "test", + moduleFilter = ".unitTest", + qualifyingAnnotations = setOf(PAPARAZZI_ANNOTATION), + isClassEligible = true, + methodInvocationPath = { className, methodName -> "$className*$methodName" }, + testGradleCommands = GradleCommand( + argumentFlag = "--rerun-tasks --tests '$1'", + classCommand = "verifyPaparazziDebug", + methodCommand = "verifyPaparazziDebug" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "--rerun-tasks --tests '$1'", + classCommand = "recordPaparazziDebug", + methodCommand = "recordPaparazziDebug" + ), + findSourceMethod = ::findPaparazziMethod + ), + + Preview( + srcRoot = "screenshotTest", + moduleFilter = ".screenshotTest", + qualifyingAnnotations = setOf(PREVIEW_ANNOTATION), + isClassEligible = false, // TODO: This is just for now, eventually we may want class-level markers too + methodInvocationPath = { className, methodName -> "$className*$methodName" }, + testGradleCommands = GradleCommand( + argumentFlag = "--rerun-tasks --tests '$1'", + classCommand = "validateDebugScreenshotTest", + methodCommand = "validateDebugScreenshotTest" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "--updateFilter '$1'", + classCommand = "updateDebugScreenshotTest", // TODO: Need to parameterize for build variant + methodCommand = "updateDebugScreenshotTest" + ), + findSourceMethod = ::findPreviewMethod + ) +} + +fun PsiElement.determineTestFlavor(): TestFlavor? { + if (this !is KtElement) return null + val path = this.containingKtFile.virtualFilePath + return TestFlavor.entries.find { "/${it.srcRoot}/" in path } +} + +fun TestFlavor.isQualifying(functions: Set): Boolean = + functions.any { it.hasQualifyingAnnotation(this.qualifyingAnnotations) } diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/BaseScreenshotAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/BaseScreenshotAction.kt index 19591ec7..3045e782 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/BaseScreenshotAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/BaseScreenshotAction.kt @@ -35,6 +35,8 @@ import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor import dev.testify.methodName import dev.testify.moduleName import dev.testify.testifyClassInvocationPath @@ -45,14 +47,16 @@ import org.jetbrains.plugins.gradle.action.GradleExecuteTaskAction import org.jetbrains.plugins.gradle.settings.GradleSettings import org.jetbrains.plugins.gradle.util.GradleConstants -abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnAction() { +abstract class BaseScreenshotAction( + private val anchorElement: PsiElement, + protected val testFlavor: TestFlavor +) : AnAction() { override fun getActionUpdateThread() = ActionUpdateThread.BGT - abstract val classGradleCommand: String - abstract val classMenuText: String + abstract val gradleCommand: GradleCommand - abstract val methodGradleCommand: String + abstract val classMenuText: String abstract val methodMenuText: String abstract val icon: String @@ -71,14 +75,22 @@ abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnA return if (isClass()) (anchorElement as? KtClass)?.name else null } - private fun String.toFullGradleCommand(event: AnActionEvent): String { + private fun String.toFullGradleCommand( + event: AnActionEvent, + argumentFlag: String + ): String { val arguments = when (anchorElement) { - is KtNamedFunction -> anchorElement.testifyMethodInvocationPath + is KtNamedFunction -> anchorElement.testifyMethodInvocationPath(testFlavor) is KtClass -> anchorElement.testifyClassInvocationPath else -> null } val command = ":${event.moduleName}:$this" - return if (arguments != null) "$command -PtestClass=$arguments" else command + return if (arguments != null) { + val argFormatted = argumentFlag.replace("$1", arguments) + "$command $argFormatted" + } else { + command + } } private fun isClass(): Boolean { @@ -93,8 +105,9 @@ abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnA val workingDirectory: String = executionContext.getProjectPath() ?: "" val executor = RunAnythingAction.EXECUTOR_KEY.getData(dataContext) - val gradleCommand = if (isClass()) classGradleCommand else methodGradleCommand - val fullCommandLine = gradleCommand.toFullGradleCommand(event) + val argumentFlag = gradleCommand.argumentFlag + val gradleCommand = if (isClass()) gradleCommand.classCommand else gradleCommand.methodCommand + val fullCommandLine = gradleCommand.toFullGradleCommand(event, argumentFlag) GradleExecuteTaskAction.runGradle(project, executor, workingDirectory, fullCommandLine) } diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotClearAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotClearAction.kt index e5db4291..0050ef6b 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotClearAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotClearAction.kt @@ -25,18 +25,22 @@ package dev.testify.actions.screenshot import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor -class ScreenshotClearAction(anchorElement: PsiElement) : BaseScreenshotAction(anchorElement) { +class ScreenshotClearAction(anchorElement: PsiElement, testFlavor: TestFlavor) : + BaseScreenshotAction(anchorElement, testFlavor) { - override val classGradleCommand: String - get() = "screenshotClear" + override val gradleCommand: GradleCommand + get() = GradleCommand( + argumentFlag = "-PtestClass=", + classCommand = "screenshotClear", + methodCommand = "screenshotClear" + ) override val classMenuText: String get() = "Clear screenshots from device" - override val methodGradleCommand: String - get() = "screenshotClear" - override val methodMenuText: String get() = "Clear screenshots from device" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotPullAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotPullAction.kt index b88961e3..ff62c0c9 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotPullAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotPullAction.kt @@ -25,18 +25,22 @@ package dev.testify.actions.screenshot import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor -class ScreenshotPullAction(anchorElement: PsiElement) : BaseScreenshotAction(anchorElement) { +class ScreenshotPullAction(anchorElement: PsiElement, testFlavor: TestFlavor) : + BaseScreenshotAction(anchorElement, testFlavor) { - override val classGradleCommand: String - get() = "screenshotPull" + override val gradleCommand: GradleCommand + get() = GradleCommand( + argumentFlag = "-PtestClass=", + classCommand = "screenshotPull", + methodCommand = "screenshotPull" + ) override val classMenuText: String get() = "Pull all screenshots for '$className'" - override val methodGradleCommand: String - get() = "screenshotPull" - override val methodMenuText: String get() = "Pull screenshots for '$methodName()'" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotRecordAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotRecordAction.kt index 6f2d8bd6..dbbf1eaa 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotRecordAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotRecordAction.kt @@ -25,18 +25,18 @@ package dev.testify.actions.screenshot import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor -class ScreenshotRecordAction(anchorElement: PsiElement) : BaseScreenshotAction(anchorElement) { +class ScreenshotRecordAction(anchorElement: PsiElement, testFlavor: TestFlavor) : + BaseScreenshotAction(anchorElement, testFlavor) { - override val classGradleCommand: String - get() = "screenshotRecord" + override val gradleCommand: GradleCommand + get() = testFlavor.recordGradleCommands override val classMenuText: String get() = "Record baseline for all '$className' tests" - override val methodGradleCommand: String - get() = "screenshotRecord" - override val methodMenuText: String get() = "Record baseline for '$methodName()'" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotTestAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotTestAction.kt index 2b9dd7e1..8bfbf42b 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotTestAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotTestAction.kt @@ -25,18 +25,18 @@ package dev.testify.actions.screenshot import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor -class ScreenshotTestAction(anchorElement: PsiElement) : BaseScreenshotAction(anchorElement) { +class ScreenshotTestAction(anchorElement: PsiElement, testFlavor: TestFlavor) : + BaseScreenshotAction(anchorElement, testFlavor) { - override val classGradleCommand: String - get() = "screenshotTest" + override val gradleCommand: GradleCommand + get() = testFlavor.testGradleCommands override val classMenuText: String get() = "Run all '$className' screenshot tests" - override val methodGradleCommand: String - get() = "screenshotTest" - override val methodMenuText: String get() = "Test '$methodName()'" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseFileAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseFileAction.kt index a62eaa93..884ae8ef 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseFileAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseFileAction.kt @@ -30,9 +30,13 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement +import dev.testify.TestFlavor import dev.testify.baselineImageName -abstract class BaseFileAction(protected val anchorElement: PsiElement) : BaseUtilityAction() { +abstract class BaseFileAction( + protected val anchorElement: PsiElement, + private val testFlavor: TestFlavor +) : BaseUtilityAction() { override fun getActionUpdateThread() = ActionUpdateThread.EDT diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseUtilityAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseUtilityAction.kt index 3d5d6cbb..fe428fce 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseUtilityAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/BaseUtilityAction.kt @@ -28,15 +28,17 @@ import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.fileTypes.FileTypeManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiClass import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.PsiMethod +import com.intellij.psi.search.FileTypeIndex import com.intellij.psi.search.FilenameIndex import com.intellij.psi.search.GlobalSearchScope -import com.intellij.psi.search.PsiShortNamesCache +import dev.testify.TestFlavor import dev.testify.baselineImageName import dev.testify.getVirtualFile import org.jetbrains.kotlin.idea.util.projectStructure.module @@ -46,22 +48,12 @@ abstract class BaseUtilityAction : AnAction() { override fun getActionUpdateThread() = ActionUpdateThread.EDT - private fun findClassByName(className: String, project: Project): PsiClass? { - val psiShortNamesCache = PsiShortNamesCache.getInstance(project) - val classes = psiShortNamesCache.getClassesByName(className, GlobalSearchScope.projectScope(project)) - return classes.firstOrNull() - } - private fun navigateToClass(psiClass: PsiClass, project: Project) { val psiFile = psiClass.containingFile.virtualFile val descriptor = OpenFileDescriptor(project, psiFile, psiClass.textOffset) FileEditorManager.getInstance(project).openTextEditor(descriptor, true) } - private fun findMethod(methodName: String, psiClass: PsiClass): PsiMethod? { - return psiClass.findMethodsByName(methodName, false).firstOrNull() - } - protected fun navigateToMethod(psiMethod: PsiMethod, project: Project) { val psiFile = psiMethod.containingFile.virtualFile val descriptor = OpenFileDescriptor(project, psiFile, psiMethod.textOffset) @@ -72,22 +64,40 @@ abstract class BaseUtilityAction : AnAction() { val imageFile = this.getVirtualFile() val project = this.project if (imageFile != null && project != null) { - imageFile.nameWithoutExtension.let { imageName -> - val parts = imageName.split("_") - if (parts.size == 2) { - val (className, methodName) = parts - findClassByName(className, project)?.let { psiClass -> - return findMethod(methodName, psiClass) - } - } + TestFlavor.entries.forEach { testFlavor -> + val method = testFlavor.findSourceMethod(imageFile, project) + if (method != null) return method } } return null } + fun findFilesByPartialNameOrRegex( + project: Project, + partialName: String? = null, + regex: Regex? = null, + scope: GlobalSearchScope = GlobalSearchScope.projectScope(project) + ): List { + val fileType = FileTypeManager.getInstance().getStdFileType("Image") + val allFiles = FileTypeIndex.getFiles(fileType, scope) + val fileList = allFiles.filter { file -> + when { + partialName != null && file.path.contains(partialName, ignoreCase = true) -> true + regex != null && regex.matches(file.path) -> true + else -> false + } + } + + return fileList + } + protected fun findBaselineImage(currentFile: PsiFile, baselineImageName: String): VirtualFile? { if (currentFile is KtFile && currentFile.module != null) { - val files = FilenameIndex.getVirtualFilesByName(baselineImageName, currentFile.module!!.moduleContentScope) +// val files = FilenameIndex.getVirtualFilesByName(baselineImageName, currentFile.module!!.moduleContentScope) + val files = findFilesByPartialNameOrRegex( + project = currentFile.project, + partialName = baselineImageName + ) if (files.isNotEmpty()) { return files.first() } diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/DeleteBaselineAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/DeleteBaselineAction.kt index 61baa95c..bd99ddd2 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/DeleteBaselineAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/DeleteBaselineAction.kt @@ -29,9 +29,10 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement import dev.testify.ConfirmationDialogWrapper +import dev.testify.TestFlavor import java.awt.event.ActionEvent -class DeleteBaselineAction(anchorElement: PsiElement) : BaseFileAction(anchorElement) { +class DeleteBaselineAction(anchorElement: PsiElement, testFlavor: TestFlavor) : BaseFileAction(anchorElement, testFlavor) { override val menuText: String get() = "Delete ${shortDisplayName(anchorElement)}" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToBaselineAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToBaselineAction.kt index 57999270..97736de8 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToBaselineAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToBaselineAction.kt @@ -26,6 +26,7 @@ package dev.testify.actions.utility import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.fileEditor.FileEditorManager +import dev.testify.TestFlavor import dev.testify.baselineImageName import dev.testify.findScreenshotAnnotatedFunction diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToSourceAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToSourceAction.kt index 070d2ba5..55cb2424 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToSourceAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToSourceAction.kt @@ -26,6 +26,7 @@ package dev.testify.actions.utility import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.vfs.VirtualFile +import dev.testify.TestFlavor import dev.testify.getVirtualFile class GoToSourceAction : BaseUtilityAction() { diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/RevealBaselineAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/RevealBaselineAction.kt index 8edb7487..24286b8e 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/RevealBaselineAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/RevealBaselineAction.kt @@ -28,9 +28,10 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement +import dev.testify.TestFlavor import java.awt.event.ActionEvent -class RevealBaselineAction(anchorElement: PsiElement) : BaseFileAction(anchorElement) { +class RevealBaselineAction(anchorElement: PsiElement, testFlavor: TestFlavor) : BaseFileAction(anchorElement, testFlavor) { override val icon = "reveal" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt index 5d753ce1..67693af5 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt @@ -29,7 +29,10 @@ import com.intellij.codeInsight.daemon.LineMarkerProvider import com.intellij.openapi.editor.markup.GutterIconRenderer import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil +import dev.testify.TestFlavor +import dev.testify.determineTestFlavor import dev.testify.hasScreenshotAnnotation +import dev.testify.isQualifying import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction @@ -42,15 +45,15 @@ class ScreenshotClassMarkerProvider : LineMarkerProvider { override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { if (element !is KtClass) return null - if (!element.containingKtFile.virtualFilePath.contains("androidTest")) return null - return element.getLineMarkerInfo() + val testFlavor = element.determineTestFlavor() ?: return null + return element.getLineMarkerInfo(testFlavor) } - private fun KtClass.getLineMarkerInfo(): LineMarkerInfo? { - - val functions = PsiTreeUtil.findChildrenOfType(this, KtNamedFunction::class.java) + private fun KtClass.getLineMarkerInfo(testFlavor: TestFlavor): LineMarkerInfo? { + if (testFlavor.isClassEligible.not()) return null + val functions: Set = PsiTreeUtil.findChildrenOfType(this, KtNamedFunction::class.java).filterNotNull().toSet() if (functions.isEmpty()) return null - if (functions.none(KtNamedFunction::hasScreenshotAnnotation)) return null + if (testFlavor.isQualifying(functions).not()) return null val anchorElement = this.nameIdentifier ?: return null @@ -59,7 +62,7 @@ class ScreenshotClassMarkerProvider : LineMarkerProvider { anchorElement.textRange, IconHelper.ICON_CAMERA, { "Android Testify Commands" }, - ScreenshotClassNavHandler(this), + ScreenshotClassNavHandler(this, testFlavor), GutterIconRenderer.Alignment.RIGHT, { "" } ) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt index fd6a0aec..b121338e 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt @@ -37,6 +37,7 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiUtilCore import com.intellij.ui.awt.RelativePoint +import dev.testify.TestFlavor import dev.testify.actions.screenshot.ScreenshotClearAction import dev.testify.actions.screenshot.ScreenshotPullAction import dev.testify.actions.screenshot.ScreenshotRecordAction @@ -44,7 +45,10 @@ import dev.testify.actions.screenshot.ScreenshotTestAction import java.awt.event.ComponentEvent import java.awt.event.MouseEvent -class ScreenshotClassNavHandler(private val anchorElement: PsiElement) : GutterIconNavigationHandler { +class ScreenshotClassNavHandler( + private val anchorElement: PsiElement, + private val testFlavor: TestFlavor +) : GutterIconNavigationHandler { override fun navigate(e: MouseEvent?, nameIdentifier: PsiElement) { if (e == null) return @@ -69,10 +73,10 @@ class ScreenshotClassNavHandler(private val anchorElement: PsiElement) : GutterI private fun createActionGroupPopup(event: ComponentEvent, anchorElement: PsiElement): JBPopup { val group = DefaultActionGroup( - ScreenshotTestAction(anchorElement), - ScreenshotRecordAction(anchorElement), - ScreenshotPullAction(anchorElement), - ScreenshotClearAction(anchorElement) + ScreenshotTestAction(anchorElement, testFlavor), + ScreenshotRecordAction(anchorElement, testFlavor), + ScreenshotPullAction(anchorElement, testFlavor), + ScreenshotClearAction(anchorElement, testFlavor), ) val dataContext = DataManager.getInstance().getDataContext(event.component) return JBPopupFactory.getInstance().createActionGroupPopup( diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt index 40297381..f1d03b37 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt @@ -37,6 +37,7 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiUtilCore import com.intellij.ui.awt.RelativePoint +import dev.testify.TestFlavor import dev.testify.actions.screenshot.ScreenshotClearAction import dev.testify.actions.screenshot.ScreenshotPullAction import dev.testify.actions.screenshot.ScreenshotRecordAction @@ -46,7 +47,10 @@ import dev.testify.actions.utility.RevealBaselineAction import java.awt.event.ComponentEvent import java.awt.event.MouseEvent -class ScreenshotInstrumentationAnnotationNavHandler(private val anchorElement: PsiElement) : +class ScreenshotInstrumentationAnnotationNavHandler( + private val anchorElement: PsiElement, + private val testFlavor: TestFlavor +) : GutterIconNavigationHandler { override fun navigate(e: MouseEvent?, nameIdentifier: PsiElement) { @@ -72,12 +76,12 @@ class ScreenshotInstrumentationAnnotationNavHandler(private val anchorElement: P private fun createActionGroupPopup(event: ComponentEvent, anchorElement: PsiElement): JBPopup { val group = DefaultActionGroup( - ScreenshotTestAction(anchorElement), - ScreenshotRecordAction(anchorElement), - ScreenshotPullAction(anchorElement), - ScreenshotClearAction(anchorElement), - RevealBaselineAction(anchorElement), - DeleteBaselineAction(anchorElement) + ScreenshotTestAction(anchorElement, testFlavor), + ScreenshotRecordAction(anchorElement, testFlavor), + ScreenshotPullAction(anchorElement, testFlavor), + ScreenshotClearAction(anchorElement, testFlavor), + RevealBaselineAction(anchorElement, testFlavor), + DeleteBaselineAction(anchorElement, testFlavor) ) val dataContext = DataManager.getInstance().getDataContext(event.component) return JBPopupFactory.getInstance().createActionGroupPopup( diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt index eece71d8..d393f637 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt @@ -28,6 +28,9 @@ import com.intellij.codeInsight.daemon.LineMarkerInfo import com.intellij.codeInsight.daemon.LineMarkerProvider import com.intellij.openapi.editor.markup.GutterIconRenderer import com.intellij.psi.PsiElement +import dev.testify.TestFlavor +import dev.testify.determineTestFlavor +import dev.testify.getQualifyingAnnotation import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtNamedFunction @@ -41,23 +44,20 @@ class ScreenshotInstrumentationLineMarkerProvider : LineMarkerProvider { override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { if (element !is KtNamedFunction) return null - if (!element.containingKtFile.virtualFilePath.contains("androidTest")) return null - return element.getLineMarkerInfo() + val testFlavor = element.determineTestFlavor() ?: return null + return element.getLineMarkerInfo(testFlavor) } - private fun KtNamedFunction.getLineMarkerInfo(): LineMarkerInfo? { + private fun KtNamedFunction.getLineMarkerInfo(testFlavor: TestFlavor): LineMarkerInfo? { analyze(this) { - val annotation = symbol.annotations.firstOrNull { - it.classId?.asSingleFqName() == FqName(SCREENSHOT_INSTRUMENTATION) || - it.classId?.asSingleFqName() == FqName(SCREENSHOT_INSTRUMENTATION_LEGACY) - } + val annotation = getQualifyingAnnotation(this@getLineMarkerInfo, testFlavor.qualifyingAnnotations) val anchorElement = annotation?.psi ?: return null return LineMarkerInfo( anchorElement.firstChild, anchorElement.textRange, IconHelper.ICON_CAMERA, { "Android Testify Commands" }, - ScreenshotInstrumentationAnnotationNavHandler(this@getLineMarkerInfo), + ScreenshotInstrumentationAnnotationNavHandler(this@getLineMarkerInfo, testFlavor), GutterIconRenderer.Alignment.RIGHT, { "" } ) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/TooltipProvider.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/TooltipProvider.kt index 210b0c17..47b86345 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/TooltipProvider.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/TooltipProvider.kt @@ -2,3 +2,5 @@ package dev.testify.extensions const val SCREENSHOT_INSTRUMENTATION = "dev.testify.annotation.ScreenshotInstrumentation" const val SCREENSHOT_INSTRUMENTATION_LEGACY = "com.shopify.testify.annotation.ScreenshotInstrumentation" +const val PAPARAZZI_ANNOTATION = "org.junit.Test" +const val PREVIEW_ANNOTATION = "com.android.tools.screenshot.PreviewTest" diff --git a/Samples/Paparazzi/src/test/java/dev/testify/samples/paparazzi/ui/common/composables/CastMemberScreenshotTest.kt b/Samples/Paparazzi/src/test/java/dev/testify/samples/paparazzi/ui/common/composables/CastMemberScreenshotTest.kt index 2a684263..1cf938df 100644 --- a/Samples/Paparazzi/src/test/java/dev/testify/samples/paparazzi/ui/common/composables/CastMemberScreenshotTest.kt +++ b/Samples/Paparazzi/src/test/java/dev/testify/samples/paparazzi/ui/common/composables/CastMemberScreenshotTest.kt @@ -1,6 +1,7 @@ package dev.testify.samples.paparazzi.ui.common.composables import android.content.Context +import androidx.compose.material3.Text import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.test.espresso.idling.concurrent.IdlingThreadPoolExecutor @@ -62,6 +63,20 @@ class CastMemberScreenshotTest { // Coil.setImageLoader(imageLoader) // } + @Test + fun a() { + paparazzi.snapshot { + Text("A") + } + } + + @Test + fun b() { + paparazzi.snapshot { + Text("B") + } + } + @Test fun default() { diff --git a/Samples/Paparazzi/src/test/snapshots/images/dev.testify.samples.paparazzi.ui.common.composables_CastMemberScreenshotTest_b.png b/Samples/Paparazzi/src/test/snapshots/images/dev.testify.samples.paparazzi.ui.common.composables_CastMemberScreenshotTest_b.png new file mode 100644 index 00000000..7f4be19e Binary files /dev/null and b/Samples/Paparazzi/src/test/snapshots/images/dev.testify.samples.paparazzi.ui.common.composables_CastMemberScreenshotTest_b.png differ