From f40331189b44ad5bee1a1d42db5eb749c5d20879 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 5 Sep 2026 12:40:19 +0800 Subject: [PATCH] fix(android): wait for stable PDD product page (#222) --- android/app/build.gradle.kts | 4 +- .../automation/PurchaseRehearsalExecutor.kt | 42 +++++++++-- .../agent/PurchaseRehearsalExecutorTest.kt | 71 +++++++++++++++++-- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8187763..49f2338 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 = 56 - versionName = "0.9.43" + versionCode = 57 + versionName = "0.9.44" 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 778e382..3d8f485 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 @@ -213,10 +213,20 @@ class PurchaseRehearsalExecutor( var clickAttempted = false var nextClickPoll = 0 var lastClickReason = FreshClickReason.UNKNOWN + var stableEvidenceReads = 0 + var pddForegroundObserved = false repeat(OPEN_PRODUCT_POLL_LIMIT) { poll -> val snapshot = driver.capture() pageProblem(snapshot)?.let { return it } - if (snapshot.packageName == PDD_PACKAGE) return null + if (snapshot.packageName == PDD_PACKAGE) { + pddForegroundObserved = true + val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null) + stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0 + if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null + pause(OPEN_PRODUCT_POLL_MILLIS) + return@repeat + } + stableEvidenceReads = 0 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 && poll >= nextClickPoll) { @@ -234,6 +244,9 @@ class PurchaseRehearsalExecutor( } pause(OPEN_PRODUCT_POLL_MILLIS) } + if (pddForegroundObserved) { + return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面") + } if (clickAttempted) { val message = when (lastClickReason) { FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化" @@ -247,14 +260,20 @@ class PurchaseRehearsalExecutor( } private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? { - repeat(50) { + var stableEvidenceReads = 0 + repeat(PRODUCT_PAGE_POLL_LIMIT) { val snapshot = driver.capture() pageProblem(snapshot)?.let { return it } val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null) if (screen.hasPurchaseProductEvidence()) { - return recoverSoldOut(input, screen) + stableEvidenceReads++ + if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) { + return recoverSoldOut(input, screen) + } + } else { + stableEvidenceReads = 0 } - pause(100) + pause(OPEN_PRODUCT_POLL_MILLIS) } return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面") } @@ -352,7 +371,10 @@ class PurchaseRehearsalExecutor( "规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]", ) FreshActionResult.SUCCESS -> Unit - else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason()) + else -> return failure( + SPEC_ENTRY_CLICK_FAILED, + click.reason.specEntrySubreasonAfterGestureFailure(), + ) } wait = waitForSpecPanel(input, specActionSignature(wait.screen)) wait.failure?.let { return it } @@ -459,6 +481,12 @@ class PurchaseRehearsalExecutor( else -> "unknown" } + private fun FreshClickReason.specEntrySubreasonAfterGestureFailure(): String = when (this) { + FreshClickReason.SUCCESS -> "gesture_failed_after_action_click_no_effect" + FreshClickReason.UNKNOWN -> "gesture_failed_after_unclassified_action_result" + else -> specEntrySubreason() + } + private fun selectSpecs( input: PurchaseExecutionInput, rule: PurchaseRule, @@ -814,7 +842,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_POLL_LIMIT = 150 + private const val PRODUCT_PAGE_POLL_LIMIT = 150 + private const val PRODUCT_PAGE_STABLE_READS = 2 private const val OPEN_PRODUCT_RETRY_POLLS = 10 private const val OPEN_PRODUCT_POLL_MILLIS = 100L private const val SPEC_ENTRY_READY_WAIT_POLLS = 20 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 71dbd49..435c35c 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 @@ -440,15 +440,50 @@ class PurchaseRehearsalExecutorTest { @Test fun `persistent open click failure is bounded and remains safely failed`() { val driver = FakePurchaseDriver( - openClickResults = MutableList(10) { FreshActionResult.FAILED }, + openClickResults = MutableList(20) { FreshActionResult.FAILED }, ) val pauses = mutableListOf() val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("RULE_ACTION_FAILED", outcome.errorCode) - assertTrue(driver.openClickCount in 1..5) - assertEquals(50, pauses.size) + assertTrue(driver.openClickCount in 1..15) + assertEquals(150, pauses.size) + } + + @Test + fun `five second browser interstitial can still reach a stable product page`() { + val pauses = mutableListOf() + val driver = FakePurchaseDriver(browserOpenVisibleAfterCaptures = 50) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(1, driver.openClickCount) + assertTrue(pauses.count { it == 100L } in 52..60) + } + + @Test + fun `pdd foreground without stable product evidence times out at fifteen seconds`() { + val pauses = mutableListOf() + val driver = FakePurchaseDriver(loadingPddCaptures = 200) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode) + assertEquals("打开拼多多后未识别到稳定商品页面", outcome.message) + assertEquals(150, pauses.count { it == 100L }) + } + + @Test + fun `explicit pdd login page fails before the product timeout`() { + val pauses = mutableListOf() + val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续")) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode) + assertTrue(pauses.count { it == 100L } < 5) } @Test @@ -505,6 +540,20 @@ class PurchaseRehearsalExecutorTest { assertFalse(driver.panel) } + @Test + fun `failed spec entry gesture preserves the action click no effect reason`() { + val driver = FakePurchaseDriver( + entryActionHasEffect = false, + specTapResult = FreshActionResult.FAILED, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode) + assertEquals("gesture_failed_after_action_click_no_effect", outcome.message) + assertFalse(outcome.message.contains("unknown")) + } + @Test fun `missing spec entry emits scalar source counts without node text`() { val diagnostics = mutableListOf() @@ -523,7 +572,7 @@ class PurchaseRehearsalExecutorTest { "specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000", diagnostics.single(), ) - assertEquals(20, pauses.count { it == 100L }) + assertEquals(21, pauses.count { it == 100L }) assertFalse(diagnostics.single().contains("选择规格")) } @@ -549,7 +598,7 @@ class PurchaseRehearsalExecutorTest { .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("rehearsal_completed", outcome.resultType) - assertEquals(1, pauses.count { it == 100L }) + assertEquals(2, pauses.count { it == 100L }) } @Test @@ -880,6 +929,8 @@ class PurchaseRehearsalExecutorTest { private val missingSpecEntry: Boolean = false, private val specEntryVisibleAfterPddCaptures: Int = 0, private val loadingPddCaptures: Int = 0, + private val browserOpenVisibleAfterCaptures: Int = 0, + private val pddProblemLabels: List = emptyList(), private val includeReviewEntry: Boolean = false, private val openReviewOnBottomClick: Boolean = false, private val reviewBackSucceeds: Boolean = true, @@ -929,11 +980,16 @@ class PurchaseRehearsalExecutorTest { private var productEvidenceLost = false private var reviewPage = false private var pddCaptureCount = 0 + private var browserCaptureCount = 0 val clicked = mutableListOf() val clickedPaths = mutableListOf() override fun capture(): UiSnapshot { if (browser && !panel && color == null && size == null) { + browserCaptureCount++ + if (browserCaptureCount <= browserOpenVisibleAfterCaptures) { + return UiSnapshot("com.heytap.browser", "BrowserActivity", listOf(node("content", "", 0, 0, 1080, 2200))) + } val openNodes = mutableListOf(node("open", "打开", 0, 100, 300, 180, clickable = true)) if (duplicateOpen) openNodes += node("open2", "打开", 400, 100, 700, 180, clickable = true) openNodes += node("content", "", 0, 0, 1080, 2200) @@ -943,6 +999,11 @@ class PurchaseRehearsalExecutorTest { if (pddCaptureCount <= loadingPddCaptures) { return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200))) } + if (pddProblemLabels.isNotEmpty()) { + return UiSnapshot(PDD, ACTIVITY, pddProblemLabels.mapIndexed { index, label -> + node("problem-$index", label, 20, 100 + index * 100, 900, 180 + index * 100) + }) + } if (!panel) { if (reviewPage) { return UiSnapshot(PDD, ACTIVITY, listOf(