diff --git a/gradle.properties b/gradle.properties index 572b88de03..3b184d6235 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ android.useAndroidX=true systemProp.org.gradle.internal.publish.checksums.insecure=true GROUP=com.squareup.workflow1 -VERSION_NAME=1.11.0-beta03-SNAPSHOT +VERSION_NAME=1.11.0-beta03-v-SNAPSHOT POM_DESCRIPTION=Square Workflow diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt index 78eb60c9a7..67c7e38675 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.yield /** * Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its @@ -188,6 +189,8 @@ public fun renderWorkflowIn( // If this is not null, then we had an Output and we want to send it with the Rendering // (stale or not). while (actionResult is ActionApplied<*> && actionResult.output == null) { + // Yield if there are any side effects (they were launched lazily) that need starting. + yield() // We have more actions we can process, so this rendering is stale. actionResult = runner.processAction(waitForAnAction = false) @@ -204,6 +207,8 @@ public fun renderWorkflowIn( renderingsAndSnapshots.value = nextRenderAndSnapshot // And emit the Output. sendOutput(actionResult, onOutput) + // Yield if there are any side effects (they were launched lazily) that need starting. + yield() } } diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt index 4deb51634a..97bbf9eea3 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Unconfined import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -29,7 +28,7 @@ import kotlinx.coroutines.test.runCurrent import okio.ByteString import kotlin.test.Test -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, WorkflowExperimentalRuntime::class) +@OptIn(ExperimentalCoroutinesApi::class, WorkflowExperimentalRuntime::class) class RenderWorkflowInTest { /** diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index 5e4778bc9f..414f83b13d 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -1,3 +1,10 @@ +public final class com/squareup/workflow1/testing/HeadlessIntegrationTestKt { + public static final fun headlessIntegrationTest (Lcom/squareup/workflow1/Workflow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static final fun headlessIntegrationTest (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;)V + public static synthetic fun headlessIntegrationTest$default (Lcom/squareup/workflow1/Workflow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V + public static synthetic fun headlessIntegrationTest$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/flow/StateFlow;Lkotlin/coroutines/CoroutineContext;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function2;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V +} + public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com/squareup/workflow1/WorkflowInterceptor { public static final field INSTANCE Lcom/squareup/workflow1/testing/RenderIdempotencyChecker; public fun onInitialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;Lkotlin/jvm/functions/Function2;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; @@ -155,3 +162,18 @@ public final class com/squareup/workflow1/testing/WorkflowTestRuntimeKt { public static synthetic fun launchForTestingWith$default (Lcom/squareup/workflow1/StatefulWorkflow;Ljava/lang/Object;Lcom/squareup/workflow1/testing/WorkflowTestParams;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; } +public final class com/squareup/workflow1/testing/WorkflowTurbine { + public static final field Companion Lcom/squareup/workflow1/testing/WorkflowTurbine$Companion; + public static final field WORKFLOW_TEST_DEFAULT_TIMEOUT_MS J + public fun (Ljava/lang/Object;Lapp/cash/turbine/ReceiveTurbine;)V + public final fun awaitNext (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun awaitNext$default (Lcom/squareup/workflow1/testing/WorkflowTurbine;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun awaitNextRendering (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun awaitNextRenderingSatisfying (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getFirstRendering ()Ljava/lang/Object; + public final fun skipRenderings (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/squareup/workflow1/testing/WorkflowTurbine$Companion { +} + diff --git a/workflow-testing/src/main/java/com/squareup/workflow1/testing/HeadlessIntegrationTest.kt b/workflow-testing/src/main/java/com/squareup/workflow1/testing/HeadlessIntegrationTest.kt new file mode 100644 index 0000000000..0014d72474 --- /dev/null +++ b/workflow-testing/src/main/java/com/squareup/workflow1/testing/HeadlessIntegrationTest.kt @@ -0,0 +1,189 @@ +package com.squareup.workflow1.testing + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.renderWorkflowIn +import com.squareup.workflow1.testing.WorkflowTurbine.Companion.WORKFLOW_TEST_DEFAULT_TIMEOUT_MS +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.milliseconds + +/** + * This is a test harness to run integration tests for a Workflow tree. The parameters passed here are + * the same as those to start a Workflow runtime with [renderWorkflowIn] except for ignoring + * state persistence as that is not needed for this style of test. + * + * The [coroutineContext] rather than a [CoroutineScope] is passed so that this harness handles the + * scope for the Workflow runtime for you but you can still specify context for it. + * + * A [testTimeout] may be specified to override the default [WORKFLOW_TEST_DEFAULT_TIMEOUT_MS] for + * any particular test. This is the max amount of time the test could spend waiting on a rendering. + * + * This will start the Workflow runtime (with params as passed) rooted at whatever Workflow + * it is called on and then create a [WorkflowTurbine] for its renderings and run [testCase] on that. + * [testCase] can thus drive the test scenario and assert against renderings. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun Workflow.headlessIntegrationTest( + props: StateFlow, + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + interceptors: List = emptyList(), + runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit +) { + val workflow = this + + runTest( + context = coroutineContext, + timeout = testTimeout.milliseconds + ) { + // We use a sub-scope so that we can cancel the Workflow runtime when we are done with it so that + // tests don't all have to do that themselves. + val workflowRuntimeScope = CoroutineScope(coroutineContext) + val renderings = renderWorkflowIn( + workflow = workflow, + props = props, + scope = workflowRuntimeScope, + interceptors = interceptors, + runtimeConfig = runtimeConfig, + onOutput = onOutput + ) + + val firstRendering = renderings.value.rendering + + // Drop one as its provided separately via `firstRendering`. + renderings.drop(1).map { + it.rendering + }.test { + val workflowTurbine = WorkflowTurbine( + firstRendering, + this + ) + workflowTurbine.testCase() + cancelAndIgnoreRemainingEvents() + } + workflowRuntimeScope.cancel() + } +} + +/** + * Version of [headlessIntegrationTest] that does not require props. For Workflows that have [Unit] + * props type. + */ +@OptIn(ExperimentalCoroutinesApi::class) +public fun Workflow.headlessIntegrationTest( + coroutineContext: CoroutineContext = UnconfinedTestDispatcher(), + interceptors: List = emptyList(), + runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + onOutput: suspend (OutputT) -> Unit = {}, + testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS, + testCase: suspend WorkflowTurbine.() -> Unit +): Unit = headlessIntegrationTest( + props = MutableStateFlow(Unit).asStateFlow(), + coroutineContext = coroutineContext, + interceptors = interceptors, + runtimeConfig = runtimeConfig, + onOutput = onOutput, + testTimeout = testTimeout, + testCase = testCase +) + +/** + * Simple wrapper around a [ReceiveTurbine] of [RenderingT] to provide convenience helper methods specific + * to Workflow renderings. + * + * @property firstRendering The first rendering of the Workflow runtime is made synchronously. This is + * provided separately if any assertions or operations are needed from it. + */ +public class WorkflowTurbine( + public val firstRendering: RenderingT, + private val receiveTurbine: ReceiveTurbine +) { + private var usedFirst = false + + /** + * Suspend waiting for the next rendering to be produced by the Workflow runtime. Note this includes + * the first (synchronously made) rendering. + * + * @return the rendering. + */ + public suspend fun awaitNextRendering(): RenderingT { + if (!usedFirst) { + usedFirst = true + return firstRendering + } + return receiveTurbine.awaitItem() + } + + public suspend fun skipRenderings(count: Int) { + val skippedCount = if (!usedFirst) { + usedFirst = true + count - 1 + } else { + count + } + + if (skippedCount > 0) { + receiveTurbine.skipItems(skippedCount) + } + } + + /** + * Suspend waiting for the next rendering to be produced by the Workflow runtime that satisfies the + * [predicate]. + * + * @return the rendering. + */ + public suspend fun awaitNextRenderingSatisfying( + predicate: (RenderingT) -> Boolean + ): RenderingT { + var rendering = awaitNextRendering() + while (!predicate(rendering)) { + rendering = awaitNextRendering() + } + return rendering + } + + /** + * Suspend waiting for the next rendering which satisfies [precondition], can successfully be mapped + * using [map] and satisfies the [satisfying] predicate when called on the [T] rendering after it + * has been mapped. + * + * @return the mapped rendering as [T] + */ + public suspend fun awaitNext( + precondition: (RenderingT) -> Boolean = { true }, + map: (RenderingT) -> T, + satisfying: T.() -> Boolean = { true } + ): T = + map( + awaitNextRenderingSatisfying { + precondition(it) && + with(map(it)) { + this.satisfying() + } + } + ) + + public companion object { + /** + * Default timeout to use while waiting for renderings. + */ + public const val WORKFLOW_TEST_DEFAULT_TIMEOUT_MS: Long = 60_000L + } +} diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/ParameterizedTestRunner.kt b/workflow-testing/src/test/java/com/squareup/workflow1/ParameterizedTestRunner.kt new file mode 100644 index 0000000000..b79911765a --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow1/ParameterizedTestRunner.kt @@ -0,0 +1,69 @@ +package com.squareup.workflow1 + +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * This file is copied from workflow-runtime:commonTest so our tests that test across the runtime + * look consistent. We could have used a JUnit library like Jupiter, but didn't. + * + * This file is copied so as to avoid creating a workflow-core-testing module (for now). + * + * We do our best to tell you what the parameter was when the failure occured by wrapping + * assertions from kotlin.test and injecting our own message. + */ +class ParameterizedTestRunner

{ + + var currentParam: P? = null + + fun runParametrizedTest( + paramSource: Sequence

, + before: () -> Unit = {}, + after: () -> Unit = {}, + test: ParameterizedTestRunner

.(param: P) -> Unit + ) { + paramSource.forEach { + before() + currentParam = it + test(it) + after() + } + } + + fun assertEquals(expected: T, actual: T) { + assertEquals(expected, actual, message = "Using: ${currentParam?.toString()}") + } + + fun assertEquals(expected: T, actual: T, originalMessage: String) { + assertEquals(expected, actual, message = "$originalMessage; Using: ${currentParam?.toString()}") + } + + fun assertTrue(statement: Boolean) { + assertTrue(statement, message = "Using: ${currentParam?.toString()}") + } + + fun assertFalse(statement: Boolean) { + assertFalse(statement, message = "Using: ${currentParam?.toString()}") + } + + inline fun assertFailsWith(block: () -> Unit) { + assertFailsWith(message = "Using: ${currentParam?.toString()}", block) + } + + fun assertNotSame(illegal: T, actual: T) { + assertNotSame(illegal, actual, message = "Using: ${currentParam?.toString()}") + } + + fun assertNotNull(actual: T?) { + assertNotNull(actual, message = "Using: ${currentParam?.toString()}") + } + + fun assertNull(actual: Any?) { + assertNull(actual, message = "Using: ${currentParam?.toString()}") + } +} diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/SideEffectLifecycleTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/SideEffectLifecycleTest.kt new file mode 100644 index 0000000000..77bfcd20d3 --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow1/SideEffectLifecycleTest.kt @@ -0,0 +1,126 @@ +package com.squareup.workflow1 + +import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS +import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES +import com.squareup.workflow1.testing.headlessIntegrationTest +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlin.coroutines.coroutineContext +import kotlin.test.Test + +@OptIn(WorkflowExperimentalRuntime::class) +class SideEffectLifecycleTest { + + private val runtimeOptions: Sequence = arrayOf( + RuntimeConfigOptions.RENDER_PER_ACTION, + setOf(RENDER_ONLY_WHEN_STATE_CHANGES), + setOf(CONFLATE_STALE_RENDERINGS), + setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES) + ).asSequence() + + private val runtimeTestRunner = ParameterizedTestRunner() + private var started = 0 + private var cancelled = 0 + private val workflow: StatefulWorkflow Unit>> = + Workflow.stateful( + initialState = 0, + render = { renderState: Int -> + // Run side effect on odd numbered state. + if (renderState % 2 == 1) { + runningSideEffect("test") { + started++ + try { + // actionSink.send(action { state = 0 }) + awaitCancellation() + } finally { + cancelled++ + } + } + } + // Rendering pair is current int state and a function to change it. + Pair( + renderState, + { newState -> actionSink.send(action { state = newState }) } + ) + } + ) + + private fun cleanup() { + started = 0 + cancelled = 0 + } + + @Test fun sideEffectsStartedWhenExpected() { + runtimeTestRunner.runParametrizedTest( + paramSource = runtimeOptions, + after = ::cleanup, + ) { runtimeConfig: RuntimeConfig -> + + workflow.headlessIntegrationTest( + runtimeConfig = runtimeConfig + ) { + // One time starts but does not stop the side effect. + repeat(1) { + val (current, setState) = awaitNextRendering() + setState.invoke(current + 1) + } + + assertEquals(1, started, "Side Effect not started 1 time.") + } + } + } + + @Test fun sideEffectsStoppedWhenExpected() { + runtimeTestRunner.runParametrizedTest( + paramSource = runtimeOptions, + after = ::cleanup, + ) { runtimeConfig: RuntimeConfig -> + + workflow.headlessIntegrationTest( + runtimeConfig = runtimeConfig + ) { + // Twice will start and stop the side effect. + repeat(2) { + val (current, setState) = awaitNextRendering() + setState.invoke(current + 1) + } + assertEquals(1, started, "Side Effect not started 1 time.") + assertEquals(1, cancelled, "Side Effect not cancelled 1 time.") + } + } + } + + /** + * @see https://github.com/square/workflow-kotlin/issues/1093 + */ + @Test fun sideEffectsStartAndStoppedWhenHandledSynchronously() { + runtimeTestRunner.runParametrizedTest( + paramSource = runtimeOptions, + after = ::cleanup, + ) { runtimeConfig: RuntimeConfig -> + + val dispatcher = StandardTestDispatcher() + workflow.headlessIntegrationTest( + coroutineContext = dispatcher, + runtimeConfig = runtimeConfig + ) { + + val (_, setState) = awaitNextRendering() + // 2 actions queued up - should start the side effect and then stop it + // on two consecutive render passes. + setState.invoke(1) + setState.invoke(2) + dispatcher.scheduler.runCurrent() + awaitNextRendering() + dispatcher.scheduler.runCurrent() + if (!runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { + // 2 rendering or 1 depending on runtime config. + awaitNextRendering() + } + + assertEquals(1, started, "Side Effect not started 1 time.") + assertEquals(1, cancelled, "Side Effect not cancelled 1 time.") + } + } + } +}