diff --git a/.gitignore b/.gitignore index 3ca746c10..e4c47b56f 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 000000000..b4672820d --- /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 c4bfe9736..bc6d28a04 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 000000000..2da89a2a9 --- /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 3b0f50f40..be8296fb8 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 b088e262d..0023fa3c1 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 1fd5723d5..849c07d16 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 000000000..447dbe256 --- /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 1e926f360..a707bad0a 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 000000000..8a1b99ade --- /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 19591ec7b..5a1547ea8 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 e5db4291d..0050ef6bb 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 b88961e30..5bd982a06 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 6f2d8bd62..dbbf1eaa5 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 2b9dd7e10..8bfbf42bf 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 a62eaa935..884ae8efc 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 3d5d6cbbe..d6897ba8d 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 61baa95c6..bd99ddd21 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 579992702..1ba7f8461 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 070d2ba51..55cb24242 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 8edb7487b..24286b8e0 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 5d753ce18..803037744 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 fd6a0aecb..5660b34b3 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 402973816..5d2f5e891 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 eece71d8a..d393f6370 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 210b0c174..47b863457 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 d3ef03af4..cbb4c9582 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/Flix/FlixLibrary/src/debug/AndroidManifest.xml b/Samples/Flix/FlixLibrary/src/debug/AndroidManifest.xml index 568741e54..ad690b49a 100644 --- a/Samples/Flix/FlixLibrary/src/debug/AndroidManifest.xml +++ b/Samples/Flix/FlixLibrary/src/debug/AndroidManifest.xml @@ -1,2 +1,4 @@ - \ No newline at end of file + + + diff --git a/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml b/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml index e6819934a..500643412 100644 --- a/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml +++ b/Samples/Flix/FlixLibrary/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ - + - \ No newline at end of file + + + diff --git a/Samples/Flix/src/debug/AndroidManifest.xml b/Samples/Flix/src/debug/AndroidManifest.xml index 1c389432b..6ba5de169 100644 --- a/Samples/Flix/src/debug/AndroidManifest.xml +++ b/Samples/Flix/src/debug/AndroidManifest.xml @@ -1,7 +1,7 @@ - +