test(android): establish workflow runner

This commit is contained in:
QiuSW
2026-07-25 18:47:22 +08:00
parent d013ac8c84
commit d57c4ae2d4
8 changed files with 548 additions and 7 deletions
@@ -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()
}
}