diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1e69498..2518b52 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 33 - versionName = "0.9.20" + versionCode = 34 + versionName = "0.9.21" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index 2f03d9c..582828d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -6,6 +6,10 @@ import java.net.URLDecoder interface PurchaseUiDriver { fun capture(): UiSnapshot fun clickFresh(target: SnapshotNode): FreshActionResult + fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome = FreshClickOutcome( + result = clickFresh(target), + reason = FreshClickReason.UNKNOWN, + ) fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult fun inputFresh(target: SnapshotNode, value: String): FreshActionResult fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean @@ -156,20 +160,38 @@ class PurchaseRehearsalExecutor( private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开") val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开") - repeat(50) { + var clickAttempted = false + var nextClickPoll = 0 + var lastClickReason = FreshClickReason.UNKNOWN + repeat(OPEN_PRODUCT_POLL_LIMIT) { poll -> val snapshot = driver.capture() pageProblem(snapshot)?.let { return it } if (snapshot.packageName == PDD_PACKAGE) return null val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases } if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一") - if (candidates.size == 1) { - return when (driver.clickFresh(candidates.single())) { - FreshActionResult.SUCCESS -> null - FreshActionResult.AMBIGUOUS -> failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一") - else -> failure("RULE_ACTION_FAILED", "打开拼多多失败") + if (candidates.size == 1 && poll >= nextClickPoll) { + clickAttempted = true + val outcome = driver.clickFreshDetailed(candidates.single()) + lastClickReason = outcome.reason + if (outcome.result == FreshActionResult.AMBIGUOUS) { + return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一") } + // The browser can rerender while handing the URL to PDD, and + // ACTION_CLICK may report false after the app transition has + // already started. The observed PDD foreground is the success + // condition; retry only this unique target after a short bound. + nextClickPoll = poll + OPEN_PRODUCT_RETRY_POLLS } - pause(100) + pause(OPEN_PRODUCT_POLL_MILLIS) + } + if (clickAttempted) { + val message = when (lastClickReason) { + FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化" + FreshClickReason.NO_CLICKABLE_ANCESTOR -> "打开拼多多入口暂时无法点击" + FreshClickReason.ACTION_CLICK_FALSE -> "打开拼多多后未进入商品页面" + else -> "打开拼多多失败" + } + return failure("RULE_ACTION_FAILED", message) } return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面") } @@ -379,6 +401,9 @@ class PurchaseRehearsalExecutor( companion object { private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + private const val OPEN_PRODUCT_POLL_LIMIT = 50 + private const val OPEN_PRODUCT_RETRY_POLLS = 10 + private const val OPEN_PRODUCT_POLL_MILLIS = 100L val DEFAULT_COLLECTOR = PddCollectorConfig( collectorId = "pddProductDetailV1", specEntryStrategy = "safeBottomSpecEntryV1", diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 2917451..411aaf6 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -96,6 +96,47 @@ class PurchaseRehearsalExecutorTest { assertTrue(driver.clicked.isEmpty()) } + @Test + fun `failed click result still succeeds when pdd actually opens`() { + val driver = FakePurchaseDriver( + openClickResults = mutableListOf(FreshActionResult.FAILED), + openPddOnFailedClick = true, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(1, driver.openClickCount) + } + + @Test + fun `browser rerender retries the same unique open target within the bound`() { + val driver = FakePurchaseDriver( + openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS), + ) + val pauses = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(2, driver.openClickCount) + assertTrue(pauses.size <= 50) + } + + @Test + fun `persistent open click failure is bounded and remains safely failed`() { + val driver = FakePurchaseDriver( + openClickResults = MutableList(10) { FreshActionResult.FAILED }, + ) + val pauses = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertTrue(driver.openClickCount in 1..5) + assertEquals(50, pauses.size) + } + @Test fun `goods id mismatch fails before opening pdd`() { var opened = false @@ -223,6 +264,8 @@ class PurchaseRehearsalExecutorTest { private val bottomPurchaseEntry: Boolean = false, private val includeReviewEntry: Boolean = false, private val hiddenSizeUntilUpSwipes: Int = 0, + private val openClickResults: MutableList = mutableListOf(), + private val openPddOnFailedClick: Boolean = false, ) : PurchaseUiDriver { var browser = false var panel = false @@ -231,6 +274,7 @@ class PurchaseRehearsalExecutorTest { var quantity = 1L var swipeCount = 0 var upSwipeCount = 0 + var openClickCount = 0 val clicked = mutableListOf() override fun capture(): UiSnapshot { @@ -278,7 +322,12 @@ class PurchaseRehearsalExecutorTest { override fun clickFresh(target: SnapshotNode): FreshActionResult { clicked += target.label when (target.label) { - "打开" -> browser = false + "打开" -> { + openClickCount++ + val result = openClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS + if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false + return result + } "选择规格", "免拼购买" -> panel = true "XL" -> size = "XL" in colors -> color = target.label