fix(agent): stabilize PDD share action (#106)
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 15
|
||||
versionName = "0.9.2"
|
||||
versionCode = 16
|
||||
versionName = "0.9.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import cn.ilapage.goauto.agent.automation.ClipboardReadOutcome
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
@@ -21,6 +22,11 @@ class ClipboardRelayActivity : Activity() {
|
||||
private val pollRunnable = Runnable { readOrRetry() }
|
||||
private var completed = false
|
||||
private lateinit var pollSession: ClipboardPollSession
|
||||
private var pollCount = 0
|
||||
private var lastMimeAccepted = false
|
||||
private var lastItemCount = 0
|
||||
private var lastContentLength: Int? = null
|
||||
private var lastTimestampFresh = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -38,7 +44,7 @@ class ClipboardRelayActivity : Activity() {
|
||||
handler.removeCallbacks(pollRunnable)
|
||||
if (!completed) {
|
||||
completed = true
|
||||
deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), null)
|
||||
deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), outcome(null))
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -56,6 +62,16 @@ class ClipboardRelayActivity : Activity() {
|
||||
val hasPlainText = description?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true
|
||||
val clip = if (hasPlainText) clipboard.primaryClip else null
|
||||
val timestampSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
pollCount++
|
||||
lastMimeAccepted = hasPlainText
|
||||
lastItemCount = clip?.itemCount ?: 0
|
||||
lastContentLength = clip?.takeIf { it.itemCount == 1 }?.getItemAt(0)?.text?.length
|
||||
lastTimestampFresh = ClipboardContentPolicy.isTimestampFresh(
|
||||
descriptionPresent = description != null,
|
||||
timestampMillis = if (timestampSupported) description?.timestamp ?: 0L else 0L,
|
||||
timestampSupported = timestampSupported,
|
||||
minTimestampMillis = minTimestamp,
|
||||
)
|
||||
val text = ClipboardContentPolicy.freshText(
|
||||
descriptionPresent = description != null,
|
||||
timestampMillis = if (timestampSupported) description?.timestamp ?: 0L else 0L,
|
||||
@@ -80,10 +96,19 @@ class ClipboardRelayActivity : Activity() {
|
||||
if (completed) return
|
||||
completed = true
|
||||
handler.removeCallbacks(pollRunnable)
|
||||
deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), value)
|
||||
deliver(intent.getStringExtra(EXTRA_REQUEST_ID).orEmpty(), outcome(value))
|
||||
finishRelay()
|
||||
}
|
||||
|
||||
private fun outcome(value: String?) = ClipboardReadOutcome(
|
||||
text = value,
|
||||
pollCount = pollCount,
|
||||
mimeAccepted = lastMimeAccepted,
|
||||
itemCount = lastItemCount,
|
||||
contentLength = lastContentLength,
|
||||
timestampFresh = lastTimestampFresh,
|
||||
)
|
||||
|
||||
private fun finishRelay() {
|
||||
finishAndRemoveTask()
|
||||
overridePendingTransition(0, 0)
|
||||
@@ -96,7 +121,7 @@ class ClipboardRelayActivity : Activity() {
|
||||
private const val POLL_INTERVAL_MILLIS = 100L
|
||||
private val pending = ConcurrentHashMap<String, PendingRead>()
|
||||
|
||||
fun readFresh(context: Context, minTimestamp: Long, timeoutMillis: Long): CharSequence? {
|
||||
fun readFresh(context: Context, minTimestamp: Long, timeoutMillis: Long): ClipboardReadOutcome {
|
||||
val boundedTimeout = timeoutMillis.coerceIn(500L, 10_000L)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val request = PendingRead()
|
||||
@@ -112,14 +137,14 @@ class ClipboardRelayActivity : Activity() {
|
||||
}.getOrDefault(false)
|
||||
if (!started) {
|
||||
pending.remove(requestId)
|
||||
return null
|
||||
return ClipboardReadOutcome(text = null)
|
||||
}
|
||||
request.latch.await(boundedTimeout, TimeUnit.MILLISECONDS)
|
||||
pending.remove(requestId)
|
||||
return request.value.get()
|
||||
return request.value.get() ?: ClipboardReadOutcome(text = null)
|
||||
}
|
||||
|
||||
private fun deliver(requestId: String, value: String?) {
|
||||
private fun deliver(requestId: String, value: ClipboardReadOutcome) {
|
||||
pending[requestId]?.let { request ->
|
||||
request.value.set(value)
|
||||
request.latch.countDown()
|
||||
@@ -129,7 +154,7 @@ class ClipboardRelayActivity : Activity() {
|
||||
|
||||
private class PendingRead {
|
||||
val latch = CountDownLatch(1)
|
||||
val value = AtomicReference<String?>(null)
|
||||
val value = AtomicReference<ClipboardReadOutcome?>(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +200,17 @@ internal object ClipboardContentPolicy {
|
||||
minTimestampMillis: Long,
|
||||
): String? {
|
||||
if (!descriptionPresent || !hasPlainText || itemCount != 1) return null
|
||||
if (timestampSupported && (timestampMillis <= 0L || timestampMillis < minTimestampMillis - TIMESTAMP_TOLERANCE_MILLIS)) return null
|
||||
if (!isTimestampFresh(descriptionPresent, timestampMillis, timestampSupported, minTimestampMillis)) return null
|
||||
return text?.takeIf { it.length <= MAX_CLIPBOARD_CHARS }
|
||||
}
|
||||
|
||||
fun isTimestampFresh(
|
||||
descriptionPresent: Boolean,
|
||||
timestampMillis: Long,
|
||||
timestampSupported: Boolean,
|
||||
minTimestampMillis: Long,
|
||||
): Boolean {
|
||||
if (!descriptionPresent) return false
|
||||
return !timestampSupported || (timestampMillis > 0L && timestampMillis >= minTimestampMillis - TIMESTAMP_TOLERANCE_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
+311
-17
@@ -1,5 +1,8 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
import java.net.URI
|
||||
|
||||
data class CurrentPageIdentityResult(
|
||||
@@ -9,6 +12,15 @@ data class CurrentPageIdentityResult(
|
||||
val shareUrl: String? = null,
|
||||
)
|
||||
|
||||
data class ClipboardReadOutcome(
|
||||
val text: CharSequence?,
|
||||
val pollCount: Int = 0,
|
||||
val mimeAccepted: Boolean = false,
|
||||
val itemCount: Int = 0,
|
||||
val contentLength: Int? = null,
|
||||
val timestampFresh: Boolean = false,
|
||||
)
|
||||
|
||||
enum class CurrentPageRestoreDecision {
|
||||
READY,
|
||||
BACK_FROM_AGENT,
|
||||
@@ -49,26 +61,40 @@ object PddShareLinkExtractor {
|
||||
|
||||
class CurrentPageIdentityRunner(
|
||||
private val driver: PddCollectorDriver,
|
||||
private val readFreshClipboard: (copiedAtMillis: Long, timeoutMillis: Long) -> CharSequence?,
|
||||
private val readFreshClipboard: (copiedAtMillis: Long, timeoutMillis: Long) -> ClipboardReadOutcome,
|
||||
private val taskId: Long = 0,
|
||||
private val diagnostic: (AgentDiagnosticEvent) -> Unit = {},
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
fun identify(rule: CollectionRule): CurrentPageIdentityResult {
|
||||
val startedAt = now()
|
||||
val evidence = rule.pageEvidence ?: return failure("RULE_INVALID", "规则缺少商品页证据")
|
||||
val config = rule.currentPageIdentity
|
||||
val detail = waitForPageEvidence(evidence, config.pageTimeoutMs)
|
||||
?: return failure("CURRENT_PDD_PAGE_NOT_FOUND", "没有找到商品详情页")
|
||||
detailProblem(detail)?.let { return failure(it.code, it.message) }
|
||||
val share = uniqueAction(detail, config.shareAliases, topOnly = true)
|
||||
?: return failure("PDD_SHARE_UNAVAILABLE", "没有找到商品分享入口")
|
||||
when (driver.clickFresh(share)) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> return failure("PDD_SHARE_UNAVAILABLE", "商品分享入口不唯一")
|
||||
else -> return failure("PDD_SHARE_UNAVAILABLE", "无法打开商品分享")
|
||||
val prepared = waitForStableShare(evidence, config.shareAliases, config.pageTimeoutMs, startedAt)
|
||||
prepared.problem?.let { return failure(it.code, it.message) }
|
||||
if (prepared.failureMessage != null) {
|
||||
return failure(prepared.failureCode ?: "PDD_SHARE_UNAVAILABLE", prepared.failureMessage)
|
||||
}
|
||||
val clicked = clickShareWithRetry(evidence, config.shareAliases, startedAt)
|
||||
if (!clicked.successful) {
|
||||
return failure("PDD_SHARE_UNAVAILABLE", clicked.message)
|
||||
}
|
||||
try {
|
||||
val panel = waitForAction(config.copyLinkAliases, config.sharePanelTimeoutMs)
|
||||
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "没有找到复制链接")
|
||||
if (panel == null) {
|
||||
diagnose(
|
||||
AgentDiagnosticStage.SHARE_PANEL,
|
||||
AgentDiagnosticReason.PANEL_TIMEOUT,
|
||||
elapsedMs = now() - startedAt,
|
||||
)
|
||||
return failure("PDD_COPY_LINK_UNAVAILABLE", "没有找到复制链接")
|
||||
}
|
||||
diagnose(
|
||||
AgentDiagnosticStage.SHARE_PANEL,
|
||||
AgentDiagnosticReason.PANEL_FOUND,
|
||||
elapsedMs = now() - startedAt,
|
||||
)
|
||||
val copy = uniqueAction(panel, config.copyLinkAliases, topOnly = false)
|
||||
?: return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
|
||||
val copiedAt = now()
|
||||
@@ -77,7 +103,14 @@ class CurrentPageIdentityRunner(
|
||||
FreshActionResult.AMBIGUOUS -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制链接入口不唯一")
|
||||
else -> return failure("PDD_COPY_LINK_UNAVAILABLE", "复制商品链接失败")
|
||||
}
|
||||
val raw = readFreshClipboard(copiedAt, config.clipboardTimeoutMs)
|
||||
val clipboard = readFreshClipboard(copiedAt, config.clipboardTimeoutMs)
|
||||
diagnose(
|
||||
stage = AgentDiagnosticStage.CLIPBOARD,
|
||||
reason = if (clipboard.text != null) AgentDiagnosticReason.CLIPBOARD_SUCCESS else AgentDiagnosticReason.CLIPBOARD_TIMEOUT,
|
||||
elapsedMs = now() - startedAt,
|
||||
clipboard = clipboard,
|
||||
)
|
||||
val raw = clipboard.text
|
||||
?: return failure("PDD_CLIPBOARD_UNAVAILABLE", "无法读取刚复制的商品链接")
|
||||
val shareUrl = PddShareLinkExtractor.extract(raw)
|
||||
?: return failure("PDD_SHARE_LINK_INVALID", "无法识别商品链接")
|
||||
@@ -87,15 +120,165 @@ class CurrentPageIdentityRunner(
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForPageEvidence(evidence: PageEvidence, timeoutMillis: Long): UiSnapshot? {
|
||||
private fun waitForStableShare(
|
||||
evidence: PageEvidence,
|
||||
aliases: List<String>,
|
||||
timeoutMillis: Long,
|
||||
startedAt: Long,
|
||||
): SharePreparation {
|
||||
val deadline = now() + timeoutMillis
|
||||
var previous: SnapshotNode? = null
|
||||
var stableSince = 0L
|
||||
var attempts = 0
|
||||
var lastCandidateCount = 0
|
||||
var lastSnapshot: UiSnapshot? = null
|
||||
do {
|
||||
val snapshot = driver.capture()
|
||||
detailProblem(snapshot)?.let { return snapshot }
|
||||
if (matchesEvidence(snapshot, evidence)) return snapshot
|
||||
lastSnapshot = snapshot
|
||||
attempts++
|
||||
detailProblem(snapshot)?.let { return SharePreparation(problem = it) }
|
||||
if (!matchesEvidence(snapshot, evidence)) {
|
||||
previous = null
|
||||
stableSince = 0L
|
||||
pause(POLL_MILLIS)
|
||||
continue
|
||||
}
|
||||
val candidates = actionCandidates(snapshot, aliases, topOnly = true)
|
||||
lastCandidateCount = candidates.size
|
||||
if (candidates.size > 1) {
|
||||
diagnoseTarget(
|
||||
AgentDiagnosticStage.PAGE_STABILITY,
|
||||
AgentDiagnosticReason.TARGET_AMBIGUOUS,
|
||||
snapshot,
|
||||
evidence,
|
||||
candidates.size,
|
||||
null,
|
||||
attempts,
|
||||
now() - startedAt,
|
||||
)
|
||||
return SharePreparation(failureMessage = "商品分享入口不唯一")
|
||||
}
|
||||
val target = candidates.singleOrNull()
|
||||
val currentNow = now()
|
||||
if (target != null && previous != null && targetsStable(previous, target)) {
|
||||
if (stableSince == 0L) stableSince = currentNow
|
||||
if (currentNow - stableSince >= SHARE_STABLE_MILLIS) {
|
||||
diagnoseTarget(
|
||||
AgentDiagnosticStage.PAGE_STABILITY,
|
||||
AgentDiagnosticReason.STABLE,
|
||||
snapshot,
|
||||
evidence,
|
||||
1,
|
||||
target,
|
||||
attempts,
|
||||
currentNow - startedAt,
|
||||
)
|
||||
return SharePreparation(target = target)
|
||||
}
|
||||
} else {
|
||||
stableSince = if (target != null) currentNow else 0L
|
||||
}
|
||||
previous = target
|
||||
pause(POLL_MILLIS)
|
||||
} while (now() <= deadline)
|
||||
return null
|
||||
val reason = if (lastCandidateCount == 0) AgentDiagnosticReason.TARGET_NOT_FOUND else AgentDiagnosticReason.PAGE_CHANGED
|
||||
diagnoseTarget(
|
||||
AgentDiagnosticStage.PAGE_STABILITY,
|
||||
reason,
|
||||
lastSnapshot,
|
||||
evidence,
|
||||
lastCandidateCount,
|
||||
previous,
|
||||
attempts,
|
||||
now() - startedAt,
|
||||
)
|
||||
return if (lastSnapshot == null || !matchesEvidence(lastSnapshot, evidence)) {
|
||||
SharePreparation(failureCode = "CURRENT_PDD_PAGE_NOT_FOUND", failureMessage = "没有找到商品详情页")
|
||||
} else {
|
||||
SharePreparation(failureMessage = "商品分享入口尚未稳定,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clickShareWithRetry(
|
||||
evidence: PageEvidence,
|
||||
aliases: List<String>,
|
||||
startedAt: Long,
|
||||
): ShareClickResult {
|
||||
val deadline = now() + SHARE_CLICK_RETRY_MILLIS
|
||||
var attempt = 0
|
||||
var lastReason = AgentDiagnosticReason.TARGET_NOT_FOUND
|
||||
do {
|
||||
attempt++
|
||||
val snapshot = driver.capture()
|
||||
detailProblem(snapshot)?.let { return ShareClickResult(false, it.message) }
|
||||
if (!matchesEvidence(snapshot, evidence)) {
|
||||
diagnoseTarget(
|
||||
AgentDiagnosticStage.SHARE_CLICK,
|
||||
AgentDiagnosticReason.PAGE_CHANGED,
|
||||
snapshot,
|
||||
evidence,
|
||||
0,
|
||||
null,
|
||||
attempt,
|
||||
now() - startedAt,
|
||||
)
|
||||
return ShareClickResult(false, "商品详情页发生变化,请重新打开商品")
|
||||
}
|
||||
val candidates = actionCandidates(snapshot, aliases, topOnly = true)
|
||||
if (candidates.size > 1) {
|
||||
diagnoseTarget(
|
||||
AgentDiagnosticStage.SHARE_CLICK,
|
||||
AgentDiagnosticReason.TARGET_AMBIGUOUS,
|
||||
snapshot,
|
||||
evidence,
|
||||
candidates.size,
|
||||
null,
|
||||
attempt,
|
||||
now() - startedAt,
|
||||
)
|
||||
return ShareClickResult(false, "商品分享入口不唯一")
|
||||
}
|
||||
val target = candidates.singleOrNull()
|
||||
if (target == null) {
|
||||
lastReason = AgentDiagnosticReason.TARGET_NOT_FOUND
|
||||
diagnoseTarget(
|
||||
AgentDiagnosticStage.SHARE_CLICK,
|
||||
lastReason,
|
||||
snapshot,
|
||||
evidence,
|
||||
0,
|
||||
null,
|
||||
attempt,
|
||||
now() - startedAt,
|
||||
)
|
||||
} else {
|
||||
val outcome = driver.clickFreshDetailed(target)
|
||||
lastReason = outcome.reason.toDiagnosticReason()
|
||||
diagnoseTarget(
|
||||
AgentDiagnosticStage.SHARE_CLICK,
|
||||
lastReason,
|
||||
snapshot,
|
||||
evidence,
|
||||
outcome.candidateCount,
|
||||
target,
|
||||
attempt,
|
||||
now() - startedAt,
|
||||
outcome.clickableAncestorDepth,
|
||||
)
|
||||
when (outcome.result) {
|
||||
FreshActionResult.SUCCESS -> return ShareClickResult(true, "")
|
||||
FreshActionResult.AMBIGUOUS -> return ShareClickResult(false, "商品分享入口不唯一")
|
||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> Unit
|
||||
}
|
||||
}
|
||||
pause(POLL_MILLIS)
|
||||
} while (now() <= deadline)
|
||||
val message = when (lastReason) {
|
||||
AgentDiagnosticReason.TARGET_NOT_FOUND, AgentDiagnosticReason.ROOT_UNAVAILABLE -> "商品分享入口发生变化,请重试"
|
||||
AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR -> "商品分享入口暂时无法点击,请重试"
|
||||
else -> "商品分享入口点击失败,请重试"
|
||||
}
|
||||
return ShareClickResult(false, message)
|
||||
}
|
||||
|
||||
private fun waitForAction(aliases: List<String>, timeoutMillis: Long): UiSnapshot? {
|
||||
@@ -119,6 +302,10 @@ class CurrentPageIdentityRunner(
|
||||
}
|
||||
|
||||
private fun uniqueAction(snapshot: UiSnapshot, aliases: List<String>, topOnly: Boolean): SnapshotNode? {
|
||||
return actionCandidates(snapshot, aliases, topOnly).singleOrNull()
|
||||
}
|
||||
|
||||
private fun actionCandidates(snapshot: UiSnapshot, aliases: List<String>, topOnly: Boolean): List<SnapshotNode> {
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom } ?: 0
|
||||
val candidates = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.label in aliases && (!topOnly || screenHeight <= 0 || node.bounds.centerY <= screenHeight * 45 / 100)
|
||||
@@ -132,7 +319,93 @@ class CurrentPageIdentityRunner(
|
||||
}
|
||||
) distinct += candidate
|
||||
}
|
||||
return distinct.singleOrNull()
|
||||
return distinct
|
||||
}
|
||||
|
||||
private fun targetsStable(previous: SnapshotNode, current: SnapshotNode): Boolean {
|
||||
if (previous.label != current.label || previous.className != current.className) return false
|
||||
val intersectionWidth = (minOf(previous.bounds.right, current.bounds.right) - maxOf(previous.bounds.left, current.bounds.left)).coerceAtLeast(0)
|
||||
val intersectionHeight = (minOf(previous.bounds.bottom, current.bounds.bottom) - maxOf(previous.bounds.top, current.bounds.top)).coerceAtLeast(0)
|
||||
val intersection = intersectionWidth.toLong() * intersectionHeight
|
||||
val union = previous.bounds.width.toLong() * previous.bounds.height +
|
||||
current.bounds.width.toLong() * current.bounds.height - intersection
|
||||
return union > 0 && intersection * 100 / union >= 80
|
||||
}
|
||||
|
||||
private fun diagnoseTarget(
|
||||
stage: AgentDiagnosticStage,
|
||||
reason: AgentDiagnosticReason,
|
||||
snapshot: UiSnapshot?,
|
||||
evidence: PageEvidence,
|
||||
candidateCount: Int,
|
||||
target: SnapshotNode?,
|
||||
attempt: Int,
|
||||
elapsedMs: Long,
|
||||
clickableAncestorDepth: Int? = null,
|
||||
) {
|
||||
diagnose(
|
||||
stage = stage,
|
||||
reason = reason,
|
||||
attempt = attempt,
|
||||
elapsedMs = elapsedMs,
|
||||
packageMatched = snapshot?.packageName == evidence.packageName,
|
||||
activityMatched = snapshot?.activityName == evidence.activityName,
|
||||
candidateCount = candidateCount,
|
||||
target = target,
|
||||
snapshot = snapshot,
|
||||
clickableAncestorDepth = clickableAncestorDepth,
|
||||
)
|
||||
}
|
||||
|
||||
private fun diagnose(
|
||||
stage: AgentDiagnosticStage,
|
||||
reason: AgentDiagnosticReason,
|
||||
attempt: Int = 0,
|
||||
elapsedMs: Long = 0,
|
||||
packageMatched: Boolean? = null,
|
||||
activityMatched: Boolean? = null,
|
||||
candidateCount: Int? = null,
|
||||
target: SnapshotNode? = null,
|
||||
snapshot: UiSnapshot? = null,
|
||||
clickableAncestorDepth: Int? = null,
|
||||
clipboard: ClipboardReadOutcome? = null,
|
||||
) {
|
||||
if (taskId <= 0) return
|
||||
runCatching {
|
||||
diagnostic(
|
||||
AgentDiagnosticEvent(
|
||||
taskId = taskId,
|
||||
stage = stage,
|
||||
reason = reason,
|
||||
attempt = attempt,
|
||||
elapsedMs = elapsedMs.coerceAtLeast(0),
|
||||
packageMatched = packageMatched,
|
||||
activityMatched = activityMatched,
|
||||
candidateCount = candidateCount,
|
||||
targetClassName = target?.className,
|
||||
targetClickable = target?.clickable,
|
||||
clickableAncestorDepth = clickableAncestorDepth,
|
||||
targetZone = target?.let { targetZone(it, snapshot) },
|
||||
clipboardPollCount = clipboard?.pollCount,
|
||||
clipboardMimeAccepted = clipboard?.mimeAccepted,
|
||||
clipboardItemCount = clipboard?.itemCount,
|
||||
clipboardContentLength = clipboard?.contentLength,
|
||||
clipboardTimestampFresh = clipboard?.timestampFresh,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun targetZone(target: SnapshotNode, snapshot: UiSnapshot?): String {
|
||||
val width = snapshot?.nodes?.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val height = snapshot?.nodes?.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
return when {
|
||||
target.bounds.centerY > height * 2 / 3 -> "bottom"
|
||||
target.bounds.centerY > height / 3 -> "middle"
|
||||
target.bounds.centerX < width / 3 -> "top-left"
|
||||
target.bounds.centerX > width * 2 / 3 -> "top-right"
|
||||
else -> "top-center"
|
||||
}
|
||||
}
|
||||
|
||||
private fun detailProblem(snapshot: UiSnapshot): PageProblem? =
|
||||
@@ -147,8 +420,29 @@ class CurrentPageIdentityRunner(
|
||||
|
||||
private fun failure(code: String, message: String) = CurrentPageIdentityResult(false, code, message)
|
||||
|
||||
private fun FreshClickReason.toDiagnosticReason(): AgentDiagnosticReason = when (this) {
|
||||
FreshClickReason.SUCCESS -> AgentDiagnosticReason.CLICK_SUCCESS
|
||||
FreshClickReason.ROOT_UNAVAILABLE -> AgentDiagnosticReason.ROOT_UNAVAILABLE
|
||||
FreshClickReason.TARGET_NOT_FOUND -> AgentDiagnosticReason.TARGET_NOT_FOUND
|
||||
FreshClickReason.TARGET_AMBIGUOUS -> AgentDiagnosticReason.TARGET_AMBIGUOUS
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR -> AgentDiagnosticReason.NO_CLICKABLE_ANCESTOR
|
||||
FreshClickReason.ACTION_CLICK_FALSE -> AgentDiagnosticReason.ACTION_CLICK_FALSE
|
||||
FreshClickReason.UNKNOWN -> AgentDiagnosticReason.UNKNOWN
|
||||
}
|
||||
|
||||
private data class SharePreparation(
|
||||
val target: SnapshotNode? = null,
|
||||
val problem: PageProblem? = null,
|
||||
val failureCode: String? = null,
|
||||
val failureMessage: String? = null,
|
||||
)
|
||||
|
||||
private data class ShareClickResult(val successful: Boolean, val message: String)
|
||||
|
||||
private companion object {
|
||||
const val POLL_MILLIS = 100L
|
||||
const val SHARE_STABLE_MILLIS = 300L
|
||||
const val SHARE_CLICK_RETRY_MILLIS = 1_000L
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-6
@@ -206,8 +206,10 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return UiSnapshot(root.packageName?.toString(), currentActivity(), nodes)
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = clickFreshDetailed(target).result
|
||||
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val label = node.preferredOrDescendantLabel()
|
||||
@@ -218,11 +220,23 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
|
||||
) candidates += node
|
||||
}
|
||||
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
if (candidates.isEmpty()) return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
if (candidates.size != 1) return FreshClickOutcome(FreshActionResult.AMBIGUOUS, FreshClickReason.TARGET_AMBIGUOUS, candidates.size)
|
||||
var node = candidates.single()
|
||||
while (!node.isClickable) node = node.parent ?: return FreshActionResult.FAILED
|
||||
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
|
||||
var ancestorDepth = 0
|
||||
while (!node.isClickable) {
|
||||
node = node.parent ?: return FreshClickOutcome(
|
||||
FreshActionResult.FAILED,
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR,
|
||||
candidates.size,
|
||||
)
|
||||
ancestorDepth++
|
||||
}
|
||||
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
|
||||
} else {
|
||||
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, candidates.size, ancestorDepth)
|
||||
}
|
||||
}
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
|
||||
|
||||
+21
@@ -37,9 +37,30 @@ data class UiSnapshot(
|
||||
|
||||
enum class FreshActionResult { SUCCESS, NOT_FOUND, AMBIGUOUS, FAILED }
|
||||
|
||||
enum class FreshClickReason {
|
||||
SUCCESS,
|
||||
ROOT_UNAVAILABLE,
|
||||
TARGET_NOT_FOUND,
|
||||
TARGET_AMBIGUOUS,
|
||||
NO_CLICKABLE_ANCESTOR,
|
||||
ACTION_CLICK_FALSE,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
data class FreshClickOutcome(
|
||||
val result: FreshActionResult,
|
||||
val reason: FreshClickReason,
|
||||
val candidateCount: Int = 0,
|
||||
val clickableAncestorDepth: Int? = null,
|
||||
)
|
||||
|
||||
interface PddCollectorDriver {
|
||||
fun capture(): UiSnapshot
|
||||
fun clickFresh(target: SnapshotNode): FreshActionResult
|
||||
fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome = FreshClickOutcome(
|
||||
result = clickFresh(target),
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
|
||||
fun pullDownGoodsPage(): Boolean
|
||||
fun back(): Boolean
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
|
||||
enum class AgentDiagnosticStage {
|
||||
PAGE_STABILITY,
|
||||
SHARE_CLICK,
|
||||
SHARE_PANEL,
|
||||
CLIPBOARD,
|
||||
}
|
||||
|
||||
enum class AgentDiagnosticReason {
|
||||
STABLE,
|
||||
PAGE_CHANGED,
|
||||
TARGET_NOT_FOUND,
|
||||
TARGET_AMBIGUOUS,
|
||||
ROOT_UNAVAILABLE,
|
||||
NO_CLICKABLE_ANCESTOR,
|
||||
ACTION_CLICK_FALSE,
|
||||
CLICK_SUCCESS,
|
||||
PANEL_FOUND,
|
||||
PANEL_TIMEOUT,
|
||||
CLIPBOARD_SUCCESS,
|
||||
CLIPBOARD_TIMEOUT,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
data class AgentDiagnosticEvent(
|
||||
val taskId: Long,
|
||||
val stage: AgentDiagnosticStage,
|
||||
val reason: AgentDiagnosticReason,
|
||||
val attempt: Int = 0,
|
||||
val elapsedMs: Long = 0,
|
||||
val packageMatched: Boolean? = null,
|
||||
val activityMatched: Boolean? = null,
|
||||
val candidateCount: Int? = null,
|
||||
val targetClassName: String? = null,
|
||||
val targetClickable: Boolean? = null,
|
||||
val clickableAncestorDepth: Int? = null,
|
||||
val targetZone: String? = null,
|
||||
val clipboardPollCount: Int? = null,
|
||||
val clipboardMimeAccepted: Boolean? = null,
|
||||
val clipboardItemCount: Int? = null,
|
||||
val clipboardContentLength: Int? = null,
|
||||
val clipboardTimestampFresh: Boolean? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
class SafeAgentDiagnosticRecorder(
|
||||
private val persist: (AgentDiagnosticEvent) -> Unit,
|
||||
private val onFailure: (Throwable) -> Unit = {},
|
||||
) {
|
||||
fun record(event: AgentDiagnosticEvent) {
|
||||
runCatching { persist(event) }.onFailure { runCatching { onFailure(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
internal object AgentDiagnosticRetentionPolicy {
|
||||
const val MAX_RECORDS = 50
|
||||
const val RETENTION_MILLIS = 7L * 24 * 60 * 60 * 1_000
|
||||
|
||||
fun cutoff(createdAt: Long): Long = createdAt - RETENTION_MILLIS
|
||||
}
|
||||
|
||||
class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""CREATE TABLE agent_diagnostic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
attempt INTEGER NOT NULL,
|
||||
elapsed_ms INTEGER NOT NULL,
|
||||
package_matched INTEGER,
|
||||
activity_matched INTEGER,
|
||||
candidate_count INTEGER,
|
||||
target_class_name TEXT,
|
||||
target_clickable INTEGER,
|
||||
clickable_ancestor_depth INTEGER,
|
||||
target_zone TEXT,
|
||||
clipboard_poll_count INTEGER,
|
||||
clipboard_mime_accepted INTEGER,
|
||||
clipboard_item_count INTEGER,
|
||||
clipboard_content_length INTEGER,
|
||||
clipboard_timestamp_fresh INTEGER,
|
||||
agent_version TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
|
||||
|
||||
@Synchronized
|
||||
fun record(event: AgentDiagnosticEvent) {
|
||||
require(event.taskId > 0)
|
||||
val db = writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
val values = ContentValues().apply {
|
||||
put("task_id", event.taskId)
|
||||
put("stage", event.stage.name)
|
||||
put("reason", event.reason.name)
|
||||
put("attempt", event.attempt.coerceAtLeast(0))
|
||||
put("elapsed_ms", event.elapsedMs.coerceAtLeast(0))
|
||||
putNullableBoolean("package_matched", event.packageMatched)
|
||||
putNullableBoolean("activity_matched", event.activityMatched)
|
||||
putNullableInt("candidate_count", event.candidateCount)
|
||||
event.targetClassName?.let { put("target_class_name", it.take(MAX_CLASS_NAME_CHARS)) }
|
||||
putNullableBoolean("target_clickable", event.targetClickable)
|
||||
putNullableInt("clickable_ancestor_depth", event.clickableAncestorDepth)
|
||||
event.targetZone?.takeIf(ALLOWED_ZONES::contains)?.let { put("target_zone", it) }
|
||||
putNullableInt("clipboard_poll_count", event.clipboardPollCount)
|
||||
putNullableBoolean("clipboard_mime_accepted", event.clipboardMimeAccepted)
|
||||
putNullableInt("clipboard_item_count", event.clipboardItemCount)
|
||||
putNullableInt("clipboard_content_length", event.clipboardContentLength)
|
||||
putNullableBoolean("clipboard_timestamp_fresh", event.clipboardTimestampFresh)
|
||||
put("agent_version", BuildConfig.VERSION_NAME)
|
||||
put("created_at", event.createdAt)
|
||||
}
|
||||
check(db.insert("agent_diagnostic", null, values) != -1L)
|
||||
db.delete("agent_diagnostic", "created_at < ?", arrayOf(AgentDiagnosticRetentionPolicy.cutoff(event.createdAt).toString()))
|
||||
db.execSQL(
|
||||
"DELETE FROM agent_diagnostic WHERE id NOT IN " +
|
||||
"(SELECT id FROM agent_diagnostic ORDER BY id DESC LIMIT ${AgentDiagnosticRetentionPolicy.MAX_RECORDS})",
|
||||
)
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) {
|
||||
value?.let { put(key, if (it) 1 else 0) }
|
||||
}
|
||||
|
||||
private fun ContentValues.putNullableInt(key: String, value: Int?) {
|
||||
value?.let { put(key, it.coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "goauto_diagnostics.db"
|
||||
private const val DATABASE_VERSION = 1
|
||||
private const val MAX_CLASS_NAME_CHARS = 160
|
||||
private val ALLOWED_ZONES = setOf("top-left", "top-center", "top-right", "middle", "bottom")
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ import cn.ilapage.goauto.agent.network.PurchaseAgentTask
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStore
|
||||
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.Executors
|
||||
@@ -63,6 +65,7 @@ import java.util.UUID
|
||||
class AgentForegroundService : Service() {
|
||||
private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val taskMutex = TaskExecutionMutex()
|
||||
private val runningTaskId = AtomicReference<Long?>(null)
|
||||
private val working = AtomicBoolean(false)
|
||||
@@ -79,6 +82,8 @@ class AgentForegroundService : Service() {
|
||||
private lateinit var settingsStore: AgentSettingsStore
|
||||
private lateinit var stateStore: AgentStateStore
|
||||
private lateinit var purchaseStore: PurchaseTaskStore
|
||||
private lateinit var diagnosticStore: AgentDiagnosticStore
|
||||
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
@@ -95,6 +100,16 @@ class AgentForegroundService : Service() {
|
||||
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
|
||||
stateStore.setKeepScreenOn(false)
|
||||
purchaseStore = PurchaseTaskStore(this)
|
||||
diagnosticStore = AgentDiagnosticStore(this)
|
||||
diagnosticRecorder = SafeAgentDiagnosticRecorder(
|
||||
persist = { event ->
|
||||
diagnosticExecutor.execute {
|
||||
runCatching { diagnosticStore.record(event) }
|
||||
.onFailure { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") }
|
||||
}
|
||||
},
|
||||
onFailure = { error -> Log.w("GoAutoDiagnostic", "agent diagnostic write failed: ${error.javaClass.simpleName}") },
|
||||
)
|
||||
val restoredPurchaseTaskId = purchaseStore.activeTaskId()
|
||||
runningTaskId.set(restoredPurchaseTaskId)
|
||||
stateStore.clearActiveTask()
|
||||
@@ -126,7 +141,11 @@ class AgentForegroundService : Service() {
|
||||
releaseTaskWakeLock()
|
||||
executor.shutdownNow()
|
||||
taskExecutor.shutdownNow()
|
||||
diagnosticExecutor.shutdown()
|
||||
runCatching { diagnosticExecutor.awaitTermination(1, TimeUnit.SECONDS) }
|
||||
diagnosticExecutor.shutdownNow()
|
||||
purchaseStore.close()
|
||||
diagnosticStore.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -592,6 +611,8 @@ class AgentForegroundService : Service() {
|
||||
readFreshClipboard = { copiedAt, timeout ->
|
||||
ClipboardRelayActivity.readFresh(this, copiedAt, timeout)
|
||||
},
|
||||
taskId = task.taskId,
|
||||
diagnostic = diagnosticRecorder::record,
|
||||
).identify(rule)
|
||||
if (!identityResult.successful) {
|
||||
throw TaskFailure(identityResult.code, identityResult.message)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticRetentionPolicy
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
|
||||
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AgentDiagnosticRecorderTest {
|
||||
@Test
|
||||
fun diagnosticsAreBoundedToFiftyRecordsAndSevenDays() {
|
||||
assertEquals(50, AgentDiagnosticRetentionPolicy.MAX_RECORDS)
|
||||
assertEquals(1_000L, AgentDiagnosticRetentionPolicy.cutoff(AgentDiagnosticRetentionPolicy.RETENTION_MILLIS + 1_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticWriteFailureNeverEscapesIntoCollectionFlow() {
|
||||
var failureCount = 0
|
||||
val recorder = SafeAgentDiagnosticRecorder(
|
||||
persist = { error("database unavailable") },
|
||||
onFailure = { failureCount++ },
|
||||
)
|
||||
|
||||
recorder.record(
|
||||
AgentDiagnosticEvent(
|
||||
taskId = 70,
|
||||
stage = AgentDiagnosticStage.SHARE_CLICK,
|
||||
reason = AgentDiagnosticReason.ACTION_CLICK_FALSE,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(1, failureCount)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
import cn.ilapage.goauto.agent.automation.ClipboardReadOutcome
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityConfig
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageIdentityRunner
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy
|
||||
import cn.ilapage.goauto.agent.automation.CurrentPageRestoreDecision
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
@@ -14,6 +17,8 @@ import cn.ilapage.goauto.agent.automation.PddShareLinkExtractor
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
|
||||
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -78,12 +83,13 @@ class CurrentPageCollectionTest {
|
||||
|
||||
@Test
|
||||
fun identifiesCurrentDetailThroughUniqueShareAndFreshClipboard() {
|
||||
val driver = FakeDriver(listOf(detailSnapshot(), sharePanelSnapshot(), sharePanelSnapshot()))
|
||||
var currentTime = 0L
|
||||
val driver = FakeDriver(List(6) { detailSnapshot() } + sharePanelSnapshot())
|
||||
val result = CurrentPageIdentityRunner(
|
||||
driver = driver,
|
||||
readFreshClipboard = { _, _ -> "复制成功 https://p.pinduoduo.com/abc123" },
|
||||
now = { 1_000L },
|
||||
pause = {},
|
||||
readFreshClipboard = { _, _ -> ClipboardReadOutcome("复制成功 https://p.pinduoduo.com/abc123") },
|
||||
now = { currentTime },
|
||||
pause = { currentTime += it },
|
||||
).identify(rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
@@ -107,7 +113,39 @@ class CurrentPageCollectionTest {
|
||||
assertEquals("PDD_SHARE_UNAVAILABLE", result.code)
|
||||
}
|
||||
|
||||
private class FakeDriver(private val snapshots: List<UiSnapshot>) : PddCollectorDriver {
|
||||
@Test
|
||||
fun retriesFreshShareLookupWithinBudgetAndRecordsStructuredReasons() {
|
||||
var currentTime = 0L
|
||||
val events = mutableListOf<AgentDiagnosticEvent>()
|
||||
val driver = FakeDriver(
|
||||
snapshots = List(7) { detailSnapshot() } + sharePanelSnapshot(),
|
||||
freshOutcomes = ArrayDeque(
|
||||
listOf(
|
||||
FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND),
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidateCount = 1, clickableAncestorDepth = 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = CurrentPageIdentityRunner(
|
||||
driver = driver,
|
||||
readFreshClipboard = { _, _ -> ClipboardReadOutcome("https://p.pinduoduo.com/retry", pollCount = 2, mimeAccepted = true, itemCount = 1, contentLength = 36, timestampFresh = true) },
|
||||
taskId = 70,
|
||||
diagnostic = events::add,
|
||||
now = { currentTime },
|
||||
pause = { currentTime += it },
|
||||
).identify(rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
assertTrue(events.any { it.reason == AgentDiagnosticReason.TARGET_NOT_FOUND })
|
||||
assertTrue(events.any { it.reason == AgentDiagnosticReason.CLICK_SUCCESS && it.clickableAncestorDepth == 1 })
|
||||
assertTrue(events.any { it.reason == AgentDiagnosticReason.CLIPBOARD_SUCCESS && it.clipboardPollCount == 2 })
|
||||
}
|
||||
|
||||
private class FakeDriver(
|
||||
private val snapshots: List<UiSnapshot>,
|
||||
private val freshOutcomes: ArrayDeque<FreshClickOutcome> = ArrayDeque(),
|
||||
) : PddCollectorDriver {
|
||||
private var index = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
var backCalled = false
|
||||
@@ -117,6 +155,14 @@ class CurrentPageCollectionTest {
|
||||
clicked += target.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
|
||||
clicked += target.label
|
||||
return if (freshOutcomes.isEmpty()) {
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidateCount = 1, clickableAncestorDepth = 0)
|
||||
} else {
|
||||
freshOutcomes.removeFirst()
|
||||
}
|
||||
}
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
|
||||
override fun pullDownGoodsPage() = false
|
||||
override fun back(): Boolean { backCalled = true; return true }
|
||||
|
||||
Reference in New Issue
Block a user