fix(android): wait for stable PDD product page (#222)

This commit is contained in:
QiuSW
2026-09-05 12:40:19 +08:00
parent 2895f3d72d
commit f40331189b
3 changed files with 104 additions and 13 deletions
+2 -2
View File
@@ -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"
@@ -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
@@ -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<Long>()
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<Long>()
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<Long>()
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<Long>()
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<String>()
@@ -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<String> = 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<String>()
val clickedPaths = mutableListOf<String>()
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(