Skip to content

Commit 4396fb2

Browse files
1093: Yield in runtime to run side effects that were launched lazily
Add headlessIntegrationTest to renderWorkflowIn with a nice Turbine attached. Increase perf benchmarks render pass expectations as yield() will do that. Closes #1093
1 parent 3691533 commit 4396fb2

File tree

8 files changed

+419
-7
lines changed

8 files changed

+419
-7
lines changed

benchmarks/performance-poetry/complex-poetry/src/androidTest/java/com/squareup/benchmarks/performance/complex/poetry/RenderPassTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ class RenderPassTest {
203203
useHighFrequencyRange = false,
204204
simultaneousActions = 0,
205205
baselineExpectation = RenderExpectation(
206-
totalPasses = 57..57,
206+
totalPasses = 64..64,
207207
freshRenderedNodes = 85..85,
208208
staleRenderedNodes = 608..608
209209
),
@@ -215,7 +215,7 @@ class RenderPassTest {
215215
useHighFrequencyRange = false,
216216
simultaneousActions = 0,
217217
baselineExpectation = RenderExpectation(
218-
totalPasses = 56..56,
218+
totalPasses = 60..60,
219219
freshRenderedNodes = 83..83,
220220
staleRenderedNodes = 605..605
221221
),
@@ -227,7 +227,7 @@ class RenderPassTest {
227227
useHighFrequencyRange = true,
228228
simultaneousActions = 0,
229229
baselineExpectation = RenderExpectation(
230-
totalPasses = 181..181,
230+
totalPasses = 185..185,
231231
freshRenderedNodes = 213..213,
232232
staleRenderedNodes = 2350..2350
233233
),

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ android.useAndroidX=true
88
systemProp.org.gradle.internal.publish.checksums.insecure=true
99

1010
GROUP=com.squareup.workflow1
11-
VERSION_NAME=1.11.0-beta02
11+
VERSION_NAME=1.11.0-beta02-y-SNAPSHOT
1212

1313
POM_DESCRIPTION=Square Workflow
1414

workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
1212
import kotlinx.coroutines.flow.StateFlow
1313
import kotlinx.coroutines.isActive
1414
import kotlinx.coroutines.launch
15+
import kotlinx.coroutines.yield
1516

1617
/**
1718
* Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its
@@ -182,6 +183,9 @@ public fun <PropsT, OutputT, RenderingT> renderWorkflowIn(
182183
if (!isActive) return@launch
183184

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

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

199203
nextRenderAndSnapshot = runner.nextRendering()
204+
// After rendering, yield if there are any side effects (they were launched lazily) that
205+
// need starting.
206+
yield()
200207
}
201208
}
202209

203-
// Pass on to the UI.
210+
// Pass the rendering on to the UI.
204211
renderingsAndSnapshots.value = nextRenderAndSnapshot
205212
// And emit the Output.
206213
sendOutput(actionResult, onOutput)

workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import kotlinx.coroutines.CoroutineExceptionHandler
99
import kotlinx.coroutines.CoroutineScope
1010
import kotlinx.coroutines.Dispatchers.Unconfined
1111
import kotlinx.coroutines.ExperimentalCoroutinesApi
12-
import kotlinx.coroutines.FlowPreview
1312
import kotlinx.coroutines.cancel
1413
import kotlinx.coroutines.channels.Channel
1514
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,7 +28,7 @@ import kotlinx.coroutines.test.runCurrent
2928
import okio.ByteString
3029
import kotlin.test.Test
3130

32-
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, WorkflowExperimentalRuntime::class)
31+
@OptIn(ExperimentalCoroutinesApi::class, WorkflowExperimentalRuntime::class)
3332
class RenderWorkflowInTest {
3433

3534
/**

workflow-testing/api/workflow-testing.api

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
public final class com/squareup/workflow1/testing/HeadlessIntegrationTestKt {
2+
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
3+
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
4+
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
5+
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
6+
}
7+
18
public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com/squareup/workflow1/WorkflowInterceptor {
29
public static final field INSTANCE Lcom/squareup/workflow1/testing/RenderIdempotencyChecker;
310
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 {
155162
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;
156163
}
157164

165+
public final class com/squareup/workflow1/testing/WorkflowTurbine {
166+
public static final field Companion Lcom/squareup/workflow1/testing/WorkflowTurbine$Companion;
167+
public static final field WORKFLOW_TEST_DEFAULT_TIMEOUT_MS J
168+
public fun <init> (Ljava/lang/Object;Lapp/cash/turbine/ReceiveTurbine;)V
169+
public final fun awaitNext (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
170+
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;
171+
public final fun awaitNextRendering (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
172+
public final fun awaitNextRenderingSatisfying (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
173+
public final fun getFirstRendering ()Ljava/lang/Object;
174+
public final fun skipRenderings (ILkotlin/coroutines/Continuation;)Ljava/lang/Object;
175+
}
176+
177+
public final class com/squareup/workflow1/testing/WorkflowTurbine$Companion {
178+
}
179+
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.squareup.workflow1.testing
2+
3+
import app.cash.turbine.ReceiveTurbine
4+
import app.cash.turbine.test
5+
import com.squareup.workflow1.RuntimeConfig
6+
import com.squareup.workflow1.RuntimeConfigOptions
7+
import com.squareup.workflow1.Workflow
8+
import com.squareup.workflow1.WorkflowInterceptor
9+
import com.squareup.workflow1.renderWorkflowIn
10+
import com.squareup.workflow1.testing.WorkflowTurbine.Companion.WORKFLOW_TEST_DEFAULT_TIMEOUT_MS
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.ExperimentalCoroutinesApi
13+
import kotlinx.coroutines.cancel
14+
import kotlinx.coroutines.flow.MutableStateFlow
15+
import kotlinx.coroutines.flow.StateFlow
16+
import kotlinx.coroutines.flow.asStateFlow
17+
import kotlinx.coroutines.flow.drop
18+
import kotlinx.coroutines.flow.map
19+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
20+
import kotlinx.coroutines.test.runTest
21+
import kotlin.coroutines.CoroutineContext
22+
import kotlin.time.Duration.Companion.milliseconds
23+
24+
/**
25+
* This is a test harness to run integration tests for a Workflow tree. The parameters passed here are
26+
* the same as those to start a Workflow runtime with [renderWorkflowIn] except for ignoring
27+
* state persistence as that is not needed for this style of test.
28+
*
29+
* The [coroutineContext] rather than a [CoroutineScope] is passed so that this harness handles the
30+
* scope for the Workflow runtime for you but you can still specify context for it.
31+
*
32+
* A [testTimeout] may be specified to override the default [WORKFLOW_TEST_DEFAULT_TIMEOUT_MS] for
33+
* any particular test. This is the max amount of time the test could spend waiting on a rendering.
34+
*
35+
* This will start the Workflow runtime (with params as passed) rooted at whatever Workflow
36+
* it is called on and then create a [WorkflowTurbine] for its renderings and run [testCase] on that.
37+
* [testCase] can thus drive the test scenario and assert against renderings.
38+
*/
39+
@OptIn(ExperimentalCoroutinesApi::class)
40+
public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.headlessIntegrationTest(
41+
props: StateFlow<PropsT>,
42+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
43+
interceptors: List<WorkflowInterceptor> = emptyList(),
44+
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
45+
onOutput: suspend (OutputT) -> Unit = {},
46+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
47+
testCase: suspend WorkflowTurbine<RenderingT>.() -> Unit
48+
) {
49+
val workflow = this
50+
51+
runTest(
52+
context = coroutineContext,
53+
timeout = testTimeout.milliseconds
54+
) {
55+
// We use a sub-scope so that we can cancel the Workflow runtime when we are done with it so that
56+
// tests don't all have to do that themselves.
57+
val workflowRuntimeScope = CoroutineScope(coroutineContext)
58+
val renderings = renderWorkflowIn(
59+
workflow = workflow,
60+
props = props,
61+
scope = workflowRuntimeScope,
62+
interceptors = interceptors,
63+
runtimeConfig = runtimeConfig,
64+
onOutput = onOutput
65+
)
66+
67+
val firstRendering = renderings.value.rendering
68+
69+
// Drop one as its provided separately via `firstRendering`.
70+
renderings.drop(1).map {
71+
it.rendering
72+
}.test {
73+
val workflowTurbine = WorkflowTurbine(
74+
firstRendering,
75+
this
76+
)
77+
workflowTurbine.testCase()
78+
cancelAndIgnoreRemainingEvents()
79+
}
80+
workflowRuntimeScope.cancel()
81+
}
82+
}
83+
84+
/**
85+
* Version of [headlessIntegrationTest] that does not require props. For Workflows that have [Unit]
86+
* props type.
87+
*/
88+
@OptIn(ExperimentalCoroutinesApi::class)
89+
public fun <OutputT, RenderingT> Workflow<Unit, OutputT, RenderingT>.headlessIntegrationTest(
90+
coroutineContext: CoroutineContext = UnconfinedTestDispatcher(),
91+
interceptors: List<WorkflowInterceptor> = emptyList(),
92+
runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG,
93+
onOutput: suspend (OutputT) -> Unit = {},
94+
testTimeout: Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS,
95+
testCase: suspend WorkflowTurbine<RenderingT>.() -> Unit
96+
): Unit = headlessIntegrationTest(
97+
props = MutableStateFlow(Unit).asStateFlow(),
98+
coroutineContext = coroutineContext,
99+
interceptors = interceptors,
100+
runtimeConfig = runtimeConfig,
101+
onOutput = onOutput,
102+
testTimeout = testTimeout,
103+
testCase = testCase
104+
)
105+
106+
/**
107+
* Simple wrapper around a [ReceiveTurbine] of [RenderingT] to provide convenience helper methods specific
108+
* to Workflow renderings.
109+
*
110+
* @property firstRendering The first rendering of the Workflow runtime is made synchronously. This is
111+
* provided separately if any assertions or operations are needed from it.
112+
*/
113+
public class WorkflowTurbine<RenderingT>(
114+
public val firstRendering: RenderingT,
115+
private val receiveTurbine: ReceiveTurbine<RenderingT>
116+
) {
117+
private var usedFirst = false
118+
119+
/**
120+
* Suspend waiting for the next rendering to be produced by the Workflow runtime. Note this includes
121+
* the first (synchronously made) rendering.
122+
*
123+
* @return the rendering.
124+
*/
125+
public suspend fun awaitNextRendering(): RenderingT {
126+
if (!usedFirst) {
127+
usedFirst = true
128+
return firstRendering
129+
}
130+
return receiveTurbine.awaitItem()
131+
}
132+
133+
public suspend fun skipRenderings(count: Int) {
134+
val skippedCount = if (!usedFirst) {
135+
usedFirst = true
136+
count - 1
137+
} else {
138+
count
139+
}
140+
141+
if (skippedCount > 0) {
142+
receiveTurbine.skipItems(skippedCount)
143+
}
144+
}
145+
146+
/**
147+
* Suspend waiting for the next rendering to be produced by the Workflow runtime that satisfies the
148+
* [predicate].
149+
*
150+
* @return the rendering.
151+
*/
152+
public suspend fun awaitNextRenderingSatisfying(
153+
predicate: (RenderingT) -> Boolean
154+
): RenderingT {
155+
var rendering = awaitNextRendering()
156+
while (!predicate(rendering)) {
157+
rendering = awaitNextRendering()
158+
}
159+
return rendering
160+
}
161+
162+
/**
163+
* Suspend waiting for the next rendering which satisfies [precondition], can successfully be mapped
164+
* using [map] and satisfies the [satisfying] predicate when called on the [T] rendering after it
165+
* has been mapped.
166+
*
167+
* @return the mapped rendering as [T]
168+
*/
169+
public suspend fun <T> awaitNext(
170+
precondition: (RenderingT) -> Boolean = { true },
171+
map: (RenderingT) -> T,
172+
satisfying: T.() -> Boolean = { true }
173+
): T =
174+
map(
175+
awaitNextRenderingSatisfying {
176+
precondition(it) &&
177+
with(map(it)) {
178+
this.satisfying()
179+
}
180+
}
181+
)
182+
183+
public companion object {
184+
/**
185+
* Default timeout to use while waiting for renderings.
186+
*/
187+
public const val WORKFLOW_TEST_DEFAULT_TIMEOUT_MS: Long = 60_000L
188+
}
189+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.squareup.workflow1
2+
3+
import kotlin.test.assertEquals
4+
import kotlin.test.assertFailsWith
5+
import kotlin.test.assertFalse
6+
import kotlin.test.assertNotNull
7+
import kotlin.test.assertNotSame
8+
import kotlin.test.assertNull
9+
import kotlin.test.assertTrue
10+
11+
/**
12+
* This file is copied from workflow-runtime:commonTest so our tests that test across the runtime
13+
* look consistent. We could have used a JUnit library like Jupiter, but didn't.
14+
*
15+
* This file is copied so as to avoid creating a workflow-core-testing module (for now).
16+
*
17+
* We do our best to tell you what the parameter was when the failure occured by wrapping
18+
* assertions from kotlin.test and injecting our own message.
19+
*/
20+
class ParameterizedTestRunner<P : Any> {
21+
22+
var currentParam: P? = null
23+
24+
fun runParametrizedTest(
25+
paramSource: Sequence<P>,
26+
before: () -> Unit = {},
27+
after: () -> Unit = {},
28+
test: ParameterizedTestRunner<P>.(param: P) -> Unit
29+
) {
30+
paramSource.forEach {
31+
before()
32+
currentParam = it
33+
test(it)
34+
after()
35+
}
36+
}
37+
38+
fun <T> assertEquals(expected: T, actual: T) {
39+
assertEquals(expected, actual, message = "Using: ${currentParam?.toString()}")
40+
}
41+
42+
fun <T> assertEquals(expected: T, actual: T, originalMessage: String) {
43+
assertEquals(expected, actual, message = "$originalMessage; Using: ${currentParam?.toString()}")
44+
}
45+
46+
fun assertTrue(statement: Boolean) {
47+
assertTrue(statement, message = "Using: ${currentParam?.toString()}")
48+
}
49+
50+
fun assertFalse(statement: Boolean) {
51+
assertFalse(statement, message = "Using: ${currentParam?.toString()}")
52+
}
53+
54+
inline fun <reified T : Throwable> assertFailsWith(block: () -> Unit) {
55+
assertFailsWith<T>(message = "Using: ${currentParam?.toString()}", block)
56+
}
57+
58+
fun <T : Any?> assertNotSame(illegal: T, actual: T) {
59+
assertNotSame(illegal, actual, message = "Using: ${currentParam?.toString()}")
60+
}
61+
62+
fun <T : Any> assertNotNull(actual: T?) {
63+
assertNotNull(actual, message = "Using: ${currentParam?.toString()}")
64+
}
65+
66+
fun assertNull(actual: Any?) {
67+
assertNull(actual, message = "Using: ${currentParam?.toString()}")
68+
}
69+
}

0 commit comments

Comments
 (0)