fix(#150): verify PDD entry after browser click

This commit is contained in:
QiuSW
2026-08-29 14:22:25 +08:00
parent afa29f1d86
commit d847b00c5b
3 changed files with 84 additions and 10 deletions
+2 -2
View File
@@ -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"
@@ -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",
@@ -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<Long>()
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<Long>()
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<FreshActionResult> = 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<String>()
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