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/AGENTS.md b/Plugins/IntelliJ/AGENTS.md new file mode 100644 index 00000000..b4672820 --- /dev/null +++ b/Plugins/IntelliJ/AGENTS.md @@ -0,0 +1,58 @@ +# Android Testify IntelliJ Plugin - Developer Guide + +This document provides an overview of the Android Testify IntelliJ Plugin project for AI agents and developers. + +## Project Overview + +This is an IntelliJ Platform Plugin designed to enhance the development experience for Android Testify screenshot testing within Android Studio. It provides features like line markers, navigation between tests and baseline images, and other utility actions. + +## Key Technologies + +* **Language:** Kotlin +* **Build System:** Gradle (Kotlin DSL) +* **Platform:** IntelliJ Platform (specifically targeting Android Studio) +* **Testing:** JUnit, OpenTest4J + +## Project Structure + +* `build.gradle.kts`: Main build configuration. +* `gradle.properties`: Project properties, including plugin version, platform version, and dependencies. +* `src/main/resources/META-INF/plugin.xml`: Plugin configuration file (manifest), defining actions, extensions, and dependencies. +* `src/main/kotlin/dev/testify`: Source code root. + * `actions`: Contains Action classes (e.g., `GoToSourceAction`, `GoToBaselineAction`). + * `extensions`: Contains IntelliJ extensions like `LineMarkerProvider` implementations. + * `TestFlavor.kt`, `FileUtilities.kt`, `PsiExtensions.kt`: Utility classes. + +## Key Features & Components + +### 1. Navigation +* **Go To Source (`GoToSourceAction`):** Navigates from a baseline image to its corresponding test source code. +* **Go To Baseline (`GoToBaselineAction`):** Navigates from a test method to its corresponding baseline image. + +### 2. Line Markers +* **`ScreenshotInstrumentationLineMarkerProvider`:** Adds gutter icons to test methods annotated with `@ScreenshotInstrumentation`. +* **`ScreenshotClassMarkerProvider`:** Adds gutter icons to test classes. + +### 3. Dependencies +* **`org.jetbrains.kotlin`** +* **`com.intellij.gradle`** +* **`org.jetbrains.android`** +* **`com.intellij.modules.androidstudio`** + +## Build & Run + +* **Build:** `./gradlew buildPlugin` +* **Run IDE:** `./gradlew runIde` (Starts a sandboxed Android Studio instance with the plugin installed) +* **Run Tests:** `./gradlew test` + +## Configuration + +* **Plugin Version:** Defined in `gradle.properties` (`pluginVersion`). +* **Platform Version:** Defined in `gradle.properties` (`platformVersion`). Currently targeting Android Studio Otter (2025.3.1.2). +* **Since/Until Build:** Defined in `gradle.properties` (`pluginSinceBuild`, `pluginUntilBuild`). + +## Notes for Agents + +* When modifying the plugin, ensure compatibility with the target Android Studio version specified in `gradle.properties`. +* The `plugin.xml` file is the central registry for all UI actions and extensions. Any new feature usually requires an entry here. +* The project uses the IntelliJ Platform Gradle Plugin (2.x) for building and verification. diff --git a/Plugins/IntelliJ/CHANGELOG.md b/Plugins/IntelliJ/CHANGELOG.md index c4bfe973..bc6d28a0 100644 --- a/Plugins/IntelliJ/CHANGELOG.md +++ b/Plugins/IntelliJ/CHANGELOG.md @@ -2,6 +2,18 @@ # Android Testify - IntelliJ Platform Plugin - Change Log +## [Unreleased] + +## [4.0.0-alpha05] + +- Added initial support for Paparazzi and Preview tests! +- Implemented record and test functionality for Paparazzi tests. +- Enhanced "Go To Baseline" and "Go To Source" navigation for baseline images. +- Added support for build variants. +- Enabled class-level menu actions. +- Disabled "Pull" action when no baseline image is found. +- Bumped minimum IDE support to 252.*. Added support for 253.* + ## [3.0.0] - Added support for Support Android Studio Narwhal | 2025.1.1 Canary 9 | 251.+ diff --git a/Plugins/IntelliJ/GEMINI.md b/Plugins/IntelliJ/GEMINI.md new file mode 100644 index 00000000..2da89a2a --- /dev/null +++ b/Plugins/IntelliJ/GEMINI.md @@ -0,0 +1,50 @@ +# Gemini Project Context: Android Testify IntelliJ Plugin + +This document provides context for the Android Testify IntelliJ Plugin project. + +## Project Overview + +This is a Gradle-based IntelliJ Platform Plugin for [Android Testify](https://testify.dev/), an Android screenshot testing framework. The plugin is written in Kotlin and enhances the developer experience by integrating Testify commands directly into the IntelliJ IDE (including Android Studio). + +Key features of the plugin include: +- Running Testify screenshot tests. +- Recording, pulling, revealing, and deleting baseline screenshot images. +- Navigating between test source code and their corresponding baseline images. + +The plugin's functionality is defined in `src/main/resources/META-INF/plugin.xml`, and the implementation is in Kotlin under `src/main/kotlin/dev/testify/`. + +## Building and Running + +This project uses the Gradle wrapper (`gradlew`). + +- **To build the plugin:** + ```bash + ./gradlew buildPlugin + ``` + +- **To run the plugin in a development instance of the IDE:** + ```bash + ./gradlew runIde + ``` + +- **To run the plugin's tests:** + ```bash + ./gradlew test + ``` + +## Development Conventions + +The project follows standard Kotlin coding conventions. The codebase is structured around the IntelliJ Platform's action and extension system. + +- **Actions:** User-invoked commands (e.g., "Go to Baseline") are implemented as classes that extend `AnAction`. See files in `src/main/kotlin/dev/testify/actions/`. +- **Extensions:** IDE integrations, such as line markers next to screenshot tests, are implemented using extension points. See files in `src/main/kotlin/dev/testify/extensions/`. + +Dependencies and project versions are managed in the `build.gradle.kts` file and the `gradle/libs.versions.toml` version catalog. + +## Key Files + +- `README.md`: Provides a high-level overview of the plugin for users. +- `build.gradle.kts`: The main Gradle build script that configures the IntelliJ Platform, Kotlin, and dependencies. +- `src/main/resources/META-INF/plugin.xml`: The plugin's manifest file, which declares its dependencies, extensions, and actions. +- `src/main/kotlin/dev/testify/`: The root directory for the plugin's Kotlin source code. +- `gradle/libs.versions.toml`: The Gradle version catalog, defining the project's dependencies and their versions. diff --git a/Plugins/IntelliJ/build.gradle.kts b/Plugins/IntelliJ/build.gradle.kts index 3b0f50f4..be8296fb 100644 --- a/Plugins/IntelliJ/build.gradle.kts +++ b/Plugins/IntelliJ/build.gradle.kts @@ -36,7 +36,9 @@ dependencies { // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - androidStudio(providers.gradleProperty("platformVersion"), useInstaller = true) + androidStudio(version = providers.gradleProperty("platformVersion")) { + this.useInstaller = true + } // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) diff --git a/Plugins/IntelliJ/gradle.properties b/Plugins/IntelliJ/gradle.properties index b088e262..0023fa3c 100644 --- a/Plugins/IntelliJ/gradle.properties +++ b/Plugins/IntelliJ/gradle.properties @@ -1,20 +1,24 @@ # IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html +# Android Studio Plugin Development -> https://plugins.jetbrains.com/docs/intellij/android-studio.html#android-studio-releases-listing +# Samples and Examples -> https://github.com/balsikandar/Android-Studio-Plugins 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-alpha05 + # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -pluginSinceBuild = 242 -pluginUntilBuild = 252.* +pluginSinceBuild = 252 +pluginUntilBuild = 253.* # 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 +# https://plugins.jetbrains.com/docs/intellij/android-studio-releases-list.html +# Otter 2025.2.1 +platformVersion = 2025.3.1.2 # 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/gradle/libs.versions.toml b/Plugins/IntelliJ/gradle/libs.versions.toml index 1fd5723d..849c07d1 100644 --- a/Plugins/IntelliJ/gradle/libs.versions.toml +++ b/Plugins/IntelliJ/gradle/libs.versions.toml @@ -5,8 +5,8 @@ opentest4j = "1.3.0" # plugins changelog = "2.2.1" -intelliJPlatform = "2.5.0" -kotlin = "2.1.20" +intelliJPlatform = "2.10.5" +kotlin = "2.3.0" kover = "0.9.1" qodana = "2024.3.4" 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..a707bad0 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -30,22 +30,30 @@ import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.PlatformDataKeys import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.module.ModuleUtilCore 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.android.facet.AndroidFacet +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.KaPropertySymbol 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.KtClassOrObject import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.psiUtil.parents +import java.util.ArrayDeque +import java.util.Locale import java.util.concurrent.Callable -private const val ANDROID_TEST_MODULE = ".androidTest" private const val PROJECT_FORMAT = "%1s." val AnActionEvent.moduleName: String @@ -56,13 +64,26 @@ 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") return gradleModule } +val AnActionEvent.selectedBuildVariant: String + get() { + val psiFile = this.getData(PlatformDataKeys.PSI_FILE) ?: return "Debug" + val module = ModuleUtilCore.findModuleForPsiElement(psiFile) ?: return "Debug" + val variant = AndroidFacet.getInstance(module)?.properties?.SELECTED_BUILD_VARIANT ?: "debug" + return variant.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + val PsiElement.baselineImageName: String get() { val ktElement = this as? KtElement ?: return "unknown" @@ -85,20 +106,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() { @@ -112,31 +132,120 @@ val KtClass.testifyClassInvocationPath: String }).get() ?: "unknown" } -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 - ) - } - } +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? { +fun AnActionEvent.getElementAtCaret(): PsiElement? { val psiFile = this.getData(PlatformDataKeys.PSI_FILE) ?: return null val offset = this.getData(PlatformDataKeys.EDITOR)?.caretModel?.offset ?: return null - val elementAtCaret = psiFile.findElementAt(offset) - return elementAtCaret?.parents?.filterIsInstance()?.find { it.hasScreenshotAnnotation } + return psiFile.findElementAt(offset) +} + +fun AnActionEvent.findScreenshotAnnotatedFunction(testFlavor: TestFlavor): KtNamedFunction? { + val elementAtCaret = getElementAtCaret() + return elementAtCaret?.parents?.filterIsInstance()?.find { it.hasQualifyingAnnotation(testFlavor.qualifyingAnnotations) } } fun AnActionEvent.getVirtualFile(): VirtualFile? = this.getData(PlatformDataKeys.VIRTUAL_FILE) ?: (this.getData(CommonDataKeys.NAVIGATABLE_ARRAY) ?.first() as? PsiFileNode)?.virtualFile + +fun KtElement.hasPaparazziRule(): Boolean { + val containingClass = (this as? KtClassOrObject) ?: this.parents.filterIsInstance().firstOrNull() ?: return false + return containingClass.hasPaparazziRule() +} + +fun KtClassOrObject.hasPaparazziRule(): Boolean { + val containingClass = this + + return ApplicationManager.getApplication().executeOnPooledThread(Callable { + ReadAction.compute { + analyze(containingClass) { + val classSymbol = containingClass.symbol as? KaClassSymbol ?: return@analyze false + + fun hasPaparazziField(symbol: KaClassSymbol): Boolean { + return symbol.declaredMemberScope.callables.filterIsInstance().any { property -> + val typeSymbol = property.returnType.expandedSymbol as? KaClassSymbol + typeSymbol?.classId?.asSingleFqName()?.asString() == "app.cash.paparazzi.Paparazzi" + } + } + + val visited = mutableSetOf() + val queue = ArrayDeque() + queue.add(classSymbol) + + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (!visited.add(current)) continue + + if (hasPaparazziField(current)) return@analyze true + + current.superTypes.forEach { type -> + (type.expandedSymbol as? KaClassSymbol)?.let { queue.add(it) } + } + } + + false + } + } + }).get() ?: false +} + +val KtNamedFunction.paparazziScreenshotFileName: String + get() { + return ApplicationManager.getApplication().executeOnPooledThread(Callable { + ReadAction.compute { + analyze(this@paparazziScreenshotFileName) { + val functionSymbol = this@paparazziScreenshotFileName.symbol + val classSymbol = functionSymbol.containingSymbol as? KaClassSymbol + val classId = classSymbol?.classId + val packageName = classId?.packageFqName?.asString() + val relativeClassName = classId?.relativeClassName?.asString()?.replace('.', '_') + val methodName = functionSymbol.name?.asString() + + if (packageName.isNullOrEmpty()) { + "${relativeClassName}_$methodName.png" + } else { + "${packageName}_${relativeClassName}_$methodName.png" + } + } + } + }).get() ?: "unknown.png" + } + +val KtClass.paparazziScreenshotFileNamePattern: String + get() { + return ApplicationManager.getApplication().executeOnPooledThread(Callable { + ReadAction.compute { + analyze(this@paparazziScreenshotFileNamePattern) { + val classSymbol = this@paparazziScreenshotFileNamePattern.symbol as? KaClassSymbol + val classId = classSymbol?.classId + val packageName = classId?.packageFqName?.asString() + val relativeClassName = classId?.relativeClassName?.asString()?.replace('.', '_') + + if (packageName.isNullOrEmpty()) { + "${relativeClassName}_*.png" + } else { + "${packageName}_${relativeClassName}_*.png" + } + } + } + }).get() ?: "unknown.png" + } 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..8a1b99ad --- /dev/null +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -0,0 +1,110 @@ +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 = "verifyPaparazzi$Variant", + methodCommand = "verifyPaparazzi$Variant" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "--rerun-tasks --tests '$1'", + classCommand = "recordPaparazzi$Variant", + methodCommand = "recordPaparazzi$Variant" + ), + 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 = "validate${Variant}ScreenshotTest", + methodCommand = "validate${Variant}ScreenshotTest" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "--updateFilter '$1'", + classCommand = "update${Variant}ScreenshotTest", + methodCommand = "update${Variant}ScreenshotTest" + ), + findSourceMethod = ::findPreviewMethod + ) +} + +fun PsiElement.determineTestFlavor(): TestFlavor? { + if (this !is KtElement) return null + + if (this.hasPaparazziRule()) { + return TestFlavor.Paparazzi + } + + val path = this.containingKtFile.virtualFilePath + val flavor = TestFlavor.entries.find { "/${it.srcRoot}/" in path } + + if (flavor == TestFlavor.Paparazzi) { + return null + } + + return flavor +} + +fun TestFlavor.hasQualifyingAnnotation(functions: Set): Boolean = + functions.any { it.hasQualifyingAnnotation(this.qualifyingAnnotations) } + +const val Variant = "\$Variant" 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..5a1547ea 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,8 +35,12 @@ 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.Variant import dev.testify.methodName import dev.testify.moduleName +import dev.testify.selectedBuildVariant import dev.testify.testifyClassInvocationPath import dev.testify.testifyMethodInvocationPath import org.jetbrains.kotlin.psi.KtClass @@ -45,14 +49,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( + protected 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,21 +77,29 @@ 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 { + protected fun isClass(): Boolean { return anchorElement is KtClass } - final override fun actionPerformed(event: AnActionEvent) { + override fun actionPerformed(event: AnActionEvent) { val project = event.project as Project val dataContext = SimpleDataContext.getProjectContext(project) val executionContext = @@ -93,12 +107,19 @@ 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 + var commandName = if (isClass()) gradleCommand.classCommand else gradleCommand.methodCommand + + if (commandName.contains(Variant)) { + val variant = event.selectedBuildVariant + commandName = commandName.replace(Variant, variant) + } + + val fullCommandLine = commandName.toFullGradleCommand(event, argumentFlag) GradleExecuteTaskAction.runGradle(project, executor, workingDirectory, fullCommandLine) } - final override fun update(anActionEvent: AnActionEvent) { + override fun update(anActionEvent: AnActionEvent) { anActionEvent.presentation.apply { text = if (isClass()) classMenuText else methodMenuText isEnabledAndVisible = (anActionEvent.project != null) @@ -109,7 +130,7 @@ abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnA } } - private fun RunAnythingContext.getProjectPath() = when (this) { + protected fun RunAnythingContext.getProjectPath() = when (this) { is RunAnythingContext.ProjectContext -> GradleSettings.getInstance(project).linkedProjectsSettings.firstOrNull() ?.let { 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..5bd982a0 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 @@ -24,21 +24,125 @@ */ package dev.testify.actions.screenshot +import com.intellij.ide.actions.runAnything.RunAnythingContext +import com.intellij.ide.actions.runAnything.activity.RunAnythingProvider +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.impl.SimpleDataContext +import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil +import com.intellij.openapi.module.ModuleUtilCore import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor +import dev.testify.paparazziScreenshotFileName +import dev.testify.paparazziScreenshotFileNamePattern +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtNamedFunction +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption -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()'" override val icon = "pull" + + override fun actionPerformed(event: AnActionEvent) { + if (testFlavor == TestFlavor.Paparazzi) { + handlePaparazziPull(event) + } else { + super.actionPerformed(event) + } + } + + override fun update(anActionEvent: AnActionEvent) { + super.update(anActionEvent) + if (testFlavor == TestFlavor.Paparazzi) { + anActionEvent.presentation.isEnabled = isPaparazziPullAvailable() + } + } + + private fun isPaparazziPullAvailable(): Boolean { + val module = ModuleUtilCore.findModuleForPsiElement(anchorElement) + val workingDirectory = module?.let { ExternalSystemApiUtil.getExternalProjectPath(it) } ?: return false + + val fileName = if (isClass()) { + (anchorElement as? KtClass)?.paparazziScreenshotFileNamePattern + } else { + (anchorElement as? KtNamedFunction)?.paparazziScreenshotFileName + } ?: return false + + val sourceDir = File(workingDirectory, "build/paparazzi/failures") + + if (fileName.contains("*")) { + val regex = fileName.replace("*", ".*").toRegex() + val files = sourceDir.listFiles { _, name -> regex.matches(name) } + return files?.isNotEmpty() == true + } else { + return File(sourceDir, fileName).exists() + } + } + + private fun handlePaparazziPull(event: AnActionEvent) { + val project = event.project ?: return + + val module = ModuleUtilCore.findModuleForPsiElement(anchorElement) + val workingDirectory = module?.let { ExternalSystemApiUtil.getExternalProjectPath(it) } + + if (workingDirectory == null) { + Notification("Android Testify", "Screenshot Pull", "Could not determine module path.", NotificationType.ERROR).notify(project) + return + } + + val fileName = if (isClass()) { + (anchorElement as? KtClass)?.paparazziScreenshotFileNamePattern + } else { + (anchorElement as? KtNamedFunction)?.paparazziScreenshotFileName + } ?: return + + val sourceDir = File(workingDirectory, "build/paparazzi/failures") + val destDir = File(workingDirectory, "src/test/snapshots/images") + + if (!destDir.exists()) { + destDir.mkdirs() + } + + var count = 0 + + if (fileName.contains("*")) { + val regex = fileName.replace("*", ".*").toRegex() + val files = sourceDir.listFiles { _, name -> regex.matches(name) } + files?.forEach { file -> + val destFile = File(destDir, file.name) + Files.move(file.toPath(), destFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + count++ + } + } else { + val sourceFile = File(sourceDir, fileName) + if (sourceFile.exists()) { + val destFile = File(destDir, fileName) + Files.move(sourceFile.toPath(), destFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + count++ + } + } + + val message = if (count > 0) "Moved $count screenshot(s) to baseline." else "No failure screenshots found." + val type = if (count > 0) NotificationType.INFORMATION else NotificationType.WARNING + + Notification("Android Testify", "Screenshot Pull", message, type).notify(project) + } } 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..d6897ba8 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,39 @@ 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 = 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..1ba7f846 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 @@ -27,21 +27,26 @@ import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.fileEditor.FileEditorManager import dev.testify.baselineImageName +import dev.testify.determineTestFlavor import dev.testify.findScreenshotAnnotatedFunction +import dev.testify.getElementAtCaret class GoToBaselineAction : BaseUtilityAction() { override fun getActionUpdateThread() = ActionUpdateThread.BGT override fun update(event: AnActionEvent) { - event.presentation.isEnabledAndVisible = event.findScreenshotAnnotatedFunction()?.let { function -> + val testFlavor = event.getElementAtCaret()?.determineTestFlavor() ?: return + val screenshotTestFunction = event.findScreenshotAnnotatedFunction(testFlavor) + event.presentation.isEnabledAndVisible = screenshotTestFunction?.let { function -> isBaselineInProject(function) } ?: false } override fun actionPerformed(event: AnActionEvent) { val project = event.project ?: return - event.findScreenshotAnnotatedFunction()?.let { function -> + val testFlavor = event.getElementAtCaret()?.determineTestFlavor() ?: return + event.findScreenshotAnnotatedFunction(testFlavor)?.let { function -> findBaselineImage(function.containingFile, function.baselineImageName)?.let { virtualFile -> FileEditorManager.getInstance(project).openFile(virtualFile, true) } 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..80303774 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.hasScreenshotAnnotation +import dev.testify.TestFlavor +import dev.testify.determineTestFlavor +import dev.testify.hasPaparazziRule +import dev.testify.hasQualifyingAnnotation import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction @@ -42,16 +45,16 @@ 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.hasQualifyingAnnotation(functions).not()) return null + if ((testFlavor == TestFlavor.Paparazzi) && this.hasPaparazziRule().not()) return null val anchorElement = this.nameIdentifier ?: return null return LineMarkerInfo( @@ -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..5660b34b 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 @@ -68,12 +72,13 @@ 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) - ) + val group = DefaultActionGroup() + group.add(ScreenshotTestAction(anchorElement, testFlavor)) + group.add(ScreenshotRecordAction(anchorElement, testFlavor)) + group.add(ScreenshotPullAction(anchorElement, testFlavor)) + if (testFlavor == TestFlavor.Testify) { + group.add(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..5d2f5e89 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) { @@ -70,15 +74,17 @@ class ScreenshotInstrumentationAnnotationNavHandler(private val anchorElement: P } private fun createActionGroupPopup(event: ComponentEvent, anchorElement: PsiElement): JBPopup { + val group = DefaultActionGroup() + + group.add(ScreenshotTestAction(anchorElement, testFlavor)) + group.add(ScreenshotRecordAction(anchorElement, testFlavor)) + group.add(ScreenshotPullAction(anchorElement, testFlavor)) + if (testFlavor == TestFlavor.Testify) { + group.add(ScreenshotClearAction(anchorElement, testFlavor)) + } + group.add(RevealBaselineAction(anchorElement, testFlavor)) + group.add(DeleteBaselineAction(anchorElement, testFlavor)) - val group = DefaultActionGroup( - ScreenshotTestAction(anchorElement), - ScreenshotRecordAction(anchorElement), - ScreenshotPullAction(anchorElement), - ScreenshotClearAction(anchorElement), - RevealBaselineAction(anchorElement), - DeleteBaselineAction(anchorElement) - ) 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/Plugins/IntelliJ/src/main/resources/META-INF/plugin.xml b/Plugins/IntelliJ/src/main/resources/META-INF/plugin.xml index d3ef03af..cbb4c958 100644 --- a/Plugins/IntelliJ/src/main/resources/META-INF/plugin.xml +++ b/Plugins/IntelliJ/src/main/resources/META-INF/plugin.xml @@ -4,10 +4,10 @@ Android Testify - Screenshot Instrumentation Tests ndtp - com.intellij.modules.platform org.jetbrains.kotlin com.intellij.gradle org.jetbrains.android + com.intellij.modules.androidstudio messages.MyBundle 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/java/dev/testify/samples/paparazzi/ui/common/composables/UnitTestExample.kt b/Samples/Paparazzi/src/test/java/dev/testify/samples/paparazzi/ui/common/composables/UnitTestExample.kt new file mode 100644 index 00000000..2d67a76a --- /dev/null +++ b/Samples/Paparazzi/src/test/java/dev/testify/samples/paparazzi/ui/common/composables/UnitTestExample.kt @@ -0,0 +1,13 @@ +package dev.testify.samples.paparazzi.ui.common.composables + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UnitTestExample { + + @Test + fun `This is an example test`() { + assertEquals(4, 2 + 2) + } +} 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