diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8774c1..5831b1a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - `Collection.getAnnotation()` renamed to `Collection.findAnnotation()`. - Package for `getScreenshotAnnotationName()` changed from `dev.testify.internal.extensions` to `dev.testify.annotation`. - `ScreenshotRule.initializeView()` is now a top-level function. +- `EspressoHelper` now extends `ScreenshotLifecycle` and `beforeScreenshot()` has been replaced with `afterInitializeView()` #### Added @@ -22,6 +23,7 @@ - `outputFileName()` added as an extension method for `Context`. - Interface `AssertionState` - Interface `ScreenshotLifecycleHost` +- `assertSame()` is now available as a top-level function, decoupled from `ScreenshotRule` #### Removed diff --git a/Ext/Compose/src/main/java/dev/testify/ComposableScreenshotRule.kt b/Ext/Compose/src/main/java/dev/testify/ComposableScreenshotRule.kt index db479892..248ffc67 100644 --- a/Ext/Compose/src/main/java/dev/testify/ComposableScreenshotRule.kt +++ b/Ext/Compose/src/main/java/dev/testify/ComposableScreenshotRule.kt @@ -132,6 +132,7 @@ open class ComposableScreenshotRule( */ override fun afterInitializeView(activity: Activity) { composeActions?.invoke(composeTestRule) + composeTestRule.waitForIdle() super.afterInitializeView(activity) } diff --git a/Library/src/main/java/dev/testify/ScreenshotRule.kt b/Library/src/main/java/dev/testify/ScreenshotRule.kt index d8d7b12a..8780dd0b 100644 --- a/Library/src/main/java/dev/testify/ScreenshotRule.kt +++ b/Library/src/main/java/dev/testify/ScreenshotRule.kt @@ -30,7 +30,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.os.Bundle -import android.view.View +import android.view.View.NO_ID import androidx.annotation.CallSuper import androidx.annotation.IdRes import androidx.annotation.LayoutRes @@ -42,47 +42,30 @@ import dev.testify.annotation.TestifyLayout import dev.testify.annotation.findAnnotation import dev.testify.annotation.getScreenshotAnnotationName import dev.testify.annotation.getScreenshotInstrumentationAnnotation -import dev.testify.internal.DEFAULT_FOLDER_FORMAT -import dev.testify.internal.DeviceStringFormatter import dev.testify.internal.ScreenshotRuleCompatibilityMethods import dev.testify.internal.TestifyConfiguration -import dev.testify.internal.assertExpectedDevice import dev.testify.internal.exception.ActivityNotRegisteredException import dev.testify.internal.exception.AssertSameMustBeLastException -import dev.testify.internal.exception.FailedToCaptureBitmapException import dev.testify.internal.exception.FinalizeDestinationException import dev.testify.internal.exception.MissingAssertSameException import dev.testify.internal.exception.MissingScreenshotInstrumentationAnnotationException -import dev.testify.internal.exception.NoScreenshotsOnUiThreadException -import dev.testify.internal.exception.ScreenshotBaselineNotDefinedException -import dev.testify.internal.exception.ScreenshotIsDifferentException import dev.testify.internal.exception.ScreenshotTestIgnoredException +import dev.testify.internal.extensions.TestInstrumentationRegistry.Companion.isRecordMode import dev.testify.internal.extensions.TestInstrumentationRegistry.Companion.getModuleName import dev.testify.internal.extensions.TestInstrumentationRegistry.Companion.instrumentationPrintln import dev.testify.internal.extensions.cyan import dev.testify.internal.extensions.isInvokedFromPlugin -import dev.testify.internal.formatDeviceString import dev.testify.internal.helpers.ActivityProvider import dev.testify.internal.helpers.EspressoActions import dev.testify.internal.helpers.EspressoHelper import dev.testify.internal.helpers.ResourceWrapper -import dev.testify.internal.helpers.findRootView -import dev.testify.internal.helpers.isRunningOnUiThread -import dev.testify.internal.helpers.outputFileName import dev.testify.internal.helpers.registerActivityProvider import dev.testify.internal.logic.AssertionState import dev.testify.internal.logic.ScreenshotLifecycleHost import dev.testify.internal.logic.ScreenshotLifecycleObserver -import dev.testify.internal.logic.compareBitmaps -import dev.testify.internal.logic.initializeView -import dev.testify.internal.logic.takeScreenshot -import dev.testify.internal.processor.capture.createBitmapFromDrawingCache -import dev.testify.internal.processor.diff.HighContrastDiff -import dev.testify.output.getDestination +import dev.testify.internal.logic.assertSame import dev.testify.report.ReportSession import dev.testify.report.Reporter -import org.junit.Assert.assertTrue -import org.junit.Assume.assumeTrue import org.junit.AssumptionViolatedException import org.junit.rules.TestRule import org.junit.runner.Description @@ -257,6 +240,7 @@ open class ScreenshotRule @JvmOverloads constructor( configuration.applyAnnotations(methodAnnotations) espressoHelper.reset() + addScreenshotObserver(espressoHelper) getInstrumentation().testDescription = TestDescription( methodName = methodName, @@ -265,7 +249,7 @@ open class ScreenshotRule @JvmOverloads constructor( reporter?.startTest(getInstrumentation().testDescription) val testifyLayout = methodAnnotations?.findAnnotation() - targetLayoutId = testifyLayout?.resolvedLayoutId ?: View.NO_ID + targetLayoutId = testifyLayout?.resolvedLayoutId ?: NO_ID } @get:LayoutRes @@ -336,6 +320,10 @@ open class ScreenshotRule @JvmOverloads constructor( return intent } + override fun assureActivity(intent: Intent?) { + launchActivity(intent) + } + override fun launchActivity(startIntent: Intent?): T { try { return super.launchActivity(startIntent) @@ -375,120 +363,20 @@ open class ScreenshotRule @JvmOverloads constructor( @ExperimentalTestApi fun assertSame() { - assertSameInvoked = true addScreenshotObserver(this) - - notifyObservers { it.beforeAssertSame() } - - if (isRunningOnUiThread()) { - throw NoScreenshotsOnUiThreadException() - } - - try { - launchActivity(activityIntent) - } catch (e: ScreenshotTestIgnoredException) { - // Exit gracefully; mark test as ignored - assumeTrue(false) - return - } - try { - try { - val description = getInstrumentation().testDescription - reporter?.captureOutput(this) - outputFileName = testContext.outputFileName(description) - - notifyObservers { it.beforeInitializeView(activity) } - initializeView(activityProvider = this, assertionState = this, configuration = configuration) - notifyObservers { it.afterInitializeView(activity) } - - espressoHelper.beforeScreenshot() - - val rootView = activity.findRootView(rootViewId) - val screenshotView: View? = screenshotViewProvider?.invoke(rootView) - - configuration.beforeScreenshot(rootView) - - notifyObservers { it.beforeScreenshot(activity) } - - val currentBitmap = takeScreenshot( - activity, - outputFileName, - screenshotView, - configuration.captureMethod ?: ::createBitmapFromDrawingCache - ) ?: throw FailedToCaptureBitmapException() - - notifyObservers { it.afterScreenshot(activity, currentBitmap) } - - if (configuration.pauseForInspection) { - Thread.sleep(LAYOUT_INSPECTION_TIME_MS.toLong()) - } - - assertExpectedDevice(testContext, description.name, isRecordMode) - - val destination = getDestination(activity, outputFileName) - - val baselineBitmap = loadBaselineBitmapForComparison(testContext, description.name) - ?: if (isRecordMode || recordMode) { - instrumentationPrintln( - "\n\t✓ " + "Recording baseline for ${description.name}".cyan() - ) - - if (!destination.finalize()) - throw FinalizeDestinationException(destination.description) - - return - } else { - throw ScreenshotBaselineNotDefinedException( - moduleName = getModuleName(), - testName = description.name, - testClass = description.fullyQualifiedTestName, - deviceKey = formatDeviceString( - DeviceStringFormatter( - testContext, - null - ), - DEFAULT_FOLDER_FORMAT - ) - ) - } - - if (compareBitmaps(baselineBitmap, currentBitmap, configuration.getBitmapCompare())) { - assertTrue( - "Could not delete cached bitmap ${description.name}", - deleteBitmap(destination) - ) - } else { - if (!destination.finalize()) - throw FinalizeDestinationException(destination.description) - - if (TestifyFeatures.GenerateDiffs.isEnabled(activity)) { - HighContrastDiff(configuration.exclusionRects) - .name(outputFileName) - .baseline(baselineBitmap) - .current(currentBitmap) - .exactness(configuration.exactness) - .generate(context = activity) - } - if (isRecordMode || recordMode) { - instrumentationPrintln( - "\n\t✓ " + "Recording baseline for ${description.name}".cyan() - ) - } else { - throw ScreenshotIsDifferentException(getModuleName(), description.fullyQualifiedTestName) - } - } - } finally { - } + assertSame( + state = this, + configuration = configuration, + testContext = testContext, + screenshotLifecycleHost = this, + activityProvider = this, + activityIntent = activityIntent, + reporter = reporter + ) } finally { - ResourceWrapper.afterTestFinished(activity) - configuration.afterTestFinished() - TestifyFeatures.reset() removeScreenshotObserver(this) - if (throwable != null) { - //noinspection ThrowFromfinallyBlock - throw RuntimeException(throwable) - } + removeScreenshotObserver(espressoHelper) } } @@ -541,9 +429,4 @@ open class ScreenshotRule @JvmOverloads constructor( field = value assertSameInvoked = value } - - companion object { - const val NO_ID = -1 - private const val LAYOUT_INSPECTION_TIME_MS = 60000 - } } diff --git a/Library/src/main/java/dev/testify/annotation/TestifyLayout.kt b/Library/src/main/java/dev/testify/annotation/TestifyLayout.kt index 71491aba..c77426db 100644 --- a/Library/src/main/java/dev/testify/annotation/TestifyLayout.kt +++ b/Library/src/main/java/dev/testify/annotation/TestifyLayout.kt @@ -25,8 +25,8 @@ package dev.testify.annotation +import android.view.View.NO_ID import androidx.annotation.LayoutRes -import dev.testify.ScreenshotRule.Companion.NO_ID /** * The [TestifyLayout] annotation allows you to specify a layout resource to be automatically diff --git a/Library/src/main/java/dev/testify/internal/helpers/ActivityProvider.kt b/Library/src/main/java/dev/testify/internal/helpers/ActivityProvider.kt index 7a87dd7d..b7a8c239 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/ActivityProvider.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/ActivityProvider.kt @@ -26,9 +26,11 @@ package dev.testify.internal.helpers import android.app.Activity import android.app.Instrumentation +import android.content.Intent interface ActivityProvider { fun getActivity(): T + fun assureActivity(intent: Intent?) } private object ActivityProviderRegistry { diff --git a/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt b/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt index 58d3e437..1ac8c7ca 100644 --- a/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt +++ b/Library/src/main/java/dev/testify/internal/helpers/EspressoHelper.kt @@ -23,12 +23,14 @@ */ package dev.testify.internal.helpers +import android.app.Activity import androidx.test.espresso.Espresso +import dev.testify.ScreenshotLifecycle import dev.testify.internal.TestifyConfiguration typealias EspressoActions = () -> Unit -class EspressoHelper(private val configuration: TestifyConfiguration) { +class EspressoHelper(private val configuration: TestifyConfiguration) : ScreenshotLifecycle { var actions: EspressoActions? = null @@ -36,7 +38,7 @@ class EspressoHelper(private val configuration: TestifyConfiguration) { actions = null } - fun beforeScreenshot() { + override fun afterInitializeView(activity: Activity) { actions?.invoke() Espresso.onIdle() diff --git a/Library/src/main/java/dev/testify/internal/logic/AssertSame.kt b/Library/src/main/java/dev/testify/internal/logic/AssertSame.kt new file mode 100644 index 00000000..ff0e8dc3 --- /dev/null +++ b/Library/src/main/java/dev/testify/internal/logic/AssertSame.kt @@ -0,0 +1,191 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2023 ndtp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package dev.testify.internal.logic + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.view.View +import androidx.test.platform.app.InstrumentationRegistry +import dev.testify.TestifyFeatures +import dev.testify.deleteBitmap +import dev.testify.internal.DEFAULT_FOLDER_FORMAT +import dev.testify.internal.DeviceStringFormatter +import dev.testify.internal.TestifyConfiguration +import dev.testify.internal.assertExpectedDevice +import dev.testify.internal.exception.FailedToCaptureBitmapException +import dev.testify.internal.exception.NoScreenshotsOnUiThreadException +import dev.testify.internal.exception.ScreenshotBaselineNotDefinedException +import dev.testify.internal.exception.ScreenshotIsDifferentException +import dev.testify.internal.exception.ScreenshotTestIgnoredException +import dev.testify.internal.exception.TestifyException +import dev.testify.internal.extensions.TestInstrumentationRegistry +import dev.testify.internal.extensions.cyan +import dev.testify.internal.formatDeviceString +import dev.testify.internal.helpers.ActivityProvider +import dev.testify.internal.helpers.ResourceWrapper +import dev.testify.internal.helpers.findRootView +import dev.testify.internal.helpers.isRunningOnUiThread +import dev.testify.internal.helpers.outputFileName +import dev.testify.internal.processor.capture.createBitmapFromDrawingCache +import dev.testify.internal.processor.diff.HighContrastDiff +import dev.testify.loadBaselineBitmapForComparison +import dev.testify.output.getDestination +import dev.testify.report.Reporter +import dev.testify.testDescription +import org.junit.Assert +import org.junit.Assume + +/** + * Assert if the Activity matches the baseline screenshot. + * + * Using the provided [AssertionState] and [TestifyConfiguration], capture a bitmap of the Activity provided by + * [ActivityProvider] and compare it to the baseline image already recorded. + * + * @param state - the current state of the test + * @param configuration - a fully configured TestifyConfiguration instance + * @param testContext - the [Context] of the test runner + * @param screenshotLifecycleHost - an instance of [ScreenshotLifecycleHost] to notify of screenshot events + * @param activityProvider - an [ActivityProvider] which can provide a valid Activity instance + * @param activityIntent - optional, an [Intent] to pass to the [ActivityProvider] + * @param reporter - optional, an instance of [Reporter] which can log the test status + * + * @throws TestifyException + */ +internal fun assertSame( + state: AssertionState, + configuration: TestifyConfiguration, + testContext: Context, + screenshotLifecycleHost: ScreenshotLifecycleHost, + activityProvider: ActivityProvider, + activityIntent: Intent?, + reporter: Reporter? +) { + state.assertSameInvoked = true + + screenshotLifecycleHost.notifyObservers { it.beforeAssertSame() } + + if (isRunningOnUiThread()) { + throw NoScreenshotsOnUiThreadException() + } + + try { + activityProvider.assureActivity(activityIntent) + } catch (e: ScreenshotTestIgnoredException) { + // Exit gracefully; mark test as ignored + Assume.assumeTrue(false) + return + } + + var activity: TActivity? = null + + try { + activity = activityProvider.getActivity() + val description = InstrumentationRegistry.getInstrumentation().testDescription + reporter?.captureOutput() + val outputFileName = testContext.outputFileName(description) + + screenshotLifecycleHost.notifyObservers { it.beforeInitializeView(activity) } + initializeView(activityProvider = activityProvider, assertionState = state, configuration = configuration) + screenshotLifecycleHost.notifyObservers { it.afterInitializeView(activity) } + + val rootView = activity.findRootView(state.rootViewId) + val screenshotView: View? = state.screenshotViewProvider?.invoke(rootView) + + configuration.beforeScreenshot(rootView) + screenshotLifecycleHost.notifyObservers { it.beforeScreenshot(activity) } + + val currentBitmap = takeScreenshot( + activity, + outputFileName, + screenshotView, + configuration.captureMethod ?: ::createBitmapFromDrawingCache + ) ?: throw FailedToCaptureBitmapException() + + screenshotLifecycleHost.notifyObservers { it.afterScreenshot(activity, currentBitmap) } + + if (configuration.pauseForInspection) { + Thread.sleep(LAYOUT_INSPECTION_TIME_MS) + } + + assertExpectedDevice(testContext, description.name) + + val baselineBitmap = loadBaselineBitmapForComparison(testContext, description.name) + ?: if (TestInstrumentationRegistry.isRecordMode) { + TestInstrumentationRegistry.instrumentationPrintln( + "\n\t✓ " + "Recording baseline for ${description.name}".cyan() + ) + return + } else { + throw ScreenshotBaselineNotDefinedException( + moduleName = TestInstrumentationRegistry.getModuleName(), + testName = description.name, + testClass = description.fullyQualifiedTestName, + deviceKey = formatDeviceString( + DeviceStringFormatter( + testContext, + null + ), + DEFAULT_FOLDER_FORMAT + ) + ) + } + + if (compareBitmaps(baselineBitmap, currentBitmap, configuration.getBitmapCompare())) { + Assert.assertTrue( + "Could not delete cached bitmap ${description.name}", + deleteBitmap(getDestination(activity, outputFileName)) + ) + } else { + if (TestifyFeatures.GenerateDiffs.isEnabled(activity)) { + HighContrastDiff(configuration.exclusionRects) + .name(outputFileName) + .baseline(baselineBitmap) + .current(currentBitmap) + .exactness(configuration.exactness) + .generate(context = activity) + } + if (TestInstrumentationRegistry.isRecordMode) { + TestInstrumentationRegistry.instrumentationPrintln( + "\n\t✓ " + "Recording baseline for ${description.name}".cyan() + ) + } else { + throw ScreenshotIsDifferentException( + TestInstrumentationRegistry.getModuleName(), + description.fullyQualifiedTestName + ) + } + } + } finally { + activity?.let { ResourceWrapper.afterTestFinished(activity) } + configuration.afterTestFinished() + TestifyFeatures.reset() + if (state.throwable != null) { + //noinspection ThrowFromfinallyBlock + throw RuntimeException(state.throwable) + } + } +} + +private const val LAYOUT_INSPECTION_TIME_MS = 60000L diff --git a/Library/src/main/java/dev/testify/internal/logic/InitiliazeView.kt b/Library/src/main/java/dev/testify/internal/logic/InitiliazeView.kt index 4ddfd2c1..63fb8604 100644 --- a/Library/src/main/java/dev/testify/internal/logic/InitiliazeView.kt +++ b/Library/src/main/java/dev/testify/internal/logic/InitiliazeView.kt @@ -25,7 +25,7 @@ package dev.testify.internal.logic import android.app.Activity import android.os.Debug -import dev.testify.ScreenshotRule +import android.view.View.NO_ID import dev.testify.internal.TestifyConfiguration import dev.testify.internal.exception.ViewModificationException import dev.testify.internal.helpers.ActivityProvider @@ -57,7 +57,7 @@ fun initializeView( var viewModificationException: Throwable? = null activity.runOnUiThread { - if (assertionState.targetLayoutId != ScreenshotRule.NO_ID) { + if (assertionState.targetLayoutId != NO_ID) { activity.layoutInflater.inflate(assertionState.targetLayoutId, parentView, true) } diff --git a/Library/src/main/java/dev/testify/internal/processor/diff/HighContrastDiff.kt b/Library/src/main/java/dev/testify/internal/processor/diff/HighContrastDiff.kt index 52d0a110..d2e3a493 100644 --- a/Library/src/main/java/dev/testify/internal/processor/diff/HighContrastDiff.kt +++ b/Library/src/main/java/dev/testify/internal/processor/diff/HighContrastDiff.kt @@ -40,6 +40,13 @@ import dev.testify.saveBitmapToDestination * * This diff image is a high-contrast image where each difference, regardless of how minor, is indicated in red * against a black background. + * + * Legend: + * - Black: Identical + * - Gray: Excluded from diff + * - Yellow: Different, but within allowable tolerances + * - Red: Different in excess of allowable tolerances + * */ class HighContrastDiff(private val exclusionRects: Set) {