test(android): establish workflow runner
This commit is contained in:
@@ -87,6 +87,7 @@ dependencies {
|
||||
|
||||
// Unit tests
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.roubao.autopilot.workflow
|
||||
|
||||
const val MAX_RETRIES_PER_STEP = 3
|
||||
|
||||
data class WorkflowStep(
|
||||
val id: String,
|
||||
val timeoutMillis: Long,
|
||||
val maxRetries: Int = 0
|
||||
) {
|
||||
init {
|
||||
require(id.isNotBlank()) { "Workflow step id must not be blank" }
|
||||
require(timeoutMillis > 0) { "Workflow step timeout must be positive" }
|
||||
require(maxRetries in 0..MAX_RETRIES_PER_STEP) {
|
||||
"Workflow step maxRetries must be between 0 and $MAX_RETRIES_PER_STEP"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class WorkflowState {
|
||||
IDLE,
|
||||
RUNNING,
|
||||
RETRYING,
|
||||
SUCCEEDED,
|
||||
FAILED,
|
||||
BLOCKED,
|
||||
STOPPED
|
||||
}
|
||||
|
||||
enum class WorkflowFailureCode {
|
||||
TIMEOUT,
|
||||
TRANSIENT_AUTOMATION,
|
||||
AUTOMATION_EXCEPTION
|
||||
}
|
||||
|
||||
enum class SafetyStopReason {
|
||||
LOGIN_REQUIRED,
|
||||
VERIFICATION_REQUIRED,
|
||||
RISK_CONTROL,
|
||||
UNKNOWN_PAGE,
|
||||
PAYMENT_BOUNDARY
|
||||
}
|
||||
|
||||
sealed interface AutomationResult {
|
||||
data object Success : AutomationResult
|
||||
|
||||
data class RetryableFailure(
|
||||
val code: WorkflowFailureCode
|
||||
) : AutomationResult
|
||||
|
||||
data class FatalFailure(
|
||||
val code: WorkflowFailureCode
|
||||
) : AutomationResult
|
||||
|
||||
data class Blocked(
|
||||
val reason: SafetyStopReason
|
||||
) : AutomationResult
|
||||
|
||||
data object Stopped : AutomationResult
|
||||
}
|
||||
|
||||
fun interface AutomationGateway {
|
||||
suspend fun execute(step: WorkflowStep): AutomationResult
|
||||
}
|
||||
|
||||
data class WorkflowTransition(
|
||||
val from: WorkflowState,
|
||||
val to: WorkflowState,
|
||||
val stepId: String?,
|
||||
val attempt: Int?
|
||||
)
|
||||
|
||||
data class WorkflowReport(
|
||||
val state: WorkflowState,
|
||||
val completedStepIds: List<String>,
|
||||
val attemptsByStep: Map<String, Int>,
|
||||
val transitions: List<WorkflowTransition>,
|
||||
val failureCode: WorkflowFailureCode? = null,
|
||||
val safetyStopReason: SafetyStopReason? = null
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.roubao.autopilot.workflow
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
class WorkflowRunner(
|
||||
private val automation: AutomationGateway
|
||||
) {
|
||||
private val running = AtomicBoolean(false)
|
||||
private val mutableState = MutableStateFlow(WorkflowState.IDLE)
|
||||
|
||||
@Volatile
|
||||
private var stopRequested = false
|
||||
|
||||
@Volatile
|
||||
private var currentExecution: Job? = null
|
||||
|
||||
val state: StateFlow<WorkflowState> = mutableState.asStateFlow()
|
||||
|
||||
suspend fun run(steps: List<WorkflowStep>): WorkflowReport {
|
||||
require(steps.isNotEmpty()) { "Workflow must contain at least one step" }
|
||||
check(running.compareAndSet(false, true)) { "WorkflowRunner is already running" }
|
||||
|
||||
stopRequested = false
|
||||
val completedSteps = mutableListOf<String>()
|
||||
val attempts = linkedMapOf<String, Int>()
|
||||
val transitions = mutableListOf<WorkflowTransition>()
|
||||
|
||||
if (mutableState.value != WorkflowState.IDLE) {
|
||||
transition(WorkflowState.IDLE, null, null, transitions)
|
||||
}
|
||||
|
||||
try {
|
||||
for (step in steps) {
|
||||
var attempt = 0
|
||||
while (attempt <= step.maxRetries) {
|
||||
if (stopRequested) {
|
||||
return terminalReport(
|
||||
state = WorkflowState.STOPPED,
|
||||
completedSteps = completedSteps,
|
||||
attempts = attempts,
|
||||
transitions = transitions,
|
||||
step = step,
|
||||
attempt = attempt.takeIf { it > 0 }
|
||||
)
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
attempts[step.id] = attempt
|
||||
transition(WorkflowState.RUNNING, step.id, attempt, transitions)
|
||||
|
||||
when (val result = executeStep(step)) {
|
||||
AutomationResult.Success -> {
|
||||
completedSteps += step.id
|
||||
break
|
||||
}
|
||||
|
||||
is AutomationResult.Blocked -> {
|
||||
return terminalReport(
|
||||
state = WorkflowState.BLOCKED,
|
||||
completedSteps = completedSteps,
|
||||
attempts = attempts,
|
||||
transitions = transitions,
|
||||
step = step,
|
||||
attempt = attempt,
|
||||
safetyStopReason = result.reason
|
||||
)
|
||||
}
|
||||
|
||||
is AutomationResult.FatalFailure -> {
|
||||
return terminalReport(
|
||||
state = WorkflowState.FAILED,
|
||||
completedSteps = completedSteps,
|
||||
attempts = attempts,
|
||||
transitions = transitions,
|
||||
step = step,
|
||||
attempt = attempt,
|
||||
failureCode = result.code
|
||||
)
|
||||
}
|
||||
|
||||
is AutomationResult.RetryableFailure -> {
|
||||
if (attempt > step.maxRetries) {
|
||||
return terminalReport(
|
||||
state = WorkflowState.FAILED,
|
||||
completedSteps = completedSteps,
|
||||
attempts = attempts,
|
||||
transitions = transitions,
|
||||
step = step,
|
||||
attempt = attempt,
|
||||
failureCode = result.code
|
||||
)
|
||||
}
|
||||
transition(WorkflowState.RETRYING, step.id, attempt, transitions)
|
||||
}
|
||||
|
||||
AutomationResult.Stopped -> {
|
||||
return terminalReport(
|
||||
state = WorkflowState.STOPPED,
|
||||
completedSteps = completedSteps,
|
||||
attempts = attempts,
|
||||
transitions = transitions,
|
||||
step = step,
|
||||
attempt = attempt
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return terminalReport(
|
||||
state = WorkflowState.SUCCEEDED,
|
||||
completedSteps = completedSteps,
|
||||
attempts = attempts,
|
||||
transitions = transitions,
|
||||
step = null,
|
||||
attempt = null
|
||||
)
|
||||
} finally {
|
||||
currentExecution = null
|
||||
running.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestStop() {
|
||||
stopRequested = true
|
||||
currentExecution?.cancel(CancellationException("Workflow stop requested"))
|
||||
}
|
||||
|
||||
private suspend fun executeStep(step: WorkflowStep): AutomationResult = supervisorScope {
|
||||
val execution: Deferred<AutomationResult> = async {
|
||||
try {
|
||||
automation.execute(step)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (_: Exception) {
|
||||
AutomationResult.FatalFailure(WorkflowFailureCode.AUTOMATION_EXCEPTION)
|
||||
}
|
||||
}
|
||||
currentExecution = execution
|
||||
|
||||
try {
|
||||
withTimeout(step.timeoutMillis) {
|
||||
execution.await()
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
AutomationResult.RetryableFailure(WorkflowFailureCode.TIMEOUT)
|
||||
} catch (error: CancellationException) {
|
||||
if (stopRequested) {
|
||||
AutomationResult.Stopped
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
execution.cancel()
|
||||
if (currentExecution === execution) {
|
||||
currentExecution = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun terminalReport(
|
||||
state: WorkflowState,
|
||||
completedSteps: List<String>,
|
||||
attempts: Map<String, Int>,
|
||||
transitions: MutableList<WorkflowTransition>,
|
||||
step: WorkflowStep?,
|
||||
attempt: Int?,
|
||||
failureCode: WorkflowFailureCode? = null,
|
||||
safetyStopReason: SafetyStopReason? = null
|
||||
): WorkflowReport {
|
||||
transition(state, step?.id, attempt, transitions)
|
||||
return WorkflowReport(
|
||||
state = state,
|
||||
completedStepIds = completedSteps.toList(),
|
||||
attemptsByStep = attempts.toMap(),
|
||||
transitions = transitions.toList(),
|
||||
failureCode = failureCode,
|
||||
safetyStopReason = safetyStopReason
|
||||
)
|
||||
}
|
||||
|
||||
private fun transition(
|
||||
next: WorkflowState,
|
||||
stepId: String?,
|
||||
attempt: Int?,
|
||||
transitions: MutableList<WorkflowTransition>
|
||||
) {
|
||||
val previous = mutableState.value
|
||||
mutableState.value = next
|
||||
transitions += WorkflowTransition(
|
||||
from = previous,
|
||||
to = next,
|
||||
stepId = stepId,
|
||||
attempt = attempt
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 接口已定,供应商待定 | 模型输出必须符合本项目 JSON Schema。 |
|
||||
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
|
||||
| 后端测试 | 标准库 `testing` + `httptest` | MVP 已定 | 覆盖状态机、权限、幂等、SQLite 事务和输入校验。 |
|
||||
| Android 测试 | Gradle `test` + 真实设备 smoke | 基线已验证 | 上游没有 `test/androidTest` 源;T-003 建立 workflow 测试。 |
|
||||
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | workflow 基线已实现 | T-003 已覆盖成功、timeout、有限 retry、安全阻塞、用户停止和并发拒绝;真实 UI 动作仍需真机 smoke。 |
|
||||
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
|
||||
|
||||
## Roubao 上游版本基线
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-25
|
||||
- 阶段:T-002 测试设备基线和就绪检查完成,准备执行 T-003
|
||||
- Git:当前分支为 `main`;T-001 和 T-002 均已纳入 Git 历史
|
||||
- 阶段:T-003 Android workflow 测试骨架完成,准备执行 T-004
|
||||
- Git:当前分支为 `main`;T-001 至 T-003 均已纳入 Git 历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||
- 后端:已决定使用 Go 1.23.0 + Gin 1.11.0;Go Blueprint v0.10.11 骨架尚未接入
|
||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:`lintDebug test assembleDebug` 成功;T-002 新增 5 个纯 Kotlin 测试,
|
||||
Debug/Release 两个变体共执行 10 次且全部通过
|
||||
- 测试:`lintDebug test assembleDebug` 成功;T-002/T-003 共 11 个纯 Kotlin
|
||||
测试,Debug/Release 两个变体共执行 22 次且全部通过
|
||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||
用户停止和单 runner 并发拒绝;尚未连接真实拼多多动作
|
||||
- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.2 (7)`;拼多多
|
||||
`8.17.0 (81700)`
|
||||
- 设备就绪:肉包采购无障碍已启用并连接;可观察前台包名;拼多多首页未发现登录、
|
||||
@@ -36,6 +38,7 @@
|
||||
| `docs/` | 已有 | Harness Coding 文档 |
|
||||
| `docs/tasks/T-001.md` | DONE | Android 可构建、可安装、可启动基线 |
|
||||
| `docs/tasks/T-002.md` | DONE | 设备版本、无障碍、前台和登录阻塞就绪检查 |
|
||||
| `docs/tasks/T-003.md` | DONE | 可注入 Fake automation 的受限 workflow runner |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||
| `backend-api/` | 待建 | Go-Gin、管理 Web 和数据目标目录 |
|
||||
@@ -43,9 +46,9 @@
|
||||
|
||||
## 任务摘要
|
||||
|
||||
- 已完成:T-001 Android 基线;T-002 测试设备基线和就绪检查。
|
||||
- 已完成:T-001 Android 基线;T-002 设备就绪检查;T-003 workflow 测试骨架。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:T-003 建立 Android workflow 测试骨架。
|
||||
- 下一个可领取任务:T-004 导入本机蝦皮订单验证样本。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: T-003
|
||||
title: 建立 Android workflow 测试骨架
|
||||
phase: 0
|
||||
deps:
|
||||
- T-001
|
||||
status: DONE
|
||||
created: 2026-07-25
|
||||
context_ref: d013ac8c84b69ab1bcf5b2cfd5385c93049d5439
|
||||
work_branch: main
|
||||
write_paths:
|
||||
- android-buyer/app/build.gradle.kts
|
||||
- android-buyer/app/src/main/java/com/roubao/autopilot/workflow/**
|
||||
- android-buyer/app/src/test/java/com/roubao/autopilot/workflow/**
|
||||
- docs/03-tech-stack.md
|
||||
- docs/current-state.md
|
||||
- docs/tasks/T-003.md
|
||||
- progress.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
现有 Roubao `MobileAgent` 直接组合 VLM、设备控制和 UI 状态,无法在本地 JVM 中稳定
|
||||
验证采购步骤的超时、重试和安全停止。T-101 开始操作拼多多前,需要先建立一个小型、
|
||||
可注入 Fake automation 的工作流内核,防止自动化逻辑只能靠真机人工回归。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:为 F-004 至 F-007 提供可测试执行骨架。
|
||||
- 用户故事:US-004 执行受限采购探针、US-006 安全失败和可恢复。
|
||||
- 交互:本任务无新界面。
|
||||
- 架构:Android workflow 层和 automation adapter 接口。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 定义步骤、自动化结果、状态、失败原因、安全停止原因和运行报告。
|
||||
2. `WorkflowRunner` 每次只运行一个工作流;每步使用协程 timeout,重试次数有硬上限。
|
||||
3. 用户停止只取消当前 automation 子任务并返回 `STOPPED` 报告,不取消调用方协程。
|
||||
4. 登录、验证码、风控、未知页面和支付边界作为不可重试的 `BLOCKED` 终态。
|
||||
5. 使用 `kotlinx-coroutines-test` 和 Fake automation 覆盖成功、重试、超时、阻塞、
|
||||
用户停止和并发运行拒绝。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [x] Fake automation 下可以验证多步骤成功状态迁移。
|
||||
- [x] 单步 timeout 可确定性触发,且不会形成无界等待。
|
||||
- [x] retry 次数有明确上限,耗尽后进入结构化失败。
|
||||
- [x] 登录、验证码、风控、未知页面和支付边界不自动重试。
|
||||
- [x] 用户停止会取消当前步骤并返回 `STOPPED`,调用方测试协程仍可完成。
|
||||
- [x] 同一 Runner 拒绝并发运行。
|
||||
- [x] `lintDebug test assembleDebug` 通过。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不实现拼多多搜索、点击或页面解析。
|
||||
- 不接入 MainActivity 或替换现有 MobileAgent。
|
||||
- 不做任务持久化、进程恢复、后台 API 或前台服务。
|
||||
- 不允许无限 retry;`maxRetries` 必须为非负且由步骤显式给出。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-07-25:任务开始
|
||||
|
||||
- 基于 T-002 提交 `d013ac8` 开始。
|
||||
- T-002 已提供设备就绪和安全阻塞事实;T-003 只定义如何消费 automation 结果。
|
||||
|
||||
### 2026-07-25:实现和验证完成
|
||||
|
||||
- 新增纯 Kotlin `WorkflowStep`、`AutomationGateway`、状态/结果/报告模型和
|
||||
`WorkflowRunner`,没有 Android UI、设备或 VLM 依赖。
|
||||
- 单步强制正 timeout;`maxRetries` 只能是 0 至 3。timeout 作为结构化可重试失败,
|
||||
重试预算耗尽后进入 `FAILED`。
|
||||
- 登录、验证码、风控、未知页面和支付边界统一返回 `BLOCKED`,即使步骤配置了重试
|
||||
也只调用 automation 一次。
|
||||
- `requestStop()` 只取消当前 automation 子任务;runner 捕获该取消并返回 `STOPPED`,
|
||||
不取消调用方协程。原子运行标记拒绝同一 runner 并发执行。
|
||||
- 增加 `kotlinx-coroutines-test 1.7.3`,用虚拟时间验证两次 100ms timeout 在
|
||||
200ms 后失败,不产生真实等待。
|
||||
- 新增 6 个 workflow 测试;连同 T-002 的 5 个测试,在 Debug/Release 两个变体
|
||||
共执行 22 次,0 failure、0 error、0 skipped。
|
||||
- `.\gradlew.bat lintDebug test assembleDebug --no-daemon`:成功。
|
||||
@@ -53,3 +53,11 @@
|
||||
登录/验证码/风控分类,并新增设备检查首屏。
|
||||
- 影响:OnePlus PKG110 + 拼多多 8.17.0 已形成可复现基线;T-101 可以在明确安全
|
||||
门禁下实现关键词搜索,不再依赖 Shizuku 状态猜测设备是否就绪。
|
||||
|
||||
## 2026-07-25 Android workflow 测试骨架
|
||||
|
||||
- 类型:阶段完成
|
||||
- 内容:完成 T-003;建立可注入 Fake automation 的纯 Kotlin runner,覆盖 timeout、
|
||||
有限 retry、安全阻塞、用户停止和并发拒绝。
|
||||
- 影响:T-101 至 T-104 可以把真实拼多多动作接到稳定状态机上,自动化异常不再依赖
|
||||
真机人工判断。
|
||||
|
||||
Reference in New Issue
Block a user