test(android): establish workflow runner
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package com.roubao.autopilot.workflow
|
||||
|
||||
import java.util.ArrayDeque
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.test.currentTime
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class WorkflowRunnerTest {
|
||||
@Test
|
||||
fun `multiple steps complete with explicit transitions`() = runTest {
|
||||
val automation = QueueAutomation(
|
||||
AutomationResult.Success,
|
||||
AutomationResult.Success
|
||||
)
|
||||
val runner = WorkflowRunner(automation)
|
||||
|
||||
val report = runner.run(
|
||||
listOf(
|
||||
step("open_app"),
|
||||
step("verify_page")
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(WorkflowState.SUCCEEDED, report.state)
|
||||
assertEquals(listOf("open_app", "verify_page"), report.completedStepIds)
|
||||
assertEquals(mapOf("open_app" to 1, "verify_page" to 1), report.attemptsByStep)
|
||||
assertEquals(
|
||||
listOf(
|
||||
WorkflowState.RUNNING,
|
||||
WorkflowState.RUNNING,
|
||||
WorkflowState.SUCCEEDED
|
||||
),
|
||||
report.transitions.map { it.to }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retryable failure retries once then succeeds`() = runTest {
|
||||
val automation = QueueAutomation(
|
||||
AutomationResult.RetryableFailure(WorkflowFailureCode.TRANSIENT_AUTOMATION),
|
||||
AutomationResult.Success
|
||||
)
|
||||
val runner = WorkflowRunner(automation)
|
||||
|
||||
val report = runner.run(listOf(step("search", maxRetries = 1)))
|
||||
|
||||
assertEquals(WorkflowState.SUCCEEDED, report.state)
|
||||
assertEquals(2, report.attemptsByStep["search"])
|
||||
assertEquals(
|
||||
listOf(
|
||||
WorkflowState.RUNNING,
|
||||
WorkflowState.RETRYING,
|
||||
WorkflowState.RUNNING,
|
||||
WorkflowState.SUCCEEDED
|
||||
),
|
||||
report.transitions.map { it.to }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timeout is bounded and fails after retry budget`() = runTest {
|
||||
val runner = WorkflowRunner {
|
||||
delay(10_000)
|
||||
AutomationResult.Success
|
||||
}
|
||||
|
||||
val report = runner.run(
|
||||
listOf(step("wait_for_page", timeoutMillis = 100, maxRetries = 1))
|
||||
)
|
||||
|
||||
assertEquals(WorkflowState.FAILED, report.state)
|
||||
assertEquals(WorkflowFailureCode.TIMEOUT, report.failureCode)
|
||||
assertEquals(2, report.attemptsByStep["wait_for_page"])
|
||||
assertEquals(200, currentTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all safety reasons block without retry`() = runTest {
|
||||
SafetyStopReason.entries.forEach { reason ->
|
||||
var calls = 0
|
||||
val runner = WorkflowRunner {
|
||||
calls += 1
|
||||
AutomationResult.Blocked(reason)
|
||||
}
|
||||
|
||||
val report = runner.run(listOf(step("guard", maxRetries = 3)))
|
||||
|
||||
assertEquals(WorkflowState.BLOCKED, report.state)
|
||||
assertEquals(reason, report.safetyStopReason)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user stop cancels current child and returns stopped report`() = runTest {
|
||||
val started = CompletableDeferred<Unit>()
|
||||
val runner = WorkflowRunner {
|
||||
started.complete(Unit)
|
||||
awaitCancellation()
|
||||
}
|
||||
|
||||
val runningReport = async {
|
||||
runner.run(listOf(step("long_action", timeoutMillis = 60_000)))
|
||||
}
|
||||
started.await()
|
||||
runner.requestStop()
|
||||
|
||||
val report = runningReport.await()
|
||||
assertEquals(WorkflowState.STOPPED, report.state)
|
||||
assertTrue(coroutineContext.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent run is rejected`() = runTest {
|
||||
val started = CompletableDeferred<Unit>()
|
||||
val runner = WorkflowRunner {
|
||||
started.complete(Unit)
|
||||
awaitCancellation()
|
||||
}
|
||||
val firstRun = async {
|
||||
runner.run(listOf(step("first", timeoutMillis = 60_000)))
|
||||
}
|
||||
started.await()
|
||||
|
||||
try {
|
||||
runner.run(listOf(step("second")))
|
||||
fail("Expected concurrent run to be rejected")
|
||||
} catch (error: IllegalStateException) {
|
||||
assertEquals("WorkflowRunner is already running", error.message)
|
||||
} finally {
|
||||
runner.requestStop()
|
||||
assertEquals(WorkflowState.STOPPED, firstRun.await().state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun step(
|
||||
id: String,
|
||||
timeoutMillis: Long = 1_000,
|
||||
maxRetries: Int = 0
|
||||
) = WorkflowStep(
|
||||
id = id,
|
||||
timeoutMillis = timeoutMillis,
|
||||
maxRetries = maxRetries
|
||||
)
|
||||
|
||||
private class QueueAutomation(
|
||||
vararg results: AutomationResult
|
||||
) : AutomationGateway {
|
||||
private val remaining = ArrayDeque(results.toList())
|
||||
|
||||
override suspend fun execute(step: WorkflowStep): AutomationResult =
|
||||
remaining.removeFirst()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user