From 88d3a7ec8d4f27c562b2f0cf7838b74f56c59c92 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 23 Nov 2025 17:27:48 -0500 Subject: [PATCH 01/20] Initial support for Paparazzi and Preview tests --- ...otlin-compiler-10187843400902363094.salive | 0 Plugins/IntelliJ/CHANGELOG.md | 2 + Plugins/IntelliJ/gradle.properties | 6 +-- .../main/kotlin/dev/testify/PsiExtensions.kt | 37 ++++++++++------- .../src/main/kotlin/dev/testify/TestFlavor.kt | 40 +++++++++++++++++++ .../ScreenshotClassMarkerProvider.kt | 15 ++++--- ...enshotInstrumentationLineMarkerProvider.kt | 14 +++---- .../dev/testify/extensions/TooltipProvider.kt | 2 + 8 files changed, 86 insertions(+), 30 deletions(-) create mode 100644 Plugins/IntelliJ/.kotlin/sessions/kotlin-compiler-10187843400902363094.salive create mode 100644 Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt diff --git a/Plugins/IntelliJ/.kotlin/sessions/kotlin-compiler-10187843400902363094.salive b/Plugins/IntelliJ/.kotlin/sessions/kotlin-compiler-10187843400902363094.salive new file mode 100644 index 000000000..e69de29bb diff --git a/Plugins/IntelliJ/CHANGELOG.md b/Plugins/IntelliJ/CHANGELOG.md index c4bfe9736..697fabe63 100644 --- a/Plugins/IntelliJ/CHANGELOG.md +++ b/Plugins/IntelliJ/CHANGELOG.md @@ -2,6 +2,8 @@ # Android Testify - IntelliJ Platform Plugin - Change Log +## [Unreleased] + ## [3.0.0] - Added support for Support Android Studio Narwhal | 2025.1.1 Canary 9 | 251.+ diff --git a/Plugins/IntelliJ/gradle.properties b/Plugins/IntelliJ/gradle.properties index b088e262d..dc752f95c 100644 --- a/Plugins/IntelliJ/gradle.properties +++ b/Plugins/IntelliJ/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = dev.testify pluginName = Android Testify - Screenshot Instrumentation Tests pluginRepositoryUrl = https://github.com/ndtp/android-testify/tree/main/Plugins/IntelliJ # SemVer format -> https://semver.org -pluginVersion = 3.0.0 +pluginVersion = 4.0.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 242 @@ -13,8 +13,8 @@ pluginUntilBuild = 252.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = AI -# Narwhal 2025.1.1 Canary 9 -platformVersion = 2025.1.1.9 +# Otter 2025.2.1 +platformVersion = 2025.2.1.1 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 1e926f360..6241ff945 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -34,16 +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 +import kotlin.collections.contains private const val ANDROID_TEST_MODULE = ".androidTest" private const val PROJECT_FORMAT = "%1s." @@ -113,22 +117,27 @@ val KtClass.testifyClassInvocationPath: String } val KtNamedFunction.hasScreenshotAnnotation: Boolean - get() { - return ApplicationManager.getApplication().executeOnPooledThread(Callable { - ReadAction.compute { - analyze(this@hasScreenshotAnnotation) { - this@hasScreenshotAnnotation.symbol - .annotations - .any { - it.classId?.asSingleFqName()?.asString() in listOf( - SCREENSHOT_INSTRUMENTATION, - SCREENSHOT_INSTRUMENTATION_LEGACY - ) - } - } + get() = hasQualifyingAnnotation(setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY)) + +fun KtNamedFunction.hasQualifyingAnnotation(annotationClassIds: Set): Boolean { + return ApplicationManager.getApplication().executeOnPooledThread(Callable { + ReadAction.compute { + analyze(this@hasQualifyingAnnotation) { + this@hasQualifyingAnnotation.symbol + .annotations + .any { + it.classId?.asSingleFqName()?.asString() in annotationClassIds + } } - }).get() ?: false + } + }).get() ?: false +} + +fun KaSession.getQualifyingAnnotation(function: KtNamedFunction, annotationClassIds: Set): KaAnnotation? { + return function.symbol.annotations.firstOrNull { annotation -> + annotationClassIds.any { FqName(it) == annotation.classId?.asSingleFqName() } } +} fun AnActionEvent.findScreenshotAnnotatedFunction(): KtNamedFunction? { val psiFile = this.getData(PlatformDataKeys.PSI_FILE) ?: return null diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt new file mode 100644 index 000000000..105893407 --- /dev/null +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -0,0 +1,40 @@ +package dev.testify + +import com.intellij.psi.PsiElement +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 + +enum class TestFlavor( + val path: String, + val qualifyingAnnotations: Set, + val isClassEligible: Boolean +) { + Testify( + path = "androidTest", + qualifyingAnnotations = setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY), + isClassEligible = true + ), + Paparazzi( + path = "test", + qualifyingAnnotations = setOf(PAPARAZZI_ANNOTATION), + isClassEligible = true + ), + Preview( + path = "screenshotTest", + qualifyingAnnotations = setOf(PREVIEW_ANNOTATION), + isClassEligible = false // TODO: This is just for now, eventually we may want class-level markers too + ) +} + +fun PsiElement.determineTestFlavor(): TestFlavor? { + if (this !is KtElement) return null + val path = this.containingKtFile.virtualFilePath + return TestFlavor.entries.find { "/${it.path}/" in path } +} + +fun TestFlavor.isQualifying(functions: Set): Boolean = + functions.any { it.hasQualifyingAnnotation(this.qualifyingAnnotations) } 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..ee26a07d2 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt @@ -29,7 +29,10 @@ import com.intellij.codeInsight.daemon.LineMarkerProvider import com.intellij.openapi.editor.markup.GutterIconRenderer import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil +import dev.testify.TestFlavor +import dev.testify.determineTestFlavor import dev.testify.hasScreenshotAnnotation +import dev.testify.isQualifying import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction @@ -42,15 +45,15 @@ class ScreenshotClassMarkerProvider : LineMarkerProvider { override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? { if (element !is KtClass) return null - if (!element.containingKtFile.virtualFilePath.contains("androidTest")) return null - return element.getLineMarkerInfo() + val testFlavor = element.determineTestFlavor() ?: return null + return element.getLineMarkerInfo(testFlavor) } - private fun KtClass.getLineMarkerInfo(): LineMarkerInfo? { - - val functions = PsiTreeUtil.findChildrenOfType(this, KtNamedFunction::class.java) + private fun KtClass.getLineMarkerInfo(testFlavor: TestFlavor): LineMarkerInfo? { + if (testFlavor.isClassEligible.not()) return null + val functions: Set = PsiTreeUtil.findChildrenOfType(this, KtNamedFunction::class.java).filterNotNull().toSet() if (functions.isEmpty()) return null - if (functions.none(KtNamedFunction::hasScreenshotAnnotation)) return null + if (testFlavor.isQualifying(functions).not()) return null val anchorElement = this.nameIdentifier ?: return null 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..9d4394d00 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,16 +44,13 @@ 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, 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" From ca1772d3f31196187e1cd478ef512801e2d99447 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 23 Nov 2025 19:44:10 -0500 Subject: [PATCH 02/20] Support test and record --- .../main/kotlin/dev/testify/PsiExtensions.kt | 26 +++++----- .../src/main/kotlin/dev/testify/TestFlavor.kt | 52 +++++++++++++++++-- .../screenshot/BaseScreenshotAction.kt | 26 ++++++---- .../screenshot/ScreenshotClearAction.kt | 16 +++--- .../screenshot/ScreenshotPullAction.kt | 16 +++--- .../screenshot/ScreenshotRecordAction.kt | 12 ++--- .../screenshot/ScreenshotTestAction.kt | 12 ++--- .../testify/actions/utility/BaseFileAction.kt | 6 ++- .../actions/utility/BaseUtilityAction.kt | 1 + .../actions/utility/DeleteBaselineAction.kt | 3 +- .../actions/utility/GoToBaselineAction.kt | 1 + .../actions/utility/GoToSourceAction.kt | 1 + .../actions/utility/RevealBaselineAction.kt | 3 +- .../ScreenshotClassMarkerProvider.kt | 2 +- .../extensions/ScreenshotClassNavHandler.kt | 14 +++-- ...shotInstrumentationAnnotationNavHandler.kt | 18 ++++--- ...enshotInstrumentationLineMarkerProvider.kt | 2 +- 17 files changed, 143 insertions(+), 68 deletions(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 6241ff945..245763970 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -47,7 +47,6 @@ import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.psiUtil.parents import java.util.concurrent.Callable -import kotlin.collections.contains private const val ANDROID_TEST_MODULE = ".androidTest" private const val PROJECT_FORMAT = "%1s." @@ -89,20 +88,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() { diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt index 105893407..e980a1092 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -8,25 +8,69 @@ import dev.testify.extensions.SCREENSHOT_INSTRUMENTATION_LEGACY import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtNamedFunction +data class GradleCommand( + val argumentFlag: String, + val classCommand: String, + val methodCommand: String +) + enum class TestFlavor( val path: String, val qualifyingAnnotations: Set, - val isClassEligible: Boolean + val isClassEligible: Boolean, + val methodInvocationPath: (className: String?, methodName: String?) -> String, + val testGradleCommands: GradleCommand, + val recordGradleCommands: GradleCommand, ) { Testify( path = "androidTest", qualifyingAnnotations = setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY), - isClassEligible = true + isClassEligible = true, + methodInvocationPath = { className, methodName -> "$className#$methodName" }, + testGradleCommands = GradleCommand( + argumentFlag = "-PtestClass=", + classCommand = "screenshotTest", + methodCommand = "screenshotTest" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "-PtestClass=", + classCommand = "screenshotRecord", + methodCommand = "screenshotRecord" + ) ), + Paparazzi( path = "test", qualifyingAnnotations = setOf(PAPARAZZI_ANNOTATION), - isClassEligible = true + isClassEligible = true, + methodInvocationPath = { className, methodName -> "$className*$methodName" }, + testGradleCommands = GradleCommand( + argumentFlag = "--rerun-tasks --tests", + classCommand = "verifyPaparazziDebug", + methodCommand = "verifyPaparazziDebug" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "--updateFilter", + classCommand = "recordPaparazziDebug", + methodCommand = "recordPaparazziDebug" + ) ), + Preview( path = "screenshotTest", qualifyingAnnotations = setOf(PREVIEW_ANNOTATION), - isClassEligible = false // TODO: This is just for now, eventually we may want class-level markers too + 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", + classCommand = "validateDebugScreenshotTest", + methodCommand = "validateDebugScreenshotTest" + ), + recordGradleCommands = GradleCommand( + argumentFlag = "--updateFilter", + classCommand = "updateDebugScreenshotTest", // TODO: Need to parameterize for build variant + methodCommand = "updateDebugScreenshotTest" + ) ) } 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..273f044b9 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/BaseScreenshotAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/BaseScreenshotAction.kt @@ -35,6 +35,8 @@ import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor import dev.testify.methodName import dev.testify.moduleName import dev.testify.testifyClassInvocationPath @@ -45,14 +47,16 @@ import org.jetbrains.plugins.gradle.action.GradleExecuteTaskAction import org.jetbrains.plugins.gradle.settings.GradleSettings import org.jetbrains.plugins.gradle.util.GradleConstants -abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnAction() { +abstract class BaseScreenshotAction( + private val anchorElement: PsiElement, + protected val testFlavor: TestFlavor +) : AnAction() { override fun getActionUpdateThread() = ActionUpdateThread.BGT - abstract val classGradleCommand: String - abstract val classMenuText: String + abstract val gradleCommand: GradleCommand - abstract val methodGradleCommand: String + abstract val classMenuText: String abstract val methodMenuText: String abstract val icon: String @@ -71,14 +75,17 @@ 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) "$command $argumentFlag$arguments" else command } private fun isClass(): Boolean { @@ -93,8 +100,9 @@ abstract class BaseScreenshotAction(private val anchorElement: PsiElement) : AnA val workingDirectory: String = executionContext.getProjectPath() ?: "" val executor = RunAnythingAction.EXECUTOR_KEY.getData(dataContext) - val gradleCommand = if (isClass()) classGradleCommand else methodGradleCommand - val fullCommandLine = gradleCommand.toFullGradleCommand(event) + val argumentFlag = gradleCommand.argumentFlag + val gradleCommand = if (isClass()) gradleCommand.classCommand else gradleCommand.methodCommand + val fullCommandLine = gradleCommand.toFullGradleCommand(event, argumentFlag) GradleExecuteTaskAction.runGradle(project, executor, workingDirectory, fullCommandLine) } diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotClearAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotClearAction.kt index 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..ff62c0c9a 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotPullAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotPullAction.kt @@ -25,18 +25,22 @@ package dev.testify.actions.screenshot import com.intellij.psi.PsiElement +import dev.testify.GradleCommand +import dev.testify.TestFlavor -class ScreenshotPullAction(anchorElement: PsiElement) : BaseScreenshotAction(anchorElement) { +class ScreenshotPullAction(anchorElement: PsiElement, testFlavor: TestFlavor) : + BaseScreenshotAction(anchorElement, testFlavor) { - override val classGradleCommand: String - get() = "screenshotPull" + override val gradleCommand: GradleCommand + get() = GradleCommand( + argumentFlag = "-PtestClass=", + classCommand = "screenshotPull", + methodCommand = "screenshotPull" + ) override val classMenuText: String get() = "Pull all screenshots for '$className'" - override val methodGradleCommand: String - get() = "screenshotPull" - override val methodMenuText: String get() = "Pull screenshots for '$methodName()'" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotRecordAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotRecordAction.kt index 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..23be81fc7 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 @@ -37,6 +37,7 @@ import com.intellij.psi.PsiMethod 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 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..97736de82 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToBaselineAction.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToBaselineAction.kt @@ -26,6 +26,7 @@ package dev.testify.actions.utility import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.fileEditor.FileEditorManager +import dev.testify.TestFlavor import dev.testify.baselineImageName import dev.testify.findScreenshotAnnotatedFunction diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToSourceAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/utility/GoToSourceAction.kt index 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 ee26a07d2..67693af53 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt @@ -62,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..b121338e5 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt @@ -37,6 +37,7 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiUtilCore import com.intellij.ui.awt.RelativePoint +import dev.testify.TestFlavor import dev.testify.actions.screenshot.ScreenshotClearAction import dev.testify.actions.screenshot.ScreenshotPullAction import dev.testify.actions.screenshot.ScreenshotRecordAction @@ -44,7 +45,10 @@ import dev.testify.actions.screenshot.ScreenshotTestAction import java.awt.event.ComponentEvent import java.awt.event.MouseEvent -class ScreenshotClassNavHandler(private val anchorElement: PsiElement) : GutterIconNavigationHandler { +class ScreenshotClassNavHandler( + private val anchorElement: PsiElement, + private val testFlavor: TestFlavor +) : GutterIconNavigationHandler { override fun navigate(e: MouseEvent?, nameIdentifier: PsiElement) { if (e == null) return @@ -69,10 +73,10 @@ class ScreenshotClassNavHandler(private val anchorElement: PsiElement) : GutterI private fun createActionGroupPopup(event: ComponentEvent, anchorElement: PsiElement): JBPopup { val group = DefaultActionGroup( - ScreenshotTestAction(anchorElement), - ScreenshotRecordAction(anchorElement), - ScreenshotPullAction(anchorElement), - ScreenshotClearAction(anchorElement) + ScreenshotTestAction(anchorElement, testFlavor), + ScreenshotRecordAction(anchorElement, testFlavor), + ScreenshotPullAction(anchorElement, testFlavor), + ScreenshotClearAction(anchorElement, testFlavor), ) val dataContext = DataManager.getInstance().getDataContext(event.component) return JBPopupFactory.getInstance().createActionGroupPopup( diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt index 402973816..f1d03b379 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt @@ -37,6 +37,7 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.util.PsiUtilCore import com.intellij.ui.awt.RelativePoint +import dev.testify.TestFlavor import dev.testify.actions.screenshot.ScreenshotClearAction import dev.testify.actions.screenshot.ScreenshotPullAction import dev.testify.actions.screenshot.ScreenshotRecordAction @@ -46,7 +47,10 @@ import dev.testify.actions.utility.RevealBaselineAction import java.awt.event.ComponentEvent import java.awt.event.MouseEvent -class ScreenshotInstrumentationAnnotationNavHandler(private val anchorElement: PsiElement) : +class ScreenshotInstrumentationAnnotationNavHandler( + private val anchorElement: PsiElement, + private val testFlavor: TestFlavor +) : GutterIconNavigationHandler { override fun navigate(e: MouseEvent?, nameIdentifier: PsiElement) { @@ -72,12 +76,12 @@ class ScreenshotInstrumentationAnnotationNavHandler(private val anchorElement: P private fun createActionGroupPopup(event: ComponentEvent, anchorElement: PsiElement): JBPopup { val group = DefaultActionGroup( - ScreenshotTestAction(anchorElement), - ScreenshotRecordAction(anchorElement), - ScreenshotPullAction(anchorElement), - ScreenshotClearAction(anchorElement), - RevealBaselineAction(anchorElement), - DeleteBaselineAction(anchorElement) + ScreenshotTestAction(anchorElement, testFlavor), + ScreenshotRecordAction(anchorElement, testFlavor), + ScreenshotPullAction(anchorElement, testFlavor), + ScreenshotClearAction(anchorElement, testFlavor), + RevealBaselineAction(anchorElement, testFlavor), + DeleteBaselineAction(anchorElement, testFlavor) ) val dataContext = DataManager.getInstance().getDataContext(event.component) return JBPopupFactory.getInstance().createActionGroupPopup( diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt index 9d4394d00..d393f6370 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationLineMarkerProvider.kt @@ -57,7 +57,7 @@ class ScreenshotInstrumentationLineMarkerProvider : LineMarkerProvider { anchorElement.textRange, IconHelper.ICON_CAMERA, { "Android Testify Commands" }, - ScreenshotInstrumentationAnnotationNavHandler(this@getLineMarkerInfo), + ScreenshotInstrumentationAnnotationNavHandler(this@getLineMarkerInfo, testFlavor), GutterIconRenderer.Alignment.RIGHT, { "" } ) From 18031c98d5a09f71f3d7b18345929341b22ef275 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 23 Nov 2025 19:53:08 -0500 Subject: [PATCH 03/20] Improve test/record support --- .../src/main/kotlin/dev/testify/TestFlavor.kt | 12 ++++++------ .../actions/screenshot/BaseScreenshotAction.kt | 7 ++++++- .../composables/CastMemberScreenshotTest.kt | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt index e980a1092..c4fc7643c 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -28,12 +28,12 @@ enum class TestFlavor( isClassEligible = true, methodInvocationPath = { className, methodName -> "$className#$methodName" }, testGradleCommands = GradleCommand( - argumentFlag = "-PtestClass=", + argumentFlag = "-PtestClass=$1", classCommand = "screenshotTest", methodCommand = "screenshotTest" ), recordGradleCommands = GradleCommand( - argumentFlag = "-PtestClass=", + argumentFlag = "-PtestClass=$1", classCommand = "screenshotRecord", methodCommand = "screenshotRecord" ) @@ -45,12 +45,12 @@ enum class TestFlavor( isClassEligible = true, methodInvocationPath = { className, methodName -> "$className*$methodName" }, testGradleCommands = GradleCommand( - argumentFlag = "--rerun-tasks --tests", + argumentFlag = "--rerun-tasks --tests '$1'", classCommand = "verifyPaparazziDebug", methodCommand = "verifyPaparazziDebug" ), recordGradleCommands = GradleCommand( - argumentFlag = "--updateFilter", + argumentFlag = "--updateFilter '$1'", classCommand = "recordPaparazziDebug", methodCommand = "recordPaparazziDebug" ) @@ -62,12 +62,12 @@ enum class TestFlavor( 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", + argumentFlag = "--rerun-tasks --tests '$1'", classCommand = "validateDebugScreenshotTest", methodCommand = "validateDebugScreenshotTest" ), recordGradleCommands = GradleCommand( - argumentFlag = "--updateFilter", + argumentFlag = "--updateFilter '$1'", classCommand = "updateDebugScreenshotTest", // TODO: Need to parameterize for build variant methodCommand = "updateDebugScreenshotTest" ) 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 273f044b9..3045e7828 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 @@ -85,7 +85,12 @@ abstract class BaseScreenshotAction( else -> null } val command = ":${event.moduleName}:$this" - return if (arguments != null) "$command $argumentFlag$arguments" else command + return if (arguments != null) { + val argFormatted = argumentFlag.replace("$1", arguments) + "$command $argFormatted" + } else { + command + } } private fun isClass(): Boolean { 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 2a6842635..1cf938df1 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() { From 10edf3b8975f6bbff9b9def962ed1388df1407e4 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 23 Nov 2025 20:11:45 -0500 Subject: [PATCH 04/20] Paparazzi record and test are working --- .../main/kotlin/dev/testify/PsiExtensions.kt | 8 ++++++-- .../src/main/kotlin/dev/testify/TestFlavor.kt | 16 ++++++++++------ ...n.composables_CastMemberScreenshotTest_b.png | Bin 0 -> 506 bytes 3 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 Samples/Paparazzi/src/test/snapshots/images/dev.testify.samples.paparazzi.ui.common.composables_CastMemberScreenshotTest_b.png diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 245763970..14081da22 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -48,7 +48,6 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.psiUtil.parents import java.util.concurrent.Callable -private const val ANDROID_TEST_MODULE = ".androidTest" private const val PROJECT_FORMAT = "%1s." val AnActionEvent.moduleName: String @@ -59,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") diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt index c4fc7643c..5d7ed530c 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -15,7 +15,8 @@ data class GradleCommand( ) enum class TestFlavor( - val path: String, + val srcRoot: String, + val moduleFilter: String, val qualifyingAnnotations: Set, val isClassEligible: Boolean, val methodInvocationPath: (className: String?, methodName: String?) -> String, @@ -23,7 +24,8 @@ enum class TestFlavor( val recordGradleCommands: GradleCommand, ) { Testify( - path = "androidTest", + srcRoot = "androidTest", + moduleFilter = ".androidTest", qualifyingAnnotations = setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY), isClassEligible = true, methodInvocationPath = { className, methodName -> "$className#$methodName" }, @@ -40,7 +42,8 @@ enum class TestFlavor( ), Paparazzi( - path = "test", + srcRoot = "test", + moduleFilter = ".unitTest", qualifyingAnnotations = setOf(PAPARAZZI_ANNOTATION), isClassEligible = true, methodInvocationPath = { className, methodName -> "$className*$methodName" }, @@ -50,14 +53,15 @@ enum class TestFlavor( methodCommand = "verifyPaparazziDebug" ), recordGradleCommands = GradleCommand( - argumentFlag = "--updateFilter '$1'", + argumentFlag = "--rerun-tasks --tests '$1'", classCommand = "recordPaparazziDebug", methodCommand = "recordPaparazziDebug" ) ), Preview( - path = "screenshotTest", + 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" }, @@ -77,7 +81,7 @@ enum class TestFlavor( fun PsiElement.determineTestFlavor(): TestFlavor? { if (this !is KtElement) return null val path = this.containingKtFile.virtualFilePath - return TestFlavor.entries.find { "/${it.path}/" in path } + return TestFlavor.entries.find { "/${it.srcRoot}/" in path } } fun TestFlavor.isQualifying(functions: Set): Boolean = 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 0000000000000000000000000000000000000000..7f4be19e9d1fac2af677129c7af8815b9e9e0942 GIT binary patch literal 506 zcmV&@9jNx zPTt(>9(mqdx6be#pZ9al`+cA9^GNsm9U>(e00Z#P0OfKSsZ{Ev(&;p^*({31BHHaX z7K?>`KqL}@BuQ^891deV9*YC0YPDMW0ZykA!{P8rgTVmZZWonG1%AK(2{Iau^a0#% z_pb)tZa36wH4YGq#pnR4qS2^UXEK?v*=%S5jYfm}i$}vr0KHz11N8fST0pbeM&@!k?(kqRi1Ya@rjs+74EFoIn5bYL ztX3 Date: Mon, 24 Nov 2025 09:13:41 -0500 Subject: [PATCH 05/20] Update .gitignore --- .gitignore | 1 + .../.kotlin/sessions/kotlin-compiler-10187843400902363094.salive | 0 2 files changed, 1 insertion(+) delete mode 100644 Plugins/IntelliJ/.kotlin/sessions/kotlin-compiler-10187843400902363094.salive 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/.kotlin/sessions/kotlin-compiler-10187843400902363094.salive b/Plugins/IntelliJ/.kotlin/sessions/kotlin-compiler-10187843400902363094.salive deleted file mode 100644 index e69de29bb..000000000 From 251ed1b4f6bd55ec16fcc00f1964a0538bb105d7 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Mon, 24 Nov 2025 09:37:28 -0500 Subject: [PATCH 06/20] Add support for "go to source" from all baseline image types --- .../main/kotlin/dev/testify/FileUtilities.kt | 65 +++++++++++++++++++ .../src/main/kotlin/dev/testify/TestFlavor.kt | 15 ++++- .../actions/utility/BaseUtilityAction.kt | 23 +------ 3 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt 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..2d0ec9610 --- /dev/null +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt @@ -0,0 +1,65 @@ +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): PsiClass? { + val psiShortNamesCache = PsiShortNamesCache.getInstance(project) + val classes = psiShortNamesCache.getClassesByName(className, GlobalSearchScope.projectScope(project)) + return 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: ./src/test/snapshots/images/dev.testify.samples.paparazzi.ui.common.composables_CastMemberScreenshotTest_default.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 (_, className, methodName) = parts + // _ = composables + // className = CastMemberScreenshotTest + // methodName = default + findClassByName(className, project)?.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/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt index 5d7ed530c..e972b38d6 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -1,6 +1,9 @@ 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 @@ -8,6 +11,8 @@ 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, @@ -22,6 +27,7 @@ enum class TestFlavor( val methodInvocationPath: (className: String?, methodName: String?) -> String, val testGradleCommands: GradleCommand, val recordGradleCommands: GradleCommand, + val findSourceMethod: FindSourceMethod ) { Testify( srcRoot = "androidTest", @@ -38,7 +44,8 @@ enum class TestFlavor( argumentFlag = "-PtestClass=$1", classCommand = "screenshotRecord", methodCommand = "screenshotRecord" - ) + ), + findSourceMethod = ::findTestifyMethod ), Paparazzi( @@ -56,7 +63,8 @@ enum class TestFlavor( argumentFlag = "--rerun-tasks --tests '$1'", classCommand = "recordPaparazziDebug", methodCommand = "recordPaparazziDebug" - ) + ), + findSourceMethod = ::findPaparazziMethod ), Preview( @@ -74,7 +82,8 @@ enum class TestFlavor( argumentFlag = "--updateFilter '$1'", classCommand = "updateDebugScreenshotTest", // TODO: Need to parameterize for build variant methodCommand = "updateDebugScreenshotTest" - ) + ), + findSourceMethod = ::findPreviewMethod ) } 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 23be81fc7..0176fdbae 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 @@ -35,8 +35,6 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.PsiMethod 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 @@ -47,22 +45,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) @@ -73,14 +61,9 @@ 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 From d448f36104353dc14e2ad3964e65cb6a8ee70690 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Mon, 24 Nov 2025 09:59:54 -0500 Subject: [PATCH 07/20] Improve "go to image" support --- .../main/kotlin/dev/testify/FileUtilities.kt | 17 +++++++---- .../actions/utility/BaseUtilityAction.kt | 28 ++++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt index 2d0ec9610..447dbe256 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/FileUtilities.kt @@ -7,10 +7,17 @@ import com.intellij.psi.PsiMethod import com.intellij.psi.search.GlobalSearchScope import com.intellij.psi.search.PsiShortNamesCache -fun findClassByName(className: String, project: Project): PsiClass? { +fun findClassByName(className: String, project: Project, packageName: String? = null): PsiClass? { val psiShortNamesCache = PsiShortNamesCache.getInstance(project) val classes = psiShortNamesCache.getClassesByName(className, GlobalSearchScope.projectScope(project)) - return classes.firstOrNull() + return if (packageName != null) { + classes.firstOrNull { psiClass -> + val pkg = psiClass.qualifiedName?.substringBeforeLast(".") + packageName == pkg + } + } else { + classes.firstOrNull() + } } fun findMethod(methodName: String, psiClass: PsiClass): PsiMethod? { @@ -32,7 +39,7 @@ fun findTestifyMethod(imageFile: VirtualFile, project: Project): PsiMethod? { } /** - * Input: ./src/test/snapshots/images/dev.testify.samples.paparazzi.ui.common.composables_CastMemberScreenshotTest_default.png + * 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 @@ -40,11 +47,11 @@ fun findPaparazziMethod(imageFile: VirtualFile, project: Project): PsiMethod? { // imageName = composables_CastMemberScreenshotTest_default val parts = imageName.split("_") if (parts.size == 3) { - val (_, className, methodName) = parts + val (packageName, className, methodName) = parts // _ = composables // className = CastMemberScreenshotTest // methodName = default - findClassByName(className, project)?.let { psiClass -> + findClassByName(className, project, packageName)?.let { psiClass -> return findMethod(methodName, psiClass) } } 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 0176fdbae..fe428fce6 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,13 +28,16 @@ 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 dev.testify.TestFlavor import dev.testify.baselineImageName import dev.testify.getVirtualFile @@ -69,9 +72,32 @@ abstract class BaseUtilityAction : AnAction() { return null } + fun findFilesByPartialNameOrRegex( + project: Project, + partialName: String? = null, + regex: Regex? = null, + scope: GlobalSearchScope = GlobalSearchScope.projectScope(project) + ): List { + val fileType = FileTypeManager.getInstance().getStdFileType("Image") + val allFiles = FileTypeIndex.getFiles(fileType, scope) + val fileList = allFiles.filter { file -> + when { + partialName != null && file.path.contains(partialName, ignoreCase = true) -> true + regex != null && regex.matches(file.path) -> true + else -> false + } + } + + return fileList + } + protected fun findBaselineImage(currentFile: PsiFile, baselineImageName: String): VirtualFile? { if (currentFile is KtFile && currentFile.module != null) { - val files = FilenameIndex.getVirtualFilesByName(baselineImageName, currentFile.module!!.moduleContentScope) +// val files = FilenameIndex.getVirtualFilesByName(baselineImageName, currentFile.module!!.moduleContentScope) + val files = findFilesByPartialNameOrRegex( + project = currentFile.project, + partialName = baselineImageName + ) if (files.isNotEmpty()) { return files.first() } From 50e1e058d147f87626127aaef67ec840c19f0fd4 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Mon, 5 Jan 2026 21:16:18 -0500 Subject: [PATCH 08/20] Declare explicit dependency on Android Studio --- Plugins/IntelliJ/build.gradle.kts | 4 +++- Plugins/IntelliJ/gradle.properties | 7 +++++-- Plugins/IntelliJ/gradle/libs.versions.toml | 2 +- Plugins/IntelliJ/src/main/resources/META-INF/plugin.xml | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) 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 dc752f95c..dfa8560a3 100644 --- a/Plugins/IntelliJ/gradle.properties +++ b/Plugins/IntelliJ/gradle.properties @@ -1,18 +1,21 @@ # 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 = 4.0.0 +pluginVersion = 4.0.0-alpha02 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 242 -pluginUntilBuild = 252.* +pluginUntilBuild = 253.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType = AI +# https://plugins.jetbrains.com/docs/intellij/android-studio-releases-list.html # Otter 2025.2.1 platformVersion = 2025.2.1.1 diff --git a/Plugins/IntelliJ/gradle/libs.versions.toml b/Plugins/IntelliJ/gradle/libs.versions.toml index 1fd5723d5..15d61fbfd 100644 --- a/Plugins/IntelliJ/gradle/libs.versions.toml +++ b/Plugins/IntelliJ/gradle/libs.versions.toml @@ -5,7 +5,7 @@ opentest4j = "1.3.0" # plugins changelog = "2.2.1" -intelliJPlatform = "2.5.0" +intelliJPlatform = "2.10.5" kotlin = "2.1.20" kover = "0.9.1" qodana = "2024.3.4" 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 From 0782446f5ac702abe7c053ff1c82cf95dac295d3 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 24 Jan 2026 15:13:50 -0500 Subject: [PATCH 09/20] Set minimum IDE support to 252.* --- Plugins/IntelliJ/gradle.properties | 7 ++++--- Plugins/IntelliJ/gradle/libs.versions.toml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Plugins/IntelliJ/gradle.properties b/Plugins/IntelliJ/gradle.properties index dfa8560a3..13da899f6 100644 --- a/Plugins/IntelliJ/gradle.properties +++ b/Plugins/IntelliJ/gradle.properties @@ -6,10 +6,11 @@ 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 = 4.0.0-alpha02 +pluginVersion = 4.0.0-alpha03 + # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -pluginSinceBuild = 242 +pluginSinceBuild = 252 pluginUntilBuild = 253.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension @@ -17,7 +18,7 @@ platformType = AI # https://plugins.jetbrains.com/docs/intellij/android-studio-releases-list.html # Otter 2025.2.1 -platformVersion = 2025.2.1.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 15d61fbfd..849c07d16 100644 --- a/Plugins/IntelliJ/gradle/libs.versions.toml +++ b/Plugins/IntelliJ/gradle/libs.versions.toml @@ -6,7 +6,7 @@ opentest4j = "1.3.0" # plugins changelog = "2.2.1" intelliJPlatform = "2.10.5" -kotlin = "2.1.20" +kotlin = "2.3.0" kover = "0.9.1" qodana = "2024.3.4" From eafe89b1fcf50a9efb0a1864d35f863a1046baea Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 24 Jan 2026 15:14:09 -0500 Subject: [PATCH 10/20] Add AGENTS.md file --- Plugins/IntelliJ/AGENTS.md | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Plugins/IntelliJ/AGENTS.md diff --git a/Plugins/IntelliJ/AGENTS.md b/Plugins/IntelliJ/AGENTS.md new file mode 100644 index 000000000..c4753bf72 --- /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 build` +* **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. From 57e25faed073e65438b1f7f043ae1a191744e758 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 24 Jan 2026 15:14:17 -0500 Subject: [PATCH 11/20] Add example junit test (non-paparazzi) --- .../ui/common/composables/UnitTestExample.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 Samples/Paparazzi/src/test/java/dev/testify/samples/paparazzi/ui/common/composables/UnitTestExample.kt 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 000000000..2d67a76a2 --- /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) + } +} From 0c092944e6e94567bbf56152084156dd6ab44a77 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 24 Jan 2026 15:42:07 -0500 Subject: [PATCH 12/20] Filter only Paparazzi tests. Exclude raw unit tests --- .../main/kotlin/dev/testify/PsiExtensions.kt | 39 +++++++++++++++++++ .../src/main/kotlin/dev/testify/TestFlavor.kt | 13 ++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 14081da22..3deb0be71 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -38,14 +38,17 @@ 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.concurrent.Callable private const val PROJECT_FORMAT = "%1s." @@ -151,3 +154,39 @@ fun AnActionEvent.findScreenshotAnnotatedFunction(): KtNamedFunction? { 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.parents.filterIsInstance().firstOrNull() ?: return false + + 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 +} diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt index e972b38d6..3246dd5a4 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -89,8 +89,19 @@ enum class TestFlavor( fun PsiElement.determineTestFlavor(): TestFlavor? { if (this !is KtElement) return null + + if (this.hasPaparazziRule()) { + return TestFlavor.Paparazzi + } + val path = this.containingKtFile.virtualFilePath - return TestFlavor.entries.find { "/${it.srcRoot}/" in path } + val flavor = TestFlavor.entries.find { "/${it.srcRoot}/" in path } + + if (flavor == TestFlavor.Paparazzi) { + return null + } + + return flavor } fun TestFlavor.isQualifying(functions: Set): Boolean = From 97bf4a1cf27df5ee0c2f790ebc5bcede98dfe444 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 24 Jan 2026 16:29:48 -0500 Subject: [PATCH 13/20] Support build variants --- .../main/kotlin/dev/testify/PsiExtensions.kt | 11 +++++++++++ .../src/main/kotlin/dev/testify/TestFlavor.kt | 18 ++++++++++-------- .../actions/screenshot/BaseScreenshotAction.kt | 12 ++++++++++-- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 3deb0be71..c421ce0d1 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -30,10 +30,12 @@ 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 @@ -49,6 +51,7 @@ 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 PROJECT_FORMAT = "%1s." @@ -73,6 +76,14 @@ val AnActionEvent.moduleName: String 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" diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt index 3246dd5a4..494c7900b 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -56,13 +56,13 @@ enum class TestFlavor( methodInvocationPath = { className, methodName -> "$className*$methodName" }, testGradleCommands = GradleCommand( argumentFlag = "--rerun-tasks --tests '$1'", - classCommand = "verifyPaparazziDebug", - methodCommand = "verifyPaparazziDebug" + classCommand = "verifyPaparazzi$Variant", + methodCommand = "verifyPaparazzi$Variant" ), recordGradleCommands = GradleCommand( argumentFlag = "--rerun-tasks --tests '$1'", - classCommand = "recordPaparazziDebug", - methodCommand = "recordPaparazziDebug" + classCommand = "recordPaparazzi$Variant", + methodCommand = "recordPaparazzi$Variant" ), findSourceMethod = ::findPaparazziMethod ), @@ -75,13 +75,13 @@ enum class TestFlavor( methodInvocationPath = { className, methodName -> "$className*$methodName" }, testGradleCommands = GradleCommand( argumentFlag = "--rerun-tasks --tests '$1'", - classCommand = "validateDebugScreenshotTest", - methodCommand = "validateDebugScreenshotTest" + classCommand = "validate${Variant}ScreenshotTest", + methodCommand = "validate${Variant}ScreenshotTest" ), recordGradleCommands = GradleCommand( argumentFlag = "--updateFilter '$1'", - classCommand = "updateDebugScreenshotTest", // TODO: Need to parameterize for build variant - methodCommand = "updateDebugScreenshotTest" + classCommand = "update${Variant}ScreenshotTest", + methodCommand = "update${Variant}ScreenshotTest" ), findSourceMethod = ::findPreviewMethod ) @@ -106,3 +106,5 @@ fun PsiElement.determineTestFlavor(): TestFlavor? { fun TestFlavor.isQualifying(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 3045e7828..c707ae761 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 @@ -37,8 +37,10 @@ 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 @@ -106,8 +108,14 @@ abstract class BaseScreenshotAction( val executor = RunAnythingAction.EXECUTOR_KEY.getData(dataContext) val argumentFlag = gradleCommand.argumentFlag - val gradleCommand = if (isClass()) gradleCommand.classCommand else gradleCommand.methodCommand - val fullCommandLine = gradleCommand.toFullGradleCommand(event, 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) } From 41cac26f9fba90a0f248c5f627e089f0c46e73ac Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sat, 24 Jan 2026 17:20:52 -0500 Subject: [PATCH 14/20] Pull screenshots --- .../main/kotlin/dev/testify/PsiExtensions.kt | 42 +++++++++++ .../screenshot/BaseScreenshotAction.kt | 8 +-- .../screenshot/ScreenshotPullAction.kt | 72 +++++++++++++++++++ 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index c421ce0d1..414b0f6d2 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -201,3 +201,45 @@ fun KtElement.hasPaparazziRule(): Boolean { } }).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/actions/screenshot/BaseScreenshotAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/BaseScreenshotAction.kt index c707ae761..79c15ac39 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 @@ -50,7 +50,7 @@ import org.jetbrains.plugins.gradle.settings.GradleSettings import org.jetbrains.plugins.gradle.util.GradleConstants abstract class BaseScreenshotAction( - private val anchorElement: PsiElement, + protected val anchorElement: PsiElement, protected val testFlavor: TestFlavor ) : AnAction() { @@ -95,11 +95,11 @@ abstract class BaseScreenshotAction( } } - 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 = @@ -130,7 +130,7 @@ abstract class BaseScreenshotAction( } } - 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/ScreenshotPullAction.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/actions/screenshot/ScreenshotPullAction.kt index ff62c0c9a..85737df69 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,9 +24,24 @@ */ 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, testFlavor: TestFlavor) : BaseScreenshotAction(anchorElement, testFlavor) { @@ -45,4 +60,61 @@ class ScreenshotPullAction(anchorElement: PsiElement, testFlavor: TestFlavor) : get() = "Pull screenshots for '$methodName()'" override val icon = "pull" + + override fun actionPerformed(event: AnActionEvent) { + if (testFlavor == TestFlavor.Paparazzi) { + handlePaparazziPull(event) + } else { + super.actionPerformed(event) + } + } + + 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) + } } From e86523a619ccd6a24993a9aeac6aebd35f7a83e1 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 25 Jan 2026 14:24:43 -0500 Subject: [PATCH 15/20] Disable pull action when not baseline found --- .../screenshot/BaseScreenshotAction.kt | 2 +- .../screenshot/ScreenshotPullAction.kt | 28 +++++++++++++++++++ .../extensions/ScreenshotClassNavHandler.kt | 13 +++++---- ...shotInstrumentationAnnotationNavHandler.kt | 18 ++++++------ 4 files changed, 46 insertions(+), 15 deletions(-) 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 79c15ac39..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 @@ -119,7 +119,7 @@ abstract class BaseScreenshotAction( 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) 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 85737df69..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 @@ -69,6 +69,34 @@ class ScreenshotPullAction(anchorElement: PsiElement, testFlavor: TestFlavor) : } } + 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 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 b121338e5..5660b34b3 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassNavHandler.kt @@ -72,12 +72,13 @@ class ScreenshotClassNavHandler( private fun createActionGroupPopup(event: ComponentEvent, anchorElement: PsiElement): JBPopup { - val group = DefaultActionGroup( - ScreenshotTestAction(anchorElement, testFlavor), - ScreenshotRecordAction(anchorElement, testFlavor), - ScreenshotPullAction(anchorElement, testFlavor), - ScreenshotClearAction(anchorElement, testFlavor), - ) + 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 f1d03b379..5d2f5e891 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotInstrumentationAnnotationNavHandler.kt @@ -74,15 +74,17 @@ class ScreenshotInstrumentationAnnotationNavHandler( } 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, testFlavor), - ScreenshotRecordAction(anchorElement, testFlavor), - ScreenshotPullAction(anchorElement, testFlavor), - ScreenshotClearAction(anchorElement, testFlavor), - RevealBaselineAction(anchorElement, testFlavor), - DeleteBaselineAction(anchorElement, testFlavor) - ) val dataContext = DataManager.getInstance().getDataContext(event.component) return JBPopupFactory.getInstance().createActionGroupPopup( "", From fce67e09fea1f108e3bea142b5bd695531b7b15e Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Sun, 25 Jan 2026 15:53:55 -0500 Subject: [PATCH 16/20] Support class-level menu --- .../IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt | 7 ++++++- .../IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt | 2 +- .../testify/extensions/ScreenshotClassMarkerProvider.kt | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 414b0f6d2..95ae3090a 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -167,7 +167,12 @@ fun AnActionEvent.getVirtualFile(): VirtualFile? = ?.first() as? PsiFileNode)?.virtualFile fun KtElement.hasPaparazziRule(): Boolean { - val containingClass = this.parents.filterIsInstance().firstOrNull() ?: return false + 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 { diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt index 494c7900b..8a1b99ade 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/TestFlavor.kt @@ -104,7 +104,7 @@ fun PsiElement.determineTestFlavor(): TestFlavor? { return flavor } -fun TestFlavor.isQualifying(functions: Set): Boolean = +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/extensions/ScreenshotClassMarkerProvider.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt index 67693af53..803037744 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/extensions/ScreenshotClassMarkerProvider.kt @@ -31,8 +31,8 @@ import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil import dev.testify.TestFlavor import dev.testify.determineTestFlavor -import dev.testify.hasScreenshotAnnotation -import dev.testify.isQualifying +import dev.testify.hasPaparazziRule +import dev.testify.hasQualifyingAnnotation import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction @@ -53,8 +53,8 @@ class ScreenshotClassMarkerProvider : LineMarkerProvider { if (testFlavor.isClassEligible.not()) return null val functions: Set = PsiTreeUtil.findChildrenOfType(this, KtNamedFunction::class.java).filterNotNull().toSet() if (functions.isEmpty()) return null - if (testFlavor.isQualifying(functions).not()) 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( From a41ec78aa8a15b13fc28236b106c2ac995870075 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Tue, 27 Jan 2026 09:38:27 -0500 Subject: [PATCH 17/20] WIP: initial attempts to fix "go to baseline" from any part of the function --- .../src/main/kotlin/dev/testify/PsiExtensions.kt | 13 +++++++------ .../testify/actions/utility/BaseUtilityAction.kt | 1 - .../testify/actions/utility/GoToBaselineAction.kt | 10 +++++++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt index 95ae3090a..a707bad0a 100644 --- a/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt +++ b/Plugins/IntelliJ/src/main/kotlin/dev/testify/PsiExtensions.kt @@ -132,9 +132,6 @@ val KtClass.testifyClassInvocationPath: String }).get() ?: "unknown" } -val KtNamedFunction.hasScreenshotAnnotation: Boolean - get() = hasQualifyingAnnotation(setOf(SCREENSHOT_INSTRUMENTATION, SCREENSHOT_INSTRUMENTATION_LEGACY)) - fun KtNamedFunction.hasQualifyingAnnotation(annotationClassIds: Set): Boolean { return ApplicationManager.getApplication().executeOnPooledThread(Callable { ReadAction.compute { @@ -155,11 +152,15 @@ fun KaSession.getQualifyingAnnotation(function: KtNamedFunction, annotationClass } } -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? = 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 fe428fce6..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 @@ -93,7 +93,6 @@ abstract class BaseUtilityAction : AnAction() { 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 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 97736de82..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 @@ -26,23 +26,27 @@ package dev.testify.actions.utility import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.fileEditor.FileEditorManager -import dev.testify.TestFlavor import dev.testify.baselineImageName +import dev.testify.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) } From a9527e7b8d1368dba7b9f53d29efd9ea1cdc5c0c Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Tue, 27 Jan 2026 09:41:44 -0500 Subject: [PATCH 18/20] Bump to 4.0.0-alpha04 --- Plugins/IntelliJ/gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/IntelliJ/gradle.properties b/Plugins/IntelliJ/gradle.properties index 13da899f6..635e5b92d 100644 --- a/Plugins/IntelliJ/gradle.properties +++ b/Plugins/IntelliJ/gradle.properties @@ -6,7 +6,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 = 4.0.0-alpha03 +pluginVersion = 4.0.0-alpha04 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html From b3af20ef7f1f16f63b88ed7e3e7f8dbbe859853d Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Wed, 11 Feb 2026 09:07:05 -0500 Subject: [PATCH 19/20] Update context files --- Plugins/IntelliJ/AGENTS.md | 2 +- Plugins/IntelliJ/GEMINI.md | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 Plugins/IntelliJ/GEMINI.md diff --git a/Plugins/IntelliJ/AGENTS.md b/Plugins/IntelliJ/AGENTS.md index c4753bf72..b4672820d 100644 --- a/Plugins/IntelliJ/AGENTS.md +++ b/Plugins/IntelliJ/AGENTS.md @@ -41,7 +41,7 @@ This is an IntelliJ Platform Plugin designed to enhance the development experien ## Build & Run -* **Build:** `./gradlew build` +* **Build:** `./gradlew buildPlugin` * **Run IDE:** `./gradlew runIde` (Starts a sandboxed Android Studio instance with the plugin installed) * **Run Tests:** `./gradlew test` 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. From 5f4c77a4e6af0ba3749eb6491c40b5e73c852161 Mon Sep 17 00:00:00 2001 From: Daniel Jette Date: Wed, 11 Feb 2026 09:09:14 -0500 Subject: [PATCH 20/20] Add changelog entry for 4.0.0-alpha05 --- Plugins/IntelliJ/CHANGELOG.md | 10 ++++++++++ Plugins/IntelliJ/gradle.properties | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Plugins/IntelliJ/CHANGELOG.md b/Plugins/IntelliJ/CHANGELOG.md index 697fabe63..bc6d28a04 100644 --- a/Plugins/IntelliJ/CHANGELOG.md +++ b/Plugins/IntelliJ/CHANGELOG.md @@ -4,6 +4,16 @@ ## [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/gradle.properties b/Plugins/IntelliJ/gradle.properties index 635e5b92d..0023fa3c1 100644 --- a/Plugins/IntelliJ/gradle.properties +++ b/Plugins/IntelliJ/gradle.properties @@ -6,7 +6,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 = 4.0.0-alpha04 +pluginVersion = 4.0.0-alpha05 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html