11package com.squareup.workflow1.testing
22
33import app.cash.turbine.ReceiveTurbine
4- import app.cash.turbine.test
5- import app.cash.turbine.testIn
64import app.cash.turbine.turbineScope
75import com.squareup.workflow1.RuntimeConfig
6+ import com.squareup.workflow1.StatefulWorkflow
87import com.squareup.workflow1.TreeSnapshot
98import com.squareup.workflow1.Workflow
109import com.squareup.workflow1.WorkflowInterceptor
1110import com.squareup.workflow1.config.JvmTestRuntimeConfigTools
1211import com.squareup.workflow1.renderWorkflowIn
12+ import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFresh
13+ import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromCompleteSnapshot
14+ import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromState
15+ import com.squareup.workflow1.testing.WorkflowTestParams.StartMode.StartFromWorkflowSnapshot
1316import com.squareup.workflow1.testing.WorkflowTurbine.Companion.WORKFLOW_TEST_DEFAULT_TIMEOUT_MS
1417import kotlinx.coroutines.CoroutineScope
1518import kotlinx.coroutines.ExperimentalCoroutinesApi
1619import kotlinx.coroutines.cancel
1720import kotlinx.coroutines.channels.Channel
1821import kotlinx.coroutines.flow.MutableStateFlow
19- import kotlinx.coroutines.flow.receiveAsFlow
22+ import kotlinx.coroutines.flow.SharingStarted
2023import kotlinx.coroutines.flow.StateFlow
2124import kotlinx.coroutines.flow.asStateFlow
22- import kotlinx.coroutines.flow.SharingStarted
2325import kotlinx.coroutines.flow.drop
2426import kotlinx.coroutines.flow.map
27+ import kotlinx.coroutines.flow.receiveAsFlow
2528import kotlinx.coroutines.flow.shareIn
2629import kotlinx.coroutines.test.UnconfinedTestDispatcher
2730import kotlinx.coroutines.test.runTest
@@ -34,7 +37,7 @@ import kotlin.time.Duration.Companion.milliseconds
3437 * state persistence as that is not needed for this style of test.
3538 *
3639 * The [coroutineContext] rather than a [CoroutineScope] is passed so that this harness handles the
37- * scope for the Workflow runtime for you but you can still specify context for it.
40+ * scope for the Workflow runtime for you, but you can still specify context for it.
3841 *
3942 * A [testTimeout] may be specified to override the default [WORKFLOW_TEST_DEFAULT_TIMEOUT_MS] for
4043 * any particular test. This is the max amount of time the test could spend waiting on a rendering.
@@ -48,14 +51,78 @@ import kotlin.time.Duration.Companion.milliseconds
4851@OptIn(ExperimentalCoroutinesApi ::class )
4952public fun <PropsT , OutputT , RenderingT > Workflow <PropsT , OutputT , RenderingT >.renderForTest (
5053 props : StateFlow <PropsT >,
54+ testParams : WorkflowTestParams <Nothing > = WorkflowTestParams (),
5155 coroutineContext : CoroutineContext = UnconfinedTestDispatcher (),
5256 interceptors : List <WorkflowInterceptor > = emptyList(),
53- runtimeConfig : RuntimeConfig = JvmTestRuntimeConfigTools .getTestRuntimeConfig(),
5457 onOutput : suspend (OutputT ) -> Unit = {},
5558 testTimeout : Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS ,
5659 testCase : suspend WorkflowTurbine <RenderingT , OutputT >.() -> Unit
57- ) {
58- val workflow = this
60+ ) = asStatefulWorkflow().renderForTest(
61+ props,
62+ testParams,
63+ coroutineContext,
64+ onOutput,
65+ testTimeout,
66+ testCase
67+ )
68+
69+ /* *
70+ * Version of [renderForTest] that does not require props. For Workflows that have [Unit]
71+ * props type.
72+ */
73+ @OptIn(ExperimentalCoroutinesApi ::class )
74+ public fun <OutputT , RenderingT > Workflow <Unit , OutputT , RenderingT >.renderForTest (
75+ coroutineContext : CoroutineContext = UnconfinedTestDispatcher (),
76+ testParams : WorkflowTestParams <Nothing > = WorkflowTestParams (),
77+ interceptors : List <WorkflowInterceptor > = emptyList(),
78+ onOutput : suspend (OutputT ) -> Unit = {},
79+ testTimeout : Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS ,
80+ testCase : suspend WorkflowTurbine <RenderingT , OutputT >.() -> Unit
81+ ): Unit = renderForTest(
82+ props = MutableStateFlow (Unit ).asStateFlow(),
83+ testParams = testParams,
84+ coroutineContext = coroutineContext,
85+ interceptors = interceptors,
86+ onOutput = onOutput,
87+ testTimeout = testTimeout,
88+ testCase = testCase
89+ )
90+
91+ /* *
92+ * Version of [renderForTest] for a [StatefulWorkflow]
93+ * that accepts [WorkflowTestParams] for configuring the test,
94+ * including starting from a specific state or snapshot.
95+ *
96+ * @param props StateFlow of props to send to the workflow.
97+ * @param testParams Test configuration parameters. See [WorkflowTestParams] for details.
98+ * @param coroutineContext Optional [CoroutineContext] to use for the test.
99+ * @param onOutput Callback for workflow outputs.
100+ * @param testTimeout Maximum time to wait for workflow operations in milliseconds.
101+ * @param testCase The test code to run with access to the [WorkflowTurbine].
102+ */
103+ @OptIn(ExperimentalCoroutinesApi ::class )
104+ public fun <PropsT , StateT , OutputT , RenderingT >
105+ StatefulWorkflow <PropsT , StateT , OutputT , RenderingT >.renderForTest (
106+ props : StateFlow <PropsT >,
107+ testParams : WorkflowTestParams <StateT > = WorkflowTestParams (),
108+ coroutineContext : CoroutineContext = UnconfinedTestDispatcher (),
109+ onOutput : suspend (OutputT ) -> Unit = {},
110+ testTimeout : Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS ,
111+ testCase : suspend WorkflowTurbine <RenderingT , OutputT >.() -> Unit
112+ ) {
113+ val workflow: Workflow <PropsT , OutputT , RenderingT > = this
114+
115+ // Determine the initial snapshot based on startFrom mode
116+ val initialSnapshot = when (val startFrom = testParams.startFrom) {
117+ StartFresh -> null
118+ is StartFromWorkflowSnapshot -> TreeSnapshot .forRootOnly(startFrom.snapshot)
119+ is StartFromCompleteSnapshot -> startFrom.snapshot
120+ is StartFromState -> null
121+ }
122+
123+ val interceptors = testParams.createInterceptors()
124+
125+ val runtimeConfig = testParams.runtimeConfig ? : JvmTestRuntimeConfigTools .getTestRuntimeConfig()
59126
60127 runTest(
61128 context = coroutineContext,
@@ -65,13 +132,13 @@ public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.r
65132 // tests don't all have to do that themselves.
66133 val workflowRuntimeScope = CoroutineScope (coroutineContext)
67134
68- // Capture outputs in a channel
69135 val outputsChannel = Channel <OutputT >(Channel .UNLIMITED )
70136
71137 val renderings = renderWorkflowIn(
72138 workflow = workflow,
73139 props = props,
74140 scope = workflowRuntimeScope,
141+ initialSnapshot = initialSnapshot,
75142 interceptors = interceptors,
76143 runtimeConfig = runtimeConfig,
77144 onOutput = { output ->
@@ -122,35 +189,95 @@ public fun <PropsT, OutputT, RenderingT> Workflow<PropsT, OutputT, RenderingT>.r
122189}
123190
124191/* *
125- * Version of [renderForTest] that does not require props. For Workflows that have [Unit]
126- * props type.
192+ * Version of [renderForTest] for a [StatefulWorkflow]
193+ * that accepts [WorkflowTestParams] and doesn't require props.
194+ * For Workflows that have [Unit] props type.
127195 */
128196@OptIn(ExperimentalCoroutinesApi ::class )
129- public fun <OutputT , RenderingT > Workflow < Unit , OutputT , RenderingT >. renderForTest (
130- coroutineContext : CoroutineContext = UnconfinedTestDispatcher (),
131- interceptors : List < WorkflowInterceptor > = emptyList (),
132- runtimeConfig : RuntimeConfig = JvmTestRuntimeConfigTools .getTestRuntimeConfig (),
133- onOutput : suspend (OutputT ) -> Unit = {},
134- testTimeout : Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS ,
135- testCase : suspend WorkflowTurbine <RenderingT , OutputT >.() -> Unit
136- ): Unit = renderForTest(
197+ public fun <StateT , OutputT , RenderingT >
198+ StatefulWorkflow < Unit , StateT , OutputT , RenderingT >. renderForTest (
199+ testParams : WorkflowTestParams < StateT > = WorkflowTestParams (),
200+ coroutineContext : CoroutineContext = UnconfinedTestDispatcher (),
201+ onOutput : suspend (OutputT ) -> Unit = {},
202+ testTimeout : Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS ,
203+ testCase : suspend WorkflowTurbine <RenderingT , OutputT >.() -> Unit
204+ ): Unit = renderForTest(
137205 props = MutableStateFlow (Unit ).asStateFlow(),
206+ testParams = testParams,
207+ coroutineContext = coroutineContext,
208+ onOutput = onOutput,
209+ testTimeout = testTimeout,
210+ testCase = testCase
211+ )
212+
213+ /* *
214+ * Convenience function to test a workflow starting from a specific state.
215+ *
216+ * This is equivalent to calling [renderForTest] with
217+ * `testParams = WorkflowTestParams(startFrom = StartFromState(initialState))`.
218+ *
219+ * @param props StateFlow of props to send to the workflow.
220+ * @param initialState The state to start the workflow from.
221+ * @param coroutineContext Optional [CoroutineContext] to use for the test.
222+ * @param onOutput Callback for workflow outputs.
223+ * @param testTimeout Maximum time to wait for workflow operations in milliseconds.
224+ * @param testCase The test code to run with access to the [WorkflowTurbine].
225+ */
226+ @OptIn(ExperimentalCoroutinesApi ::class )
227+ public fun <PropsT , StateT , OutputT , RenderingT >
228+ StatefulWorkflow <PropsT , StateT , OutputT , RenderingT >.renderForTestFromStateWith (
229+ props : StateFlow <PropsT >,
230+ initialState : StateT ,
231+ coroutineContext : CoroutineContext = UnconfinedTestDispatcher (),
232+ onOutput : suspend (OutputT ) -> Unit = {},
233+ testTimeout : Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS ,
234+ testCase : suspend WorkflowTurbine <RenderingT , OutputT >.() -> Unit
235+ ): Unit = renderForTest(
236+ props = props,
237+ testParams = WorkflowTestParams (startFrom = StartFromState (initialState)),
238+ coroutineContext = coroutineContext,
239+ onOutput = onOutput,
240+ testTimeout = testTimeout,
241+ testCase = testCase
242+ )
243+
244+ /* *
245+ * Convenience function to test a workflow starting from a specific state.
246+ * Version for workflows with [Unit] props.
247+ *
248+ * @param initialState The state to start the workflow from.
249+ * @param coroutineContext Optional [CoroutineContext] to use for the test.
250+ * @param onOutput Callback for workflow outputs.
251+ * @param testTimeout Maximum time to wait for workflow operations in milliseconds.
252+ * @param testCase The test code to run with access to the [WorkflowTurbine].
253+ */
254+ @OptIn(ExperimentalCoroutinesApi ::class )
255+ public fun <StateT , OutputT , RenderingT >
256+ StatefulWorkflow <Unit , StateT , OutputT , RenderingT >.renderForTestFromStateWith (
257+ initialState : StateT ,
258+ coroutineContext : CoroutineContext = UnconfinedTestDispatcher (),
259+ onOutput : suspend (OutputT ) -> Unit = {},
260+ testTimeout : Long = WORKFLOW_TEST_DEFAULT_TIMEOUT_MS ,
261+ testCase : suspend WorkflowTurbine <RenderingT , OutputT >.() -> Unit
262+ ): Unit = renderForTestFromStateWith(
263+ props = MutableStateFlow (Unit ).asStateFlow(),
264+ initialState = initialState,
138265 coroutineContext = coroutineContext,
139- interceptors = interceptors,
140- runtimeConfig = runtimeConfig,
141266 onOutput = onOutput,
142267 testTimeout = testTimeout,
143268 testCase = testCase
144269)
145270
146271/* *
147- * Simple wrapper around a [ReceiveTurbine] of [RenderingT] to provide convenience helper methods specific
148- * to Workflow renderings.
272+ * Provides independent access to three flows emitted by the workflow runtime: renderings, snapshots,
273+ * and outputs. Uses [shareIn] to broadcast the combined rendering/snapshot flow to multiple turbines,
274+ * ensuring all emissions are available to all flows without race conditions.
149275 *
150- * @property firstRendering The first rendering of the Workflow runtime is made synchronously. This is
151- * provided separately if any assertions or operations are needed from it.
152- * @property firstSnapshot The first snapshot of the Workflow runtime is made synchronously. This is
153- * provided separately if any assertions or operations are needed from it.
276+ * @property firstRendering The first rendering, made synchronously when the workflow runtime starts.
277+ * @property firstSnapshot The first snapshot, made synchronously when the workflow runtime starts.
278+ * @property renderingTurbine Turbine for consuming subsequent renderings.
279+ * @property snapshotTurbine Turbine for consuming subsequent snapshots.
280+ * @property outputTurbine Turbine for consuming workflow outputs.
154281 */
155282public class WorkflowTurbine <RenderingT , OutputT >(
156283 public val firstRendering : RenderingT ,
0 commit comments