Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ Plugins/Gradle/gradle/wrapper/
Plugins/Gradle/gradlew
Plugins/Gradle/gradlew.bat
.intellijPlatform/
Plugins/IntelliJ/.kotlin
2 changes: 2 additions & 0 deletions Plugins/IntelliJ/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

# Android Testify - IntelliJ Platform Plugin - Change Log

## [Unreleased]

## [3.0.0]

- Added support for Support Android Studio Narwhal | 2025.1.1 Canary 9 | 251.+
Expand Down
6 changes: 3 additions & 3 deletions Plugins/IntelliJ/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pluginGroup = dev.testify
pluginName = Android Testify - Screenshot Instrumentation Tests
pluginRepositoryUrl = https://github.com/ndtp/android-testify/tree/main/Plugins/IntelliJ
# SemVer format -> https://semver.org
pluginVersion = 3.0.0
pluginVersion = 4.0.0

# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 242
Expand All @@ -13,8 +13,8 @@ pluginUntilBuild = 252.*
# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension
platformType = AI

# Narwhal 2025.1.1 Canary 9
platformVersion = 2025.1.1.9
# Otter 2025.2.1
platformVersion = 2025.2.1.1

# Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html
# Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP
Expand Down
72 changes: 72 additions & 0 deletions Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt
Original file line number Diff line number Diff line change
@@ -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
}
69 changes: 40 additions & 29 deletions Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,20 @@ import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiElement
import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION
import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION_LEGACY
import org.jetbrains.kotlin.analysis.api.KaSession
import org.jetbrains.kotlin.analysis.api.analyze
import org.jetbrains.kotlin.analysis.api.annotations.KaAnnotation
import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol
import org.jetbrains.kotlin.analysis.api.symbols.name
import org.jetbrains.kotlin.idea.util.projectStructure.module
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.psiUtil.parents
import java.util.concurrent.Callable

private const val ANDROID_TEST_MODULE = ".androidTest"
private const val PROJECT_FORMAT = "%1s."

val AnActionEvent.moduleName: String
Expand All @@ -56,7 +58,12 @@ val AnActionEvent.moduleName: String
val moduleName = ktFile?.module?.name ?: ""

val modules = moduleName.removePrefix(PROJECT_FORMAT.format(projectName))
val psiModule = modules.removeSuffix(ANDROID_TEST_MODULE)

val suffix = TestFlavor.entries.find { flavor ->
modules.endsWith(flavor.moduleFilter)
}?.moduleFilter.orEmpty()
val psiModule = modules.removeSuffix(suffix)

val gradleModule = psiModule.replace(".", ":")
println("$modules $psiModule $gradleModule")

Expand Down Expand Up @@ -85,20 +92,19 @@ val PsiElement.methodName: String
return methodName ?: "unknown"
}

val KtNamedFunction.testifyMethodInvocationPath: String
get() {
return ApplicationManager.getApplication().executeOnPooledThread(Callable {
ReadAction.compute<String, Throwable> {
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<String, Throwable> {
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() {
Expand All @@ -113,22 +119,27 @@ val KtClass.testifyClassInvocationPath: String
}

val KtNamedFunction.hasScreenshotAnnotation: Boolean
get() {
return ApplicationManager.getApplication().executeOnPooledThread(Callable {
ReadAction.compute<Boolean, Throwable> {
analyze(this@hasScreenshotAnnotation) {
this@hasScreenshotAnnotation.symbol
.annotations
.any {
it.classId?.asSingleFqName()?.asString() in listOf(
SCREENSHOT_INSTRUMENTATION,
SCREENSHOT_INSTRUMENTATION_LEGACY
)
}
}
get() = hasQualifyingAnnotation(setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY))

fun KtNamedFunction.hasQualifyingAnnotation(annotationClassIds: Set<String>): Boolean {
return ApplicationManager.getApplication().executeOnPooledThread(Callable {
ReadAction.compute<Boolean, Throwable> {
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<String>): KaAnnotation? {
return function.symbol.annotations.firstOrNull { annotation ->
annotationClassIds.any { FqName(it) == annotation.classId?.asSingleFqName() }
}
}

fun AnActionEvent.findScreenshotAnnotatedFunction(): KtNamedFunction? {
val psiFile = this.getData(PlatformDataKeys.PSI_FILE) ?: return null
Expand Down
97 changes: 97 additions & 0 deletions Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package dev.testify

import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiMethod
import dev.testify.extensions.PAPARAZZI_ANNOTATION
import dev.testify.extensions.PREVIEW_ANNOTATION
import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION
import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION_LEGACY
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtNamedFunction

typealias FindSourceMethod = (imageFile: VirtualFile, project: Project) -> PsiMethod?

data class GradleCommand(
val argumentFlag: String,
val classCommand: String,
val methodCommand: String
)

enum class TestFlavor(
val srcRoot: String,
val moduleFilter: String,
val qualifyingAnnotations: Set<String>,
val isClassEligible: Boolean,
val methodInvocationPath: (className: String?, methodName: String?) -> String,
val testGradleCommands: GradleCommand,
val recordGradleCommands: GradleCommand,
val findSourceMethod: FindSourceMethod
) {
Testify(
srcRoot = "androidTest",
moduleFilter = ".androidTest",
qualifyingAnnotations = setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY),
isClassEligible = true,
methodInvocationPath = { className, methodName -> "$className#$methodName" },
testGradleCommands = GradleCommand(
argumentFlag = "-PtestClass=$1",
classCommand = "screenshotTest",
methodCommand = "screenshotTest"
),
recordGradleCommands = GradleCommand(
argumentFlag = "-PtestClass=$1",
classCommand = "screenshotRecord",
methodCommand = "screenshotRecord"
),
findSourceMethod = ::findTestifyMethod
),

Paparazzi(
srcRoot = "test",
moduleFilter = ".unitTest",
qualifyingAnnotations = setOf(PAPARAZZI_ANNOTATION),
isClassEligible = true,
methodInvocationPath = { className, methodName -> "$className*$methodName" },
testGradleCommands = GradleCommand(
argumentFlag = "--rerun-tasks --tests '$1'",
classCommand = "verifyPaparazziDebug",
methodCommand = "verifyPaparazziDebug"
),
recordGradleCommands = GradleCommand(
argumentFlag = "--rerun-tasks --tests '$1'",
classCommand = "recordPaparazziDebug",
methodCommand = "recordPaparazziDebug"
),
findSourceMethod = ::findPaparazziMethod
),

Preview(
srcRoot = "screenshotTest",
moduleFilter = ".screenshotTest",
qualifyingAnnotations = setOf(PREVIEW_ANNOTATION),
isClassEligible = false, // TODO: This is just for now, eventually we may want class-level markers too
methodInvocationPath = { className, methodName -> "$className*$methodName" },
testGradleCommands = GradleCommand(
argumentFlag = "--rerun-tasks --tests '$1'",
classCommand = "validateDebugScreenshotTest",
methodCommand = "validateDebugScreenshotTest"
),
recordGradleCommands = GradleCommand(
argumentFlag = "--updateFilter '$1'",
classCommand = "updateDebugScreenshotTest", // TODO: Need to parameterize for build variant
methodCommand = "updateDebugScreenshotTest"
),
findSourceMethod = ::findPreviewMethod
)
}

fun PsiElement.determineTestFlavor(): TestFlavor? {
if (this !is KtElement) return null
val path = this.containingKtFile.virtualFilePath
return TestFlavor.entries.find { "/${it.srcRoot}/" in path }
}

fun TestFlavor.isQualifying(functions: Set<KtNamedFunction>): Boolean =
functions.any { it.hasQualifyingAnnotation(this.qualifyingAnnotations) }
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
import com.intellij.psi.PsiElement
import dev.testify.GradleCommand
import dev.testify.TestFlavor
import dev.testify.methodName
import dev.testify.moduleName
import dev.testify.testifyClassInvocationPath
Expand All @@ -45,14 +47,16 @@ import org.jetbrains.plugins.gradle.action.GradleExecuteTaskAction
import org.jetbrains.plugins.gradle.settings.GradleSettings
import org.jetbrains.plugins.gradle.util.GradleConstants

abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnAction() {
abstract class BaseScreenshotAction(
private val anchorElement: PsiElement,
protected val testFlavor: TestFlavor
) : AnAction() {

override fun getActionUpdateThread() = ActionUpdateThread.BGT

abstract val classGradleCommand: String
abstract val classMenuText: String
abstract val gradleCommand: GradleCommand

abstract val methodGradleCommand: String
abstract val classMenuText: String
abstract val methodMenuText: String

abstract val icon: String
Expand All @@ -71,14 +75,22 @@ abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnA
return if (isClass()) (anchorElement as? KtClass)?.name else null
}

private fun String.toFullGradleCommand(event: AnActionEvent): String {
private fun String.toFullGradleCommand(
event: AnActionEvent,
argumentFlag: String
): String {
val arguments = when (anchorElement) {
is KtNamedFunction -> anchorElement.testifyMethodInvocationPath
is KtNamedFunction -> anchorElement.testifyMethodInvocationPath(testFlavor)
is KtClass -> anchorElement.testifyClassInvocationPath
else -> null
}
val command = ":${event.moduleName}:$this"
return if (arguments != null) "$command -PtestClass=$arguments" else command
return if (arguments != null) {
val argFormatted = argumentFlag.replace("$1", arguments)
"$command $argFormatted"
} else {
command
}
}

private fun isClass(): Boolean {
Expand All @@ -93,8 +105,9 @@ abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnA
val workingDirectory: String = executionContext.getProjectPath() ?: ""
val executor = RunAnythingAction.EXECUTOR_KEY.getData(dataContext)

val gradleCommand = if (isClass()) classGradleCommand else methodGradleCommand
val fullCommandLine = gradleCommand.toFullGradleCommand(event)
val argumentFlag = gradleCommand.argumentFlag
val gradleCommand = if (isClass()) gradleCommand.classCommand else gradleCommand.methodCommand
val fullCommandLine = gradleCommand.toFullGradleCommand(event, argumentFlag)
GradleExecuteTaskAction.runGradle(project, executor, workingDirectory, fullCommandLine)
}

Expand Down
Loading