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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ class RenderPassTest {
useHighFrequencyRange = false,
simultaneousActions = 0,
baselineExpectation = RenderExpectation(
totalPasses = 57..57,
totalPasses = 64..64,
freshRenderedNodes = 85..85,
staleRenderedNodes = 608..608
),
Expand All @@ -215,7 +215,7 @@ class RenderPassTest {
useHighFrequencyRange = false,
simultaneousActions = 0,
baselineExpectation = RenderExpectation(
totalPasses = 56..56,
totalPasses = 60..60,
freshRenderedNodes = 83..83,
staleRenderedNodes = 605..605
),
Expand All @@ -227,7 +227,7 @@ class RenderPassTest {
useHighFrequencyRange = true,
simultaneousActions = 0,
baselineExpectation = RenderExpectation(
totalPasses = 181..181,
totalPasses = 185..185,
freshRenderedNodes = 213..213,
staleRenderedNodes = 2350..2350
),
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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-beta02-y-SNAPSHOT

POM_DESCRIPTION=Square Workflow

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,6 +183,9 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
if (!isActive) return@launch

var nextRenderAndSnapshot: RenderingAndSnapshot<RenderingT> = runner.nextRendering()
// After rendering, yield if there are any side effects (they were launched lazily) that need
// starting.
yield()

if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) {
// Only null will allow us to continue processing actions and conflating stale renderings.
Expand All @@ -197,10 +201,13 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
if (actionResult == ActionsExhausted) break

nextRenderAndSnapshot = runner.nextRendering()
// After rendering, yield if there are any side effects (they were launched lazily) that
// need starting.
yield()
}
}

// Pass on to the UI.
// Pass the rendering on to the UI.
renderingsAndSnapshots.value = nextRenderAndSnapshot
// And emit the Output.
sendOutput(actionResult, onOutput)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {

/**
Expand Down
22 changes: 22 additions & 0 deletions workflow-testing/api/workflow-testing.api
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 <init> (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 {
}

Original file line number Diff line number Diff line change
@@ -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 <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.headlessIntegrationTest(
props: StateFlow<PropsT>,
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
interceptors: List<WorkflowInterceptor> = emptyList(),
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
onOutput: suspend (OutputT) -> Unit = {},
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
testCase: suspend WorkflowTurbine<RenderingT>.() -> 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 <OutputT, RenderingT> Workflow<Unit, OutputT, RenderingT>.headlessIntegrationTest(
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
interceptors: List<WorkflowInterceptor> = emptyList(),
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
onOutput: suspend (OutputT) -> Unit = {},
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
testCase: suspend WorkflowTurbine<RenderingT>.() -> 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<RenderingT>(
public val firstRendering: RenderingT,
private val receiveTurbine: ReceiveTurbine<RenderingT>
) {
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 <T> 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
}
}
Original file line number Diff line number Diff line change
@@ -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<P : Any> {

var currentParam: P? = null

fun runParametrizedTest(
paramSource: Sequence<P>,
before: () -> Unit = {},
after: () -> Unit = {},
test: ParameterizedTestRunner<P>.(param: P) -> Unit
) {
paramSource.forEach {
before()
currentParam = it
test(it)
after()
}
}

fun <T> assertEquals(expected: T, actual: T) {
assertEquals(expected, actual, message = "Using: ${currentParam?.toString()}")
}

fun <T> 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 <reified T : Throwable> assertFailsWith(block: () -> Unit) {
assertFailsWith<T>(message = "Using: ${currentParam?.toString()}", block)
}

fun <T : Any?> assertNotSame(illegal: T, actual: T) {
assertNotSame(illegal, actual, message = "Using: ${currentParam?.toString()}")
}

fun <T : Any> assertNotNull(actual: T?) {
assertNotNull(actual, message = "Using: ${currentParam?.toString()}")
}

fun assertNull(actual: Any?) {
assertNull(actual, message = "Using: ${currentParam?.toString()}")
}
}
Loading