diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt index 16368b6608..8fb68074dc 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonAppWorkflow.kt @@ -26,6 +26,7 @@ import com.squareup.workflow.Snapshot import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.action import com.squareup.workflow.renderChild +import com.squareup.workflow.runningWorker import com.squareup.workflow.ui.WorkflowUiExperimentalApi import com.squareup.workflow.ui.modal.AlertContainerScreen diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt index 168b8074a0..8f0632a9af 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/GameSessionWorkflow.kt @@ -31,6 +31,7 @@ import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowAction.Companion.noAction import com.squareup.workflow.WorkflowAction.Updater import com.squareup.workflow.action +import com.squareup.workflow.runningWorker import com.squareup.workflow.ui.WorkflowUiExperimentalApi import com.squareup.workflow.ui.modal.AlertContainerScreen import com.squareup.workflow.ui.modal.AlertScreen diff --git a/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/AiWorkflow.kt b/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/AiWorkflow.kt index ab38c89217..dc3038b4c9 100644 --- a/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/AiWorkflow.kt +++ b/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/AiWorkflow.kt @@ -28,6 +28,7 @@ import com.squareup.workflow.Snapshot import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.Worker import com.squareup.workflow.action +import com.squareup.workflow.runningWorker import com.squareup.workflow.transform import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.transform diff --git a/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt b/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt index 1699fcd436..3297291e5b 100644 --- a/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt +++ b/samples/dungeon/common/src/main/java/com/squareup/sample/dungeon/GameWorkflow.kt @@ -36,6 +36,7 @@ import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.Worker import com.squareup.workflow.action import com.squareup.workflow.renderChild +import com.squareup.workflow.runningWorker import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow diff --git a/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineWorkflow.kt b/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineWorkflow.kt index be43d528ad..0916a8a74c 100644 --- a/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineWorkflow.kt +++ b/samples/dungeon/timemachine-shakeable/src/main/java/com/squareup/sample/timemachine/shakeable/ShakeableTimeMachineWorkflow.kt @@ -28,6 +28,7 @@ import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowAction.Updater import com.squareup.workflow.action +import com.squareup.workflow.runningWorker import kotlin.time.Duration import kotlin.time.ExperimentalTime diff --git a/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/BlinkingCursorWorkflow.kt b/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/BlinkingCursorWorkflow.kt index 5ed95e944c..08fb03a26b 100644 --- a/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/BlinkingCursorWorkflow.kt +++ b/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/BlinkingCursorWorkflow.kt @@ -20,6 +20,7 @@ import com.squareup.workflow.Snapshot import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.Worker import com.squareup.workflow.action +import com.squareup.workflow.runningWorker import kotlinx.coroutines.delay /** diff --git a/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/HelloTerminalWorkflow.kt b/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/HelloTerminalWorkflow.kt index 39ee6a50d1..bdacac788a 100644 --- a/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/HelloTerminalWorkflow.kt +++ b/samples/hello-terminal/hello-terminal-app/src/main/java/com/squareup/sample/helloterminal/HelloTerminalWorkflow.kt @@ -29,6 +29,7 @@ import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.action import com.squareup.workflow.renderChild +import com.squareup.workflow.runningWorker private typealias HelloTerminalAction = WorkflowAction diff --git a/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/EditTextWorkflow.kt b/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/EditTextWorkflow.kt index f8ba1408b3..4dcfb1543b 100644 --- a/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/EditTextWorkflow.kt +++ b/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/EditTextWorkflow.kt @@ -12,6 +12,7 @@ import com.squareup.workflow.RenderContext import com.squareup.workflow.Snapshot import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.action +import com.squareup.workflow.runningWorker class EditTextWorkflow : StatefulWorkflow() { diff --git a/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/TodoWorkflow.kt b/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/TodoWorkflow.kt index 1fb501aaf1..b752125757 100644 --- a/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/TodoWorkflow.kt +++ b/samples/hello-terminal/todo-terminal-app/src/main/java/com/squareup/sample/hellotodo/TodoWorkflow.kt @@ -31,6 +31,7 @@ import com.squareup.workflow.Snapshot import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.action +import com.squareup.workflow.runningWorker private typealias TodoAction = WorkflowAction diff --git a/samples/tictactoe/common/src/main/java/com/squareup/sample/authworkflow/AuthWorkflow.kt b/samples/tictactoe/common/src/main/java/com/squareup/sample/authworkflow/AuthWorkflow.kt index 8e1980cf9f..762723ae3c 100644 --- a/samples/tictactoe/common/src/main/java/com/squareup/sample/authworkflow/AuthWorkflow.kt +++ b/samples/tictactoe/common/src/main/java/com/squareup/sample/authworkflow/AuthWorkflow.kt @@ -36,6 +36,7 @@ import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowAction.Updater +import com.squareup.workflow.runningWorker import com.squareup.workflow.rx2.asWorker import com.squareup.workflow.ui.WorkflowUiExperimentalApi import com.squareup.workflow.ui.backstack.BackStackScreen diff --git a/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt b/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt index 2edb5c0584..b8f1ef4310 100644 --- a/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt +++ b/samples/tictactoe/common/src/main/java/com/squareup/sample/gameworkflow/RunGameWorkflow.kt @@ -46,6 +46,7 @@ import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowAction.Updater +import com.squareup.workflow.runningWorker import com.squareup.workflow.rx2.asWorker import com.squareup.workflow.ui.WorkflowUiExperimentalApi import com.squareup.workflow.ui.modal.AlertContainerScreen diff --git a/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/MainWorkflowTest.kt b/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/MainWorkflowTest.kt index 7f4755c83e..00cfcf291e 100644 --- a/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/MainWorkflowTest.kt +++ b/samples/tictactoe/common/src/test/java/com/squareup/sample/mainworkflow/MainWorkflowTest.kt @@ -11,6 +11,7 @@ import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.action import com.squareup.workflow.rendering +import com.squareup.workflow.runningWorker import com.squareup.workflow.stateless import com.squareup.workflow.testing.launchForTestingFromStartWith import com.squareup.workflow.ui.WorkflowUiExperimentalApi diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index 491faa5e4a..5920fb1b2d 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -33,14 +33,12 @@ public abstract interface class com/squareup/workflow/RenderContext { public abstract fun onEvent (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; public abstract fun renderChild (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; public abstract fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V - public abstract fun runningWorker (Lcom/squareup/workflow/Worker;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V } public final class com/squareup/workflow/RenderContext$DefaultImpls { public static fun makeActionSink (Lcom/squareup/workflow/RenderContext;)Lcom/squareup/workflow/Sink; public static fun onEvent (Lcom/squareup/workflow/RenderContext;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function1; public static synthetic fun renderChild$default (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun runningWorker$default (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Worker;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } public abstract interface class com/squareup/workflow/Sink { @@ -213,12 +211,9 @@ public final class com/squareup/workflow/Workflows { public static final fun contraMap (Lcom/squareup/workflow/Sink;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/Sink; public static final fun getIdentifier (Lcom/squareup/workflow/Workflow;)Lcom/squareup/workflow/WorkflowIdentifier; public static final fun getWorkflowIdentifier (Lkotlin/reflect/KClass;)Lcom/squareup/workflow/WorkflowIdentifier; - public static final fun impostorWorkflowIdentifier (Lkotlin/reflect/KClass;Lcom/squareup/workflow/WorkflowIdentifier;)Lcom/squareup/workflow/WorkflowIdentifier; public static final fun invoke (Lcom/squareup/workflow/EventHandler;)V public static final fun makeEventSink (Lcom/squareup/workflow/RenderContext;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/Sink; public static final fun mapRendering (Lcom/squareup/workflow/Workflow;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/Workflow; - public static final fun onWorkerOutput (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Worker;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V - public static synthetic fun onWorkerOutput$default (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Worker;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static final fun renderChild (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; public static final fun renderChild (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Workflow;Ljava/lang/String;)Ljava/lang/Object; public static final fun renderChild (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Workflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; @@ -227,6 +222,7 @@ public final class com/squareup/workflow/Workflows { public static synthetic fun renderChild$default (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Workflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; public static final fun rendering (Lcom/squareup/workflow/Workflow$Companion;Ljava/lang/Object;)Lcom/squareup/workflow/Workflow; public static final fun runningWorker (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Worker;Ljava/lang/String;)V + public static final fun runningWorker (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Worker;Lkotlin/reflect/KType;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public static synthetic fun runningWorker$default (Lcom/squareup/workflow/RenderContext;Lcom/squareup/workflow/Worker;Ljava/lang/String;ILjava/lang/Object;)V public static final fun sendAndAwaitApplication (Lcom/squareup/workflow/Sink;Lcom/squareup/workflow/WorkflowAction;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun stateful (Lcom/squareup/workflow/Workflow$Companion;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/StatefulWorkflow; diff --git a/workflow-core/src/main/java/com/squareup/workflow/LifecycleWorker.kt b/workflow-core/src/main/java/com/squareup/workflow/LifecycleWorker.kt index 921484fcb1..3999de1d52 100644 --- a/workflow-core/src/main/java/com/squareup/workflow/LifecycleWorker.kt +++ b/workflow-core/src/main/java/com/squareup/workflow/LifecycleWorker.kt @@ -73,6 +73,5 @@ abstract class LifecycleWorker : Worker { /** * Equates [LifecycleWorker]s that have the same concrete class. */ - override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = - this::class == otherWorker::class + override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = true } diff --git a/workflow-core/src/main/java/com/squareup/workflow/RenderContext.kt b/workflow-core/src/main/java/com/squareup/workflow/RenderContext.kt index d515cca15a..7fb42b5aac 100644 --- a/workflow-core/src/main/java/com/squareup/workflow/RenderContext.kt +++ b/workflow-core/src/main/java/com/squareup/workflow/RenderContext.kt @@ -21,6 +21,8 @@ package com.squareup.workflow import com.squareup.workflow.WorkflowAction.Companion.noAction import com.squareup.workflow.WorkflowAction.Updater +import kotlin.reflect.KType +import kotlin.reflect.typeOf /** * Facilities for a [Workflow] to interact with other [Workflow]s and the outside world from inside @@ -109,19 +111,6 @@ interface RenderContext { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT - /** - * Ensures [worker] is running. When the [Worker] emits an output, [handler] is called - * to determine the [WorkflowAction] to take. When the worker finishes, nothing happens (although - * another render pass may be triggered). - * - * @param key An optional string key that is used to distinguish between identical [Worker]s. - */ - fun runningWorker( - worker: Worker, - key: String = "", - handler: (T) -> WorkflowAction - ) - /** * Ensures [sideEffect] is running with the given [key]. * @@ -196,12 +185,62 @@ fun RenderContext.runningWork worker: Worker, key: String = "" ) { - // Need to cast to Any so the compiler doesn't complain about unreachable code. - runningWorker(worker as Worker, key) { + runningWorker(worker, key) { + // The compiler thinks this code is unreachable, and it is correct. But we have to pass a lambda + // here so we might as well check at runtime as well. + @Suppress("UNREACHABLE_CODE", "ThrowableNotThrown") throw AssertionError("Worker emitted $it") } } +/** + * Ensures [worker] is running. When the [Worker] emits an output, [handler] is called + * to determine the [WorkflowAction] to take. When the worker finishes, nothing happens (although + * another render pass may be triggered). + * + * Like workflows, workers are kept alive across multiple render passes if they're the same type, + * and different workers of distinct types can be run concurrently. However, unlike workflows, + * workers are compared by their _declared_ type, not their actual type. This means that if you + * pass a worker stored in a variable to this function, the type that will be used to compare the + * worker will be the type of the variable, not the type of the object the variable refers to. + * + * @param key An optional string key that is used to distinguish between identical [Worker]s. + */ +@OptIn(ExperimentalStdlibApi::class) +/* ktlint-disable parameter-list-wrapping */ +inline fun , PropsT, StateT, OutputT> + RenderContext.runningWorker( + worker: W, + key: String = "", + noinline handler: (T) -> WorkflowAction +) { +/* ktlint-enable parameter-list-wrapping */ + runningWorker(worker, typeOf(), key, handler) +} + +/** + * Ensures [worker] is running. When the [Worker] emits an output, [handler] is called + * to determine the [WorkflowAction] to take. When the worker finishes, nothing happens (although + * another render pass may be triggered). + * + * @param workerType `typeOf()` + * @param key An optional string key that is used to distinguish between identical [Worker]s. + */ +@OptIn(ExperimentalStdlibApi::class) +@PublishedApi +/* ktlint-disable parameter-list-wrapping */ +internal fun + RenderContext.runningWorker( + worker: Worker, + workerType: KType, + key: String = "", + handler: (T) -> WorkflowAction +) { +/* ktlint-enable parameter-list-wrapping */ + val workerWorkflow = WorkerWorkflow(workerType, key) + renderChild(workerWorkflow, props = worker, key = key, handler = handler) +} + /** * Alternative to [RenderContext.actionSink] that allows externally defined * event types to be mapped to anonymous [WorkflowAction]s. @@ -223,8 +262,8 @@ fun RenderContext.mak "Use runningWorker", ReplaceWith("runningWorker(worker, key, handler)", "com.squareup.workflow.runningWorker") ) -fun RenderContext.onWorkerOutput( +inline fun RenderContext.onWorkerOutput( worker: Worker, key: String = "", - handler: (T) -> WorkflowAction + noinline handler: (T) -> WorkflowAction ) = runningWorker(worker, key, handler) diff --git a/workflow-core/src/main/java/com/squareup/workflow/Sink.kt b/workflow-core/src/main/java/com/squareup/workflow/Sink.kt index 8325291e52..c1398386d9 100644 --- a/workflow-core/src/main/java/com/squareup/workflow/Sink.kt +++ b/workflow-core/src/main/java/com/squareup/workflow/Sink.kt @@ -18,6 +18,7 @@ package com.squareup.workflow +import com.squareup.workflow.WorkflowAction.Updater import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.suspendCancellableCoroutine @@ -104,15 +105,18 @@ suspend fun < action: WorkflowAction ) { suspendCancellableCoroutine { continuation -> - val resumingAction = action({ "sendAndAwaitExecution($action)" }) { - // Don't execute anything if the caller was cancelled while we were in the queue. - if (!continuation.isActive) return@action + val resumingAction = object : WorkflowAction { + override fun toString(): String = "sendAndAwaitApplication($action)" + override fun Updater.apply() { + // Don't execute anything if the caller was cancelled while we were in the queue. + if (!continuation.isActive) return - with(action) { - // Forward our Updater to the real action. - apply() + with(action) { + // Forward our Updater to the real action. + apply() + } + continuation.resume(Unit) } - continuation.resume(Unit) } send(resumingAction) } diff --git a/workflow-core/src/main/java/com/squareup/workflow/Worker.kt b/workflow-core/src/main/java/com/squareup/workflow/Worker.kt index 67c26adebd..8b940b2266 100644 --- a/workflow-core/src/main/java/com/squareup/workflow/Worker.kt +++ b/workflow-core/src/main/java/com/squareup/workflow/Worker.kt @@ -154,19 +154,20 @@ interface Worker { fun run(): Flow /** - * Override this method to define equivalence between [Worker]s. + * Override this method to define equivalence between [Worker]s. The default implementation + * returns true if this worker's class is the same as [otherWorker]'s class. * * At the end of every render pass, the set of [Worker]s that were requested by the workflow are - * compared to the set from the last render pass using this method. Equivalent workers are allowed - * to keep running. New workers are started ([run] is called and the returned [Flow] is - * collected). Old workers are cancelled by cancelling their collecting coroutines. + * compared to the set from the last render pass using this method. Workers are compared by their + * _declared_ type. Equivalent workers are allowed to keep running. New workers are started ([run] + * is called and the returned [Flow] is collected). Old workers are cancelled by cancelling their + * collecting coroutines. Workers for which [doesSameWorkAs] returns false will also be restarted. * * Implementations of this method should not be based on object identity. For example, a [Worker] * that performs a network request might check that two workers are requests to the same endpoint * and have the same request data. * - * Most implementations of this method will check for concrete type equality, and then match - * on constructor parameters. + * Most implementations of this method should compare constructor parameters. * * E.g: * @@ -384,23 +385,18 @@ fun Worker.transform( /** * A generic [Worker] implementation that defines equivalent workers as those having equivalent - * [type]s. This is used by all the [Worker] builder functions. + * [outputType]s. This is used by all the [Worker] builder functions. */ @PublishedApi internal class TypedWorker( - private val type: KType, + private val outputType: KType, private val work: Flow ) : Worker { - override fun run(): Flow = work - - override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = - otherWorker is TypedWorker && otherWorker.type == type - - override fun toString(): String = "TypedWorker($type)" + override fun toString(): String = "TypedWorker($outputType)" } -private class TimerWorker( +private data class TimerWorker( private val delayMs: Long, private val key: String ) : Worker { @@ -412,17 +408,14 @@ private class TimerWorker( override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = otherWorker is TimerWorker && otherWorker.key == key - - override fun toString(): String = "TimerWorker(delayMs=$delayMs)" } private object FinishedWorker : Worker { override fun run(): Flow = emptyFlow() - override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = otherWorker === FinishedWorker override fun toString(): String = "FinishedWorker" } -private class WorkerWrapper( +private data class WorkerWrapper( private val wrapped: Worker, private val flow: Flow ) : Worker { diff --git a/workflow-core/src/main/java/com/squareup/workflow/WorkerWorkflow.kt b/workflow-core/src/main/java/com/squareup/workflow/WorkerWorkflow.kt new file mode 100644 index 0000000000..879944e24d --- /dev/null +++ b/workflow-core/src/main/java/com/squareup/workflow/WorkerWorkflow.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:JvmMultifileClass +@file:JvmName("Workflows") + +package com.squareup.workflow + +import com.squareup.workflow.WorkflowAction.Updater +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import kotlin.reflect.KType + +/** + * The [Workflow] that implements the logic for actually running [Worker]s. + * + * This workflow is an [ImpostorWorkflow] and uses the entire [KType] of the [Worker] as its + * [realIdentifier], so that the runtime can ensure that distinct worker types are allowed to run + * concurrently. Implements [Worker.doesSameWorkAs] by taking the actual worker instance as its + * props, and checking [Worker.doesSameWorkAs] in [onPropsChanged]. When this returns false, it + * means a new worker session needs to be started, and that is achieved by storing a monotonically- + * increasing integer as the state, and incrementing it whenever the worker needs to be restarted. + * + * Note that since this workflow uses an [unsnapshottableIdentifier] as its [realIdentifier], it is + * not snapshottable, but that's fine because the only state this workflow maintains is only used + * to determine whether to restart workers during the lifetime of a single runtime instance. + * + * @param workerType The [KType] representing the particular type of `Worker`. + * @param key The key used to render this workflow, as passed to [RenderContext.runningWorker]. + * Used for naming the worker's coroutine. + */ +@OptIn(ExperimentalWorkflowApi::class) +internal class WorkerWorkflow( + private val workerType: KType, + private val key: String +) : StatefulWorkflow, Int, OutputT, Unit>(), + ImpostorWorkflow { + + override val realIdentifier: WorkflowIdentifier = unsnapshottableIdentifier(workerType) + override fun describeRealIdentifier(): String? = "worker $workerType" + + override fun initialState( + props: Worker, + snapshot: Snapshot? + ): Int = 0 + + override fun onPropsChanged( + old: Worker, + new: Worker, + state: Int + ): Int = if (!old.doesSameWorkAs(new)) state + 1 else state + + override fun render( + props: Worker, + state: Int, + context: RenderContext, Int, OutputT> + ) { + // Scope the side effect coroutine to the state value, so the worker will be re-started when + // it changes (such that doesSameWorkAs returns false above). + context.runningSideEffect(state.toString()) { + runWorker(props, key, context.actionSink) + } + } + + override fun snapshotState(state: Int): Snapshot? = null +} + +/** + * Does the actual running of a worker passed to [RenderContext.runningWorker] by setting up the + * coroutine environment for the worker, performing some validation, etc., and finally actually + * collecting the worker's [Flow]. + * + * Visible for testing. + */ +@OptIn(ExperimentalWorkflowApi::class) +internal suspend fun runWorker( + worker: Worker, + renderKey: String, + actionSink: Sink, Int, OutputT>> +) { + withContext(CoroutineName(worker.debugName(renderKey))) { + worker.runWithNullCheck() + .collectToSink(actionSink) { output -> + EmitWorkerOutputAction(worker, renderKey, output) + } + } +} + +private class EmitWorkerOutputAction( + private val worker: Worker<*>, + private val renderKey: String, + private val output: O +) : WorkflowAction { + override fun toString(): String = + "${EmitWorkerOutputAction::class.qualifiedName}(worker=$worker, key=\"$renderKey\")" + + override fun Updater.apply() { + setOutput(output) + } +} + +/** + * In unit tests, if you use a mocking library to create a Worker, the run method will return null + * even though the return type is non-nullable in Kotlin. Kotlin helps out with this by throwing an + * NPE before before any kotlin code gets the null, but the NPE that it throws includes an almost + * completely useless stacktrace and no other details. + * + * This method does an explicit null check and throws an exception with a more helpful message. + * + * See [#842](https://github.com/square/workflow/issues/842). + */ +@Suppress("USELESS_ELVIS") +private fun Worker.runWithNullCheck(): Flow = + run() ?: throw NullPointerException( + "Worker $this returned a null Flow. " + + "If this is a test mock, make sure you mock the run() method!" + ) + +private fun Worker<*>.debugName(key: String) = + toString().let { if (key.isBlank()) it else "$it:$key" } diff --git a/workflow-core/src/main/java/com/squareup/workflow/WorkflowAction.kt b/workflow-core/src/main/java/com/squareup/workflow/WorkflowAction.kt index 41a0fc12f6..38082ea4f9 100644 --- a/workflow-core/src/main/java/com/squareup/workflow/WorkflowAction.kt +++ b/workflow-core/src/main/java/com/squareup/workflow/WorkflowAction.kt @@ -38,7 +38,7 @@ interface WorkflowAction { val props: P, var state: S ) { - internal var output: WorkflowOutput<@UnsafeVariance O>? = null + internal var maybeOutput: WorkflowOutput<@UnsafeVariance O>? = null private set @Deprecated("Use state instead.", ReplaceWith("state")) @@ -53,7 +53,7 @@ interface WorkflowAction { * If this method is not called, there will be no output. */ fun setOutput(output: O) { - this.output = WorkflowOutput(output) + this.maybeOutput = WorkflowOutput(output) } } @@ -234,7 +234,7 @@ fun WorkflowAction.applyTo( ): Pair?> { val updater = Updater(props, state) updater.apply() - return Pair(updater.state, updater.output) + return Pair(updater.state, updater.maybeOutput) } /** Wrapper around a potentially-nullable [OutputT] value. */ diff --git a/workflow-core/src/main/java/com/squareup/workflow/WorkflowIdentifier.kt b/workflow-core/src/main/java/com/squareup/workflow/WorkflowIdentifier.kt index 61e99a7830..dbefd8fc7a 100644 --- a/workflow-core/src/main/java/com/squareup/workflow/WorkflowIdentifier.kt +++ b/workflow-core/src/main/java/com/squareup/workflow/WorkflowIdentifier.kt @@ -212,21 +212,9 @@ fun unsnapshottableIdentifier(type: KType): WorkflowIdentifier = WorkflowIdentif val KClass>.workflowIdentifier: WorkflowIdentifier get() { val workflowClass = this@workflowIdentifier - require( - !ImpostorWorkflow::class.java.isAssignableFrom(workflowClass.java) - ) { "Cannot create WorkflowIdentifier from a KClass of ImpostorWorkflow: ${workflowClass.qualifiedName}" } + require(!ImpostorWorkflow::class.java.isAssignableFrom(workflowClass.java)) { + "Cannot create WorkflowIdentifier from a KClass of ImpostorWorkflow: " + + workflowClass.qualifiedName.toString() + } return WorkflowIdentifier(type = workflowClass) } - -/** - * Creates a [WorkflowIdentifier] that identifies the [ImpostorWorkflow] this [KClass] represents. - * - * @param realIdentifier The [WorkflowIdentifier] corresponding to this workflow's - * [ImpostorWorkflow.realIdentifier]. - */ -@OptIn(ExperimentalStdlibApi::class) -@TestOnly -@ExperimentalWorkflowApi -fun KClass.impostorWorkflowIdentifier( - realIdentifier: WorkflowIdentifier -): WorkflowIdentifier = WorkflowIdentifier(type = this, proxiedIdentifier = realIdentifier) diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/internal/NullFlowWorker.java b/workflow-core/src/test/java/com/squareup/workflow/NullFlowWorker.java similarity index 90% rename from workflow-runtime/src/test/java/com/squareup/workflow/internal/NullFlowWorker.java rename to workflow-core/src/test/java/com/squareup/workflow/NullFlowWorker.java index 7aab1dbc28..afa273214f 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/internal/NullFlowWorker.java +++ b/workflow-core/src/test/java/com/squareup/workflow/NullFlowWorker.java @@ -1,6 +1,5 @@ -package com.squareup.workflow.internal; +package com.squareup.workflow; -import com.squareup.workflow.Worker; import kotlinx.coroutines.flow.Flow; import org.jetbrains.annotations.NotNull; @@ -11,6 +10,7 @@ * See #842. */ class NullFlowWorker implements Worker { + @NotNull @Override public Flow run() { //noinspection ConstantConditions return null; diff --git a/workflow-core/src/test/java/com/squareup/workflow/SinkTest.kt b/workflow-core/src/test/java/com/squareup/workflow/SinkTest.kt index 68fffae7f3..ecb8e7dea0 100644 --- a/workflow-core/src/test/java/com/squareup/workflow/SinkTest.kt +++ b/workflow-core/src/test/java/com/squareup/workflow/SinkTest.kt @@ -15,10 +15,14 @@ */ package com.squareup.workflow +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runBlockingTest +import java.util.concurrent.atomic.AtomicInteger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -35,7 +39,7 @@ class SinkTest { private val sink = RecordingSink() - @Test fun `collectToActionSink sends action`() { + @Test fun `collectToSink sends action`() { runBlockingTest { val flow = MutableStateFlow(1) val collector = launch { @@ -71,6 +75,59 @@ class SinkTest { } } + @Test fun `collectToSink propagates backpressure`() { + val channel = Channel() + val flow = channel.consumeAsFlow() + // Used to assert ordering. + val counter = AtomicInteger(0) + val sentActions = mutableListOf>() + val sink = object : Sink> { + override fun send(value: WorkflowAction) { + sentActions += value + } + } + + runBlockingTest { + val collectJob = launch { + flow.collectToSink(sink) { action { setOutput(it) } } + } + + val sendJob = launch(start = UNDISPATCHED) { + assertEquals(0, counter.getAndIncrement()) + channel.send("a") + assertEquals(1, counter.getAndIncrement()) + channel.send("b") + assertEquals(4, counter.getAndIncrement()) + channel.close() + assertEquals(5, counter.getAndIncrement()) + } + advanceUntilIdle() + assertEquals(2, counter.getAndIncrement()) + + sentActions.removeFirst() + .also { + advanceUntilIdle() + // Sender won't resume until we've _applied_ the action. + assertEquals(3, counter.getAndIncrement()) + } + .applyTo(Unit, Unit) + .let { (_, output) -> + assertEquals(6, counter.getAndIncrement()) + assertEquals("a", output?.value) + } + + sentActions.removeFirst() + .applyTo(Unit, Unit) + .let { (_, output) -> + assertEquals(7, counter.getAndIncrement()) + assertEquals("b", output?.value) + } + + collectJob.cancel() + sendJob.cancel() + } + } + @Test fun `sendAndAwaitApplication applies action`() { var applications = 0 val action = action { diff --git a/workflow-core/src/test/java/com/squareup/workflow/WorkerTest.kt b/workflow-core/src/test/java/com/squareup/workflow/WorkerTest.kt index 3e1e24840b..bb1a9c6dc9 100644 --- a/workflow-core/src/test/java/com/squareup/workflow/WorkerTest.kt +++ b/workflow-core/src/test/java/com/squareup/workflow/WorkerTest.kt @@ -1,41 +1,91 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.squareup.workflow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotSame import kotlin.test.assertTrue +@OptIn(ExperimentalStdlibApi::class) class WorkerTest { - @Test fun `default Worker#doesSameWorkAs implementation compares by concrete type`() { - class Worker1 : Worker { - override fun run(): Flow = emptyFlow() - } + @Test fun `timer returns equivalent workers keyed`() { + val worker1 = Worker.timer(1, "key") + val worker2 = Worker.timer(1, "key") - class Worker2 : Worker { - override fun run(): Flow = emptyFlow() - } + assertNotSame(worker1, worker2) + assertTrue(worker1.doesSameWorkAs(worker2)) + } + + @Test fun `timer returns non-equivalent workers based on key`() { + val worker1 = Worker.timer(1, "key1") + val worker2 = Worker.timer(1, "key2") - assertTrue(Worker1().doesSameWorkAs(Worker1())) - assertFalse(Worker1().doesSameWorkAs(Worker2())) + assertFalse(worker1.doesSameWorkAs(worker2)) } - @Test fun `createSideEffect workers are equivalent`() { - val worker1 = Worker.createSideEffect {} - val worker2 = Worker.createSideEffect {} - assertTrue(worker1.doesSameWorkAs(worker2)) + @Test fun `finished worker is equivalent to self`() { + assertTrue( + Worker.finished() + .doesSameWorkAs(Worker.finished()) + ) } - @Test fun `TypedWorkers are compared by higher types`() { - val worker1 = Worker.create> { } - val worker2 = Worker.create> { } - assertFalse(worker1.doesSameWorkAs(worker2)) + @Test fun `transformed workers are equivalent with equivalent source`() { + val source = Worker.create {} + val transformed1 = source.transform { flow -> flow.buffer(1) } + val transformed2 = source.transform { flow -> flow.conflate() } + + assertTrue(transformed1.doesSameWorkAs(transformed2)) } - @Test fun `TypedWorkers are equivalent with higher types`() { - val worker1 = Worker.create> { } - val worker2 = Worker.create> { } - assertTrue(worker1.doesSameWorkAs(worker2)) + @Test fun `transformed workers are not equivalent with nonequivalent source`() { + val source1 = object : Worker { + override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = false + override fun run(): Flow = emptyFlow() + } + val source2 = object : Worker { + override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = false + override fun run(): Flow = emptyFlow() + } + val transformed1 = source1.transform { flow -> flow.conflate() } + val transformed2 = source2.transform { flow -> flow.conflate() } + + assertFalse(transformed1.doesSameWorkAs(transformed2)) + } + + @Test fun `transformed workers transform flows`() { + val source = flowOf(1, 2, 3).asWorker() + val transformed = source.transform { flow -> flow.map { it.toString() } } + + val transformedValues = runBlocking { + transformed.run() + .toList() + } + + assertEquals(listOf("1", "2", "3"), transformedValues) } } diff --git a/workflow-core/src/test/java/com/squareup/workflow/WorkerWorkflowTest.kt b/workflow-core/src/test/java/com/squareup/workflow/WorkerWorkflowTest.kt new file mode 100644 index 0000000000..1afdf21143 --- /dev/null +++ b/workflow-core/src/test/java/com/squareup/workflow/WorkerWorkflowTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.coroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class WorkerWorkflowTest { + + /** + * This should be impossible, since the return type is non-nullable. However it is very easy to + * accidentally create a mock using libraries like Mockito in unit tests that return null Flows. + */ + @Test fun `runWorker throws when flow is null`() { + val nullFlowWorker = NullFlowWorker() + + val error = runBlocking { + assertFailsWith { + runWorker(nullFlowWorker, "", NoopSink) + } + } + + assertEquals( + "Worker NullFlowWorker.toString returned a null Flow. " + + "If this is a test mock, make sure you mock the run() method!", + error.message + ) + } + + @Test fun `runWorker coroutine is named without key`() { + val worker = CoroutineNameWorker() + runBlocking { + runWorker(worker, renderKey = "", actionSink = NoopSink) + } + + assertEquals("CoroutineNameWorker.toString", worker.recordedName) + } + + @Test fun `runWorker coroutine is named with key`() { + val worker = CoroutineNameWorker() + runBlocking { + runWorker(worker, renderKey = "foo", actionSink = NoopSink) + } + + assertEquals("CoroutineNameWorker.toString:foo", worker.recordedName) + } + + private object NoopSink : Sink { + override fun send(value: Any?) { + // Noop + } + } + + private class CoroutineNameWorker : Worker { + var recordedName: String? = null + private set + + override fun run(): Flow = flow { + recordedName = (coroutineContext[CoroutineName] as CoroutineName).name + } + + override fun toString(): String = "CoroutineNameWorker.toString" + } +} diff --git a/workflow-core/src/test/java/com/squareup/workflow/WorkflowIdentifierTest.kt b/workflow-core/src/test/java/com/squareup/workflow/WorkflowIdentifierTest.kt index f62465a59e..672ddb0c9f 100644 --- a/workflow-core/src/test/java/com/squareup/workflow/WorkflowIdentifierTest.kt +++ b/workflow-core/src/test/java/com/squareup/workflow/WorkflowIdentifierTest.kt @@ -226,26 +226,6 @@ class WorkflowIdentifierTest { ) } - @Test fun `impostorWorkflowIdentifier is equal to identifier from ImpostorWorkflow`() { - val instanceId = TestImpostor1(TestWorkflow1).identifier - val classId = TestImpostor1::class.impostorWorkflowIdentifier(TestWorkflow1.identifier) - assertEquals(instanceId, classId) - } - - @Test - fun `impostorWorkflowIdentifier is not equal to identifier from ImpostorWorkflow with different proxied class`() { - val instanceId = TestImpostor1(TestWorkflow1).identifier - val classId = TestImpostor1::class.impostorWorkflowIdentifier(TestWorkflow2.identifier) - assertNotEquals(instanceId, classId) - } - - @Test - fun `impostorWorkflowIdentifier is not equal to identifier from different ImpostorWorkflow with same proxied class`() { - val instanceId = TestImpostor1(TestWorkflow1).identifier - val classId = TestImpostor2::class.impostorWorkflowIdentifier(TestWorkflow1.identifier) - assertNotEquals(instanceId, classId) - } - @Test fun `getRealIdentifierType() returns self for non-impostor workflow`() { val id = TestWorkflow1.identifier assertEquals(TestWorkflow1::class, id.getRealIdentifierType()) diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api index 9a83cdbe5c..f71077bf03 100644 --- a/workflow-runtime/api/workflow-runtime.api +++ b/workflow-runtime/api/workflow-runtime.api @@ -8,8 +8,8 @@ public final class com/squareup/workflow/NoopWorkflowInterceptor : com/squareup/ } public final class com/squareup/workflow/RenderWorkflowKt { - public static final fun renderWorkflowIn (Lcom/squareup/workflow/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow/TreeSnapshot;Ljava/util/List;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow/TreeSnapshot;Ljava/util/List;Lkotlinx/coroutines/CoroutineDispatcher;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; + public static final fun renderWorkflowIn (Lcom/squareup/workflow/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow/TreeSnapshot;Ljava/util/List;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Lcom/squareup/workflow/TreeSnapshot;Ljava/util/List;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; } public final class com/squareup/workflow/RenderingAndSnapshot { diff --git a/workflow-runtime/src/main/java/com/squareup/workflow/RenderWorkflow.kt b/workflow-runtime/src/main/java/com/squareup/workflow/RenderWorkflow.kt index 179dae4c51..2ee2e0ab50 100644 --- a/workflow-runtime/src/main/java/com/squareup/workflow/RenderWorkflow.kt +++ b/workflow-runtime/src/main/java/com/squareup/workflow/RenderWorkflow.kt @@ -18,7 +18,6 @@ package com.squareup.workflow import com.squareup.workflow.internal.WorkflowRunner import com.squareup.workflow.internal.chained import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart.ATOMIC import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,7 +27,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlin.coroutines.EmptyCoroutineContext /** * Launches the [workflow] in a new coroutine in [scope] and returns a [StateFlow] of its @@ -123,16 +121,11 @@ fun renderWorkflowIn( props: StateFlow, initialSnapshot: TreeSnapshot = TreeSnapshot.NONE, interceptors: List = emptyList(), - workerDispatcher: CoroutineDispatcher? = null, onOutput: suspend (OutputT) -> Unit ): StateFlow> { val chainedInterceptor = interceptors.chained() - val runner = - WorkflowRunner( - scope, workflow, props, initialSnapshot, chainedInterceptor, - workerDispatcher ?: EmptyCoroutineContext - ) + val runner = WorkflowRunner(scope, workflow, props, initialSnapshot, chainedInterceptor) // Rendering is synchronous, so we can run the first render pass before launching the runtime // coroutine to calculate the initial rendering. @@ -154,15 +147,15 @@ fun renderWorkflowIn( // Launch atomically so the finally block is run even if the scope is cancelled before the // coroutine starts executing. scope.launch(start = ATOMIC) { - while (isActive) { - // It might look weird to start by consuming the output before getting the rendering below, - // but remember the first render pass already occurred above, before this coroutine was even - // launched. - runner.nextOutput() - ?.let { onOutput(it.value) } + while (isActive) { + // It might look weird to start by consuming the output before getting the rendering below, + // but remember the first render pass already occurred above, before this coroutine was even + // launched. + runner.nextOutput() + ?.let { onOutput(it.value) } - renderingsAndSnapshots.value = runner.nextRendering() - } + renderingsAndSnapshots.value = runner.nextRendering() + } } return renderingsAndSnapshots diff --git a/workflow-runtime/src/main/java/com/squareup/workflow/internal/RealRenderContext.kt b/workflow-runtime/src/main/java/com/squareup/workflow/internal/RealRenderContext.kt index 12085baa3d..f0a2e752eb 100644 --- a/workflow-runtime/src/main/java/com/squareup/workflow/internal/RealRenderContext.kt +++ b/workflow-runtime/src/main/java/com/squareup/workflow/internal/RealRenderContext.kt @@ -19,14 +19,12 @@ package com.squareup.workflow.internal import com.squareup.workflow.RenderContext import com.squareup.workflow.Sink -import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import kotlinx.coroutines.channels.SendChannel internal class RealRenderContext( private val renderer: Renderer, - private val workerRunner: WorkerRunner, private val sideEffectRunner: SideEffectRunner, private val eventActionsChannel: SendChannel> ) : RenderContext, Sink> { @@ -40,14 +38,6 @@ internal class RealRenderContext( ): ChildRenderingT } - interface WorkerRunner { - fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) - } - interface SideEffectRunner { fun runningSideEffect( key: String, @@ -85,15 +75,6 @@ internal class RealRenderContext( return renderer.render(child, props, key, handler) } - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - checkNotFrozen() - workerRunner.runningWorker(worker, key, handler) - } - override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit diff --git a/workflow-runtime/src/main/java/com/squareup/workflow/internal/SubtreeManager.kt b/workflow-runtime/src/main/java/com/squareup/workflow/internal/SubtreeManager.kt index 1bd4a269f2..5c40328d89 100644 --- a/workflow-runtime/src/main/java/com/squareup/workflow/internal/SubtreeManager.kt +++ b/workflow-runtime/src/main/java/com/squareup/workflow/internal/SubtreeManager.kt @@ -25,7 +25,6 @@ import com.squareup.workflow.WorkflowInterceptor.WorkflowSession import com.squareup.workflow.WorkflowOutput import kotlinx.coroutines.selects.SelectBuilder import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext /** * Responsible for tracking child workflows, starting them and tearing them down when necessary. @@ -102,8 +101,7 @@ internal class SubtreeManager( private val emitActionToParent: (WorkflowAction) -> Any?, private val workflowSession: WorkflowSession? = null, private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, - private val idCounter: IdCounter? = null, - private val workerContext: CoroutineContext = EmptyCoroutineContext + private val idCounter: IdCounter? = null ) : RealRenderContext.Renderer { /** @@ -199,8 +197,7 @@ internal class SubtreeManager( ::acceptChildOutput, workflowSession, interceptor, - idCounter = idCounter, - workerContext = workerContext + idCounter = idCounter ) return WorkflowChildNode(child, handler, workflowNode) .also { node = it } diff --git a/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkerChildNode.kt b/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkerChildNode.kt deleted file mode 100644 index 7ceb36655b..0000000000 --- a/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkerChildNode.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2020 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.squareup.workflow.internal - -import com.squareup.workflow.Worker -import com.squareup.workflow.WorkflowAction -import com.squareup.workflow.internal.InlineLinkedList.InlineListNode -import kotlinx.coroutines.channels.ReceiveChannel - -/** - * Holds the channel representing the outputs of a worker, as well as a tombstone flag that is - * true after the worker has finished and we've reported that fact to the workflow. This is to - * prevent the workflow from entering an infinite loop of getting `Finished` events if it - * continues to listen to the worker after it finishes. - * - * @param worker The first instance of the worker that was used to start this worker. This instance - * will be the receiver for the [Worker.doesSameWorkAs] call in future render passes. - * @param channel A [ReceiveChannel] that represents the subscription to the worker's flow. - * @param tombstone Mutable flag that starts as false and is set to true once the channel has been - * closed (i.e. the flow completes successfully). [WorkerChildNode]s continue to be tracked even - * after the worker finishes so that they aren't immediately restarted on the next render pass. This - * flag indicates that the channel should not be polled on the next tick. - */ -internal class WorkerChildNode( - val worker: Worker, - val key: String, - val channel: ReceiveChannel>, - var tombstone: Boolean = false, - private var handler: (T) -> WorkflowAction -) : InlineListNode> { - - override var nextListNode: WorkerChildNode<*, *, *, *>? = null - - /** - * Updates the handler function that will be invoked by [acceptUpdate]. - */ - fun setHandler(newHandler: (T2) -> WorkflowAction) { - @Suppress("UNCHECKED_CAST") - handler = newHandler as (T) -> WorkflowAction - } - - @Suppress("UNCHECKED_CAST") - fun acceptUpdate(value: Any?): WorkflowAction = - handler(value as T) - - /** - * Returns true if this worker does the same work as [otherWorker] and has the same key. - */ - fun matches( - otherWorker: Worker<*>, - key: String - ): Boolean = worker.doesSameWorkAs(otherWorker) && this.key == key -} diff --git a/workflow-runtime/src/main/java/com/squareup/workflow/internal/Workers.kt b/workflow-runtime/src/main/java/com/squareup/workflow/internal/Workers.kt deleted file mode 100644 index 1e08ef54f1..0000000000 --- a/workflow-runtime/src/main/java/com/squareup/workflow/internal/Workers.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2019 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.squareup.workflow.internal - -import com.squareup.workflow.Worker -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers.Unconfined -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.produceIn -import kotlinx.coroutines.plus -import kotlin.coroutines.CoroutineContext - -/** - * Launches a new coroutine that is a child of this node's scope, and calls - * [com.squareup.workflow.Worker.run] from that coroutine. Returns a [ReceiveChannel] that - * will emit everything from the worker. The channel will be closed when the flow completes. - */ -@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) -internal fun CoroutineScope.launchWorker( - worker: Worker, - key: String, - workerContext: CoroutineContext -): ReceiveChannel> = worker.runWithNullCheck() - .transformToValueOrDone() - .catch { e -> - // Workers that failed (as opposed to just cancelled) should have their failure reason - // re-thrown from the workflow runtime. If we don't unwrap the cause here, they'll just - // cause the runtime to cancel. - val cancellationCause = e.unwrapCancellationCause() - throw cancellationCause ?: e - } - // produceIn implicitly creates a buffer (it uses a Channel to bridge between contexts). This - // operator is required to override the default buffer size. - .buffer(RENDEZVOUS) - .produceIn(createWorkerScope(worker, key, workerContext)) - -/** - * In unit tests, if you use a mocking library to create a Worker, the run method will return null - * even though the return type is non-nullable in Kotlin. Kotlin helps out with this by throwing an - * NPE before before any kotlin code gets the null, but the NPE that it throws includes an almost - * completely useless stacktrace and no other details. - * - * This method does an explicit null check and throws an exception with a more helpful message. - * - * See [#842](https://github.com/square/workflow/issues/842). - */ -@Suppress("USELESS_ELVIS") -private fun Worker.runWithNullCheck(): Flow = - run() ?: throw NullPointerException( - "Worker $this returned a null Flow. " + - "If this is a test mock, make sure you mock the run() method!" - ) - -/** - * Pretend we can use ReceiveChannel.onReceiveOrClosed. - * - * See https://github.com/Kotlin/kotlinx.coroutines/issues/1584 and - * https://github.com/square/workflow/issues/626. - */ -internal class ValueOrDone private constructor(private val _value: Any?) { - - val isDone: Boolean get() = this === Done - - @Suppress("UNCHECKED_CAST") - val value: T - get() { - check(!isDone) - return _value as T - } - - companion object { - private val Done = ValueOrDone(null) - - fun value(value: T): ValueOrDone = ValueOrDone(value) - fun done(): ValueOrDone = Done - } -} - -private fun Flow.transformToValueOrDone(): Flow> = flow { - collect { - emit(ValueOrDone.value(it)) - } - emit(ValueOrDone.done()) -} - -private fun CoroutineScope.createWorkerScope( - worker: Worker<*>, - key: String, - workerContext: CoroutineContext -): CoroutineScope = this + CoroutineName(worker.debugName(key)) + Unconfined + workerContext - -private fun Worker<*>.debugName(key: String) = - toString().let { if (key.isBlank()) it else "$it:$key" } diff --git a/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowNode.kt b/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowNode.kt index cb277d103a..d7c13e1c89 100644 --- a/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowNode.kt +++ b/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowNode.kt @@ -19,7 +19,6 @@ import com.squareup.workflow.ExperimentalWorkflowApi import com.squareup.workflow.NoopWorkflowInterceptor import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.TreeSnapshot -import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowIdentifier @@ -29,7 +28,6 @@ import com.squareup.workflow.WorkflowOutput import com.squareup.workflow.applyTo import com.squareup.workflow.intercept import com.squareup.workflow.internal.RealRenderContext.SideEffectRunner -import com.squareup.workflow.internal.RealRenderContext.WorkerRunner import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -42,7 +40,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.selects.SelectBuilder import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext /** * A node in a state machine tree. Manages the actual state for a given [Workflow]. @@ -64,9 +61,8 @@ internal class WorkflowNode( private val emitOutputToParent: (OutputT) -> Any? = { WorkflowOutput(it) }, override val parent: WorkflowSession? = null, private val interceptor: WorkflowInterceptor = NoopWorkflowInterceptor, - idCounter: IdCounter? = null, - private val workerContext: CoroutineContext = EmptyCoroutineContext -) : CoroutineScope, WorkerRunner, SideEffectRunner, WorkflowSession { + idCounter: IdCounter? = null +) : CoroutineScope, SideEffectRunner, WorkflowSession { /** * Context that has a job that will live as long as this node. @@ -85,10 +81,8 @@ internal class WorkflowNode( emitActionToParent = ::applyAction, workflowSession = this, interceptor = interceptor, - idCounter = idCounter, - workerContext = workerContext + idCounter = idCounter ) - private val workers = ActiveStagingList>() private val sideEffects = ActiveStagingList() private var lastProps: PropsT = initialProps private val eventActionsChannel = @@ -141,26 +135,6 @@ internal class WorkflowNode( ) } - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - // Prevent duplicate workers with the same key. - workers.forEachStaging { - require(!(it.matches(worker, key))) { - "Expected keys to be unique for $worker: key=$key" - } - } - - // Start tracking this case so we can be ready to render it. - val stagedWorker = workers.retainOrCreate( - predicate = { it.matches(worker, key) }, - create = { createWorkerNode(worker, key, handler) } - ) - stagedWorker.setHandler(handler) - } - override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit @@ -189,27 +163,6 @@ internal class WorkflowNode( // Listen for any child workflow updates. subtreeManager.tickChildren(selector) - // Listen for any subscription updates. - workers.forEachActive { child -> - // Skip children that have finished but are still being run by the workflow. - if (child.tombstone) return@forEachActive - - with(selector) { - child.channel.onReceive { valueOrDone -> - if (valueOrDone.isDone) { - // Set the tombstone flag so we don't continue to listen to the subscription. - child.tombstone = true - // Nothing to do on close other than update the session, so don't emit any output. - return@onReceive null - } else { - val update = child.acceptUpdate(valueOrDone.value) - @Suppress("UNCHECKED_CAST") - return@onReceive applyAction(update as WorkflowAction) - } - } - } - } - // Listen for any events. with(selector) { eventActionsChannel.onReceive { action -> @@ -244,7 +197,6 @@ internal class WorkflowNode( val context = RealRenderContext( renderer = subtreeManager, - workerRunner = this, sideEffectRunner = this, eventActionsChannel = eventActionsChannel ) @@ -254,7 +206,6 @@ internal class WorkflowNode( // Tear down workflows and workers that are obsolete. subtreeManager.commitRenderedChildren() - workers.commitStaging { it.channel.cancel() } // Side effect jobs are launched lazily, since they can send actions to the sink, and can only // be started after context is frozen. sideEffects.forEachStaging { it.job.start() } @@ -286,15 +237,6 @@ internal class WorkflowNode( return tickResult?.let { emitOutputToParent(it.value) } as T? } - private fun createWorkerNode( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ): WorkerChildNode { - val workerChannel = launchWorker(worker, key, workerContext) - return WorkerChildNode(worker, key, workerChannel, handler = handler) - } - private fun createSideEffectNode( key: String, sideEffect: suspend () -> Unit diff --git a/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowRunner.kt b/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowRunner.kt index 4d406496ff..6c6fb10982 100644 --- a/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/main/java/com/squareup/workflow/internal/WorkflowRunner.kt @@ -29,8 +29,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.produceIn import kotlinx.coroutines.selects.select -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext @OptIn(ExperimentalCoroutinesApi::class, ExperimentalWorkflowApi::class) internal class WorkflowRunner( @@ -38,8 +36,7 @@ internal class WorkflowRunner( protoWorkflow: Workflow, props: StateFlow, snapshot: TreeSnapshot, - interceptor: WorkflowInterceptor, - workerContext: CoroutineContext = EmptyCoroutineContext + interceptor: WorkflowInterceptor ) { private val workflow = protoWorkflow.asStatefulWorkflow() private val idCounter = IdCounter() @@ -65,7 +62,6 @@ internal class WorkflowRunner( initialProps = currentProps, snapshot = snapshot, baseContext = scope.coroutineContext, - workerContext = workerContext, interceptor = interceptor, idCounter = idCounter ) diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/RenderWorkflowInTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/RenderWorkflowInTest.kt index d4c6fbb3ae..5c08b2f344 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/RenderWorkflowInTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow/RenderWorkflowInTest.kt @@ -32,7 +32,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.TestCoroutineScope -import org.junit.Ignore import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -66,27 +65,39 @@ class RenderWorkflowInTest { assertEquals("props: foo", renderings.value.rendering) } - @Ignore("https://github.com/square/workflow/issues/1197") - @Test fun `workers from initial rendering are never started when scope cancelled before start`() { - var workerWasRan = false - var cancellationException: Throwable? = null + @Test + fun `side effects from initial rendering in root workflow are never started when scope cancelled before start`() { + var sideEffectWasRan = false val workflow = Workflow.stateless { - runningWorker(Worker.createSideEffect { - workerWasRan = true - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> - cancellationException = cause - } - } - }) + runningSideEffect("test") { + sideEffectWasRan = true + } } scope.cancel() renderWorkflowIn(workflow, scope, MutableStateFlow(Unit)) {} scope.advanceUntilIdle() - assertFalse(workerWasRan) - assertNull(cancellationException) + assertFalse(sideEffectWasRan) + } + + @Test + fun `side effects from initial rendering in non-root workflow are never started when scope cancelled before start`() { + var sideEffectWasRan = false + val childWorkflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true + } + } + val workflow = Workflow.stateless { + renderChild(childWorkflow) + } + + scope.cancel() + renderWorkflowIn(workflow, scope, MutableStateFlow(Unit)) {} + + scope.advanceUntilIdle() + assertFalse(sideEffectWasRan) } @Test fun `new renderings are emitted on update`() { @@ -197,34 +208,71 @@ class RenderWorkflowInTest { assertTrue(scope.isActive) } - /** - * The initial render pass can start workers, but if it fails afterwards, those workers should - * still be cancelled. - */ - @Test fun `exception from initial render cancels runtime`() { - var workerWasRan = false - var cancellationException: Throwable? = null + @Test + fun `side effects from initial rendering in root workflow are never started when initial render of root workflow fails`() { + var sideEffectWasRan = false val workflow = Workflow.stateless { - runningWorker(Worker.createSideEffect { - workerWasRan = true + runningSideEffect("test") { + sideEffectWasRan = true + } + throw ExpectedException() + } + + assertFailsWith { + renderWorkflowIn(workflow, scope, MutableStateFlow(Unit)) {} + } + scope.advanceUntilIdle() + assertFalse(sideEffectWasRan) + } + + @Test + fun `side effects from initial rendering in non-root workflow are cancelled when initial render of root workflow fails`() { + var sideEffectWasRan = false + var cancellationException: Throwable? = null + val childWorkflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { cause -> cancellationException = cause } } - }) + } + } + val workflow = Workflow.stateless { + renderChild(childWorkflow) throw ExpectedException() } - scope.pauseDispatcher() assertFailsWith { renderWorkflowIn(workflow, scope, MutableStateFlow(Unit)) {} } - assertTrue(workerWasRan) + scope.advanceUntilIdle() + assertTrue(sideEffectWasRan) assertNotNull(cancellationException) val realCause = generateSequence(cancellationException) { it.cause } .firstOrNull { it !is CancellationException } assertTrue(realCause is ExpectedException) } + @Test + fun `side effects from initial rendering in non-root workflow are never started when initial render of non-root workflow fails`() { + var sideEffectWasRan = false + val childWorkflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true + } + throw ExpectedException() + } + val workflow = Workflow.stateless { + renderChild(childWorkflow) + } + + assertFailsWith { + renderWorkflowIn(workflow, scope, MutableStateFlow(Unit)) {} + } + scope.advanceUntilIdle() + assertFalse(sideEffectWasRan) + } + @Test fun `exception from non-initial render fails parent scope`() { val trigger = CompletableDeferred() // Throws an exception when trigger is completed. diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowInterceptorTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowInterceptorTest.kt index 106b853cbd..f1f10caa6b 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowInterceptorTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowInterceptorTest.kt @@ -67,12 +67,6 @@ class WorkflowInterceptorTest { handler: (ChildOutputT) -> WorkflowAction ): ChildRenderingT = fail() - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ): Unit = fail() - override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowOperatorsTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowOperatorsTest.kt index c163a9de5a..a417b8e481 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowOperatorsTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow/WorkflowOperatorsTest.kt @@ -233,7 +233,7 @@ class WorkflowOperatorsTest { context: RenderContext ): T { // Listen to the flow to trigger a re-render when it updates. - context.runningWorker(rerenderWorker) { WorkflowAction.noAction() } + context.runningWorker(rerenderWorker as Worker) { WorkflowAction.noAction() } return flow.value } diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/internal/ChainedWorkflowInterceptorTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/internal/ChainedWorkflowInterceptorTest.kt index 82d9bb8f35..0ece908d1b 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/internal/ChainedWorkflowInterceptorTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow/internal/ChainedWorkflowInterceptorTest.kt @@ -22,7 +22,6 @@ import com.squareup.workflow.NoopWorkflowInterceptor import com.squareup.workflow.RenderContext import com.squareup.workflow.Sink import com.squareup.workflow.Snapshot -import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowIdentifier @@ -270,14 +269,6 @@ class ChainedWorkflowInterceptorTest { fail() } - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - fail() - } - override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/internal/RealRenderContextTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/internal/RealRenderContextTest.kt index 1e2de2819d..3f1c10043d 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/internal/RealRenderContextTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow/internal/RealRenderContextTest.kt @@ -22,7 +22,6 @@ import com.squareup.workflow.RenderContext import com.squareup.workflow.Sink import com.squareup.workflow.Snapshot import com.squareup.workflow.StatefulWorkflow -import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowAction.Companion.noAction @@ -31,7 +30,6 @@ import com.squareup.workflow.action import com.squareup.workflow.applyTo import com.squareup.workflow.internal.RealRenderContext.Renderer import com.squareup.workflow.internal.RealRenderContext.SideEffectRunner -import com.squareup.workflow.internal.RealRenderContext.WorkerRunner import com.squareup.workflow.internal.RealRenderContextTest.TestRenderer.Rendering import com.squareup.workflow.makeEventSink import com.squareup.workflow.renderChild @@ -72,15 +70,7 @@ class RealRenderContextTest { ) as ChildRenderingT } - private class TestRunner : WorkerRunner, SideEffectRunner { - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - // No-op - } - + private class TestRunner : SideEffectRunner { override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit @@ -115,15 +105,7 @@ class RealRenderContextTest { ): ChildRenderingT = fail() } - private class PoisonRunner : WorkerRunner, SideEffectRunner { - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - fail() - } - + private class PoisonRunner : SideEffectRunner { override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit @@ -255,18 +237,16 @@ class RealRenderContextTest { val child = Workflow.stateless { fail() } assertFailsWith { context.renderChild(child) } - val worker = Worker.from { Unit } - assertFailsWith { context.runningWorker(worker) { fail() } } assertFailsWith { context.freeze() } } private fun createdPoisonedContext(): RealRenderContext { - val workerRunner = PoisonRunner() - return RealRenderContext(PoisonRenderer(), workerRunner, workerRunner, eventActionsChannel) + val workerRunner = PoisonRunner() + return RealRenderContext(PoisonRenderer(), workerRunner, eventActionsChannel) } private fun createTestContext(): RealRenderContext { val workerRunner = TestRunner() - return RealRenderContext(TestRenderer(), workerRunner, workerRunner, eventActionsChannel) + return RealRenderContext(TestRenderer(), workerRunner, eventActionsChannel) } } diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkersTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkersTest.kt deleted file mode 100644 index 479a385a0b..0000000000 --- a/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkersTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright 2019 Square Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:Suppress("EXPERIMENTAL_API_USAGE") - -package com.squareup.workflow.internal - -import com.squareup.workflow.Worker -import com.squareup.workflow.asWorker -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.consume -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.supervisorScope -import kotlinx.coroutines.yield -import java.util.concurrent.atomic.AtomicInteger -import kotlin.coroutines.ContinuationInterceptor -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.coroutines.coroutineContext -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertSame -import kotlin.test.assertTrue - -class WorkersTest { - - @Test fun `launchWorker propagates backpressure`() { - val channel = Channel() - val worker = channel.consumeAsFlow() - .asWorker() - // Used to assert ordering. - val counter = AtomicInteger(0) - - runBlocking { - val workerOutputs = launchWorker(worker) - - launch(start = UNDISPATCHED) { - assertEquals(0, counter.getAndIncrement()) - channel.send("a") - assertEquals(1, counter.getAndIncrement()) - channel.send("b") - assertEquals(3, counter.getAndIncrement()) - channel.close() - assertEquals(4, counter.getAndIncrement()) - } - yield() - assertEquals(2, counter.getAndIncrement()) - - assertEquals("a", workerOutputs.poll()!!.value) - yield() - assertEquals(5, counter.getAndIncrement()) - - assertEquals("b", workerOutputs.poll()!!.value) - yield() - assertEquals(6, counter.getAndIncrement()) - - // Cancel the worker so we can exit this loop. - workerOutputs.cancel() - } - } - - @Test fun `launchWorker emits done when complete immediately`() { - val channel = Channel(capacity = 1) - - runBlocking { - val workerOutputs = launchWorker( - channel.consumeAsFlow() - .asWorker() - ) - assertTrue(workerOutputs.isEmpty) - - channel.close() - assertTrue(workerOutputs.receive().isDone) - } - } - - @Test fun `launchWorker emits done when complete after sending`() { - val channel = Channel(capacity = 1) - - runBlocking { - val workerOutputs = launchWorker( - channel.consumeAsFlow() - .asWorker() - ) - assertTrue(workerOutputs.isEmpty) - - channel.send("foo") - assertEquals("foo", workerOutputs.receive().value) - - channel.close() - assertTrue(workerOutputs.receive().isDone) - } - } - - @Test fun `launchWorker does not emit done when failed`() { - val channel = Channel(capacity = 1) - - runBlocking { - // Needed so that cancelling the channel doesn't cancel our job, which means receive will - // throw the JobCancellationException instead of the actual channel failure. - supervisorScope { - val workerOutputs = launchWorker( - channel.consumeAsFlow() - .asWorker() - ) - assertTrue(workerOutputs.isEmpty) - - channel.close(ExpectedException()) - assertFailsWith { workerOutputs.receive() } - } - } - } - - @Test fun `launchWorker completes after emitting done`() { - val channel = Channel(capacity = 1) - - runBlocking { - val workerOutputs = launchWorker( - channel.consumeAsFlow() - .asWorker() - ) - channel.close() - assertTrue(workerOutputs.receive().isDone) - - assertTrue(channel.isClosedForReceive) - } - } - - /** - * This should be impossible, since the return type is non-nullable. However it is very easy to - * accidentally create a mock using libraries like Mockito in unit tests that return null Flows. - */ - @Test fun `launchWorker throws when flow is null`() { - val nullFlowWorker = NullFlowWorker() - - val error = runBlocking { - assertFailsWith { - launchWorker(nullFlowWorker) - } - } - - assertEquals( - "Worker NullFlowWorker.toString returned a null Flow. " + - "If this is a test mock, make sure you mock the run() method!", - error.message - ) - } - - @Test fun `launchWorker coroutine is named without key`() { - val output = runBlocking { - launchWorker(CoroutineNameWorker) - .consume { receive() } - .value - } - - assertEquals("CoroutineNameWorker.toString", output) - } - - @Test fun `launchWorker coroutine is named with key`() { - val output = runBlocking { - launchWorker(CoroutineNameWorker, key = "foo") - .consume { receive() } - .value - } - - assertEquals("CoroutineNameWorker.toString:foo", output) - } - - @Test fun `launchWorker dispatcher is unconfined`() { - val worker = Worker.from { coroutineContext[ContinuationInterceptor] } - - runBlocking { - val interceptor = launchWorker(worker) - .consume { receive() } - .value - - assertSame(Dispatchers.Unconfined, interceptor) - } - } - - private fun CoroutineScope.launchWorker( - worker: Worker, - key: String = "" - ) = launchWorker( - worker, - key = key, - workerContext = EmptyCoroutineContext - ) - - private class ExpectedException : RuntimeException() - - private object CoroutineNameWorker : Worker { - override fun run(): Flow = flow { - val nameElement = coroutineContext[CoroutineName] as CoroutineName - emit(nameElement.name) - } - - override fun toString(): String = "CoroutineNameWorker.toString" - } -} diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowNodeTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowNodeTest.kt index be0b1efddd..ae54fd1790 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowNodeTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowNodeTest.kt @@ -23,7 +23,6 @@ import com.squareup.workflow.Sink import com.squareup.workflow.Snapshot import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.TreeSnapshot -import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowAction.Companion.emitOutput @@ -33,7 +32,6 @@ import com.squareup.workflow.WorkflowInterceptor import com.squareup.workflow.WorkflowInterceptor.WorkflowSession import com.squareup.workflow.WorkflowOutput import com.squareup.workflow.action -import com.squareup.workflow.asWorker import com.squareup.workflow.contraMap import com.squareup.workflow.identifier import com.squareup.workflow.makeEventSink @@ -45,21 +43,16 @@ import com.squareup.workflow.stateful import com.squareup.workflow.stateless import com.squareup.workflow.writeUtf8WithLength import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Unconfined import kotlinx.coroutines.Job -import kotlinx.coroutines.Runnable -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.selects.select import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout -import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext import kotlin.test.AfterTest @@ -275,122 +268,6 @@ class WorkflowNodeTest { sink.send(emitOutput("event2")) } - @Test fun `worker gets value`() { - val channel = Channel(capacity = 1) - var update: String? = null - val workflow = object : StringWorkflow() { - override fun initialState( - props: String, - snapshot: Snapshot? - ): String { - assertNull(snapshot) - return props - } - - override fun render( - props: String, - state: String, - context: RenderContext - ): String { - context.runningWorker(channel.asWorker()) { - check(update == null) - update = it - action { setOutput("update:$it") } - } - return "" - } - } - val node = WorkflowNode(workflow.id(), workflow, "", TreeSnapshot.NONE, context) - - assertEquals(null, update) - node.render(workflow, "") - assertEquals(null, update) - - // Shouldn't have the update yet, since we haven't sent anything. - val output = runBlocking { - try { - withTimeout(1) { - select?> { - node.tick(this) - } - } - fail("Expected exception") - } catch (e: TimeoutCancellationException) { - // Expected. - } - - channel.send("element") - - withTimeout(1) { - select?> { - node.tick(this) - } - } - } - - assertEquals("element", update) - assertEquals("update:element", output?.value) - } - - @Test fun `worker is cancelled`() { - val channel = Channel(capacity = 0) - lateinit var doClose: () -> Unit - val workflow = object : StringWorkflow() { - override fun initialState( - props: String, - snapshot: Snapshot? - ): String { - assertNull(snapshot) - return props - } - - fun update(value: String) = action { - setOutput("update:$value") - } - - val finish = action { - state = "finished" - } - - override fun render( - props: String, - state: String, - context: RenderContext - ): String { - when (state) { - "listen" -> { - context.runningWorker(channel.asWorker(closeOnCancel = true)) { - update(it) - } - doClose = { context.actionSink.send(finish) } - } - } - return "" - } - } - val node = WorkflowNode(workflow.id(), workflow, "listen", TreeSnapshot.NONE, context) - - runBlocking { - node.render(workflow, "listen") - assertFalse(channel.isClosedForSend) - doClose() - - // This tick will process the event handler, it won't close the channel yet. - withTimeout(1) { - select?> { - node.tick(this) - } - } - - assertFalse(channel.isClosedForSend) - - // This should close the channel. - node.render(workflow, "") - - assertTrue(channel.isClosedForSend) - } - } - @Test fun `sideEffect is not started until after render completes`() { var started = false val workflow = Workflow.stateless { @@ -410,36 +287,6 @@ class WorkflowNodeTest { } } - @Test fun `sideEffect is launched with dispatcher from workflow context`() { - class TestDispatcher : CoroutineDispatcher() { - override fun dispatch( - context: CoroutineContext, - block: Runnable - ) = Unconfined.dispatch(context, block) - - override fun isDispatchNeeded(context: CoroutineContext): Boolean = - Unconfined.isDispatchNeeded(context) - } - - val baseDispatcher = TestDispatcher() - val sideEffectDispatcher = TestDispatcher() - var contextFromWorker: CoroutineContext? = null - val workflow = Workflow.stateless { - runningSideEffect("the key") { - contextFromWorker = coroutineContext - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = TreeSnapshot.NONE, baseContext = context + baseDispatcher, - workerContext = context + sideEffectDispatcher - ) - - node.render(workflow.asStatefulWorkflow(), Unit) - assertSame(baseDispatcher, node.coroutineContext[ContinuationInterceptor]) - assertSame(baseDispatcher, contextFromWorker!![ContinuationInterceptor]) - } - @Test fun `sideEffect coroutine is named`() { var contextFromWorker: CoroutineContext? = null val workflow = Workflow.stateless { @@ -460,27 +307,6 @@ class WorkflowNodeTest { ) } - @Test fun `sideEffect ignores name from worker context`() { - var contextFromWorker: CoroutineContext? = null - val workflow = Workflow.stateless { - runningSideEffect("the key") { - contextFromWorker = coroutineContext - } - } - val node = WorkflowNode( - workflow.id(), workflow.asStatefulWorkflow(), initialProps = Unit, - snapshot = TreeSnapshot.NONE, baseContext = context, - workerContext = context + CoroutineName("ignored name") - ) - - node.render(workflow.asStatefulWorkflow(), Unit) - assertEquals(WorkflowNodeId(workflow).toString(), node.coroutineContext[CoroutineName]!!.name) - assertEquals( - "sideEffect[the key] for ${WorkflowNodeId(workflow)}", - contextFromWorker!![CoroutineName]!!.name - ) - } - @Test fun `sideEffect can send to actionSink`() { val workflow = Workflow.stateless { runningSideEffect("key") { @@ -1351,84 +1177,13 @@ class WorkflowNodeTest { assertNull(output?.value) } - @Test fun `worker action changes state`() { - val workflow = Workflow.stateful( - initialState = { "initial" }, - render = { _, state -> - runningWorker(Worker.from { "hello" }) { action { this.state = "${this.state}->$it" } } - return@stateful state - } - ) - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = TreeSnapshot.NONE, - baseContext = Unconfined - ) - node.render(workflow.asStatefulWorkflow(), Unit) - - runBlocking { - select?> { - node.tick(this) - } - } - - val state = node.render(workflow.asStatefulWorkflow(), Unit) - assertEquals("initial->hello", state) - } - - @Test fun `worker action emits output`() { - val workflow = Worker.from { "hello" } - .asWorkflow() - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = TreeSnapshot.NONE, - baseContext = Unconfined, - emitOutputToParent = { WorkflowOutput("output:$it") } - ) - node.render(workflow.asStatefulWorkflow(), Unit) - - val output = runBlocking { - select?> { - node.tick(this) - } - } - - assertEquals("output:hello", output?.value) - } - - @Test fun `worker action allows null output`() { - val workflow = Worker.from { null } - .asWorkflow() - val node = WorkflowNode( - workflow.id(), - workflow.asStatefulWorkflow(), - initialProps = Unit, - snapshot = TreeSnapshot.NONE, - baseContext = Unconfined, - emitOutputToParent = { WorkflowOutput(it) } - ) - node.render(workflow.asStatefulWorkflow(), Unit) - - val output = runBlocking { - select?> { - node.tick(this) - } - } - - assertNull(output?.value) - } - @Test fun `child action changes state`() { - val child = Worker.from { "hello" } - .asWorkflow() val workflow = Workflow.stateful( initialState = { "initial" }, render = { _, state -> - renderChild(child) { action { this.state = "${this.state}->$it" } } + runningSideEffect("test") { + actionSink.send(action { this.state = "${this.state}->hello" }) + } return@stateful state } ) @@ -1452,10 +1207,10 @@ class WorkflowNodeTest { } @Test fun `child action emits output`() { - val child = Worker.from { "hello" } - .asWorkflow() val workflow = Workflow.stateless { - renderChild(child) { action { setOutput("child:$it") } } + runningSideEffect("test") { + actionSink.send(action { setOutput("child:hello") }) + } } val node = WorkflowNode( workflow.id(), @@ -1477,10 +1232,10 @@ class WorkflowNodeTest { } @Test fun `child action allows null output`() { - val child = Worker.from { null } - .asWorkflow() val workflow = Workflow.stateless { - renderChild(child) { action { setOutput(null) } } + runningSideEffect("test") { + actionSink.send(action { setOutput(null) }) + } } val node = WorkflowNode( workflow.id(), @@ -1506,8 +1261,4 @@ class WorkflowNodeTest { override val renderKey: String = "" override val parent: WorkflowSession? = null } - - private fun Worker.asWorkflow() = Workflow.stateless { - runningWorker(this@asWorkflow) { action { setOutput(it) } } - } } diff --git a/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowRunnerTest.kt b/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowRunnerTest.kt index 5df727fa0d..63653f3a99 100644 --- a/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowRunnerTest.kt +++ b/workflow-runtime/src/test/java/com/squareup/workflow/internal/WorkflowRunnerTest.kt @@ -197,16 +197,13 @@ class WorkflowRunnerTest { } val runner = WorkflowRunner(workflow, MutableStateFlow(Unit)) runner.nextRendering() - val output = scope.async { runner.nextOutput() } dispatcher.resumeDispatcher() dispatcher.advanceUntilIdle() - assertTrue(output.isActive) assertNull(cancellationException) runner.cancelRuntime() dispatcher.advanceUntilIdle() - assertTrue(output.isCancelled) assertNotNull(cancellationException) val causes = generateSequence(cancellationException) { it.cause } assertTrue(causes.all { it is CancellationException }) diff --git a/workflow-rx2/src/test/java/com/squareup/workflow/rx2/PublisherWorkerTest.kt b/workflow-rx2/src/test/java/com/squareup/workflow/rx2/PublisherWorkerTest.kt index 48b068b9ac..db45760861 100644 --- a/workflow-rx2/src/test/java/com/squareup/workflow/rx2/PublisherWorkerTest.kt +++ b/workflow-rx2/src/test/java/com/squareup/workflow/rx2/PublisherWorkerTest.kt @@ -18,6 +18,7 @@ package com.squareup.workflow.rx2 import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.action +import com.squareup.workflow.runningWorker import com.squareup.workflow.stateless import com.squareup.workflow.testing.launchForTestingFromStartWith import io.reactivex.BackpressureStrategy.BUFFER diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index 6cb2755fba..9e34b972fe 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -14,7 +14,6 @@ public abstract interface class com/squareup/workflow/testing/RenderTestResult { public abstract interface class com/squareup/workflow/testing/RenderTester { public abstract fun expectSideEffect (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/testing/RenderTester; - public abstract fun expectWorker (Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; public abstract fun expectWorkflow (Lcom/squareup/workflow/WorkflowIdentifier;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; public abstract fun expectWorkflow (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/testing/RenderTester; public abstract fun expectWorkflow (Lkotlin/reflect/KClass;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; @@ -37,7 +36,6 @@ public final class com/squareup/workflow/testing/RenderTester$ChildWorkflowMatch public final class com/squareup/workflow/testing/RenderTester$DefaultImpls { public static synthetic fun expectSideEffect$default (Lcom/squareup/workflow/testing/RenderTester;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; - public static synthetic fun expectWorker$default (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; public static fun expectWorkflow (Lcom/squareup/workflow/testing/RenderTester;Lcom/squareup/workflow/WorkflowIdentifier;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; public static fun expectWorkflow (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/reflect/KClass;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; public static synthetic fun expectWorkflow$default (Lcom/squareup/workflow/testing/RenderTester;Lcom/squareup/workflow/WorkflowIdentifier;Ljava/lang/Object;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; @@ -57,14 +55,22 @@ public final class com/squareup/workflow/testing/RenderTester$RenderChildInvocat public final class com/squareup/workflow/testing/RenderTesterKt { public static final fun expectSideEffect (Lcom/squareup/workflow/testing/RenderTester;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; - public static final fun expectWorker (Lcom/squareup/workflow/testing/RenderTester;Lcom/squareup/workflow/Worker;Ljava/lang/String;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; - public static synthetic fun expectWorker$default (Lcom/squareup/workflow/testing/RenderTester;Lcom/squareup/workflow/Worker;Ljava/lang/String;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; public static final fun renderTester (Lcom/squareup/workflow/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; public static final fun renderTester (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; public static final fun testRender (Lcom/squareup/workflow/StatefulWorkflow;Ljava/lang/Object;Ljava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; public static final fun testRender (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; } +public final class com/squareup/workflow/testing/RenderTesterWorkersKt { + public static final fun expectWorker (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/reflect/KClass;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; + public static final fun expectWorker (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/reflect/KType;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; + public static synthetic fun expectWorker$default (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/reflect/KClass;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; + public static synthetic fun expectWorker$default (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/reflect/KType;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; + public static final fun expectWorkerOutputting (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/reflect/KType;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;)Lcom/squareup/workflow/testing/RenderTester; + public static synthetic fun expectWorkerOutputting$default (Lcom/squareup/workflow/testing/RenderTester;Lkotlin/reflect/KType;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/WorkflowOutput;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow/testing/RenderTester; + public static final fun keyDescription (Ljava/lang/String;)Ljava/lang/String; +} + public final class com/squareup/workflow/testing/WorkerSink : com/squareup/workflow/Worker { public fun (Ljava/lang/String;Lkotlin/reflect/KClass;)V public fun doesSameWorkAs (Lcom/squareup/workflow/Worker;)Z diff --git a/workflow-testing/src/main/java/com/squareup/workflow/testing/RealRenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow/testing/RealRenderTester.kt index 67fb23f8ec..363a1f9b24 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow/testing/RealRenderTester.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow/testing/RealRenderTester.kt @@ -96,21 +96,6 @@ internal class RealRenderTester( expectations += ExpectedWorkflow(matcher, exactMatch, description) } - override fun expectWorker( - matchesWhen: (otherWorker: Worker<*>) -> Boolean, - key: String, - output: WorkflowOutput?, - description: String - ): RenderTester { - val expectedWorker = ExpectedWorker(matchesWhen, key, output, description) - if (output != null) { - checkNoOutputs(expectedWorker) - childWillEmitOutput = true - } - expectations += expectedWorker - return this - } - override fun expectSideEffect( description: String, exactMatch: Boolean, @@ -119,7 +104,11 @@ internal class RealRenderTester( expectations += ExpectedSideEffect(matcher, exactMatch, description) } + @OptIn(ExperimentalStdlibApi::class) override fun render(block: (RenderingT) -> Unit): RenderTestResult { + // Allow unexpected workers. + expectWorker(description = "unexpected worker", exactMatch = false) { _, _ -> true } + // Clone the expectations to run a "dry" render pass. val noopContext = deepCloneForRender() workflow.render(props, state, noopContext) @@ -180,7 +169,7 @@ internal class RealRenderTester( } exactMatches.size > 1 -> { throw AssertionError( - "Multiple workflows matched $description:\n" + + "Multiple expectations matched $description:\n" + exactMatches.joinToString(separator = "\n") { " ${it.first.describe()}" } ) } @@ -201,35 +190,11 @@ internal class RealRenderTester( return match.childRendering as ChildRenderingT } - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - val description = "worker $worker" + - key.takeUnless { it.isEmpty() } - ?.let { " with key \"$it\"" } - .orEmpty() - val expected = consumeExpectedWorker>( - predicate = { it.matchesWhen(worker) && it.key == key }, - description = { description } - ) - - if (expected?.output != null) { - check(processedAction == null) { - "Expected only one output to be expected: $description expected to emit " + - "${expected.output.value} but $processedAction was already processed." - } - @Suppress("UNCHECKED_CAST") - processedAction = handler(expected.output.value as T) - } - } - override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit ) { - val description = "sideEffect with key \"$key\"" + val description = "side effect with key \"$key\"" val matches = expectations.filterIsInstance() .mapNotNull { @@ -242,7 +207,7 @@ internal class RealRenderTester( if (exactMatches.size > 1) { throw AssertionError( - "Multiple side effects matched $description:\n" + + "Multiple expectations matched $description:\n" + matches.joinToString(separator = "\n") { " ${it.describe()}" } ) } @@ -277,28 +242,6 @@ internal class RealRenderTester( } } - private inline fun > consumeExpectedWorker( - predicate: (T) -> Boolean, - description: () -> String - ): T? { - val matchedExpectations = expectations.filterIsInstance() - .filter(predicate) - return when (matchedExpectations.size) { - 0 -> null - 1 -> { - val expected = matchedExpectations[0] - // Move the worker to the consumed list. - expectations -= expected - consumedExpectations += expected - expected - } - else -> throw AssertionError( - "Multiple workers matched ${description()}:\n" + - matchedExpectations.joinToString(separator = "\n") { " ${it.describe()}" } - ) - } - } - private fun deepCloneForRender(): RenderContext = RealRenderTester( workflow, props, state, // Copy the list of expectations since it's mutable. diff --git a/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderIdempotencyChecker.kt b/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderIdempotencyChecker.kt index 776fea3d9c..d17c418550 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderIdempotencyChecker.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderIdempotencyChecker.kt @@ -18,7 +18,6 @@ package com.squareup.workflow.testing import com.squareup.workflow.ExperimentalWorkflowApi import com.squareup.workflow.RenderContext import com.squareup.workflow.Sink -import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowInterceptor @@ -99,17 +98,6 @@ private class RecordingRenderContext( childRenderings.removeLast() as ChildRenderingT } - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - if (!replaying) { - delegate.runningWorker(worker, key, handler) - } - // Else noop. - } - override fun runningSideEffect( key: String, sideEffect: suspend () -> Unit diff --git a/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderTester.kt b/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderTester.kt index 36d21702e3..6f70af14ea 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderTester.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderTester.kt @@ -17,7 +17,6 @@ package com.squareup.workflow.testing import com.squareup.workflow.ExperimentalWorkflowApi import com.squareup.workflow.StatefulWorkflow -import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowIdentifier @@ -380,25 +379,6 @@ interface RenderTester { matcher: (RenderChildInvocation) -> ChildWorkflowMatch ): RenderTester - /** - * Specifies that this render pass is expected to run a particular worker. - * - * @param matchesWhen Predicate used to determine if this matches the worker being ran. - * @param key The key passed to [runningWorker][com.squareup.workflow.RenderContext.runningWorker] - * when rendering this workflow. - * @param output If non-null, [WorkflowOutput.value] will be emitted when this worker is ran. - * The [WorkflowAction] used to handle this output can be verified using methods on - * [RenderTestResult]. - * @param description Optional string that will be used to describe this expectation in error - * messages. - */ - fun expectWorker( - matchesWhen: (otherWorker: Worker<*>) -> Boolean, - key: String = "", - output: WorkflowOutput? = null, - description: String = "" - ): RenderTester - /** * Specifies that this render pass is expected to run a side effect with a key that satisfies * [matcher]. This expectation is strict, and will fail if multiple side effects match. @@ -485,37 +465,6 @@ interface RenderTester { } } -/** - * Specifies that this render pass is expected to run a particular worker. - * - * @param doesSameWorkAs Worker passed to the actual worker's - * [doesSameWorkAs][Worker.doesSameWorkAs] method to identify the worker. Note that the actual - * method is called on the worker instance given by the workflow-under-test, and the value of this - * argument is passed to that method – if you need custom comparison logic for some reason, use - * the overload of this method that takes a `matchesWhen` parameter. - * @param key The key passed to [runningWorker][com.squareup.workflow.RenderContext.runningWorker] - * when rendering this workflow. - * @param output If non-null, [WorkflowOutput.value] will be emitted when this worker is ran. - * The [WorkflowAction] used to handle this output can be verified using methods on - * [RenderTestResult]. - * @param description Optional string that will be used to describe this expectation in error - * messages. - */ -/* ktlint-disable parameter-list-wrapping */ -fun - RenderTester.expectWorker( - doesSameWorkAs: Worker<*>, - key: String = "", - output: WorkflowOutput? = null, - description: String = "" -): RenderTester = expectWorker( -/* ktlint-enable parameter-list-wrapping */ - matchesWhen = { it.doesSameWorkAs(doesSameWorkAs) }, - key = key, - output = output, - description = description.ifBlank { doesSameWorkAs.toString() } -) - /** * Specifies that this render pass is expected to run a particular side effect. * diff --git a/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderTesterWorkers.kt b/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderTesterWorkers.kt new file mode 100644 index 0000000000..d34b05de6d --- /dev/null +++ b/workflow-testing/src/main/java/com/squareup/workflow/testing/RenderTesterWorkers.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.testing + +import com.squareup.workflow.ExperimentalWorkflowApi +import com.squareup.workflow.Worker +import com.squareup.workflow.WorkflowAction +import com.squareup.workflow.WorkflowOutput +import com.squareup.workflow.testing.RenderTester.ChildWorkflowMatch.Matched +import com.squareup.workflow.testing.RenderTester.ChildWorkflowMatch.NotMatched +import kotlin.reflect.KClass +import kotlin.reflect.KType +import kotlin.reflect.KTypeProjection +import kotlin.reflect.full.allSupertypes +import kotlin.reflect.full.createType +import kotlin.reflect.full.isSubtypeOf +import kotlin.reflect.full.starProjectedType +import kotlin.reflect.typeOf + +private const val WORKER_WORKFLOW_CLASS_NAME = "com.squareup.workflow.WorkerWorkflow" + +/** + * Specifies that this render pass is expected to run a [Worker] with the given [outputType]. + * + * @param outputType the [KType] of the [Worker]'s `OutputT` type parameter. + * @param key The key passed to [runningWorker][com.squareup.workflow.runningWorker] when rendering + * this workflow. + * @param output If non-null, [WorkflowOutput.value] will be emitted when this worker is ran. + * The [WorkflowAction] used to handle this output can be verified using methods on + * [RenderTestResult]. + * @param description Optional string that will be used to describe this expectation in error + * messages. + */ +/* ktlint-disable parameter-list-wrapping */ +@OptIn(ExperimentalStdlibApi::class) +inline fun + RenderTester.expectWorkerOutputting( + outputType: KType, + key: String = "", + crossinline assertWorker: (Worker<*>) -> Unit = {}, + output: WorkflowOutput<*>? = null, + description: String = "" +): RenderTester = expectWorker( +/* ktlint-enable parameter-list-wrapping */ + workerType = Worker::class.createType(listOf(KTypeProjection.covariant(outputType))), + key = key, + assertWorker = { assertWorker(it) }, + output = output, + description = description.ifBlank { "worker outputting $outputType" + keyDescription(key) } +) + +/** + * Specifies that this render pass is expected to run a [Worker] that has the same type of the given + * worker and for which the actual worker's [`doesSameWorkAs`][Worker.doesSameWorkAs] method returns + * true. If a worker is ran that matches the type of [expected], but the actual worker's + * `doesSameWorkAs` returns false, then an [AssertionError] will be thrown. If you need to perform + * custom assertions, use the overload of this method that takes an `assertWhen` parameter. + * + * @param expected Worker passed to the actual worker's + * [doesSameWorkAs][Worker.doesSameWorkAs] method to assert the worker matches. + * @param key The key passed to [runningWorker][com.squareup.workflow.runningWorker] when rendering + * this workflow. + * @param output If non-null, [WorkflowOutput.value] will be emitted when this worker is ran. + * The [WorkflowAction] used to handle this output can be verified using methods on + * [RenderTestResult]. + * @param description Optional string that will be used to describe this expectation in error + * messages. + */ +/* ktlint-disable parameter-list-wrapping */ +@OptIn(ExperimentalStdlibApi::class) +inline fun < + PropsT, StateT, OutputT, RenderingT, WorkerOutputT, reified WorkerT : Worker> + RenderTester.expectWorker( + expected: WorkerT, + key: String = "", + output: WorkflowOutput? = null, + description: String = "" +): RenderTester = expectWorker( +/* ktlint-enable parameter-list-wrapping */ + workerType = typeOf(), + key = key, + assertWorker = { + if (!it.doesSameWorkAs(expected)) { + throw AssertionError( + "Expected actual worker's doesSameWorkAs to return true for expected worker $description\n" + + " expected=$expected\n" + + " actual=$it" + ) + } + }, + output = output, + description = description.ifBlank { "worker $expected" + keyDescription(key) } +) + +/** + * Specifies that this render pass is expected to run a [Worker] with the given [workerClass]. The + * worker's output type is not taken into consideration. + * + * @param workerClass The [KClass] of the worker that is expected to be ran. + * @param key The key passed to [runningWorker][com.squareup.workflow.runningWorker] when rendering + * this workflow. + * @param assertWorker A function that will be passed the actual worker that matches this + * expectation and can perform custom assertions on the worker instance. + * @param output If non-null, [WorkflowOutput.value] will be emitted when this worker is ran. + * The [WorkflowAction] used to handle this output can be verified using methods on + * [RenderTestResult]. + * @param description Optional string that will be used to describe this expectation in error + * messages. + */ +@OptIn(ExperimentalWorkflowApi::class) +/* ktlint-disable parameter-list-wrapping */ +inline fun > + RenderTester.expectWorker( + workerClass: KClass, + key: String = "", + crossinline assertWorker: (WorkerT) -> Unit = {}, + output: WorkflowOutput? = null, + description: String = "" +): RenderTester = +/* ktlint-enable parameter-list-wrapping */ + expectWorker( + workerType = workerClass.starProjectedType, + key = key, + assertWorker = { + @Suppress("UNCHECKED_CAST") + assertWorker(it as WorkerT) + }, + output = output, + description = description.ifBlank { "worker $workerClass" + keyDescription(key) } + ) + +/** + * Specifies that this render pass is expected to run a [Worker] whose [KType] matches [workerType]. + * + * @param workerType The [KType] of the [Worker] that is expected to be ran. This will be compared + * against the concrete type of the worker that is passed to + * [runningWorker][com.squareup.workflow.runningWorker], but may be a supertype of that type. E.g. + * an expected worker type of `typeOf>>()` will match a worker that + * has the type `SomeConcreteWorker>`. + * @param key The key passed to [runningWorker][com.squareup.workflow.runningWorker] when rendering + * this workflow. + * @param assertWorker A function that will be passed the actual worker that matches this + * expectation and can perform custom assertions on the worker instance. + * @param output If non-null, [WorkflowOutput.value] will be emitted when this worker is ran. + * The [WorkflowAction] used to handle this output can be verified using methods on + * [RenderTestResult]. + * @param description Optional string that will be used to describe this expectation in error + * messages. + */ +@OptIn(ExperimentalWorkflowApi::class) +/* ktlint-disable parameter-list-wrapping */ +fun + RenderTester.expectWorker( + workerType: KType, + key: String = "", + assertWorker: (Worker<*>) -> Unit = {}, + output: WorkflowOutput<*>? = null, + description: String = "" +): RenderTester = +/* ktlint-enable parameter-list-wrapping */ + expectWorker( + description = description.ifBlank { workerType.toString() + keyDescription(key) }, + output = output, + exactMatch = true + ) { worker, actualKey -> + (key == actualKey && workerType.isInstance(worker)) + .also { if (it) assertWorker(worker) } + } + +/** + * Specifies that this render pass is expected to run a [Worker] that matches [predicate]. + * + * @param description A string that will be used to describe this expectation in error messages. + * This overload requires a description since none can be derived from the predicate. + * @param output If non-null, [WorkflowOutput.value] will be emitted when this worker is ran. + * The [WorkflowAction] used to handle this output can be verified using methods on + * [RenderTestResult]. + * @param exactMatch If true, then the test will fail if any other matching expectations are also + * exact matches, and the expectation will only be allowed to match a single worker. + * If false, the match will only be used if no other expectations return exclusive matches (in + * which case the first match will be used), and the expectation may match multiple workers. + * @param predicate A function which receives the actual instance of the worker, and the key + * string, passed to [runningWorker][com.squareup.workflow.runningWorker], and returns true if the + * worker matches the expectation. + */ +@OptIn(ExperimentalWorkflowApi::class) +/* ktlint-disable parameter-list-wrapping */ +internal fun + RenderTester.expectWorker( + description: String, + output: WorkflowOutput<*>? = null, + exactMatch: Boolean = true, + predicate: (Worker<*>, key: String) -> Boolean +): RenderTester = +/* ktlint-enable parameter-list-wrapping */ + expectWorkflow( + description = description, + exactMatch = exactMatch + ) { invocation -> + if (invocation.workflow::class.qualifiedName == WORKER_WORKFLOW_CLASS_NAME && + predicate(invocation.props as Worker<*>, invocation.renderKey) + ) { + Matched(Unit, output) + } else { + NotMatched + } + } + +@PublishedApi internal fun keyDescription(key: String) = + key.takeUnless { it.isEmpty() } + ?.let { " with key \"$key\"" } + .orEmpty() + +private fun KType.isInstance(value: Any): Boolean { + val actualClass = value::class + if (classifier == actualClass) { + // Exact KClass match means we don't need to compare any type parameters or supertypes. + return true + } + // If the types aren't the same, then check for subtyping. Note that allSupertypes does not + // include actualClass. + return actualClass.allSupertypes.any { it.isSubtypeOf(this) } +} diff --git a/workflow-testing/src/main/java/com/squareup/workflow/testing/WorkflowTestRuntime.kt b/workflow-testing/src/main/java/com/squareup/workflow/testing/WorkflowTestRuntime.kt index dd9d6d6865..dc9b930439 100644 --- a/workflow-testing/src/main/java/com/squareup/workflow/testing/WorkflowTestRuntime.kt +++ b/workflow-testing/src/main/java/com/squareup/workflow/testing/WorkflowTestRuntime.kt @@ -32,7 +32,6 @@ import com.squareup.workflow.testing.WorkflowTestParams.StartMode.StartFromCompl import com.squareup.workflow.testing.WorkflowTestParams.StartMode.StartFromState import com.squareup.workflow.testing.WorkflowTestParams.StartMode.StartFromWorkflowSnapshot import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Unconfined @@ -51,7 +50,6 @@ import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.jetbrains.annotations.TestOnly -import kotlin.coroutines.ContinuationInterceptor import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -379,7 +377,6 @@ fun workflow = this@launchForTestingWith, scope = workflowScope, props = propsFlow, - workerDispatcher = context[ContinuationInterceptor] as? CoroutineDispatcher, initialSnapshot = snapshot, interceptors = interceptors ) { output -> outputs.send(output) } diff --git a/workflow-testing/src/test/java/com/squareup/workflow/WorkerCompositionIntegrationTest.kt b/workflow-testing/src/test/java/com/squareup/workflow/WorkerCompositionIntegrationTest.kt index 438c7830d2..02e418a666 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow/WorkerCompositionIntegrationTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow/WorkerCompositionIntegrationTest.kt @@ -20,7 +20,6 @@ package com.squareup.workflow import com.squareup.workflow.WorkflowAction.Companion.noAction import com.squareup.workflow.testing.WorkerSink import com.squareup.workflow.testing.launchForTestingFromStartWith -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers.Unconfined import kotlinx.coroutines.Job @@ -33,6 +32,7 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.coroutineContext import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFails import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotSame @@ -66,7 +66,7 @@ class WorkerCompositionIntegrationTest { } } val workflow = Workflow.stateless { props -> - if (props) runningWorker(worker) { noAction() } + if (props) runningWorker(worker) } workflow.launchForTestingFromStartWith(true) { @@ -88,7 +88,7 @@ class WorkerCompositionIntegrationTest { stops++ } } - val workflow = Workflow.stateless { runningWorker(worker) { noAction() } } + val workflow = Workflow.stateless { runningWorker(worker) } workflow.launchForTestingFromStartWith { assertEquals(1, starts) @@ -117,7 +117,7 @@ class WorkerCompositionIntegrationTest { } } val workflow = Workflow.stateless { props -> - if (props) runningWorker(worker) { noAction() } + if (props) runningWorker(worker) } workflow.launchForTestingFromStartWith(false) { @@ -154,20 +154,14 @@ class WorkerCompositionIntegrationTest { } @Test fun `runningWorker gets error`() { - val channel = Channel() - val workflow = Workflow.stateless { - runningWorker( - channel.consumeAsFlow() - .asWorker() - ) { action { setOutput(it) } } + val workflow = Workflow.stateless { + runningWorker(Worker.createSideEffect { throw ExpectedException() }) } assertFailsWith { workflow.launchForTestingFromStartWith { assertFalse(this.hasOutput) - channel.cancel(CancellationException(null, ExpectedException())) - awaitNextOutput() } } @@ -234,11 +228,12 @@ class WorkerCompositionIntegrationTest { runningWorker(worker) } - assertFailsWith { + val error = assertFails { workflow.launchForTestingFromStartWith { // Nothing to do. } } + assertTrue("java.lang.Void" in error.message!!) } @Test fun `runningWorker doesn't throw when worker finishes`() { diff --git a/workflow-testing/src/test/java/com/squareup/workflow/WorkerStressTest.kt b/workflow-testing/src/test/java/com/squareup/workflow/WorkerStressTest.kt index d3b06155d5..8b73c9a775 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow/WorkerStressTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow/WorkerStressTest.kt @@ -16,7 +16,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.fail -private const val WORKER_COUNT = 1000 +private const val WORKER_COUNT = 500 class WorkerStressTest { diff --git a/workflow-testing/src/test/java/com/squareup/workflow/WorkerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow/WorkerTest.kt index 7edc363328..d2e8d37bd4 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow/WorkerTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow/WorkerTest.kt @@ -18,41 +18,18 @@ package com.squareup.workflow import com.squareup.workflow.testing.test -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotSame import kotlin.test.assertTrue +/** Worker tests that use the [Worker.test] function. Core tests are in the core module. */ class WorkerTest { private class ExpectedException : RuntimeException() - @Test fun `create returns equivalent workers`() { - val worker1 = Worker.create {} - val worker2 = Worker.create {} - - assertNotSame(worker1, worker2) - assertTrue(worker1.doesSameWorkAs(worker2)) - } - - @Test fun `create returns non-equivalent workers based on type`() { - val worker1 = Worker.create {} - val worker2 = Worker.create {} - - assertFalse(worker1.doesSameWorkAs(worker2)) - } - @Test fun `create emits and finishes`() { val worker = Worker.create { emit("hello") @@ -152,21 +129,6 @@ class WorkerTest { } } - @Test fun `timer returns equivalent workers keyed`() { - val worker1 = Worker.timer(1, "key") - val worker2 = Worker.timer(1, "key") - - assertNotSame(worker1, worker2) - assertTrue(worker1.doesSameWorkAs(worker2)) - } - - @Test fun `timer returns non-equivalent workers based on key`() { - val worker1 = Worker.timer(1, "key1") - val worker2 = Worker.timer(1, "key2") - - assertFalse(worker1.doesSameWorkAs(worker2)) - } - @Test fun `timer emits and finishes after delay`() { val testDispatcher = TestCoroutineDispatcher() val worker = Worker.timer(1000) @@ -186,43 +148,4 @@ class WorkerTest { assertFinished() } } - - @Test fun `finished worker is equivalent to self`() { - assertTrue(Worker.finished().doesSameWorkAs(Worker.finished())) - } - - @Test fun `transformed workers are equivalent with equivalent source`() { - val source = Worker.create {} - val transformed1 = source.transform { flow -> flow.buffer(1) } - val transformed2 = source.transform { flow -> flow.conflate() } - - assertTrue(transformed1.doesSameWorkAs(transformed2)) - } - - @Test fun `transformed workers are not equivalent with nonequivalent source`() { - val source1 = object : Worker { - override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = false - override fun run(): Flow = emptyFlow() - } - val source2 = object : Worker { - override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = false - override fun run(): Flow = emptyFlow() - } - val transformed1 = source1.transform { flow -> flow.conflate() } - val transformed2 = source2.transform { flow -> flow.conflate() } - - assertFalse(transformed1.doesSameWorkAs(transformed2)) - } - - @Test fun `transformed workers transform flows`() { - val source = flowOf(1, 2, 3).asWorker() - val transformed = source.transform { flow -> flow.map { it.toString() } } - - val transformedValues = runBlocking { - transformed.run() - .toList() - } - - assertEquals(listOf("1", "2", "3"), transformedValues) - } } diff --git a/workflow-testing/src/test/java/com/squareup/workflow/testing/RealRenderTesterTest.kt b/workflow-testing/src/test/java/com/squareup/workflow/testing/RealRenderTesterTest.kt index d0f9f3b391..24e6856250 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow/testing/RealRenderTesterTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow/testing/RealRenderTesterTest.kt @@ -31,7 +31,6 @@ import com.squareup.workflow.WorkflowIdentifier import com.squareup.workflow.WorkflowOutput import com.squareup.workflow.contraMap import com.squareup.workflow.identifier -import com.squareup.workflow.impostorWorkflowIdentifier import com.squareup.workflow.renderChild import com.squareup.workflow.runningWorker import com.squareup.workflow.stateful @@ -88,14 +87,14 @@ class RealRenderTesterTest { } val tester = workflow.testRender(Unit) .expectWorkflow(child.identifier, rendering = Unit, output = WorkflowOutput(Unit)) - .expectWorker(matchesWhen = { true }, output = WorkflowOutput(Unit)) + .expectWorker(typeOf>(), output = WorkflowOutput(Unit)) val failure = assertFailsWith { tester.render() } assertEquals( - "Expected only one output to be expected: worker $worker expected to emit " + + "Expected only one output to be expected: child worker ${typeOf>()} expected to emit " + "kotlin.Unit but WorkflowAction.noAction() was already processed.", failure.message ) @@ -114,94 +113,22 @@ class RealRenderTesterTest { tester.expectWorkflow(workflow::class, rendering = Unit) } - @Test fun `expectWorker with output throws when already expecting worker output`() { - // Don't need an implementation, the test should fail before even calling render. - val workflow = Workflow.stateless {} - val tester = workflow.testRender(Unit) - .expectWorker(matchesWhen = { true }, output = WorkflowOutput(Unit)) - - val failure = assertFailsWith { - tester.expectWorker(matchesWhen = { true }, output = WorkflowOutput(Unit)) - } - - val failureMessage = failure.message!! - assertTrue(failureMessage.startsWith("Expected only one child to emit an output:")) - assertEquals( - 3, failureMessage.lines().size, - "Expected error message to have 3 lines, but was ${failureMessage.lines().size}" - ) - assertEquals(2, failureMessage.lines() - .count { "ExpectedWorker" in it }) - } - - @Test fun `renderChild throws when already expecting worker output`() { - val child = Workflow.stateless {} - val worker = Worker.finished() - val workflow = Workflow.stateless { - runningWorker(worker) { noAction() } - renderChild(child) { noAction() } - } - val tester = workflow.testRender(Unit) - .expectWorkflow(child.identifier, rendering = Unit, output = WorkflowOutput(Unit)) - .expectWorker(matchesWhen = { true }, output = WorkflowOutput(Unit)) - - val failure = assertFailsWith { - tester.render() - } - - assertEquals( - "Expected only one output to be expected: child workflow ${child.identifier} " + - "expected to emit kotlin.Unit but WorkflowAction.noAction() was already processed.", - failure.message - ) - } - - @Test fun `expectWorker without output doesn't throw when already expecting output`() { - // Don't need an implementation, the test should fail before even calling render. - val workflow = Workflow.stateless {} - val tester = workflow.testRender(Unit) - .expectWorker(matchesWhen = { true }, output = WorkflowOutput(Unit)) - - // Doesn't throw. - tester.expectWorker(matchesWhen = { true }) - } - - @Test fun `expectWorker taking Worker matches`() { - val worker = object : Worker { - override fun doesSameWorkAs(otherWorker: Worker<*>) = otherWorker === this - override fun run() = emptyFlow() - } - val workflow = Workflow.stateless { - runningWorker(worker) { noAction() } - } - - workflow.testRender(Unit) - .expectWorker(worker) - .render() - } - - @Test fun `expectWorker taking Worker doesn't match`() { - val worker1 = object : Worker { - override fun doesSameWorkAs(otherWorker: Worker<*>) = otherWorker === this - override fun toString(): String = "Worker1" - override fun run() = emptyFlow() - } - val worker2 = object : Worker { - override fun doesSameWorkAs(otherWorker: Worker<*>) = otherWorker === this - override fun toString(): String = "Worker2" - override fun run() = emptyFlow() - } + @Test fun `expectSideEffect throws when already expecting side effect with same key`() { val workflow = Workflow.stateless { - runningWorker(worker1) { noAction() } + runningSideEffect("the key") {} } val tester = workflow.testRender(Unit) - .expectWorker(worker2) + .expectSideEffect(key = "the key") + .expectSideEffect(description = "duplicate match") { it == "the key" } val error = assertFailsWith { tester.render() } - assertTrue( - error.message!!.startsWith("Expected 1 more workflows, workers, or side effects to be ran:") + assertEquals( + "Multiple expectations matched side effect with key \"the key\":\n" + + " side effect with key \"the key\"\n" + + " duplicate match", + error.message ) } @@ -264,6 +191,7 @@ class RealRenderTesterTest { @Test fun `sending to sink throws when child output expected`() { class TestAction : WorkflowAction { override fun Updater.apply() {} + override fun toString(): String = "TestAction" } val workflow = Workflow.stateful>( @@ -276,12 +204,17 @@ class RealRenderTesterTest { ) workflow.testRender(Unit) - .expectWorker(matchesWhen = { true }, output = WorkflowOutput(Unit)) + .expectWorker(typeOf>(), output = WorkflowOutput(Unit)) .render { sink -> val error = assertFailsWith { sink.send(TestAction()) } - assertTrue(error.message!!.startsWith("Expected only one child to emit an output:")) + assertEquals( + "Tried to send action to sink after another action was already processed:\n" + + " processed action=WorkflowAction.noAction()\n" + + " attempted action=TestAction", + error.message + ) } } @@ -410,7 +343,7 @@ class RealRenderTesterTest { val error = assertFailsWith { tester.render() } - assertEquals("Tried to run unexpected sideEffect with key \"effect\"", error.message) + assertEquals("Tried to run unexpected side effect with key \"effect\"", error.message) } @Test fun `runningSideEffect throws when no expectations match but other side effects matched`() { @@ -424,7 +357,7 @@ class RealRenderTesterTest { val error = assertFailsWith { tester.render() } - assertEquals("Tried to run unexpected sideEffect with key \"unexpected\"", error.message) + assertEquals("Tried to run unexpected side effect with key \"unexpected\"", error.message) } @Test fun `runningSideEffect throws when multiple expectations match`() { @@ -439,7 +372,7 @@ class RealRenderTesterTest { tester.render() } assertEquals( - "Multiple side effects matched sideEffect with key \"effect\":\n" + + "Multiple expectations matched side effect with key \"effect\":\n" + " side effect with key \"effect\"\n" + " custom", error.message @@ -572,7 +505,7 @@ class RealRenderTesterTest { } assertEquals( """ - Multiple workflows matched child workflow ${Child::class.workflowIdentifier}: + Multiple expectations matched child workflow ${Child::class.workflowIdentifier}: workflow identifier=${OutputNothingChild::class.workflowIdentifier}, key=, rendering=kotlin.Unit, output=null workflow identifier=${Child::class.workflowIdentifier}, key=, rendering=kotlin.Unit, output=null """.trimIndent(), @@ -594,18 +527,18 @@ class RealRenderTesterTest { tester.render() } - @Test fun `runningWorker throws when expectation doesn't match`() { - val worker = object : Worker { + @Test fun `render throws when worker expectation doesn't match`() { + val worker = object : Worker { override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = true override fun run(): Flow = emptyFlow() override fun toString(): String = "TestWorker" } val workflow = Workflow.stateless { - runningWorker(worker) + runningWorker(worker) { fail() } } val tester = workflow.testRender(Unit) - .expectWorker(matchesWhen = { false }) + .expectWorker(typeOf>()) val error = assertFailsWith { tester.render() @@ -642,7 +575,7 @@ class RealRenderTesterTest { runningWorker(worker, key = "key") } val tester = workflow.testRender(Unit) - .expectWorker(matchesWhen = { true }) + .expectWorker(typeOf>()) val error = assertFailsWith { tester.render() @@ -664,7 +597,7 @@ class RealRenderTesterTest { } val tester = workflow.testRender(Unit) .expectWorker( - matchesWhen = { true }, + typeOf>(), key = "wrong key" ) @@ -686,17 +619,17 @@ class RealRenderTesterTest { runningWorker(worker) } val tester = workflow.testRender(Unit) - .expectWorker(matchesWhen = { true }) - .expectWorker(matchesWhen = { true }) + .expectWorker(worker) + .expectWorker(worker, description = "duplicate expectation") val error = assertFailsWith { tester.render() } assertEquals( """ - Multiple workers matched worker TestWorker: - worker key=, output=null - worker key=, output=null + Multiple expectations matched child worker com.squareup.workflow.Worker: + worker TestWorker + duplicate expectation """.trimIndent(), error.message ) @@ -737,7 +670,7 @@ class RealRenderTesterTest { // Do nothing. } val tester = workflow.testRender(Unit) - .expectWorker(matchesWhen = { true }) + .expectWorker(typeOf>()) val error = assertFailsWith { tester.render() @@ -783,7 +716,7 @@ class RealRenderTesterTest { } workflow.testRender(Unit) .expectWorkflow( - TestImpostor::class.impostorWorkflowIdentifier(TestWorkflow::class.workflowIdentifier), + TestImpostor(TestWorkflow()).identifier, Unit ) .render {} @@ -811,10 +744,8 @@ class RealRenderTesterTest { val workflow = Workflow.stateless { renderChild(TestImpostor(TestWorkflowActual())) } - val expectedId = - TestImpostor::class.impostorWorkflowIdentifier(TestWorkflowExpected::class.workflowIdentifier) - val actualId = - TestImpostor::class.impostorWorkflowIdentifier(TestWorkflowActual::class.workflowIdentifier) + val expectedId = TestImpostor(TestWorkflowExpected()).identifier + val actualId = TestImpostor(TestWorkflowActual()).identifier val tester = workflow.testRender(Unit) .expectWorkflow(expectedId, Unit) @@ -851,8 +782,7 @@ class RealRenderTesterTest { val workflow = Workflow.stateless { renderChild(TestImpostorActual(TestWorkflow())) } - val expectedId = - TestImpostorExpected::class.impostorWorkflowIdentifier(TestWorkflow::class.workflowIdentifier) + val expectedId = TestImpostorExpected(TestWorkflow()).identifier workflow.testRender(Unit) .expectWorkflow(expectedId, Unit) @@ -920,10 +850,7 @@ class RealRenderTesterTest { runningWorker(worker) { TestAction(it) } } val testResult = workflow.testRender(Unit) - .expectWorker( - matchesWhen = { true }, - output = WorkflowOutput("output") - ) + .expectWorker(worker, output = WorkflowOutput("output")) .render() testResult.verifyAction { diff --git a/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkerRenderExpectationsTest.kt b/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkerRenderExpectationsTest.kt new file mode 100644 index 0000000000..c2950ea8c1 --- /dev/null +++ b/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkerRenderExpectationsTest.kt @@ -0,0 +1,388 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.squareup.workflow.testing + +import com.squareup.workflow.Worker +import com.squareup.workflow.Workflow +import com.squareup.workflow.WorkflowOutput +import com.squareup.workflow.action +import com.squareup.workflow.runningWorker +import com.squareup.workflow.stateless +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlin.reflect.typeOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.fail + +// TODO(https://github.com/square/workflow-kotlin/issues/117) Add more failure tests +@OptIn(ExperimentalStdlibApi::class) +class WorkerRenderExpectationsTest { + + @Test fun `expectWorkerOutputting() works`() { + val stringWorker = object : Worker { + override fun run(): Flow = emptyFlow() + } + val intWorker = object : Worker { + override fun run(): Flow = emptyFlow() + } + val workflow = Workflow.stateless { + runningWorker(stringWorker) { action { setOutput(it) } } + runningWorker(intWorker) { action { setOutput(it.toString()) } } + } + + // Exact string match + workflow.testRender(Unit) + .expectWorkerOutputting(typeOf()) + .render() + workflow.testRender(Unit) + .expectWorkerOutputting(typeOf(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Supertype match + workflow.testRender(Unit) + .expectWorkerOutputting(typeOf()) + .render() + workflow.testRender(Unit) + .expectWorkerOutputting(typeOf(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Other type match + workflow.testRender(Unit) + .expectWorkerOutputting(typeOf()) + .render() + workflow.testRender(Unit) + .expectWorkerOutputting(typeOf(), output = WorkflowOutput(42)) + .render() + .verifyActionResult { _, output -> assertEquals("42", output?.value) } + } + + @Test fun `expectWorker(worker instance) works`() { + val stringWorker = object : Worker { + override fun run(): Flow = emptyFlow() + } + val intWorker = object : Worker { + override fun run(): Flow = emptyFlow() + } + val workflow = Workflow.stateless { + runningWorker(stringWorker) { action { setOutput(it) } } + runningWorker(intWorker) { action { setOutput(it.toString()) } } + } + + // Exact string match + workflow.testRender(Unit) + .expectWorker(stringWorker) + .render() + workflow.testRender(Unit) + .expectWorker(stringWorker, output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Other type match + workflow.testRender(Unit) + .expectWorker(intWorker) + .render() + workflow.testRender(Unit) + .expectWorker(intWorker, output = WorkflowOutput(42)) + .render() + .verifyActionResult { _, output -> assertEquals("42", output?.value) } + } + + @Test fun `expectWorker(worker class) works`() { + abstract class EmptyWorkerCovariant : Worker { + final override fun run(): Flow = emptyFlow() + } + + open class EmptyWorker : EmptyWorkerCovariant() + + class EmptyStringWorker : EmptyWorker() + class EmptyIntWorker : EmptyWorker() + + val workflow = Workflow.stateless { + runningWorker(EmptyStringWorker()) { action { setOutput(it) } } + runningWorker(EmptyIntWorker()) { action { setOutput(it.toString()) } } + } + + // Exact string match + workflow.testRender(Unit) + .expectWorker(EmptyStringWorker::class) + .render() + workflow.testRender(Unit) + .expectWorker(EmptyStringWorker::class, output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Supertype match without type args + workflow.testRender(Unit) + .expectWorker(EmptyWorker::class) + .render() + workflow.testRender(Unit) + .expectWorker(EmptyWorker::class, output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Invariant supertype match with exact type args + workflow.testRender(Unit) + .expectWorker(EmptyWorker::class) + .render() + workflow.testRender(Unit) + .expectWorker(EmptyWorker::class, output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Covariant supertype match + workflow.testRender(Unit) + .expectWorker(EmptyWorkerCovariant::class) + .render() + workflow.testRender(Unit) + .expectWorker(EmptyWorkerCovariant::class, output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Other type match + workflow.testRender(Unit) + .expectWorker(EmptyIntWorker::class) + .render() + workflow.testRender(Unit) + .expectWorker(EmptyIntWorker::class, output = WorkflowOutput(42)) + .render() + .verifyActionResult { _, output -> assertEquals("42", output?.value) } + + // Match with instance of parameterized workflow + Workflow + .stateless { + runningWorker(EmptyWorker()) { action { setOutput(it) } } + } + .let { + it.testRender(Unit) + .expectWorker(EmptyWorker::class) + .render() + it.testRender(Unit) + .expectWorker(EmptyWorker::class, output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + } + } + + @Test fun `expectWorker(worker KType) works`() { + abstract class EmptyWorkerCovariant : Worker { + final override fun run(): Flow = emptyFlow() + } + + open class EmptyWorker : EmptyWorkerCovariant() + + class EmptyStringWorker : EmptyWorker() + class EmptyIntWorker : EmptyWorker() + + val workflow = Workflow.stateless { + runningWorker(EmptyStringWorker()) { action { setOutput(it) } } + runningWorker(EmptyIntWorker()) { action { setOutput(it.toString()) } } + } + + // Exact string match + workflow.testRender(Unit) + .expectWorker(typeOf()) + .render() + workflow.testRender(Unit) + .expectWorker(typeOf(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Supertype match without type args + workflow.testRender(Unit) + .expectWorker(typeOf>()) + .render() + workflow.testRender(Unit) + .expectWorker(typeOf>(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Invariant supertype match with exact type args + workflow.testRender(Unit) + .expectWorker(typeOf>()) + .render() + workflow.testRender(Unit) + .expectWorker(typeOf>(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Invariant supertype match with supertype args + workflow.testRender(Unit) + .expectWorker(typeOf>()) + .expectWorker(EmptyIntWorker::class) + .apply { + val error = assertFailsWith { render() } + assertEquals( + "Expected 1 more workflows, workers, or side effects to be ran:\n" + + " ${typeOf>()}", + error.message + ) + } + + // Covariant supertype match with exact type args + workflow.testRender(Unit) + .expectWorker(typeOf>()) + .render() + workflow.testRender(Unit) + .expectWorker(typeOf>(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Covariant supertype match with supertype args + workflow.testRender(Unit) + .expectWorker(typeOf>()) + .render() + workflow.testRender(Unit) + .expectWorker(typeOf>(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + + // Other type match + workflow.testRender(Unit) + .expectWorker(typeOf()) + .render() + workflow.testRender(Unit) + .expectWorker(typeOf(), output = WorkflowOutput(42)) + .render() + .verifyActionResult { _, output -> assertEquals("42", output?.value) } + + // Match with instance of parameterized workflow + Workflow + .stateless { + runningWorker(EmptyWorker()) { action { setOutput(it) } } + } + .let { + it.testRender(Unit) + .expectWorker(typeOf>()) + .render() + it.testRender(Unit) + .expectWorker(typeOf>(), output = WorkflowOutput("foo")) + .render() + .verifyActionResult { _, output -> assertEquals("foo", output?.value) } + } + } + + @Test fun `expectWorker(instance) considers doesSameWorkAs`() { + open class RequestWorker(private val request: String) : Worker { + override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = + otherWorker is RequestWorker && otherWorker.request == request + + override fun toString(): String = "RequestWorker(request=$request)" + override fun run(): Flow = fail() + } + + val workflow = Workflow.stateless { + runningWorker(RequestWorker("foo")) { action { setOutput(it) } } + } + + // Matching input + workflow.testRender(Unit) + .expectWorker(RequestWorker("foo")) + .render() + + // Non-matching input + workflow.testRender(Unit) + .expectWorker(RequestWorker("bar")) + .apply { + val error = assertFailsWith { render() } + assertEquals( + "Expected actual worker's doesSameWorkAs to return true for expected worker \n" + + " expected=RequestWorker(request=bar)\n" + + " actual=RequestWorker(request=foo)", + error.message + ) + } + } + + @Test fun `expectWorker(instance) with default doesSameWorkAs() and different types`() { + open class ParentWorker : Worker { + override fun run(): Flow = fail() + override fun toString(): String = "ParentWorker" + } + + class ChildWorker : ParentWorker() { + override fun toString(): String = "ChildWorker" + } + + // Use an explicit type to trick the runtime. + val trickWorker: ParentWorker = ChildWorker() + val honestWorker = ParentWorker() + + // This shouldn't even render, since workers should be the same type? + val multiWorkflow = Workflow.stateless { + // TODO(https://github.com/square/workflow-kotlin/issues/120) There's a major bug here, + // renderTester is allowing duplicate workflows to be rendered. This test should fail because + // of that, not because trick doesn't match the expectation for honest. + runningWorker(trickWorker) { action { setOutput(it) } } + runningWorker(honestWorker) { action { setOutput(it) } } + } + multiWorkflow.testRender(Unit) + .expectWorker(trickWorker, description = "trick") + .expectWorker(honestWorker, description = "honest") + .apply { + val error = assertFailsWith { render() } + assertEquals( + "Expected actual worker's doesSameWorkAs to return true for expected worker honest\n" + + " expected=ParentWorker\n" + + " actual=ChildWorker", + error.message + ) + } + + // Using keys clears up the ambiguity. + val multiKeyedWorkflow = Workflow.stateless { + runningWorker(trickWorker, key = "trick") { action { setOutput(it) } } + runningWorker(honestWorker, key = "honest") { action { setOutput(it) } } + } + multiKeyedWorkflow.testRender(Unit) + .expectWorker(trickWorker, key = "trick", description = "trick") + .expectWorker(honestWorker, key = "honest", description = "honest") + .render() + + val trickWorkflow = Workflow.stateless { + runningWorker(trickWorker) { action { setOutput(it) } } + } + trickWorkflow.testRender(Unit) + .expectWorker(honestWorker) + .apply { + val error = assertFailsWith { render() } + assertEquals( + "Expected actual worker's doesSameWorkAs to return true for expected worker \n" + + " expected=ParentWorker\n" + + " actual=ChildWorker", + error.message + ) + } + + val honestWorkflow = Workflow.stateless { + runningWorker(honestWorker) { action { setOutput(it) } } + } + honestWorkflow.testRender(Unit) + .expectWorker(trickWorker) + .apply { + val error = assertFailsWith { render() } + assertEquals( + "Expected actual worker's doesSameWorkAs to return true for expected worker \n" + + " expected=ChildWorker\n" + + " actual=ParentWorker", + error.message + ) + } + } +} diff --git a/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkerSinkTest.kt b/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkerSinkTest.kt index 741a38eaee..f23ddec3f1 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkerSinkTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkerSinkTest.kt @@ -15,17 +15,22 @@ */ package com.squareup.workflow.testing +import com.squareup.workflow.Worker import kotlinx.coroutines.CoroutineStart.UNDISPATCHED import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield +import kotlin.reflect.full.isSupertypeOf +import kotlin.reflect.typeOf import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -34,6 +39,26 @@ import kotlin.test.assertTrue class WorkerSinkTest { + @OptIn(ExperimentalStdlibApi::class) + @Test fun types() { + abstract class IntermediateWorker : Worker + class MyWorker : IntermediateWorker() { + override fun run(): Flow = emptyFlow() + } + + assertTrue(typeOf>().isSupertypeOf(typeOf())) + assertTrue(typeOf>().isSupertypeOf(typeOf())) + assertTrue(typeOf>().isSupertypeOf(typeOf())) + + assertTrue(typeOf>().isSupertypeOf(typeOf>())) + assertTrue(typeOf>().isSupertypeOf(typeOf>())) + assertTrue(typeOf>().isSupertypeOf(typeOf>())) + + assertTrue(typeOf>().isSupertypeOf(typeOf())) + assertTrue(typeOf>().isSupertypeOf(typeOf())) + assertTrue(typeOf>().isSupertypeOf(typeOf())) + } + @Test fun `workers are equivalent with matching type and name`() { val fooWorker = WorkerSink("foo") val otherFooWorker = WorkerSink("foo") diff --git a/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkflowTestRuntimeTest.kt b/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkflowTestRuntimeTest.kt index 44a34f1622..096cb5d965 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkflowTestRuntimeTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow/testing/WorkflowTestRuntimeTest.kt @@ -21,6 +21,7 @@ import com.squareup.workflow.Snapshot import com.squareup.workflow.Worker import com.squareup.workflow.Workflow import com.squareup.workflow.internal.util.rethrowingUncaughtExceptions +import com.squareup.workflow.runningWorker import com.squareup.workflow.stateful import com.squareup.workflow.stateless import com.squareup.workflow.testing.WorkflowTestParams.StartMode.StartFromWorkflowSnapshot diff --git a/workflow-tracing/src/main/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptor.kt b/workflow-tracing/src/main/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptor.kt index 548fff5eaf..4946745470 100644 --- a/workflow-tracing/src/main/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptor.kt +++ b/workflow-tracing/src/main/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptor.kt @@ -32,7 +32,6 @@ import com.squareup.workflow.ExperimentalWorkflowApi import com.squareup.workflow.RenderContext import com.squareup.workflow.Sink import com.squareup.workflow.Snapshot -import com.squareup.workflow.Worker import com.squareup.workflow.WorkflowAction import com.squareup.workflow.WorkflowAction.Updater import com.squareup.workflow.WorkflowInterceptor @@ -41,9 +40,6 @@ import com.squareup.workflow.WorkflowOutput import com.squareup.workflow.applyTo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flow import okio.buffer import okio.sink import java.io.File @@ -141,12 +137,6 @@ class TracingWorkflowInterceptor internal constructor( private var gcDetector: GcDetector? = null private val workflowNamesById = mutableMapOf() - private val workerDescriptionsById = mutableMapOf() - - private var workerIdCounter = 1 - - /** Used to look up workers to perform doesSameWorkAs checks until Workers go away. */ - private val workersById = mutableMapOf>() override fun onSessionStarted( workflowScope: CoroutineScope, @@ -179,7 +169,7 @@ class TracingWorkflowInterceptor internal constructor( onWorkflowStarted( workflowId = session.sessionId, parentId = session.parent?.sessionId, - workflowType = session.identifier.toString(), + workflowType = session.identifier.let { it.describeRealIdentifier() ?: it.toString() }, key = session.renderKey, initialProps = props, initialState = initialState, @@ -364,41 +354,6 @@ class TracingWorkflowInterceptor internal constructor( workflowNamesById -= workflowId } - private fun onWorkerStarted( - workerId: Long, - parentWorkflowId: Long, - key: String, - description: String - ) { - val parentName = workflowNamesById.getValue(parentWorkflowId) - workerDescriptionsById[workerId] = description - logger?.log( - AsyncDurationBegin( - id = "workflow", - name = "Worker: ${workerId.toHex()}", - category = "workflow", - args = mapOf( - "parent" to parentName, - "key" to key, - "description" to description - ) - ) - ) - } - - private fun onWorkerStopped(workerId: Long) { - val description = workerDescriptionsById.getValue(workerId) - logger?.log( - AsyncDurationEnd( - id = "workflow", - name = "Worker: ${workerId.toHex()}", - category = "workflow", - args = mapOf("description" to description) - ) - ) - workerDescriptionsById -= workerId - } - private fun onBeforeWorkflowRendered( workflowId: Long, props: Any?, @@ -454,26 +409,6 @@ class TracingWorkflowInterceptor internal constructor( ) } - private fun onWorkerOutput( - workerId: Long, - parentWorkflowId: Long, - output: Any - ) { - val parentName = workflowNamesById.getValue(parentWorkflowId) - val description = workerDescriptionsById.getValue(workerId) - logger?.log( - Instant( - name = "Worker output: $parentName", - category = "update", - args = mapOf( - "workerId" to workerId.toHex(), - "description" to description, - "output" to output.toString() - ) - ) - ) - } - private fun onPropsChanged( workflowId: Long?, oldProps: Any?, @@ -540,15 +475,6 @@ class TracingWorkflowInterceptor internal constructor( ) } - /** - * Creates a unique ID for a worker by incrementing the worker counter int, then storing the - * previous counter value in the most-significant half of the workflow ID's bits (since those - * bits are unused by workflow IDs in the real world). - */ - private fun createWorkerId(session: WorkflowSession) = - workerIdCounter++.toLong() - .shl(32) xor session.sessionId - private inner class TracingRenderContext( private val delegate: RenderContext, private val session: WorkflowSession @@ -560,17 +486,6 @@ class TracingWorkflowInterceptor internal constructor( val wrapperAction = TracingAction(value, session) delegate.actionSink.send(wrapperAction) } - - override fun runningWorker( - worker: Worker, - key: String, - handler: (T) -> WorkflowAction - ) { - val wrappedWorker = TracingWorker(session, worker, key) - delegate.runningWorker(wrappedWorker, key) { output -> - TracingAction(handler(output), session) - } - } } private inner class TracingAction( @@ -591,42 +506,6 @@ class TracingWorkflowInterceptor internal constructor( ) } } - - private inner class TracingWorker( - private val session: WorkflowSession, - private val delegate: Worker, - private val key: String - ) : Worker { - - val workerId: Long - - init { - val existingWorker = workersById.filterValues { it.doesSameWorkAs(this) } - .entries - .singleOrNull() - workerId = existingWorker?.key ?: (createWorkerId(session).also { workerId -> - onWorkerStarted(workerId, session.sessionId, key, delegate.toString()) - workersById[workerId] = this - }) - } - - override fun doesSameWorkAs(otherWorker: Worker<*>): Boolean = - otherWorker is TracingWorker<*> && - session == otherWorker.session && - delegate.doesSameWorkAs(otherWorker.delegate) - - override fun run(): Flow = flow { - try { - delegate.run() - .collect { output -> - onWorkerOutput(workerId, session.sessionId, output ?: "{null}") - emit(output) - } - } finally { - onWorkerStopped(workerId) - } - } - } } @Suppress("NOTHING_TO_INLINE") diff --git a/workflow-tracing/src/test/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptorTest.kt b/workflow-tracing/src/test/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptorTest.kt index d0204e38f7..1d37f2907e 100644 --- a/workflow-tracing/src/test/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptorTest.kt +++ b/workflow-tracing/src/test/java/com/squareup/workflow/diagnostic/tracing/TracingWorkflowInterceptorTest.kt @@ -23,6 +23,7 @@ import com.squareup.workflow.StatefulWorkflow import com.squareup.workflow.action import com.squareup.workflow.asWorker import com.squareup.workflow.renderWorkflowIn +import com.squareup.workflow.runningWorker import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers.Unconfined diff --git a/workflow-tracing/src/test/resources/com/squareup/workflow/diagnostic/tracing/expected_trace_file.txt b/workflow-tracing/src/test/resources/com/squareup/workflow/diagnostic/tracing/expected_trace_file.txt index 242e896024..e4fc871aca 100644 --- a/workflow-tracing/src/test/resources/com/squareup/workflow/diagnostic/tracing/expected_trace_file.txt +++ b/workflow-tracing/src/test/resources/com/squareup/workflow/diagnostic/tracing/expected_trace_file.txt @@ -36,7 +36,10 @@ {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"0","props":"2","state":"changed state"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"1","props":"0","state":"initial"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, -{"name":"Worker: 100000000","cat":"workflow","ph":"b","ts":0,"pid":0,"tid":0,"id":"workflow","args":{"parent":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","key":"","description":"TypedWorker(kotlin.String)"}}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"workflow","ph":"b","ts":0,"pid":0,"tid":0,"id":"workflow","args":{"workflowId":"2","initialProps":"TypedWorker(kotlin.String)","initialState":"0","restoredFromSnapshot":false,"parent":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)"}}, +{"name":"worker com.squareup.workflow.Worker (2)","ph":"N","ts":0,"pid":0,"tid":0,"id":"2"}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"2","props":"TypedWorker(kotlin.String)","state":"0"}}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"kotlin.Unit"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"Render Pass","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, @@ -49,19 +52,25 @@ {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"0","props":"3","state":"changed state"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"1","props":"0","state":"initial"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, +{"name":"Props changed: worker com.squareup.workflow.Worker (2)","ph":"i","ts":0,"pid":0,"tid":0,"s":"t","args":{"oldProps":"TypedWorker(kotlin.String)","newProps":"TypedWorker(kotlin.String)","oldState":"0","newState":"{no change}"}}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"2","props":"TypedWorker(kotlin.String)","state":"0"}}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"kotlin.Unit"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"Render Pass","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, {"name":"Snapshot","ph":"B","ts":0,"pid":0,"tid":0,"args":{}}, {"name":"Snapshot","ph":"E","ts":0,"pid":0,"tid":0,"args":{}}, -{"name":"Worker output: WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"update","ph":"i","ts":0,"pid":0,"tid":0,"s":"t","args":{"workerId":"100000000","description":"TypedWorker(kotlin.String)","output":"fired!"}}, -{"name":"WorkflowAction: WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"update","ph":"i","ts":0,"pid":0,"tid":0,"s":"p","args":{"action":"action()-TestWorkflow","oldState":"changed state","newState":"{no change}","output":"fired!"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","ph":"O","ts":0,"pid":0,"tid":0,"id":"0","args":{"snapshot":"changed state"}}, +{"name":"Sink received: worker com.squareup.workflow.Worker (2)","cat":"update","ph":"i","ts":0,"pid":0,"tid":0,"s":"t","args":{"action":"sendAndAwaitApplication(com.squareup.workflow.EmitWorkerOutputAction(worker=TypedWorker(kotlin.String), key=\"\"))"}}, +{"name":"WorkflowAction: worker com.squareup.workflow.Worker (2)","cat":"update","ph":"i","ts":0,"pid":0,"tid":0,"s":"p","args":{"action":"sendAndAwaitApplication(com.squareup.workflow.EmitWorkerOutputAction(worker=TypedWorker(kotlin.String), key=\"\"))","oldState":"0","newState":"{no change}","output":"fired!"}}, +{"name":"worker com.squareup.workflow.Worker (2)","ph":"O","ts":0,"pid":0,"tid":0,"id":"2","args":{"snapshot":"0"}}, {"name":"Render Pass","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"props":"3"}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"0","props":"3","state":"changed state"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"1","props":"0","state":"initial"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, +{"name":"Props changed: worker com.squareup.workflow.Worker (2)","ph":"i","ts":0,"pid":0,"tid":0,"s":"t","args":{"oldProps":"TypedWorker(kotlin.String)","newProps":"TypedWorker(kotlin.String)","oldState":"0","newState":"{no change}"}}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"2","props":"TypedWorker(kotlin.String)","state":"0"}}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"kotlin.Unit"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"Render Pass","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, @@ -76,22 +85,23 @@ {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, {"name":"GC detected","cat":"system","ph":"i","ts":0,"pid":0,"tid":0,"s":"g","args":{"freeMemory":42,"totalMemory":43}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","cat":"workflow","ph":"b","ts":0,"pid":0,"tid":0,"id":"workflow","args":{"workflowId":"2","initialProps":"1","initialState":"initial","restoredFromSnapshot":false,"parent":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","ph":"N","ts":0,"pid":0,"tid":0,"id":"2"}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"2","props":"1","state":"initial"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","cat":"workflow","ph":"b","ts":0,"pid":0,"tid":0,"id":"workflow","args":{"workflowId":"3","initialProps":"1","initialState":"initial","restoredFromSnapshot":false,"parent":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","ph":"N","ts":0,"pid":0,"tid":0,"id":"3"}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"3","props":"1","state":"initial"}}, {"name":"GC detected","cat":"system","ph":"i","ts":0,"pid":0,"tid":0,"s":"g","args":{"freeMemory":42,"totalMemory":43}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","cat":"workflow","ph":"b","ts":0,"pid":0,"tid":0,"id":"workflow","args":{"workflowId":"3","initialProps":"0","initialState":"initial","restoredFromSnapshot":false,"parent":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","ph":"N","ts":0,"pid":0,"tid":0,"id":"3"}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"3","props":"0","state":"initial"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","cat":"workflow","ph":"b","ts":0,"pid":0,"tid":0,"id":"workflow","args":{"workflowId":"4","initialProps":"0","initialState":"initial","restoredFromSnapshot":false,"parent":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","ph":"N","ts":0,"pid":0,"tid":0,"id":"4"}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"4","props":"0","state":"initial"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"Render Pass","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, {"name":"Snapshot","ph":"B","ts":0,"pid":0,"tid":0,"args":{}}, {"name":"Snapshot","ph":"E","ts":0,"pid":0,"tid":0,"args":{}}, -{"name":"Worker: 100000000","cat":"workflow","ph":"e","ts":0,"pid":0,"tid":0,"id":"workflow"}, +{"name":"worker com.squareup.workflow.Worker (2)","cat":"workflow","ph":"e","ts":0,"pid":0,"tid":0,"id":"workflow"}, +{"name":"worker com.squareup.workflow.Worker (2)","ph":"D","ts":0,"pid":0,"tid":0,"id":"2"}, {"name":"Props changed: {root}","ph":"i","ts":0,"pid":0,"tid":0,"s":"t","args":{"oldProps":"4","newProps":"5","oldState":"changed state","newState":"{no change}"}}, {"name":"Props changed: WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","ph":"i","ts":0,"pid":0,"tid":0,"s":"t","args":{"oldProps":"4","newProps":"5","oldState":"changed state","newState":"{no change}"}}, {"name":"Render Pass","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"props":"5"}}, @@ -99,10 +109,10 @@ {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"0","props":"5","state":"changed state"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"1","props":"0","state":"initial"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (1)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"2","props":"1","state":"initial"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"3","props":"0","state":"initial"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"3","props":"1","state":"initial"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","cat":"rendering","ph":"B","ts":0,"pid":0,"tid":0,"args":{"workflowId":"4","props":"0","state":"initial"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"initial"}}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"Render Pass","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, @@ -118,10 +128,10 @@ {"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (0)","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"Render Pass","cat":"rendering","ph":"E","ts":0,"pid":0,"tid":0,"args":{"rendering":"rendering"}}, {"name":"used/free memory","ph":"C","ts":0,"pid":0,"tid":0,"args":{"usedMemory":1,"freeMemory":42}}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","cat":"workflow","ph":"e","ts":0,"pid":0,"tid":0,"id":"workflow"}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (3)","ph":"D","ts":0,"pid":0,"tid":0,"id":"3"}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","cat":"workflow","ph":"e","ts":0,"pid":0,"tid":0,"id":"workflow"}, -{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (2)","ph":"D","ts":0,"pid":0,"tid":0,"id":"2"}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","cat":"workflow","ph":"e","ts":0,"pid":0,"tid":0,"id":"workflow"}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow) (4)","ph":"D","ts":0,"pid":0,"tid":0,"id":"4"}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","cat":"workflow","ph":"e","ts":0,"pid":0,"tid":0,"id":"workflow"}, +{"name":"WorkflowIdentifier(com.squareup.workflow.diagnostic.tracing.TracingWorkflowInterceptorTest$TestWorkflow):second (3)","ph":"D","ts":0,"pid":0,"tid":0,"id":"3"}, {"name":"Snapshot","ph":"B","ts":0,"pid":0,"tid":0,"args":{}}, {"name":"Snapshot","ph":"E","ts":0,"pid":0,"tid":0,"args":{}}, {"name":"Props changed: {root}","ph":"i","ts":0,"pid":0,"tid":0,"s":"t","args":{"oldProps":"6","newProps":"7","oldState":"changed state","newState":"{no change}"}},