Compare commits

...
42 changed files with 2013 additions and 207 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ android {
buildTypes {
debug {
versionNameSuffix = "-370-diag1"
versionNameSuffix = "-373-fix1"
}
release {
isMinifyEnabled = false
+4 -1
View File
@@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:usesCleartextTraffic="true" />
<application android:usesCleartextTraffic="true">
<receiver android:name=".diagnostics.NavigationProbeReceiver"
android:exported="true" android:permission="android.permission.DUMP" />
</application>
</manifest>
@@ -0,0 +1,9 @@
package cn.ilapage.goauto.agent.diagnostics
internal object BrowserProbeClickPolicy {
fun allowed(rootPackage: String?, nodePackage: String?, label: String, visible: Boolean,
enabled: Boolean, clickable: Boolean, rootWindow: Int, nodeWindow: Int): Boolean =
rootPackage in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser") &&
nodePackage == rootPackage && label in setOf("打开拼多多APP", "打开拼多多 App") &&
visible && enabled && clickable && rootWindow >= 0 && nodeWindow == rootWindow
}
@@ -0,0 +1,94 @@
package cn.ilapage.goauto.agent.diagnostics
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.automation.SnapshotNode as UiSnapshotNode
internal class NavigationProbe(
private val capture: () -> UiSnapshot,
private val launch: ((PddLaunchEvent) -> Unit) -> Boolean,
private val clickBrowserEntry: (UiSnapshotNode) -> FreshClickOutcome,
private val now: () -> Long,
private val pause: (Long) -> Unit,
private val emit: (String) -> Unit,
private val windowId: () -> Int? = { null },
private val isActive: () -> Boolean = { true },
) {
fun run() {
val started = now()
fun canAct() = isActive() && !Thread.currentThread().isInterrupted && now() - started < 20_000
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
val before = capture()
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
fun problem(page: UiSnapshot) = PddPageClassifier.classify(page.packageName, page.activityName,
page.nodes.filter { it.visible }.map { it.label }) != null
if (problem(before)) { emit("stopped=page_problem"); return }
val initial = PddScreenParser.parse(before, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
// Values remain in memory. Neither text nor its hash is logged/persisted.
val oldTitle = initial.summary.title
val oldWindow = windowId()
val initiallyPdd = before.packageName == "com.xunmeng.pinduoduo"
emit("baseline;pdd=$initiallyPdd;root=${initial.rootAvailable};product=${initiallyPdd && initial.hasPurchaseProductEvidence()};title=${oldTitle != null};window=$oldWindow")
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
if (!launch { event ->
emit("launch;t=${now() - started};path=${event.path};status=${event.status};exception=${event.exceptionClass ?: "none"}")
}) { emit("stopped=launch_failed"); return }
var stable = 0
var legacyAccepted = false
var browserClicks = 0
var nextBrowserClick = 0L
var polls = 0
var lastSignal: String? = null
while (canAct() && polls < 201) {
val readStarted = now()
val page = capture()
if (!canAct()) { emit("stopped=inactive_or_expired;t=${now() - started}"); return }
if (problem(page)) { emit("stopped=page_problem;t=${now() - started}"); return }
val screen = PddScreenParser.parse(page, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
val product = page.packageName == "com.xunmeng.pinduoduo" && screen.hasPurchaseProductEvidence()
stable = if (product) stable + 1 else 0
val titleComparison = when {
oldTitle == null || screen.summary.title == null -> "unknown"
oldTitle == screen.summary.title -> "same"
else -> "different"
}
val category = when (page.packageName) {
"com.xunmeng.pinduoduo" -> "pdd"
in BROWSERS -> "browser"
null -> "none"
else -> "other"
}
val signal = "pkg=$category;root=${screen.rootAvailable};product=$product;panel=${screen.specPanelOpen};entry=${screen.specEntry != null};nodes=${page.nodes.size};title=$titleComparison;window=${windowId()}"
if (signal != lastSignal || polls % 10 == 0) {
emit("sample;t=${now() - started};readMs=${now() - readStarted};$signal")
lastSignal = signal
}
// Observation only: never treats this as proof of goods identity or calls the executor.
if (stable >= 2 && !legacyAccepted) {
legacyAccepted = true
emit("legacy_would_accept;t=${now() - started};title=$titleComparison;identityVerified=false")
}
if (category == "browser") {
val entries = page.nodes.filter { it.visible && it.enabled && it.label in OPEN_PDD_ALIASES }
if (entries.size > 1) { emit("stopped=browser_ambiguous"); return }
if (entries.size == 1 && browserClicks < 3 && now() >= nextBrowserClick) {
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
val result = clickBrowserEntry(entries.single())
browserClicks++
emit("browser_click;t=${now() - started};attempt=$browserClicks;result=${result.result};reason=${result.reason}")
if (result.result == FreshActionResult.AMBIGUOUS || result.result == FreshActionResult.BLOCKED) {
emit("stopped=browser_click_unsafe"); return
}
nextBrowserClick = now() + 1_000
}
}
polls++
pause(100)
}
emit("finished;t=${now() - started};samples=$polls;legacyAccepted=$legacyAccepted;identityVerified=false")
}
companion object {
private val BROWSERS = setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
private val OPEN_PDD_ALIASES = setOf("打开拼多多APP", "打开拼多多 App")
}
}
@@ -0,0 +1,109 @@
package cn.ilapage.goauto.agent.diagnostics
import android.content.BroadcastReceiver
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.SystemClock
import android.util.Log
import android.view.accessibility.AccessibilityNodeInfo
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.service.AgentForegroundService
import java.util.UUID
/** Debug APK only; manifest requires the shell/system DUMP permission. No arbitrary URL or action. */
class NavigationProbeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val goodsId = intent.getStringExtra("goodsId").orEmpty()
val mode = intent.getStringExtra("mode").orEmpty()
if (!goodsId.matches(Regex("[1-9][0-9]{0,19}")) || mode !in setOf("direct", "browser")) {
Log.i(TAG, "rejected=invalid_arguments")
return
}
val service = AgentForegroundService.diagnosticInstance
val accessibility = GoAutoAccessibilityService.instance
if (service == null || accessibility == null) {
Log.i(TAG, "rejected=service_unavailable")
return
}
val runId = UUID.randomUUID().toString()
val accepted = service.runNavigationProbe { serviceContext ->
// Delayed execution must not use a destroyed accessibility instance.
if (GoAutoAccessibilityService.instance !== accessibility) {
Log.i(TAG, "run=$runId;stopped=accessibility_changed")
return@runNavigationProbe
}
Log.i(TAG, "run=$runId;started;mode=$mode;synthetic=true;noBusinessTask=true")
val deadline = SystemClock.elapsedRealtime() + 20_000
val canAct = { AgentForegroundService.diagnosticInstance === service &&
GoAutoAccessibilityService.instance === accessibility && !Thread.currentThread().isInterrupted &&
SystemClock.elapsedRealtime() < deadline }
val guardedContext = object : ContextWrapper(serviceContext) {
override fun startActivity(intent: Intent) {
// Includes direct-failure fallback and package-manager Binder delays.
check(canAct()) { "probe_inactive" }
super.startActivity(intent)
}
}
NavigationProbe(
capture = {
check(canAct()) { "probe_inactive" }
accessibility.capture()
},
launch = { report -> canAct() && PddLinkLauncher(guardedContext).open(
"https://mobile.yangkeduo.com/goods.html?goods_id=$goodsId", mode == "direct", report,
) },
clickBrowserEntry = { safeBrowserClick(accessibility, canAct) },
now = SystemClock::elapsedRealtime,
pause = { Thread.sleep(it) },
emit = { Log.i(TAG, "run=$runId;$it") },
windowId = { accessibility.rootInActiveWindow?.let { root ->
try { root.windowId } finally { @Suppress("DEPRECATION") root.recycle() }
} },
isActive = canAct,
).run()
}
if (!accepted) Log.i(TAG, "run=$runId;rejected=device_busy")
}
/** Fresh native node only: no ancestors, gestures, coordinates, or PDD-package actions. */
private fun safeBrowserClick(service: GoAutoAccessibilityService, canAct: () -> Boolean): FreshClickOutcome {
fun blocked() = FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.TARGET_NOT_FOUND)
if (!canAct()) return blocked()
val root = service.rootInActiveWindow ?: return blocked()
val nodes = mutableListOf<AccessibilityNodeInfo>()
fun visit(node: AccessibilityNodeInfo) {
nodes += node
if (nodes.size >= 2_000 || !canAct()) return
for (i in 0 until node.childCount) {
if (nodes.size >= 2_000 || !canAct()) break
node.getChild(i)?.let(::visit)
}
}
try {
visit(root)
if (nodes.size >= 2_000 || !canAct()) return blocked()
val candidates = nodes.filter { node ->
val label = node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() }
?: node.contentDescription?.toString()?.trim().orEmpty()
BrowserProbeClickPolicy.allowed(root.packageName?.toString(), node.packageName?.toString(), label,
node.isVisibleToUser, node.isEnabled, node.isClickable, root.windowId, node.windowId)
}
if (candidates.size != 1) return blocked()
val current = service.rootInActiveWindow ?: return blocked()
val sameWindow = try { current.windowId == root.windowId && current.packageName == root.packageName }
finally { @Suppress("DEPRECATION") current.recycle() }
if (!sameWindow || !canAct()) return blocked()
val clicked = candidates.single().performAction(AccessibilityNodeInfo.ACTION_CLICK)
return FreshClickOutcome(if (clicked) FreshActionResult.SUCCESS else FreshActionResult.FAILED,
if (clicked) FreshClickReason.SUCCESS else FreshClickReason.ACTION_CLICK_FALSE)
} finally {
nodes.forEach { @Suppress("DEPRECATION") it.recycle() }
}
}
private companion object { const val TAG = "GoAutoNavProbe" }
}
@@ -122,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
).restore(packageName, timeoutMillis)
fun openAgentPreservingTab(): Boolean = runCatching {
foregroundRevision.incrementAndGet() // Explicit navigation invalidates handoff even before its window event arrives.
dismissPurchaseResultBubble()
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
@@ -340,7 +341,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
labels = { listOfNotNull(it.text?.toString(), it.contentDescription?.toString()) },
)
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome = clickFreshDetailed(target) { true }
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome =
clickFreshDetailed(target) { node ->
val targetIsBrowser = PurchaseNavigationReadiness.isBrowser(node.packageName?.toString())
val foregroundIsBrowser = PurchaseNavigationReadiness.isBrowser(currentPackage())
targetIsBrowser && foregroundIsBrowser && allowed()
}
private fun clickFreshDetailed(target: SnapshotNode, beforeClick: (AccessibilityNodeInfo) -> Boolean): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
@@ -370,6 +380,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (sizeAdviceBlocked(node)) return FreshClickOutcome(
FreshActionResult.BLOCKED, FreshClickReason.SIZE_ADVICE_BLOCKED, candidates.size, ancestorDepth,
)
if (!beforeClick(node)) return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, candidates.size, ancestorDepth)
} else {
@@ -1,8 +1,26 @@
package cn.ilapage.goauto.agent.automation
enum class PddLaunchPath { DIRECT, BROWSER }
enum class PddLaunchStatus { ACCEPTED, REJECTED, EXCEPTION }
/** No URL, exception message, or page content may enter this event. */
data class PddLaunchEvent(val path: PddLaunchPath, val status: PddLaunchStatus, val exceptionClass: String? = null)
internal object PddLaunchFallback {
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
return runCatching(browser).getOrDefault(false)
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean,
diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
fun attempt(path: PddLaunchPath, launch: () -> Boolean): Boolean {
val result = runCatching(launch)
val event = PddLaunchEvent(path, when {
result.isFailure -> PddLaunchStatus.EXCEPTION
result.getOrDefault(false) -> PddLaunchStatus.ACCEPTED
else -> PddLaunchStatus.REJECTED
}, result.exceptionOrNull()?.javaClass?.name)
// Observability must never change routing or turn an accepted launch into a retry.
runCatching { diagnostic(event) }
return result.getOrDefault(false)
}
if (preferDirect && attempt(PddLaunchPath.DIRECT, direct)) return true
return attempt(PddLaunchPath.BROWSER, browser)
}
}
@@ -31,7 +31,7 @@ object PddPageClassifier {
}
class PddLinkLauncher(private val context: Context) {
fun open(url: String, preferDirect: Boolean = false): Boolean {
fun open(url: String, preferDirect: Boolean = false, diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
return false
@@ -42,7 +42,7 @@ class PddLinkLauncher(private val context: Context) {
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(direct)
true
}, browser = { openBrowser(uri) })
}, browser = { openBrowser(uri) }, diagnostic = diagnostic)
}
private fun openBrowser(uri: Uri): Boolean {
@@ -51,7 +51,9 @@ class PddLinkLauncher(private val context: Context) {
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
}
if (browser != null) base.setPackage(browser)
return runCatching { context.startActivity(base); true }.getOrDefault(false)
// The outer fallback boundary retains the previous false-on-error behavior and records only the class.
context.startActivity(base)
return true
}
private fun isPddHost(host: String?): Boolean =
@@ -0,0 +1,55 @@
package cn.ilapage.goauto.agent.automation
/** In-memory stability evidence only. This is not proof of the page's goodsId. */
internal data class ProductStabilityKey(val title: String?, val specEntry: Boolean, val quickEntry: Boolean, val panel: Boolean)
internal class PurchaseNavigationReadiness(
private val startedAt: Long,
private val path: PddLaunchPath?,
private val acceptedAt: Long,
) {
private var browserObserved = false
private var returnedAt: Long? = null
private var stableSince: Long? = null
private var stableKey: ProductStabilityKey? = null
private var samples = 0
private var lastClickAt: Long? = null
fun expired(now: Long): Boolean = now - startedAt !in 0 until TIMEOUT_MS
fun observe(now: Long, packageName: String?, key: ProductStabilityKey?): Boolean {
if (expired(now)) return false
if (isBrowser(packageName)) browserObserved = true
if (packageName != PDD_PACKAGE) returnedAt = null
val eligible = path == PddLaunchPath.DIRECT || (path == PddLaunchPath.BROWSER && browserObserved)
if (!eligible || packageName != PDD_PACKAGE || key == null) {
stableSince = null
stableKey = null
samples = 0
return false
}
if (returnedAt == null) returnedAt = now
if (stableKey != key || stableSince == null) {
stableKey = key
stableSince = now
samples = 0
}
samples++
val bufferStart = if (path == PddLaunchPath.DIRECT) acceptedAt else requireNotNull(returnedAt)
return now - bufferStart >= BUFFER_MS && now - requireNotNull(stableSince) >= STABLE_MS && samples >= 3
}
fun mayClickBrowser(now: Long, packageName: String?): Boolean =
!expired(now) && path == PddLaunchPath.BROWSER && isBrowser(packageName) &&
(lastClickAt == null || now - requireNotNull(lastClickAt) >= 1_000L)
fun clicked(now: Long) { lastClickAt = now }
companion object {
const val TIMEOUT_MS = 30_000L
const val BUFFER_MS = 3_000L
const val STABLE_MS = 1_000L
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
fun isBrowser(packageName: String?): Boolean = packageName in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
}
}
@@ -0,0 +1,59 @@
package cn.ilapage.goauto.agent.automation
/** Service-instance memory only; never restored from results or the upload outbox. */
internal class PurchaseProbeHandoff {
internal data class Credential(
val taskId: Long, val goodsId: String, val sourceAttemptId: String,
val generation: Long, val completedAt: Long, val accessibility: Any, val foregroundRevision: Long,
)
internal data class Execution(val generation: Long, val previous: Credential?)
private var generation = 0L
private var credential: Credential? = null
private var evaluatedGeneration = -1L
private var invalidatedGeneration = -1L
private var authorizedGeneration = -1L
@Synchronized fun beginExecution(): Execution {
val previous = credential
credential = null
return Execution(++generation, previous)
}
@Synchronized fun invalidate() {
credential = null
invalidatedGeneration = generation
}
@Synchronized fun completeProbe(
execution: Execution, taskId: Long, goodsId: String, attemptId: String,
completedAt: Long, accessibility: Any, foregroundRevision: Long,
successful: Boolean, safelyStored: Boolean, submitPending: Boolean, replayed: Boolean,
) {
if (execution.generation != generation || !successful || !safelyStored || !submitPending || replayed || attemptId.isBlank()) return
credential = Credential(taskId, goodsId, attemptId, generation, completedAt, accessibility, foregroundRevision)
}
@Synchronized fun eligible(
execution: Execution, taskId: Long, goodsId: String, phase: String, attemptId: String,
accessibility: Any, foregroundRevision: Long, now: Long,
): Boolean {
if (evaluatedGeneration == execution.generation) return false
evaluatedGeneration = execution.generation
val prior = execution.previous ?: return false
val eligible = generation == execution.generation && invalidatedGeneration != execution.generation &&
prior.generation + 1 == execution.generation && phase == "purchase" &&
prior.taskId == taskId && prior.goodsId == goodsId && attemptId.isNotBlank() && prior.sourceAttemptId != attemptId &&
prior.accessibility === accessibility && prior.foregroundRevision == foregroundRevision &&
now - prior.completedAt in 0 until 30_000L
if (eligible) authorizedGeneration = execution.generation
return eligible
}
/** Revalidate the already consumed credential after a potentially slow fresh capture; never creates another. */
@Synchronized fun stillValid(execution: Execution, accessibility: Any?, foregroundRevision: Long, now: Long): Boolean {
val prior = execution.previous ?: return false
return authorizedGeneration == execution.generation && generation == execution.generation &&
invalidatedGeneration != execution.generation && prior.accessibility === accessibility &&
prior.foregroundRevision == foregroundRevision && now - prior.completedAt in 0 until 30_000L
}
}
@@ -16,6 +16,9 @@ interface PurchaseUiDriver {
result = clickFresh(target),
reason = FreshClickReason.UNKNOWN,
)
/** Recheck after fresh-node Binder reads and immediately before a browser navigation click. */
fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome =
if (allowed()) clickFreshDetailed(target) else FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
/**
* Reacquires one address-entry node by its accessibility path and validates
* its immutable snapshot traits before clicking its nearest clickable
@@ -85,6 +88,7 @@ data class PurchaseExecutionInput(
val minUnitPriceCent: Long,
val maxUnitPriceCent: Long,
val addressSuffix: String = "",
val reuseProbeProduct: Boolean = false,
)
data class PurchaseExecutionOutcome(
@@ -109,6 +113,9 @@ class PurchaseRehearsalExecutor(
private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
private val probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
private val acceptedLaunchPath: () -> PddLaunchPath? = { null },
private val monotonicNow: () -> Long = { System.nanoTime() / 1_000_000L },
private val confirmProbeHandoff: () -> Boolean = { true },
) {
private var purchasePanelContext: PurchasePanelContext? = null
@@ -121,12 +128,9 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
// unrelated screen), so only skip navigation when a fresh snapshot
// still carries safe PDD product/spec evidence.
// Only the service's one-shot probe handoff permits reuse; fresh structure alone is insufficient.
if (
input.phase == "purchase" &&
input.phase == "purchase" && input.reuseProbeProduct &&
action.type == PurchaseActionType.OPEN_PRODUCT &&
canReuseCurrentProduct(input)
) continue
@@ -205,7 +209,7 @@ class PurchaseRehearsalExecutor(
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
screen.problem == null && screen.hasPurchaseProductEvidence() && confirmProbeHandoff()
}
private fun validateBeforeDeviceAction(
@@ -260,57 +264,38 @@ class PurchaseRehearsalExecutor(
private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
purchasePanelContext = null
val startedAt = monotonicNow()
if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
val readiness = PurchaseNavigationReadiness(startedAt, acceptedLaunchPath(), monotonicNow())
val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开")
var clickAttempted = false
var nextClickPoll = 0
var lastClickReason = FreshClickReason.UNKNOWN
var stableEvidenceReads = 0
var pddForegroundObserved = false
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
repeat(300) {
if (readiness.expired(monotonicNow())) return navigationTimeout()
val snapshot = driver.capture()
if (readiness.expired(monotonicNow())) return navigationTimeout()
pageProblem(snapshot)?.let { return it }
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) {
clickAttempted = true
val outcome = driver.clickFreshDetailed(candidates.single())
lastClickReason = outcome.reason
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
val key = if (screen.problem == null && screen.hasPurchaseProductEvidence()) ProductStabilityKey(
screen.summary.title, screen.specEntry != null, screen.quickConfirmationEntry != null, screen.specPanelOpen,
) else null
if (readiness.observe(monotonicNow(), snapshot.packageName, key)) return null
if (readiness.mayClickBrowser(monotonicNow(), snapshot.packageName)) {
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 && !readiness.expired(monotonicNow())) {
val outcome = driver.clickNavigationFresh(candidates.single()) { !readiness.expired(monotonicNow()) }
readiness.clicked(monotonicNow())
if (readiness.expired(monotonicNow())) return navigationTimeout()
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(OPEN_PRODUCT_POLL_MILLIS)
if (readiness.expired(monotonicNow())) return navigationTimeout()
pause(100)
}
if (pddForegroundObserved) {
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
}
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", "没有进入拼多多商品页面")
return navigationTimeout()
}
private fun navigationTimeout() = failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
var stableEvidenceReads = 0
repeat(PRODUCT_PAGE_POLL_LIMIT) {
@@ -1267,10 +1252,8 @@ class PurchaseRehearsalExecutor(
companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
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
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
@@ -103,6 +103,16 @@ data class PurchaseAgentTask(
val attemptNumber: Int = 0,
)
data class PurchaseResultAcknowledgement(val pending: Boolean, val replayed: Boolean) {
companion object {
fun from(data: JSONObject) = PurchaseResultAcknowledgement(
pending = data.optString("status") == "pending",
// The server deliberately omits replayed=false. A malformed explicit value is not a first response.
replayed = data.has("replayed") && data.opt("replayed") != false,
)
}
}
data class CollectionHistoryItem(
val taskId: Long,
val attemptNumber: Int,
@@ -527,9 +537,10 @@ class AgentApiClient(private val serverUrl: String) {
return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", payload, token)).getJSONObject("data"))
}
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String) {
fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String): PurchaseResultAcknowledgement {
val payload = JSONObject(payloadJson)
requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token))
val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data")
return PurchaseResultAcknowledgement.from(data)
}
fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage<CollectionHistoryItem> {
@@ -7,6 +7,7 @@ import android.database.sqlite.SQLiteOpenHelper
import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage {
PRODUCT_NAVIGATION,
SPEC_ENTRY_CLICK,
SPEC_ENTRY_GESTURE,
QUICK_CONFIRMATION_CLICK,
@@ -24,6 +25,12 @@ enum class AgentDiagnosticStage {
}
enum class AgentDiagnosticReason {
NAV_DIRECT_ACCEPTED,
NAV_DIRECT_REJECTED,
NAV_DIRECT_EXCEPTION,
NAV_BROWSER_ACCEPTED,
NAV_BROWSER_REJECTED,
NAV_BROWSER_EXCEPTION,
SIZE_ADVICE_CLICK_BLOCKED,
DETAIL_ENTRY_MATCHED,
SPEC_PANEL_OPENED,
@@ -1,15 +1,32 @@
package cn.ilapage.goauto.agent.persistence
import cn.ilapage.goauto.agent.network.AgentApiException
object PurchaseRejectionPolicy {
val codes = setOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT")
fun terminalCode(error: Exception): String? = (error as? AgentApiException)
?.takeIf { it.status == 409 && it.code in codes }?.code
}
/** Uploads already-persisted results only; it never invokes device automation. */
class PurchaseOutboxUploader(
private val pending: () -> List<PendingPurchaseOutbox>,
private val submit: (PendingPurchaseOutbox) -> Unit,
private val markUploaded: (PendingPurchaseOutbox) -> Unit,
private val afterUploaded: (PendingPurchaseOutbox) -> Unit = {},
private val markRejected: (PendingPurchaseOutbox, String) -> Unit = { _, _ -> error("Rejection persistence not configured") },
) {
fun flush() {
pending().forEach { item ->
submit(item)
try {
submit(item)
} catch (error: Exception) {
val code = PurchaseRejectionPolicy.terminalCode(error) ?: throw error
markRejected(item, code)
return@forEach
}
markUploaded(item)
afterUploaded(item)
}
}
}
@@ -0,0 +1,27 @@
package cn.ilapage.goauto.agent.persistence
data class RejectedPurchaseResult(
val id: Long, val taskId: Long, val attemptId: String, val payloadJson: String,
val rejectedAt: Long, val errorCode: String, val acknowledgedAt: Long?,
)
internal object PurchaseRejectionSql {
val migration = listOf(
"ALTER TABLE purchase_outbox ADD COLUMN rejected_at INTEGER",
"ALTER TABLE purchase_outbox ADD COLUMN rejection_error_code TEXT",
"ALTER TABLE purchase_outbox ADD COLUMN acknowledged_at INTEGER",
)
const val activeTask = "SELECT task_id FROM purchase_task WHERE upload_status != 'rejected' AND (status IN ('running','order_submit_started') OR upload_status='pending') ORDER BY updated_at ASC LIMIT 1"
const val acknowledge = "UPDATE purchase_outbox SET acknowledged_at=? WHERE id=? AND upload_status='rejected' AND acknowledged_at IS NULL"
/** The caller must wrap both writes in one transaction. Zero task rows means a newer attempt replaced it. */
fun reject(item: PendingPurchaseOutbox, code: String, now: Long, update: (String, List<Any>) -> Int) {
require(code in PurchaseRejectionPolicy.codes)
check(update(
"UPDATE purchase_outbox SET upload_status='rejected',rejected_at=?,rejection_error_code=?,updated_at=? WHERE id=? AND task_id=? AND attempt_id=? AND upload_status='pending'",
listOf(now, code, now, item.id, item.taskId, item.attemptId),
) == 1)
update("UPDATE purchase_task SET upload_status='rejected',updated_at=? WHERE task_id=? AND attempt_id=?",
listOf(now, item.taskId, item.attemptId))
}
}
@@ -66,6 +66,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
)
db.execSQL("CREATE INDEX idx_purchase_outbox_pending ON purchase_outbox(upload_status, id)")
db.execSQL("CREATE INDEX idx_purchase_attempt_task ON purchase_attempt(task_id, created_at)")
PurchaseRejectionSql.migration.forEach(db::execSQL)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -74,6 +75,7 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
db.execSQL("ALTER TABLE purchase_task ADD COLUMN final_confirmation_json TEXT")
db.execSQL("ALTER TABLE purchase_task ADD COLUMN irreversible_at INTEGER")
}
if (oldVersion < 3 && newVersion >= 3) PurchaseRejectionSql.migration.forEach(db::execSQL)
}
@Synchronized
@@ -225,8 +227,8 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
@Synchronized
fun activeTaskId(): Long? = readableDatabase.rawQuery(
"SELECT task_id FROM purchase_task WHERE status IN (?,?) OR upload_status=? ORDER BY updated_at ASC LIMIT 1",
arrayOf(STATUS_RUNNING, STATUS_ORDER_SUBMIT_STARTED, UPLOAD_PENDING),
PurchaseRejectionSql.activeTask,
null,
).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null }
@Synchronized
@@ -243,9 +245,53 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N
}
}
@Synchronized
fun markRejected(item: PendingPurchaseOutbox, code: String) {
val db = writableDatabase
db.beginTransaction()
try {
PurchaseRejectionSql.reject(item, code, System.currentTimeMillis()) { sql, args ->
db.compileStatement(sql).use { statement ->
args.forEachIndexed { index, value ->
if (value is Long) statement.bindLong(index + 1, value)
else statement.bindString(index + 1, value.toString())
}
statement.executeUpdateDelete()
}
}
db.setTransactionSuccessful()
} finally { db.endTransaction() }
}
@Synchronized
fun rejectedResults(): List<RejectedPurchaseResult> = readableDatabase.rawQuery(
"SELECT id,task_id,attempt_id,payload_json,rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox WHERE upload_status='rejected' ORDER BY id DESC", null,
).use { cursor -> buildList {
while (cursor.moveToNext()) add(RejectedPurchaseResult(
cursor.getLong(0), cursor.getLong(1), cursor.getString(2), cursor.getString(3),
cursor.getLong(4), cursor.getString(5), if (cursor.isNull(6)) null else cursor.getLong(6),
))
} }
@Synchronized
fun rejectedCount(): Int = readableDatabase.rawQuery(
"SELECT COUNT(*) FROM purchase_outbox WHERE upload_status='rejected'", null,
).use { it.moveToFirst(); it.getInt(0) }
@Synchronized
fun isCurrentAttemptRejected(taskId: Long, attemptId: String): Boolean = readableDatabase.rawQuery(
"SELECT 1 FROM purchase_task WHERE task_id=? AND attempt_id=? AND upload_status='rejected'",
arrayOf(taskId.toString(), attemptId),
).use { it.moveToFirst() }
@Synchronized
fun acknowledgeRejection(outboxId: Long) {
writableDatabase.execSQL(PurchaseRejectionSql.acknowledge, arrayOf(System.currentTimeMillis(), outboxId))
}
companion object {
private const val DATABASE_NAME = "goauto_purchase.db"
private const val DATABASE_VERSION = 2
private const val DATABASE_VERSION = 3
private const val STATUS_RUNNING = "running"
const val STATUS_ORDER_SUBMIT_STARTED = "order_submit_started"
private const val STATUS_COMPLETED = "completed"
@@ -41,6 +41,11 @@ import cn.ilapage.goauto.agent.automation.CollectionRule
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseProbeHandoff
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
import cn.ilapage.goauto.agent.network.PurchaseResultAcknowledgement
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
@@ -91,6 +96,7 @@ class AgentForegroundService : Service() {
private var repurchaseSession: RepurchaseSession? = null
private val repurchaseEvidence = java.util.concurrent.ConcurrentHashMap<Long, java.util.concurrent.ConcurrentHashMap<Long, RepurchaseExecutionEvidence>>()
private val taskMutex = TaskExecutionMutex()
private val probeHandoff = PurchaseProbeHandoff()
private val backfillGuard = OrderBackfillGuard(taskMutex)
private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false)
@@ -154,6 +160,7 @@ class AgentForegroundService : Service() {
// Separate, serialized I/O ticks keep history reads and result observation off the
// heartbeat scheduler and, especially, off the single purchase execution thread.
repurchaseExecutor.scheduleWithFixedDelay(::tickRepurchase, 2, 2, TimeUnit.SECONDS)
diagnosticInstance = this
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -197,6 +204,9 @@ class AgentForegroundService : Service() {
}
override fun onDestroy() {
stateStore.stop()
probeHandoff.invalidate()
if (diagnosticInstance === this) diagnosticInstance = null
repurchaseClosed.set(true)
repurchase.requestStop(repurchase.state.roundId)
repurchaseExecutor.shutdownNow()
@@ -220,6 +230,30 @@ class AgentForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
/** Shell-protected debug receiver only. Never claims/resets a server task or invokes a purchase executor. */
internal fun runNavigationProbe(probe: (Context) -> Unit): Boolean {
if (!BuildConfig.DEBUG) return false
val reservation = Long.MAX_VALUE - 2
val acquired = synchronized(taskMutex) {
!repurchaseClosed.get() && repurchaseLocalIdle() && !repurchaseState.busy &&
activeCollectionCooldown() == null && taskMutex.tryAcquire(reservation)
}
if (!acquired) return false
probeHandoff.beginExecution()
cancelIdleReturn("导航诊断")
try {
taskExecutor.execute {
try { probe(this) }
catch (error: Exception) { Log.w("GoAutoNavProbe", "probe_exception=${error.javaClass.name}") }
finally { taskMutex.release(reservation) }
}
} catch (error: java.util.concurrent.RejectedExecutionException) {
taskMutex.release(reservation)
return false
}
return true
}
private fun triggerSync() {
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
executor.execute {
@@ -235,12 +269,13 @@ class AgentForegroundService : Service() {
}
private fun synchronizeAgent(): String {
var heartbeatSucceeded = false
return try {
val serverUrl = ServerUrlPolicy.normalize(settingsStore.serverUrl())
val api = AgentApiClient(serverUrl)
var credentials = identityStore.credentials()
stateStore.update("CONNECTING", "正在连接并校验设备身份", credentials?.deviceId ?: 0L, credentials != null)
updateNotification("正在连接")
stateStore.update("CONNECTING", "正在同步", credentials?.deviceId ?: 0L, credentials != null, connection = true)
updateNotification("正在同步")
if (!registeredThisProcess.get()) {
val registration = api.register(deviceInfo(), credentials?.token)
@@ -257,17 +292,30 @@ class AgentForegroundService : Service() {
}
val activeCredentials = credentials ?: error("设备尚未取得认证凭据")
if (taskMutex.currentTaskId() == null) {
recoverInterruptedPurchases(api, activeCredentials.token)
flushPurchaseOutbox(api, activeCredentials.token)
runningTaskId.set(purchaseStore.activeTaskId())
val sync = AgentSyncCycle(
heartbeat = { currentTaskId ->
api.heartbeat(activeCredentials.token, currentTaskId, AgentCapabilities.supported).also {
check(it.deviceId == activeCredentials.deviceId) { "心跳设备身份不一致" }
}
},
activeTaskId = {
if (taskMutex.currentTaskId() != null) runningTaskId.get()
else purchaseStore.activeTaskId().also(runningTaskId::set)
},
executionActive = { taskMutex.currentTaskId() != null },
recover = { recoverInterruptedPurchases(api, activeCredentials.token) },
flush = { flushPurchaseOutbox(api, activeCredentials.token) },
hasPending = { purchaseStore.pendingOutbox().isNotEmpty() },
).run()
if (sync.failureCode != null) {
cancelIdleReturn("同步未完成")
val message = cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(sync.failureCode, 0)
stateStore.update(sync.failureCode, message, activeCredentials.deviceId, true, connection = true)
updateNotification(message)
return if (sync.failureCode == "AUTH_ERROR") MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
}
val heartbeat = api.heartbeat(
activeCredentials.token,
currentTaskId = runningTaskId.get(),
capabilities = AgentCapabilities.supported,
)
check(heartbeat.deviceId == activeCredentials.deviceId) { "心跳返回了不同的设备身份" }
val heartbeat = requireNotNull(sync.heartbeat)
heartbeatSucceeded = true
stateStore.update(
code = if (heartbeat.busy) "BUSY" else "ONLINE",
message = if (heartbeat.busy) "设备在线,正在执行任务" else "设备在线空闲",
@@ -276,30 +324,32 @@ class AgentForegroundService : Service() {
heartbeat = true,
)
updateNotification(if (heartbeat.busy) "在线 · 执行中" else "在线 · 空闲")
scheduleTask(api, activeCredentials.token)
if (sync.canClaim) scheduleTask(api, activeCredentials.token) else MANUAL_BUSY
} catch (error: AgentApiException) {
cancelIdleReturn("网络或服务端请求失败")
val authenticationError = error.code in setOf(
"DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED",
)
val authenticationError = isAgentAuthenticationError(error)
val connectionFailure = syncConnectionFailureCode(error, heartbeatSucceeded)
val code = connectionFailure ?: "TASK_ERROR"
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
stateStore.update(
code = if (authenticationError) "AUTH_ERROR" else "NETWORK_ERROR",
message = "${error.code}:${error.message}",
code = code,
message = if (connectionFailure == null) "任务同步暂未完成,请稍后重试" else cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation.connection(code, 0),
deviceId = storedCredentials?.deviceId ?: 0L,
tokenStored = storedCredentials != null,
connection = connectionFailure != null,
)
updateNotification(if (authenticationError) "设备认证失败" else "连接失败,等待网络恢复")
updateNotification(if (authenticationError) "设备认证失败" else if (heartbeatSucceeded) "任务同步暂未完成" else "连接失败,等待恢复")
if (authenticationError) MANUAL_AUTH_ERROR else MANUAL_NETWORK_ERROR
} catch (error: Exception) {
cancelIdleReturn("Agent 运行异常")
val configured = settingsStore.serverUrl().isNotBlank()
val storedCredentials = runCatching { identityStore.credentials() }.getOrNull()
stateStore.update(
code = if (configured) "ERROR" else "CONFIG_REQUIRED",
message = error.message ?: "Agent 运行失败",
code = if (heartbeatSucceeded) "TASK_ERROR" else if (configured) "ERROR" else "CONFIG_REQUIRED",
message = if (configured) "同步暂未完成,请稍后重试" else "请配置服务端",
deviceId = storedCredentials?.deviceId ?: 0L,
tokenStored = storedCredentials != null,
connection = !heartbeatSucceeded,
)
updateNotification(if (configured) "运行异常" else "等待配置服务端")
if (configured) MANUAL_ERROR else MANUAL_CONFIG_REQUIRED
@@ -308,8 +358,6 @@ class AgentForegroundService : Service() {
private fun scheduleTask(api: AgentApiClient, token: String): String {
if (taskMutex.currentTaskId() != null) return MANUAL_BUSY
recoverInterruptedPurchases(api, token)
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
@@ -484,6 +532,7 @@ class AgentForegroundService : Service() {
}
try {
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
probeHandoff.beginExecution()
val server = settingsStore.serverUrl()
val credentials = identityStore.credentials() ?: error("设备尚未注册")
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
@@ -573,11 +622,12 @@ class AgentForegroundService : Service() {
publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。", replacementOriginType, replacementOriginTaskId)
return
}
if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) {
if (!tryAcquireCurrentPage(taskMutex, working, CURRENT_PAGE_RESERVATION_ID)) {
publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。", replacementOriginType, replacementOriginTaskId)
return
}
cancelIdleReturn("正在创建当前页面采集任务")
probeHandoff.beginExecution()
taskExecutor.execute {
var taskId = 0L
try {
@@ -719,6 +769,12 @@ class AgentForegroundService : Service() {
}
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
// Take and clear before claim/start or any eligibility checks. Every execution consumes the previous handoff.
val handoffExecution = probeHandoff.beginExecution()
var probeCompletedAt: Long? = null
var probeAccessibility: GoAutoAccessibilityService? = null
var probeForegroundRevision = -1L
var acceptedPath: PddLaunchPath? = null
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
@@ -783,8 +839,35 @@ class AgentForegroundService : Service() {
} else {
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) },
openLink = { url ->
probeHandoff.invalidate()
acceptedPath = null
val navigationStarted = SystemClock.elapsedRealtime()
PddLinkLauncher(this).open(url, preferDirect = true) { event ->
if (event.status == PddLaunchStatus.ACCEPTED) acceptedPath = event.path
probeDiagnostic(AgentDiagnosticEvent(
taskId = task.taskId,
stage = AgentDiagnosticStage.PRODUCT_NAVIGATION,
reason = AgentDiagnosticReason.valueOf("NAV_${event.path}_${event.status}"),
elapsedMs = SystemClock.elapsedRealtime() - navigationStarted,
targetClassName = event.exceptionClass,
))
}
},
acceptedLaunchPath = { acceptedPath },
monotonicNow = SystemClock::elapsedRealtime,
confirmProbeHandoff = {
val current = GoAutoAccessibilityService.instance
probeHandoff.stillValid(handoffExecution, current,
current?.currentForegroundRevision() ?: -1L, SystemClock.elapsedRealtime())
},
probeSpecs = {
collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic).also {
probeCompletedAt = SystemClock.elapsedRealtime()
probeAccessibility = accessibility
probeForegroundRevision = accessibility.currentForegroundRevision()
}
},
probeClickDiagnostic = { stage, target, outcome ->
probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome))
},
@@ -822,6 +905,10 @@ class AgentForegroundService : Service() {
minUnitPriceCent = task.minUnitPriceCent,
maxUnitPriceCent = task.maxUnitPriceCent,
addressSuffix = task.addressSuffix,
reuseProbeProduct = probeHandoff.eligible(
handoffExecution, task.taskId, task.pddGoodsId, task.phase, task.taskAttemptId,
accessibility, accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime(),
),
),
parsedRule,
PurchaseAgentCapabilities.supported,
@@ -849,10 +936,26 @@ class AgentForegroundService : Service() {
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
}
beginIdleReturnCooldown()
flushPurchaseOutbox(api, token)
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
// Only this first synchronous upload may establish a handoff. Outbox replays have no callback.
flushPurchaseOutbox(api, token) { item, acknowledgement ->
val completedAt = probeCompletedAt
val accessibility = probeAccessibility
if (item.requestId == requestId && item.attemptId == task.taskAttemptId && completedAt != null &&
accessibility != null && GoAutoAccessibilityService.instance === accessibility &&
accessibility.currentPackage() == "com.xunmeng.pinduoduo" &&
accessibility.currentForegroundRevision() == probeForegroundRevision) {
probeHandoff.completeProbe(
handoffExecution, task.taskId, task.pddGoodsId, task.taskAttemptId, completedAt,
accessibility, probeForegroundRevision,
successful = task.phase == "spec_probe" && outcome.resultType == "spec_probe_completed",
safelyStored = resultSafelyStored, submitPending = acknowledgement.pending, replayed = acknowledgement.replayed,
)
}
}
val rejected = purchaseStore.rejectedResults().any { it.taskId == task.taskId && it.attemptId == task.taskAttemptId }
val message = if (rejected) "采购结果被服务端拒收,请在采购页或设置中查看" else if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
updateNotification(if (rejected) "$taskLabel #${task.taskId} 结果被拒收" else if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
} catch (error: AgentApiException) {
failureSnapshot("failed", error.code.takeIf { it.matches(Regex("[A-Z][A-Z0-9_]{0,63}")) } ?: "PURCHASE_API_FAILED")
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
@@ -913,6 +1016,7 @@ class AgentForegroundService : Service() {
private fun recoverInterruptedPurchases(api: AgentApiClient, token: String) {
purchaseStore.interruptedAttempts().forEach { interrupted ->
probeHandoff.beginExecution()
if (interrupted.status == PurchaseTaskStore.STATUS_ORDER_SUBMIT_STARTED) {
val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach
try {
@@ -941,9 +1045,10 @@ class AgentForegroundService : Service() {
}
val requestId = UUID.randomUUID().toString()
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
} catch (_: AgentApiException) {
} catch (error: AgentApiException) {
// Keep the local irreversible marker. The same server request ID
// is replayed after connectivity returns; the order click is never repeated.
if (isAgentAuthenticationError(error)) throw error
}
return@forEach
}
@@ -964,11 +1069,32 @@ class AgentForegroundService : Service() {
}
}
private fun flushPurchaseOutbox(api: AgentApiClient, token: String) {
private fun flushPurchaseOutbox(
api: AgentApiClient, token: String,
onFirstSubmitted: (PendingPurchaseOutbox, PurchaseResultAcknowledgement) -> Unit = { _, _ -> },
) {
var acknowledgement: PurchaseResultAcknowledgement? = null
PurchaseOutboxUploader(
pending = purchaseStore::pendingOutbox,
submit = { item -> api.submitPurchaseResult(item.taskId, item.payloadJson, token) },
submit = { item -> acknowledgement = api.submitPurchaseResult(item.taskId, item.payloadJson, token) },
markUploaded = { item -> purchaseStore.markUploaded(item.id, item.taskId, item.attemptId) },
afterUploaded = { item ->
// A local upload-mark failure creates no credential; optional handoff inspection cannot fail the upload.
runCatching { onFirstSubmitted(item, requireNotNull(acknowledgement)) }
.onFailure { probeHandoff.invalidate(); logDiagnosticPersistenceFailure(it) }
},
markRejected = { item, code ->
probeHandoff.invalidate()
purchaseStore.markRejected(item, code)
synchronized(taskMutex) {
val runtime = stateStore.read()
if (shouldClearRejectedPurchase(item.taskId, runtime.currentTaskId, runtime.currentTaskType,
taskMutex.currentTaskId(), purchaseStore.isCurrentAttemptRejected(item.taskId, item.attemptId))) {
stateStore.clearActiveTask(item.taskId)
}
}
sendBroadcast(Intent(ACTION_PURCHASE_REJECTIONS_CHANGED).setPackage(packageName))
},
).flush()
runningTaskId.set(purchaseStore.activeTaskId())
}
@@ -1031,6 +1157,7 @@ class AgentForegroundService : Service() {
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
probeHandoff.beginExecution()
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
@@ -1404,6 +1531,7 @@ class AgentForegroundService : Service() {
}
private fun requestReturnToAgent(accessibility: GoAutoAccessibilityService) {
probeHandoff.invalidate()
val launched = accessibility.openAgentPreservingTab()
if (launched) SystemClock.sleep(RETURN_CONFIRM_DELAY_MILLIS)
val returned = launched && accessibility.currentPackage() == packageName
@@ -1558,6 +1686,7 @@ class AgentForegroundService : Service() {
* 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。
*/
private fun resetPddToHome(): PddLaunchResult {
probeHandoff.invalidate()
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -1572,6 +1701,7 @@ class AgentForegroundService : Service() {
}
private fun launchPddToForeground(): PddLaunchResult {
probeHandoff.invalidate()
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -1586,10 +1716,13 @@ class AgentForegroundService : Service() {
}
companion object {
@Volatile internal var diagnosticInstance: AgentForegroundService? = null
private set
const val ACTION_REPURCHASE_PREPARE = "cn.ilapage.goauto.agent.REPURCHASE_PREPARE"
const val ACTION_REPURCHASE_CONFIRM = "cn.ilapage.goauto.agent.REPURCHASE_CONFIRM"
const val ACTION_REPURCHASE_STOP = "cn.ilapage.goauto.agent.REPURCHASE_STOP"
const val ACTION_REPURCHASE_STATE = "cn.ilapage.goauto.agent.REPURCHASE_STATE"
const val ACTION_PURCHASE_REJECTIONS_CHANGED = "cn.ilapage.goauto.agent.PURCHASE_REJECTIONS_CHANGED"
@Volatile var repurchaseState = RepurchaseState()
private set
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
@@ -191,17 +191,27 @@ class AgentSettingsStore(context: Context) {
class AgentStateStore(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
private val publicationGate = AgentStatePublicationGate()
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false) {
fun update(code: String, message: String, deviceId: Long? = null, tokenStored: Boolean? = null, heartbeat: Boolean = false, connection: Boolean = false) = publicationGate.publish {
val editor = preferences.edit()
.putString(STATE_CODE, code)
.putString(STATE_MESSAGE, message)
deviceId?.let { editor.putLong(DEVICE_ID, it) }
tokenStored?.let { editor.putBoolean(TOKEN_STORED, it) }
if (heartbeat) editor.putLong(LAST_HEARTBEAT, System.currentTimeMillis())
if (heartbeat || connection) editor.putString(CONNECTION_CODE, code)
editor.apply()
}
/** This service instance cannot publish a late heartbeat over STOPPED or a restarted service. */
fun stop() = publicationGate.stop {
preferences.edit().putString(STATE_CODE, "STOPPED").putString(CONNECTION_CODE, "STOPPED")
.putString(STATE_MESSAGE, "前台服务已停止").apply()
}
fun connectionCode(): String = preferences.getString(CONNECTION_CODE, "STOPPED") ?: "STOPPED"
fun read(): AgentState = AgentState(
code = preferences.getString(STATE_CODE, "STOPPED") ?: "STOPPED",
message = preferences.getString(STATE_MESSAGE, "前台服务尚未启动") ?: "前台服务尚未启动",
@@ -279,6 +289,7 @@ class AgentStateStore(context: Context) {
private companion object {
const val PREFERENCES = "goauto_agent_runtime"
const val STATE_CODE = "state_code"
const val CONNECTION_CODE = "connection_code"
const val STATE_MESSAGE = "state_message"
const val DEVICE_ID = "device_id"
const val LAST_HEARTBEAT = "last_heartbeat"
@@ -0,0 +1,64 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.network.HeartbeatResult
internal fun isAgentAuthenticationError(error: Throwable?): Boolean = error is AgentApiException &&
(error.status in setOf(401, 403) || error.code in setOf("DEVICE_TOKEN_INVALID", "DEVICE_INSTALL_ID_CONFLICT", "DEVICE_DISABLED"))
internal fun syncConnectionFailureCode(error: Throwable, heartbeatSucceeded: Boolean): String? = when {
isAgentAuthenticationError(error) -> "AUTH_ERROR"
heartbeatSucceeded -> null
error is AgentApiException && error.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
error is AgentApiException && error.status >= 500 -> "SERVER_ERROR"
else -> "NETWORK_ERROR"
}
internal data class AgentSyncResult(
val heartbeat: HeartbeatResult?,
val failureCode: String?,
val canClaim: Boolean,
)
/** Run under the service's existing sync exclusion; device execution keeps its task mutex. */
internal class AgentSyncCycle(
private val heartbeat: (Long?) -> HeartbeatResult,
private val activeTaskId: () -> Long?,
private val executionActive: () -> Boolean,
private val recover: () -> Unit,
private val flush: () -> Unit,
private val hasPending: () -> Boolean,
) {
fun run(): AgentSyncResult {
var pulse = runCatching { heartbeat(activeTaskId()) }
var authFailure = isAgentAuthenticationError(pulse.exceptionOrNull())
var recovered = false
var flushed = false
if (!authFailure && !executionActive()) {
val recovery = runCatching(recover)
recovered = recovery.isSuccess
authFailure = isAgentAuthenticationError(recovery.exceptionOrNull())
if (!authFailure) {
val upload = runCatching(flush)
flushed = upload.isSuccess
authFailure = isAgentAuthenticationError(upload.exceptionOrNull())
}
if (!authFailure && (pulse.exceptionOrNull() as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH") {
pulse = runCatching { heartbeat(activeTaskId()) }
}
}
val failure = pulse.exceptionOrNull()
return AgentSyncResult(
pulse.getOrNull(),
when {
authFailure -> "AUTH_ERROR"
failure == null -> null
isAgentAuthenticationError(failure) -> "AUTH_ERROR"
(failure as? AgentApiException)?.code == "DEVICE_TASK_MISMATCH" -> "TASK_MISMATCH"
failure is AgentApiException && failure.status >= 500 -> "SERVER_ERROR"
else -> "NETWORK_ERROR"
},
pulse.isSuccess && !authFailure && recovered && flushed && !executionActive() && !hasPending() && activeTaskId() == null,
)
}
}
@@ -0,0 +1,16 @@
package cn.ilapage.goauto.agent.service
internal fun shouldClearRejectedPurchase(
rejectedTaskId: Long, runtimeTaskId: Long?, runtimeTaskType: String?,
executingTaskId: Long?, currentAttemptRejected: Boolean,
): Boolean = executingTaskId == null && runtimeTaskId == rejectedTaskId &&
runtimeTaskType == "purchase" && currentAttemptRejected
internal class AgentStatePublicationGate {
private var stopped = false
@Synchronized fun publish(write: () -> Unit) { if (!stopped) write() }
@Synchronized fun stop(write: () -> Unit) { stopped = true; write() }
}
internal fun tryAcquireCurrentPage(mutex: TaskExecutionMutex, syncing: java.util.concurrent.atomic.AtomicBoolean, reservation: Long): Boolean =
synchronized(mutex) { !syncing.get() && mutex.tryAcquire(reservation) }
@@ -24,6 +24,7 @@ import cn.ilapage.goauto.agent.network.CollectionHistoryItem
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
@@ -58,6 +59,7 @@ class AgentSettingsFragment : Fragment() {
private lateinit var connectionFeedback: TextView
private lateinit var disabledReason: TextView
private lateinit var diagnostics: TextView
private lateinit var rejectedResultsButton: MaterialButton
private lateinit var accessibilityText: TextView
private lateinit var collectionIntervalStartLayout: TextInputLayout
private lateinit var collectionIntervalStartInput: TextInputEditText
@@ -326,6 +328,20 @@ class AgentSettingsFragment : Fragment() {
diagnostics = context.label("—", 14f, context.getColor(R.color.agent_text))
diagnostics.setPadding(0, context.dp(10), 0, 0)
addView(diagnostics)
rejectedResultsButton = MaterialButton(context).apply {
text = "查看拒收结果"
minHeight = context.dp(48)
setOnClickListener {
val records = PurchaseTaskStore(context).use { it.rejectedResults() }
MaterialAlertDialogBuilder(context).setTitle("采购结果被服务端拒收")
.setMessage(records.joinToString("\n\n") {
PurchaseRejectionPresentation.notice(it) + "\n${it.errorCode}" +
if (it.acknowledgedAt != null) "\n已核对(仅本机记录)" else ""
}.ifBlank { "暂无拒收结果" })
.setPositiveButton("知道了", null).show()
}
}
addView(rejectedResultsButton, fullWidth(8))
}))
}
return context.page(content)
@@ -669,12 +685,14 @@ class AgentSettingsFragment : Fragment() {
disabledReason.text = if (busy) "任务执行中,暂时不能修改服务器或设备名称。" else ""
val installId = runCatching { identityStore.installId() }.getOrElse { "读取失败" }
val rejectedCount = PurchaseTaskStore(context).use { it.rejectedCount() }
rejectedResultsButton.visibility = if (rejectedCount > 0) View.VISIBLE else View.GONE
diagnostics.text = buildString {
append("installId:$installId\n")
append("Device Token:${if (state.tokenStored) "已配置" else "未配置"}\n")
append("注册状态:${if (state.deviceId > 0) "已注册(设备 ${state.deviceId})" else "未注册"}\n")
append("Agent 版本:${BuildConfig.VERSION_NAME}\n")
append("服务端连接:${if (state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")) "已连接" else "未连接"}\n")
append("服务端连接:${PurchaseRejectionPresentation.connection(stateStore.connectionCode(), rejectedCount)}\n")
append("保持屏幕常亮:${if (state.keepScreenOn) "已开启" else "仅在任务执行或采集间隔时开启"}")
}
accessibilityText.text = when (AccessibilityReadinessDetector.current(context)) {
@@ -0,0 +1,28 @@
package cn.ilapage.goauto.agent.ui
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
import org.json.JSONObject
internal object PurchaseRejectionPresentation {
private fun payload(item: RejectedPurchaseResult) = runCatching { JSONObject(item.payloadJson) }.getOrNull()
private fun orderNo(item: RejectedPurchaseResult): String? = payload(item)?.optString("pddOrderNo")
?.takeIf { it.matches(Regex("[0-9A-Za-z-]{1,80}")) }
fun showBanner(item: RejectedPurchaseResult): Boolean = item.acknowledgedAt == null &&
(payload(item)?.optString("resultType") in setOf("order_created", "order_result_unknown") ||
!payload(item)?.optString("pddOrderNo").isNullOrBlank())
fun notice(item: RejectedPurchaseResult): String = buildString {
append("采购任务 #${item.taskId} 的结果被服务端拒收")
orderNo(item)?.let { append("\n拼多多订单号:$it") }
append("\n请人工核对拼多多订单及后台任务,避免重复采购")
}
fun connection(code: String, rejected: Int): String = when (code) {
"CONNECTING" -> "正在同步"
"ONLINE", "BUSY", "COLLECTION_COOLDOWN", "TASK_ERROR" -> if (rejected > 0) "已连接 · 有 $rejected 条采购结果被服务端拒收" else "已连接"
"AUTH_ERROR" -> "未连接 · 设备认证失败"
"TASK_MISMATCH" -> "未连接 · 设备任务状态不一致"
"NETWORK_ERROR" -> "未连接 · 网络暂不可用"
"SERVER_ERROR" -> "未连接 · 服务端暂不可用"
"CONFIG_REQUIRED" -> "未连接 · 请配置服务端"
else -> "未连接 · 同步暂未完成"
}
}
@@ -37,6 +37,8 @@ import cn.ilapage.goauto.agent.network.HistoryColorImage
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
import cn.ilapage.goauto.agent.service.AgentStateStore
@@ -146,8 +148,14 @@ class TaskHistoryFragment : Fragment() {
private var repurchaseButton: MaterialButton? = null
private var repurchaseDialog: androidx.appcompat.app.AlertDialog? = null
private var repurchaseDialogRound: String? = null
private var rejectionPanel: LinearLayout? = null
private var displayedRejections: List<RejectedPurchaseResult>? = null
private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED) {
renderRejections()
return
}
if (intent?.action == AgentForegroundService.ACTION_REPURCHASE_STATE) {
renderRepurchase()
if (!collection && isResumed && AgentForegroundService.repurchaseState.phase == cn.ilapage.goauto.agent.service.RepurchasePhase.FINISHED) {
@@ -200,6 +208,10 @@ class TaskHistoryFragment : Fragment() {
val context = requireContext()
pageColumn = context.column()
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
if (!collection) {
rejectionPanel = context.column(0)
pageColumn.addView(rejectionPanel)
}
pageColumn.addView(buildSearch())
if (!collection) {
repurchasePanel = context.column(0)
@@ -228,6 +240,7 @@ class TaskHistoryFragment : Fragment() {
override fun onResume() {
super.onResume()
renderRejections()
renderRepurchase()
detailState.taskId?.let { taskId ->
if (collection) loadCollectionDetail(taskId) else loadPurchaseDetail(taskId)
@@ -235,6 +248,8 @@ class TaskHistoryFragment : Fragment() {
}
override fun onDestroyView() {
rejectionPanel = null
displayedRejections = null
repurchaseDialog?.setOnDismissListener(null)
repurchaseDialog?.dismiss()
repurchaseDialog = null
@@ -247,6 +262,29 @@ class TaskHistoryFragment : Fragment() {
super.onDestroyView()
}
private fun renderRejections() {
val panel = rejectionPanel ?: return
val context = context ?: return
val records = PurchaseTaskStore(context).use { it.rejectedResults() }.filter(PurchaseRejectionPresentation::showBanner)
if (records == displayedRejections) return
displayedRejections = records
panel.removeAllViews()
records.forEach { record ->
panel.addView(context.card(context.cardColumn().apply {
addView(context.label(PurchaseRejectionPresentation.notice(record), 14f, context.getColor(R.color.agent_warning), true))
addView(MaterialButton(context).apply {
text = "已核对"
minHeight = context.dp(48)
contentDescription = "采购任务 #${record.taskId} 已核对,仅隐藏本机提醒"
setOnClickListener {
val saved = runCatching { PurchaseTaskStore(context).use { it.acknowledgeRejection(record.id) } }.isSuccess
if (saved) renderRejections() else toast("暂时无法保存核对记录,请稍后重试")
}
}, fullWidth(8))
}))
}
}
override fun onDestroy() {
unregisterCurrentPageReceiver()
imageLoader.close()
@@ -811,6 +849,7 @@ class TaskHistoryFragment : Fragment() {
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
filter.addAction(AgentForegroundService.ACTION_REPURCHASE_STATE)
filter.addAction(AgentForegroundService.ACTION_PURCHASE_REJECTIONS_CHANGED)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@@ -893,6 +932,11 @@ class TaskHistoryFragment : Fragment() {
private fun renderPurchaseDetail(detail: PurchaseHistoryDetail) {
val context = requireContext()
val task = detail.task
PurchaseTaskStore(context).use { it.rejectedResults() }.filter { it.taskId == task.taskId }.forEach { record ->
resultColumn.addView(context.card(context.cardColumn().apply {
addView(context.label(PurchaseRejectionPresentation.notice(record) + "\n${record.errorCode}", 14f, context.getColor(R.color.agent_warning)))
}))
}
val info = buildString {
append("蝦皮订单号:${task.shopeeOrderNo.ifBlank { "—" }}\n")
append("PDD 商品:${task.pddGoodsId}\n")
@@ -1,10 +1,43 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
import org.junit.Assert.*
import org.junit.Test
class PddLaunchFallbackTest {
@Test fun reportsDirectAcceptanceWithoutCallingBrowser() {
val events = mutableListOf<PddLaunchEvent>()
assertTrue(PddLaunchFallback.open(true, { true }, { error("unexpected") }, events::add))
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)), events)
}
@Test fun reportsOnlyExceptionClassThenBrowserAcceptance() {
val events = mutableListOf<PddLaunchEvent>()
assertTrue(PddLaunchFallback.open(true, { throw SecurityException("secret URL and address") }, { true }, events::add))
assertEquals(listOf(
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.ACCEPTED),
), events)
assertFalse(events.toString().contains("secret"))
}
@Test fun reportsFalseAndBrowserExceptionWithoutChangingResult() {
val events = mutableListOf<PddLaunchEvent>()
assertFalse(PddLaunchFallback.open(true, { false }, { throw IllegalStateException("private") }, events::add))
assertEquals(PddLaunchStatus.REJECTED, events[0].status)
assertEquals(PddLaunchStatus.EXCEPTION, events[1].status)
}
@Test fun diagnosticFailureDoesNotCauseBrowserFallbackOrChangeResult() {
var browserCalls = 0
assertTrue(PddLaunchFallback.open(true, { true }, { browserCalls++; true }, { error("storage failed") }))
assertEquals(0, browserCalls)
}
@Test fun browserOnlyReportsNoDirectAttempt() {
val events = mutableListOf<PddLaunchEvent>()
assertFalse(PddLaunchFallback.open(false, { error("unexpected") }, { false }, events::add))
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED)), events)
}
@Test fun directSuccessDoesNotStartBrowser() {
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
}
@@ -0,0 +1,75 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
class PurchaseNavigationReadinessTest {
private val pdd = PurchaseNavigationReadiness.PDD_PACKAGE
private val browser = "com.heytap.browser"
private val old = ProductStabilityKey("old", true, false, false)
private val fresh = old.copy(title = "new")
@Test fun `diag3 browser route ignores old product before browser then buffers returned pdd`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
for (now in listOf(921L, 1_000L, 1_738L)) assertFalse(policy.observe(now, pdd, old))
assertFalse(policy.observe(1_739, browser, null))
assertFalse(policy.observe(12_189, pdd, old))
assertFalse(policy.observe(13_000, pdd, old))
assertFalse(policy.observe(13_218, pdd, fresh))
assertFalse(policy.observe(14_218, pdd, fresh))
assertFalse(policy.observe(15_188, pdd, fresh))
assertTrue(policy.observe(15_189, pdd, fresh))
}
@Test fun `stable initial product cannot pass browser route even after buffer`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
for (now in 0L..30_000L step 100) assertFalse(policy.observe(now, pdd, old))
assertTrue(policy.expired(30_000))
}
@Test fun `direct route requires buffer plus three stable samples without an empty frame`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 500)
assertFalse(policy.observe(500, pdd, old))
assertFalse(policy.observe(2_500, pdd, old))
assertFalse(policy.observe(3_499, pdd, old))
assertTrue(policy.observe(3_500, pdd, old))
val three = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 0)
assertFalse(three.observe(3_000, pdd, old))
assertFalse(three.observe(4_000, pdd, old))
assertTrue(three.observe(4_001, pdd, old))
}
@Test fun `stability resets on package root missing evidence title and structure changes`() {
for ((pkg, key) in listOf(browser to null, pdd to null, pdd to fresh, pdd to old.copy(panel = true))) {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 0)
policy.observe(2_000, pdd, old)
policy.observe(2_500, pdd, old)
assertFalse(policy.observe(2_999, pkg, key))
assertFalse(policy.observe(3_000, pdd, old))
assertFalse(policy.observe(3_999, pdd, old))
assertTrue(policy.observe(4_000, pdd, old))
}
}
@Test fun `unknown path never passes and only approved browser route clicks at least one second apart`() {
val unknown = PurchaseNavigationReadiness(0, null, 0)
assertFalse(unknown.observe(4_000, pdd, old))
assertFalse(unknown.mayClickBrowser(4_000, browser))
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.BROWSER, 0)
for (pkg in listOf(pdd, "cn.ilapage.goauto.agent", "other.browser")) assertFalse(policy.mayClickBrowser(0, pkg))
for (pkg in listOf(browser, "com.android.chrome", "com.android.browser")) assertTrue(policy.mayClickBrowser(0, pkg))
policy.clicked(0)
assertFalse(policy.mayClickBrowser(999, browser))
assertTrue(policy.mayClickBrowser(1_000, browser))
assertFalse(policy.mayClickBrowser(30_000, browser))
}
@Test fun `deadline includes launch time and never accepts at thirty seconds`() {
val policy = PurchaseNavigationReadiness(0, PddLaunchPath.DIRECT, 29_000)
assertFalse(policy.observe(29_000, pdd, old))
assertFalse(policy.observe(29_500, pdd, old))
assertFalse(policy.observe(30_000, pdd, old))
assertTrue(policy.expired(-1))
}
}
@@ -0,0 +1,83 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import org.junit.Assert.*
import org.junit.Test
class PurchaseOutboxHandoffTest {
private val item = PendingPurchaseOutbox(1, 42, "probe", "request", "{}")
@Test fun `terminal conflict does not block later pending result`() {
val submitted = mutableListOf<Long>()
val uploaded = mutableListOf<Long>()
val result = runCatching {
PurchaseOutboxUploader(
{ listOf(item, item.copy(id = 2, taskId = 43)) },
{ submitted += it.id; if (it.id == 1L) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "untrusted", false) },
{ uploaded += it.id },
markRejected = { _, _ -> },
).flush()
}
assertTrue(result.isSuccess)
assertEquals(listOf(1L, 2L), submitted)
assertEquals(listOf(2L), uploaded)
}
@Test fun `handoff callback runs only after successful submit and local upload mark`() {
for (failure in listOf("submit", "mark", "none")) {
val events = mutableListOf<String>()
val uploader = PurchaseOutboxUploader(
pending = { listOf(item) },
submit = { events += "submit"; if (failure == "submit") error("offline") },
markUploaded = { events += "mark"; if (failure == "mark") error("disk") },
afterUploaded = { events += "handoff" },
)
runCatching(uploader::flush)
assertEquals(when (failure) {
"submit" -> listOf("submit")
"mark" -> listOf("submit", "mark")
else -> listOf("submit", "mark", "handoff")
}, events)
}
}
@Test fun `ordinary replay uploader has no handoff callback`() {
var submitted = 0
PurchaseOutboxUploader({ listOf(item) }, { submitted++ }, {}).flush()
assertEquals(1, submitted)
}
@Test fun `only whitelisted 409 is terminal independent of retryable`() {
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "OTHER")) {
for (status in listOf(409, 401, 403, 500)) for (retryable in listOf(false, true)) {
val events = mutableListOf<String>()
val result = runCatching {
PurchaseOutboxUploader({ listOf(item) },
{ throw cn.ilapage.goauto.agent.network.AgentApiException(status, code, "secret URL", retryable) },
{ events += "uploaded" }, { events += "handoff" },
{ _, fixedCode -> events += fixedCode },
).flush()
}
val rejected = status == 409 && code != "OTHER"
assertEquals(rejected, result.isSuccess)
assertEquals(if (rejected) listOf(code) else emptyList<String>(), events)
}
}
}
@Test fun `network and rejected persistence failures retain pending and stop flush`() {
for (diskFailure in listOf(false, true)) {
val events = mutableListOf<String>()
val result = runCatching {
PurchaseOutboxUploader({ listOf(item, item.copy(id = 2)) },
{ events += "submit"; if (diskFailure) throw cn.ilapage.goauto.agent.network.AgentApiException(409, "PURCHASE_STATE_CONFLICT", "raw", false) else error("network") },
{ events += "uploaded" }, { events += "handoff" },
{ _, _ -> events += "reject"; error("disk") },
).flush()
}
assertTrue(result.isFailure)
assertEquals(if (diskFailure) listOf("submit", "reject") else listOf("submit"), events)
}
}
}
@@ -0,0 +1,54 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.AgentApiClient
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import java.net.ServerSocket
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
class PurchaseOutboxHttpTest {
@Test fun `missing retryable is not evidence that an unknown 409 is terminal`() {
for (code in listOf("PURCHASE_STATE_CONFLICT", "PURCHASE_LEASE_EXPIRED", "PURCHASE_RESULT_CONFLICT", "UNKNOWN_CONFLICT")) {
ServerSocket(0).use { server ->
server.soTimeout = 3000
val executor = Executors.newSingleThreadExecutor()
val serving = executor.submit {
server.accept().use { socket ->
socket.soTimeout = 3000
val reader = socket.getInputStream().bufferedReader()
var contentLength = 0
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break
if (line.startsWith("Content-Length:", true)) contentLength = line.substringAfter(':').trim().toInt()
}
repeat(contentLength) { check(reader.read() >= 0) }
val response = """{"code":"$code","message":"untrusted diagnostic"}"""
socket.getOutputStream().write(("HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: ${response.toByteArray().size}\r\nConnection: close\r\n\r\n$response").toByteArray())
}
}
try {
val api = AgentApiClient("http://127.0.0.1:${server.localPort}")
var pending = true
val result = runCatching {
PurchaseOutboxUploader(
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
{ api.submitPurchaseResult(it.taskId,it.payloadJson,"synthetic") },
{ fail("409 is never uploaded") },
{ fail("409 is never a handoff") },
{ _, _ -> pending = false },
).flush()
}
assertEquals(code == "UNKNOWN_CONFLICT", pending)
assertEquals(code != "UNKNOWN_CONFLICT", result.isSuccess)
if (pending) assertFalse((result.exceptionOrNull() as AgentApiException).retryable)
serving.get(5, TimeUnit.SECONDS)
} finally { executor.shutdownNow() }
}
}
}
}
@@ -0,0 +1,78 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PurchaseProbeHandoff
import org.junit.Assert.*
import org.junit.Test
class PurchaseProbeHandoffTest {
@Test fun `post capture confirmation rejects expiry foreground change recreation and later execution`() {
val state = seeded()
val execution = state.beginExecution()
assertTrue(state.eligible(execution, 42, "123", "purchase", "next", accessibility, 7, 1_000))
assertTrue(state.stillValid(execution, accessibility, 7, 30_999))
assertFalse(state.stillValid(execution, accessibility, 7, 31_000))
assertFalse(state.stillValid(execution, accessibility, 8, 1_001))
assertFalse(state.stillValid(execution, Any(), 7, 1_001))
state.beginExecution()
assertFalse(state.stillValid(execution, accessibility, 7, 1_001))
}
private val accessibility = Any()
private fun seeded(age: Long = 0, success: Boolean = true, persisted: Boolean = true, pending: Boolean = true, replayed: Boolean = false): PurchaseProbeHandoff {
return PurchaseProbeHandoff().also { state ->
val execution = state.beginExecution()
state.completeProbe(execution, 42, "123", "probe-attempt", 1_000 - age, accessibility, 7,
successful = success, safelyStored = persisted, submitPending = pending, replayed = replayed)
}
}
@Test fun `handoff age is completion based exclusive thirty seconds`() {
for (age in listOf(0L, 29_999L, 30_000L, -1L)) {
val state = seeded(age)
assertEquals(age in 0..29_999, state.eligible(state.beginExecution(), 42, "123", "purchase", "purchase-attempt", accessibility, 7, 1_000))
}
}
@Test fun `handoff is consumed before all eligibility checks`() {
val state = seeded()
assertFalse(state.eligible(state.beginExecution(), 43, "123", "purchase", "next", accessibility, 7, 1_000))
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
@Test fun `different source and purchase attempts are normal but source replay cannot reuse`() {
val state = seeded()
assertTrue(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
val replay = seeded()
assertFalse(replay.eligible(replay.beginExecution(), 42, "123", "purchase", "probe-attempt", accessibility, 7, 1_000))
}
@Test fun `failed persistence failed matching and replayed uploads create no credential`() {
for (state in listOf(seeded(success = false), seeded(persisted = false), seeded(pending = false), seeded(replayed = true))) {
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
}
@Test fun `interleaving generation accessibility foreground service and explicit navigation invalidate`() {
val interleaved = seeded()
val ticket = interleaved.beginExecution()
interleaved.beginExecution()
assertFalse(interleaved.eligible(ticket, 42, "123", "purchase", "next", accessibility, 7, 1_000))
val replaced = seeded()
assertFalse(replaced.eligible(replaced.beginExecution(), 42, "123", "purchase", "next", Any(), 7, 1_000))
val foreground = seeded()
assertFalse(foreground.eligible(foreground.beginExecution(), 42, "123", "purchase", "next", accessibility, 8, 1_000))
val freshService = PurchaseProbeHandoff()
assertFalse(freshService.eligible(freshService.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
val navigated = seeded()
navigated.invalidate()
assertFalse(navigated.eligible(navigated.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
@Test fun `wrong goods or phase consumes and delayed completion cannot overwrite later execution`() {
for ((goods, phase) in listOf("456" to "purchase", "123" to "spec_probe", "123" to "collection")) {
val state = seeded()
assertFalse(state.eligible(state.beginExecution(), 42, goods, phase, "next", accessibility, 7, 1_000))
}
val state = PurchaseProbeHandoff()
val old = state.beginExecution()
state.beginExecution()
state.completeProbe(old, 42, "123", "probe", 1_000, accessibility, 7, true, true, true, false)
assertFalse(state.eligible(state.beginExecution(), 42, "123", "purchase", "next", accessibility, 7, 1_000))
}
}
@@ -12,6 +12,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome
import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.PddLaunchPath
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
@@ -24,13 +26,197 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@Test
fun `slow first browser lookup cannot compress the actual retry interval`() {
var now = 0L
val clickedAt = mutableListOf<Long>()
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
if (clickedAt.isEmpty()) now += 900
if (allowed()) clickedAt += now
return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE)
}
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(clickedAt.size > 1)
assertTrue(clickedAt.zipWithNext().all { (a, b) -> b - a >= 1_000 })
assertTrue(clickedAt.all { it < 30_000 })
}
@Test
fun `unapproved package with matching open button is never clicked`() {
var now = 0L
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun capture() = underlying.capture().copy(packageName = "other.application")
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `launch dispatch consumes the navigation deadline before first capture`() {
var now = 0L
var captures = 0
val underlying = FakePurchaseDriver()
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot { captures++; return underlying.capture() }
}
val outcome = PurchaseRehearsalExecutor(driver, { now = 30_000; true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.DIRECT }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(0, captures)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `handoff invalidated during fresh capture reopens instead of reusing`() {
var now = 29_999L
var opens = 0
val underlying = FakePurchaseDriver()
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot { now = 30_000; return underlying.capture() }
}
val outcome = PurchaseRehearsalExecutor(driver, { opens++; false }, { null }, pause = {},
monotonicNow = { now }, confirmProbeHandoff = { now < 30_000 },
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(1, opens)
assertEquals("PDD_LINK_INVALID", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `input reuse flag defaults false`() {
val task = PurchaseExecutionInput(42, "rehearsal", "purchase", input().url, input().goodsId,
"黑色", "XL", quantity = 2, minUnitPriceCent = 1000, maxUnitPriceCent = 3000)
assertFalse(task.reuseProbeProduct)
}
@Test
fun `diag3 timeline waits for browser and returned product buffer before any spec action`() {
var now = 921L
var probeAt = -1L
val underlying = FakePurchaseDriver()
val clicksAt = mutableListOf<Long>()
val browserSnapshot = FakePurchaseDriver().apply { browser = true }.capture()
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot {
if (now in 1_739 until 12_189) return browserSnapshot
val current = underlying.capture()
return if (now < 13_218) current else current.copy(nodes = current.nodes.map {
if (it.path == "title") it.copy(text = "另一件商品标题文本") else it
})
}
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
assertTrue(now in 1_739 until 12_189)
clicksAt += now
return FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS)
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
assertTrue("spec action at $now", now >= 15_200)
return underlying.clickFreshDetailed(target)
}
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { probeAt = now; "{}" },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe", reuseProbeProduct = false), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(probeAt >= 15_200)
assertTrue(clicksAt.zipWithNext().all { (a, b) -> b - a >= 1_000 })
}
@Test
fun `slow capture crossing deadline cannot click browser or enter specs`() {
var now = 0L
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun capture(): UiSnapshot { now = 30_000; return underlying.capture() }
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `fresh browser node lookup crossing deadline declines action`() {
var now = 0L
val underlying = FakePurchaseDriver().apply { browser = true }
val driver = object : PurchaseUiDriver by underlying {
override fun clickNavigationFresh(target: SnapshotNode, allowed: () -> Boolean): FreshClickOutcome {
now = 30_000
assertFalse(allowed())
return FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.UNKNOWN)
}
}
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertTrue(underlying.clicked.isEmpty())
}
@Test
fun `direct accepted launch buffers same package product without empty frame`() {
var now = 0L
var probeAt = -1L
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { probeAt = now; "{}" },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.DIRECT }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(probeAt >= 3_000)
assertEquals(0, driver.openClickCount)
}
@Test
fun `browser launch without observed browser times out on stable old pdd`() {
var now = 0L
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { error("probe must not run") },
pause = { now += it }, acceptedLaunchPath = { PddLaunchPath.BROWSER }, monotonicNow = { now },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(30_000L, now)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `untrusted current product does not bypass navigation`() {
var opens = 0
val outcome = executor(FakePurchaseDriver(), { opens++; false }, { null }, pause = {})
.execute(input().copy(reuseProbeProduct = false), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(1, opens)
assertEquals("PDD_LINK_INVALID", outcome.errorCode)
}
@Test
fun `old pdd product without accepted launch evidence times out before probe`() {
var probes = 0
val driver = FakePurchaseDriver()
val outcome = executor(driver, { true }, { probes++; "{}" }, pause = {}, path = null)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(0, probes)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `probe skips post open swipes but preserves wait and records click`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
val clicks = mutableListOf<FreshClickOutcome>()
val configured = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":1234")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" },
val outcome = executor(driver, { driver.browser = true; true }, { "{}" },
pause = pauses::add, probeClickDiagnostic = { _, _, click -> clicks += click },
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
@@ -43,7 +229,7 @@ class PurchaseRehearsalExecutorTest {
fun `blocked entry stops without gesture or probe`() {
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
@@ -53,7 +239,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `blocked size stops without gesture retry`() {
val driver = FakePurchaseDriver(forcedSizeClickReason = FreshClickReason.SIZE_ADVICE_BLOCKED)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
@@ -64,7 +250,7 @@ class PurchaseRehearsalExecutorTest {
fun `blocked gesture stops without another click`() {
val driver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.ACTION_CLICK_FALSE, specTapResult = FreshActionResult.BLOCKED)
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("SIZE_ADVICE_CLICK_BLOCKED", outcome.errorCode)
assertEquals(1, driver.specTapCount)
@@ -74,7 +260,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `purchase still fails required post open swipe`() {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertEquals(1, driver.swipeCount)
@@ -83,7 +269,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `color selection then single size heading completes without selecting color again`() {
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == "黑色" })
@@ -93,7 +279,7 @@ class PurchaseRehearsalExecutorTest {
fun `full exact summary confirms size after option leaves viewport`() {
val target = "2XL 建议131到150斤"
val driver = FakePurchaseDriver(sizes = listOf(target), hideSizeAfterSelection = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == target })
@@ -106,7 +292,7 @@ class PurchaseRehearsalExecutorTest {
sizes = listOf(target), hideSizeAfterSelection = true,
selectedSizeSummaryOverride = "2XL 建议151到170斤",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
}
@@ -160,7 +346,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
@@ -181,7 +367,7 @@ class PurchaseRehearsalExecutorTest {
fun `manual purchase retry from agent reopens the task product url`() {
val driver = FakePurchaseDriver(initiallyInAgent = true)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = {
openCount++
@@ -201,7 +387,7 @@ class PurchaseRehearsalExecutorTest {
fun `purchase phase reopens when pdd foreground has no product evidence`() {
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
@@ -217,7 +403,7 @@ class PurchaseRehearsalExecutorTest {
fun `purchase phase does not reuse a pdd login page`() {
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
@@ -231,7 +417,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `price range failure reports the observed unit price`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(maxUnitPriceCent = 1_999), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode)
@@ -246,7 +432,7 @@ class PurchaseRehearsalExecutorTest {
"\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\"",
"\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\",\"purchase.future.v1\"",
)
val outcome = PurchaseRehearsalExecutor(driver, { openCount++; true }, { null }, pause = {})
val outcome = executor(driver, { openCount++; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(unsupported), PurchaseAgentCapabilities.supported)
assertEquals("AGENT_CAPABILITY_MISMATCH", outcome.errorCode)
assertEquals(0, openCount)
@@ -256,7 +442,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `missing exact spec returns probe and never chooses similar value`() {
val driver = FakePurchaseDriver(colors = listOf("黑色加绒"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals("{\"dimensions\":[]}", outcome.probedSpecs)
@@ -268,7 +454,7 @@ class PurchaseRehearsalExecutorTest {
fun `missing exact spec without server permission preserves the real failure`() {
val driver = FakePurchaseDriver(colors = listOf("黑色加绒"))
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{\"dimensions\":[]}" }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -280,7 +466,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `exact size below the visible panel is found after a bounded vertical search`() {
val driver = FakePurchaseDriver(hiddenSizeUntilUpSwipes = 3)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -295,7 +481,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -307,7 +493,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `visible exact color keeps established path without horizontal fallback`() {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -319,7 +505,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
horizontalColorPages = listOf(listOf("黑色"), listOf("富贵粉")),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
@@ -334,7 +520,7 @@ class PurchaseRehearsalExecutorTest {
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
horizontalSpecSwipeSucceeds = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
@@ -351,7 +537,7 @@ class PurchaseRehearsalExecutorTest {
listOf("2XL 推荐125-140斤", targetSize),
),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -366,7 +552,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
horizontalSizePages = listOf(listOf("S 推荐80-95斤"), listOf(targetSize)),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -381,7 +567,7 @@ class PurchaseRehearsalExecutorTest {
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
horizontalSizePages = listOf(listOf("S 推荐80-95斤", "M 推荐95-110斤"), listOf("2XL 推荐125-140斤", targetSize)),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(
input().copy(mappedColor = "富贵粉", mappedSize = targetSize),
PurchaseRuleParser.parse(rule()),
@@ -398,7 +584,7 @@ class PurchaseRehearsalExecutorTest {
fun `two column size grid continues vertically without horizontal container`() {
val target = "3XL 推荐140-155斤"
val driver = FakePurchaseDriver(sizes = listOf(target), revealGridSizeAfterUpSwipes = 7)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(target, driver.size)
@@ -415,7 +601,7 @@ class PurchaseRehearsalExecutorTest {
horizontalSpecSwipeSucceeds = false,
revealGridSizeAfterUpSwipes = 7,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(target, driver.size)
@@ -425,7 +611,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `missing size terminates at stable viewport with bounded gestures`() {
val driver = FakePurchaseDriver(sizes = listOf("S", "M"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
assertTrue(outcome.message.contains("reason=stableViewport"))
@@ -436,7 +622,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `visible exact size does not enter horizontal fallback`() {
val driver = FakePurchaseDriver(sizes = listOf("L", "XL"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -449,7 +635,7 @@ class PurchaseRehearsalExecutorTest {
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
sizeSelectsOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -463,7 +649,7 @@ class PurchaseRehearsalExecutorTest {
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -475,7 +661,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `already selected exact size is not clicked again`() {
val driver = FakePurchaseDriver(initialSize = "XL")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -491,7 +677,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
hideSizeSelectedState = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -501,7 +687,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `final verification accepts current attempt proof when selected color scrolls out of viewport`() {
val driver = FakePurchaseDriver(hideColorAfterQuantitySet = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -517,7 +703,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
hideSizeAfterQuantitySet = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -530,7 +716,7 @@ class PurchaseRehearsalExecutorTest {
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -540,7 +726,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(quantity = 1), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -555,7 +741,7 @@ class PurchaseRehearsalExecutorTest {
hideSelectedSummaryAfterQuantitySet = true,
restoreHiddenColorOnDownSwipe = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -571,7 +757,7 @@ class PurchaseRehearsalExecutorTest {
hideSelectedSummaryAfterQuantitySet = true,
selectedColorOverrideAfterQuantitySet = "白色",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -589,7 +775,7 @@ class PurchaseRehearsalExecutorTest {
hideSizeAfterQuantitySet = true,
selectedSummaryOverrideAfterQuantitySet = "黑色 2XL",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -606,7 +792,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
finalSizesAfterQuantitySet = listOf("XL【建议B】"),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -624,7 +810,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "2XL",
hideSizeSelectedState = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -639,7 +825,7 @@ class PurchaseRehearsalExecutorTest {
selectedSizeSummaryOverride = "XL",
hideSizeSelectedState = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -651,7 +837,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
@@ -672,7 +858,7 @@ class PurchaseRehearsalExecutorTest {
cases.forEach { (reason, expected) ->
val driver = FakePurchaseDriver(forcedSizeClickReason = reason)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
@@ -684,7 +870,7 @@ class PurchaseRehearsalExecutorTest {
fun `visible unavailable exact target fails as safe target missing without probing`() {
val driver = FakePurchaseDriver(unavailableSizes = setOf("XL"))
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
@@ -697,7 +883,7 @@ class PurchaseRehearsalExecutorTest {
listOf("¥15.78", "XL ¥15.78起").forEach { unsafeTarget ->
val driver = FakePurchaseDriver()
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(mappedSize = unsafeTarget, specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
@@ -709,7 +895,7 @@ class PurchaseRehearsalExecutorTest {
fun `legacy task target with a safe trailing price selects the clean page value`() {
val clean = "XL【建议121-140斤】"
val driver = FakePurchaseDriver(sizes = listOf(clean))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(mappedSize = "$clean ¥15.78"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -719,7 +905,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `two raw page values that normalize to one size fail as ambiguous`() {
val driver = FakePurchaseDriver(sizes = listOf("XL ¥15.78", "XL ¥16.80"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -730,7 +916,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `price outside range fails before any order action`() {
val driver = FakePurchaseDriver(priceCent = 4_000)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode)
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
@@ -739,7 +925,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `ambiguous browser target stops safely`() {
val driver = FakePurchaseDriver(duplicateOpen = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_AMBIGUOUS", outcome.errorCode)
assertTrue(driver.clicked.isEmpty())
@@ -751,7 +937,7 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.FAILED),
openPddOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
@@ -764,7 +950,7 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(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)
@@ -775,46 +961,46 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `persistent open click failure is bounded and remains safely failed`() {
val driver = FakePurchaseDriver(
openClickResults = MutableList(20) { FreshActionResult.FAILED },
openClickResults = MutableList(40) { FreshActionResult.FAILED },
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(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..15)
assertEquals(150, pauses.size)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals(30, driver.openClickCount)
assertEquals(300, 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)
val outcome = executor(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)
assertTrue(pauses.count { it == 100L } in 82..90)
}
@Test
fun `pdd foreground without stable product evidence times out at fifteen seconds`() {
fun `pdd foreground without stable product evidence times out at thirty seconds`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 200)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val driver = FakePurchaseDriver(loadingPddCaptures = 400)
val outcome = executor(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 })
assertEquals(300, 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)
val outcome = executor(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)
@@ -825,7 +1011,7 @@ class PurchaseRehearsalExecutorTest {
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -841,7 +1027,7 @@ class PurchaseRehearsalExecutorTest {
)
var probes = 0
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver, { driver.browser = true; true }, { probes++; "{}" },
pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -857,7 +1043,7 @@ class PurchaseRehearsalExecutorTest {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
var probes = 0
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver, { driver.browser = true; true }, { probes++; "{}" },
pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -876,7 +1062,7 @@ class PurchaseRehearsalExecutorTest {
PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0),
))
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
@@ -891,7 +1077,7 @@ class PurchaseRehearsalExecutorTest {
PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0),
))
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -903,7 +1089,7 @@ class PurchaseRehearsalExecutorTest {
fun `scrollable spec reveal preserves configured swipe count and interval`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -918,7 +1104,7 @@ class PurchaseRehearsalExecutorTest {
"\"waitAfterMs\":700}",
"\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
@@ -930,7 +1116,7 @@ class PurchaseRehearsalExecutorTest {
fun `skipping a reveal does not skip a failed specification probe`() {
val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet())
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode)
@@ -940,7 +1126,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `unrecognized opened panel returns only scalar panel evidence`() {
val driver = FakePurchaseDriver(unrecognizedPanel = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
@@ -959,7 +1145,7 @@ class PurchaseRehearsalExecutorTest {
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -974,7 +1160,7 @@ class PurchaseRehearsalExecutorTest {
specTapResult = FreshActionResult.SUCCESS,
specTapHasEffect = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
@@ -988,7 +1174,7 @@ class PurchaseRehearsalExecutorTest {
entryActionHasEffect = false,
specTapResult = FreshActionResult.FAILED,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
@@ -1001,7 +1187,7 @@ class PurchaseRehearsalExecutorTest {
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(
val outcome = executor(
driver,
{ driver.browser = true; true },
{ null },
@@ -1022,33 +1208,31 @@ class PurchaseRehearsalExecutorTest {
fun `open spec panel waits for a late safe bottom purchase entry`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(driver.clickedPaths.contains("buy"))
// One 100ms pause belongs to the existing open-product foreground poll;
// two belong to the entry-ready wait before the bottom bar appears.
assertEquals(3, pauses.count { it == 100L })
// The browser return gets a full three-second buffer before the spec entry can be used.
assertEquals(32, pauses.count { it == 100L })
}
@Test
fun `purchase reopens and waits for product evidence instead of a visible loading frame`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
// Two stable reads belong to reopening the product; verifyProduct then
// independently requires its second stable read before continuing.
assertEquals(3, pauses.count { it == 100L })
// Browser return buffer overlaps stable reads; verifyProduct performs its independent check.
assertEquals(32, pauses.count { it == 100L })
}
@Test
fun `transient sold out page recovers before opening specs`() {
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -1059,7 +1243,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `persistent sold out page returns replacement eligible error`() {
val driver = FakePurchaseDriver(soldOut = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
@@ -1070,7 +1254,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `sold out recovery gesture failure stops immediately`() {
val driver = FakePurchaseDriver(soldOut = true, pullDownSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode)
@@ -1080,7 +1264,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `sold out recovery rejects a page that lost product evidence`() {
val driver = FakePurchaseDriver(soldOut = true, loseEvidenceAfterPull = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_NOT_MATCHED", outcome.errorCode)
@@ -1091,7 +1275,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `all unavailable specs recover on goods page and reopen panel`() {
val driver = FakePurchaseDriver(allSpecsUnavailable = true, recoverSoldOutAfterPull = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
@@ -1102,7 +1286,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `goods id mismatch fails before opening pdd`() {
var opened = false
val outcome = PurchaseRehearsalExecutor(FakePurchaseDriver(), { opened = true; true }, { null }, pause = {})
val outcome = executor(FakePurchaseDriver(), { opened = true; true }, { null }, pause = {})
.execute(input().copy(goodsId = "999"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_GOODS_MISMATCH", outcome.errorCode)
assertFalse(opened)
@@ -1111,7 +1295,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `live rule spec probe ignores historical mapping and returns before address or order actions`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(
input().copy(
executionMode = "live",
@@ -1131,7 +1315,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `spec probe opens panel through safe bottom purchase entry`() {
val driver = FakePurchaseDriver(bottomPurchaseEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
@@ -1142,7 +1326,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `purchase returns once and fails explicitly when spec entry opens reviews`() {
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, openReviewOnBottomClick = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
@@ -1163,7 +1347,7 @@ class PurchaseRehearsalExecutorTest {
)
cases.forEach { (reason, expected) ->
val driver = FakePurchaseDriver(forcedEntryClickReason = reason)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
@@ -1179,7 +1363,7 @@ class PurchaseRehearsalExecutorTest {
openReviewOnBottomClick = true,
reviewBackSucceeds = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_OPENED_REVIEW", outcome.errorCode)
@@ -1192,7 +1376,7 @@ class PurchaseRehearsalExecutorTest {
fun `spec aliases cannot bypass semantic safe candidates`() {
val driver = FakePurchaseDriver(includeReviewEntry = true)
val unsafeRule = rule().replace("[\"选择规格\"]", "[\"商品评价\"]")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(unsafeRule), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
@@ -1202,7 +1386,7 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `spec entry failures include scalar diagnostics only`() {
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
@@ -1212,7 +1396,7 @@ class PurchaseRehearsalExecutorTest {
)
val ambiguousDriver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.TARGET_AMBIGUOUS)
val ambiguous = PurchaseRehearsalExecutor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
val ambiguous = executor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
@@ -1329,6 +1513,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals(1, submitted.map { it.payloadJson }.distinct().size)
}
/** Existing spec/price tests explicitly model the trusted immediate probe handoff. */
private fun input() = PurchaseExecutionInput(
taskId = 42,
executionMode = "rehearsal",
@@ -1340,8 +1525,26 @@ class PurchaseRehearsalExecutorTest {
quantity = 2,
minUnitPriceCent = 1_000,
maxUnitPriceCent = 3_000,
reuseProbeProduct = true,
)
private fun executor(
driver: PurchaseUiDriver,
openLink: (String) -> Boolean,
probeSpecs: () -> String?,
pause: (Long) -> Unit,
panelDiagnostic: (String) -> Unit = {},
probeClickDiagnostic: (AgentDiagnosticStage, SnapshotNode, FreshClickOutcome) -> Unit = { _, _, _ -> },
path: PddLaunchPath? = PddLaunchPath.BROWSER,
): PurchaseRehearsalExecutor {
var now = 0L
return PurchaseRehearsalExecutor(driver, openLink, probeSpecs,
pause = { millis -> now += millis; pause(millis) },
panelDiagnostic = panelDiagnostic, probeClickDiagnostic = probeClickDiagnostic,
acceptedLaunchPath = { path }, monotonicNow = { now },
)
}
private fun rule() = """{
"schemaVersion":1,
"ruleType":"pddPurchase",
@@ -0,0 +1,32 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.persistence.RejectedPurchaseResult
import cn.ilapage.goauto.agent.ui.PurchaseRejectionPresentation
import org.junit.Assert.*
import org.junit.Test
class PurchaseRejectionPresentationTest {
private fun item(payload: String, acknowledged: Long? = null) = RejectedPurchaseResult(1,42,"attempt",payload,100,"PURCHASE_STATE_CONFLICT",acknowledged)
@Test fun `only unacknowledged order evidence appears on procurement banner`() {
for (payload in listOf("""{"resultType":"order_created"}""", """{"resultType":"order_result_unknown"}""", """{"resultType":"failed","pddOrderNo":"123-456"}""")) {
assertTrue(PurchaseRejectionPresentation.showBanner(item(payload)))
assertFalse(PurchaseRejectionPresentation.showBanner(item(payload, 200)))
}
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed"}""")))
assertFalse(PurchaseRejectionPresentation.showBanner(item("""{"resultType":"spec_probe_completed","pddOrderNo":null}""")))
}
@Test fun `notice includes task optional order and fixed review instruction without raw errors`() {
val message = PurchaseRejectionPresentation.notice(item("""{"resultType":"order_created","pddOrderNo":"123-456","message":"https://secret"}"""))
assertTrue(message.contains("#42")); assertTrue(message.contains("123-456"))
assertTrue(message.contains("请人工核对拼多多订单及后台任务,避免重复采购"))
assertFalse(message.contains("secret"))
}
@Test fun `connection classifies sync heartbeat and rejected separately`() {
assertEquals("正在同步", PurchaseRejectionPresentation.connection("CONNECTING", 2))
assertEquals("已连接 · 有 2 条采购结果被服务端拒收", PurchaseRejectionPresentation.connection("ONLINE", 2))
assertEquals("未连接 · 设备认证失败", PurchaseRejectionPresentation.connection("AUTH_ERROR", 2))
assertEquals("未连接 · 设备任务状态不一致", PurchaseRejectionPresentation.connection("TASK_MISMATCH", 2))
assertEquals("未连接 · 网络暂不可用", PurchaseRejectionPresentation.connection("NETWORK_ERROR", 2))
assertEquals("未连接 · 服务端暂不可用", PurchaseRejectionPresentation.connection("SERVER_ERROR", 2))
}
}
@@ -0,0 +1,19 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.network.PurchaseResultAcknowledgement
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
class PurchaseResultAcknowledgementTest {
@Test fun `server omits replayed false on pending first response`() {
val ack = PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending"}"""))
assertTrue(ack.pending)
assertFalse(ack.replayed)
}
@Test fun `failed matching and explicit or malformed replay never qualify`() {
assertFalse(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"failed"}""")).pending)
assertTrue(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending","replayed":true}""")).replayed)
assertTrue(PurchaseResultAcknowledgement.from(JSONObject("""{"status":"pending","replayed":"invalid"}""")).replayed)
}
}
@@ -63,6 +63,7 @@ class SpecOptionDedupTest {
private fun input() = PurchaseExecutionInput(
taskId = 332,
reuseProbeProduct = true, // This test starts at the trusted probe-to-purchase spec handoff.
executionMode = "rehearsal",
phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
@@ -716,6 +716,7 @@ class SpecPanelRecognitionTest {
private fun input() = PurchaseExecutionInput(
taskId = 331,
reuseProbeProduct = true, // Panel tests start at the trusted probe-to-purchase handoff.
executionMode = "rehearsal",
phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
@@ -213,7 +213,8 @@ class TruncatedSpecCardTest {
private fun run(snapshot: UiSnapshot, target: String = full, driver: Driver = Driver(snapshot)) = executor(driver).execute(
PurchaseExecutionInput(362, "rehearsal", "purchase", "https://mobile.yangkeduo.com/goods.html?goods_id=123456789",
"123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000),
"123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000,
reuseProbeProduct = true), // Exact-card tests explicitly model the immediate trusted handoff.
PurchaseRuleParser.parse("""{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}"""),
@@ -0,0 +1,100 @@
package cn.ilapage.goauto.agent.persistence
import java.sql.Connection
import java.sql.DriverManager
import org.junit.Assert.*
import org.junit.Test
class PurchaseRejectionStoreTest {
@Test fun `v2 upgrade adds exactly three nullable columns and preserves all old data`() = database { db ->
val before = rows(db, "SELECT * FROM purchase_outbox")
val taskBefore = rows(db, "SELECT * FROM purchase_task")
migrate(db)
assertEquals(3, PurchaseRejectionSql.migration.size)
assertEquals(taskBefore, rows(db, "SELECT * FROM purchase_task"))
assertEquals(before, rows(db, "SELECT id, task_id, attempt_id, request_id, payload_json, upload_status, created_at, updated_at FROM purchase_outbox"))
assertEquals(listOf(listOf(null, null, null)), rows(db, "SELECT rejected_at,rejection_error_code,acknowledged_at FROM purchase_outbox"))
val columns = rows(db, "PRAGMA table_info(purchase_outbox)").takeLast(3)
assertTrue(columns.all { it[3] == "0" && it[4] == null })
}
@Test fun `reject is atomic preserves payload excludes pending and targets current attempt only`() = database { db ->
migrate(db)
reject(db)
assertEquals(listOf(listOf("rejected", "payload", "100", "PURCHASE_STATE_CONFLICT")), rows(db, "SELECT upload_status,payload_json,rejected_at,rejection_error_code FROM purchase_outbox"))
assertEquals(listOf(listOf("completed", "rejected", "payload")), rows(db, "SELECT status,upload_status,result_json FROM purchase_task"))
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
assertTrue(rows(db, "SELECT id FROM purchase_outbox WHERE upload_status='pending'").isEmpty())
}
@Test fun `old outbox rejection cannot overwrite a newer task attempt`() = database { db ->
migrate(db)
db.createStatement().use { it.execute("UPDATE purchase_task SET attempt_id='new',status='running',upload_status='none'") }
reject(db)
assertEquals(listOf(listOf("new", "running", "none")), rows(db, "SELECT attempt_id,status,upload_status FROM purchase_task"))
}
@Test fun `rejected record never counts active even if legacy local status says running`() = database { db ->
migrate(db); reject(db)
db.createStatement().use { it.execute("UPDATE purchase_task SET status='running'") }
assertTrue(rows(db, PurchaseRejectionSql.activeTask).isEmpty())
}
@Test fun `invalid whitelist and mismatched outbox identity cannot change evidence`() = database { db ->
migrate(db)
for ((item, code) in listOf(
PendingPurchaseOutbox(1,42,"old","request","payload") to "UNKNOWN",
PendingPurchaseOutbox(1,42,"new","request","payload") to "PURCHASE_STATE_CONFLICT",
)) {
assertTrue(runCatching { PurchaseRejectionSql.reject(item, code, 100) { sql, args -> update(db, sql, args) } }.isFailure)
}
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
}
@Test fun `task update failure rolls back rejection evidence too`() = database { db ->
migrate(db)
db.createStatement().use { it.execute("CREATE TRIGGER fail_task BEFORE UPDATE ON purchase_task BEGIN SELECT RAISE(ABORT, 'test'); END") }
assertTrue(runCatching { reject(db) }.isFailure)
assertEquals(listOf(listOf("pending", null, null)), rows(db, "SELECT upload_status,rejected_at,rejection_error_code FROM purchase_outbox"))
assertEquals(listOf(listOf("pending")), rows(db, "SELECT upload_status FROM purchase_task"))
}
@Test fun `ack survives database reopen and changes no payload or task state`() {
val file = java.io.File.createTempFile("purchase-rejection-", ".db")
try {
connect(file.absolutePath).use { db ->
seed(db); migrate(db); reject(db)
update(db, PurchaseRejectionSql.acknowledge, listOf(200L, 1L))
}
connect(file.absolutePath).use { db ->
assertEquals(listOf(listOf("200", "payload", "rejected")), rows(db, "SELECT acknowledged_at,payload_json,upload_status FROM purchase_outbox"))
assertEquals(listOf(listOf("completed", "rejected")), rows(db, "SELECT status,upload_status FROM purchase_task"))
}
} finally { check(file.delete()) }
}
private fun reject(db: Connection) {
db.autoCommit = false
try {
PurchaseRejectionSql.reject(PendingPurchaseOutbox(1,42,"old","request","payload"), "PURCHASE_STATE_CONFLICT", 100) { sql, args -> update(db, sql, args) }
db.commit()
} catch (error: Exception) { db.rollback(); throw error }
finally { db.autoCommit = true }
}
private fun update(db: Connection, sql: String, args: List<Any>): Int = db.prepareStatement(sql).use { statement ->
args.forEachIndexed { index, arg -> statement.setObject(index + 1, arg) }
statement.executeUpdate()
}
private fun migrate(db: Connection) = PurchaseRejectionSql.migration.forEach { sql -> db.createStatement().use { it.execute(sql) } }
private fun connect(path: String): Connection { Class.forName("org.sqlite.JDBC"); return DriverManager.getConnection("jdbc:sqlite:$path") }
private fun database(block: (Connection) -> Unit) = connect(":memory:").use { seed(it); block(it) }
private fun rows(db: Connection, sql: String): List<List<String?>> = db.createStatement().use { statement ->
statement.executeQuery(sql).use { r -> buildList { while(r.next()) add((1..r.metaData.columnCount).map { r.getString(it) }) } }
}
private fun seed(db: Connection) = db.createStatement().use {
it.execute("CREATE TABLE purchase_task (task_id INTEGER PRIMARY KEY,attempt_id TEXT NOT NULL,rule_snapshot_hash TEXT NOT NULL,current_step TEXT NOT NULL,status TEXT NOT NULL,result_json TEXT,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL,order_submit_request_id TEXT,final_confirmation_json TEXT,irreversible_at INTEGER)")
it.execute("CREATE TABLE purchase_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT,task_id INTEGER NOT NULL,attempt_id TEXT NOT NULL,request_id TEXT NOT NULL UNIQUE,payload_json TEXT NOT NULL,upload_status TEXT NOT NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)")
it.execute("INSERT INTO purchase_task VALUES (42,'old','hash','submit_result','completed','payload','pending',1,2,NULL,NULL,NULL)")
it.execute("INSERT INTO purchase_outbox VALUES (1,42,'old','request','payload','pending',1,2)")
}
}
@@ -0,0 +1,115 @@
package cn.ilapage.goauto.agent.service
import cn.ilapage.goauto.agent.network.AgentApiException
import cn.ilapage.goauto.agent.network.HeartbeatResult
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import org.junit.Assert.*
import org.junit.Test
class AgentSyncCycleTest {
private val online = HeartbeatResult(7, true, false, 15)
private fun api(status: Int, code: String) = AgentApiException(status, code, "untrusted URL", false)
@Test fun `stale completed probe heartbeat mismatch rejected then refreshed heartbeat permits next claim`() {
val events = mutableListOf<String>()
var active: Long? = 42
var pending = true
val result = AgentSyncCycle(
heartbeat = { id -> events += "heartbeat:$id"; if (id != null) throw api(409, "DEVICE_TASK_MISMATCH") else online },
activeTaskId = { active }, executionActive = { false },
recover = { events += "recover" },
flush = {
PurchaseOutboxUploader(
{ listOf(PendingPurchaseOutbox(1,42,"probe","request","{}")) },
{ throw api(409,"PURCHASE_STATE_CONFLICT") },
{ fail("rejected probe must not be marked uploaded") },
{ fail("rejected probe must not establish handoff") },
{ _, _ -> events += "reject-probe"; pending = false; active = null },
).flush()
},
hasPending = { pending },
).run()
if (result.canClaim) events += "claim"
assertEquals(listOf("heartbeat:42", "recover", "reject-probe", "heartbeat:null", "claim"), events)
assertEquals(online, result.heartbeat)
}
@Test fun `authentication errors skip recovery and flush including unrecognized HTTP 401 and 403`() {
for ((status, code) in listOf(401 to "OTHER", 403 to "OTHER", 409 to "DEVICE_TOKEN_INVALID", 409 to "DEVICE_INSTALL_ID_CONFLICT", 409 to "DEVICE_DISABLED")) {
val result = AgentSyncCycle({ throw api(status, code) }, { 42 }, { false },
{ fail("recovery after auth failure") }, { fail("flush after auth failure") }, { true }).run()
assertFalse(result.canClaim)
assertEquals("AUTH_ERROR", result.failureCode)
}
}
@Test fun `heartbeat survives recovery or flush failure but claiming stops`() {
for (recoverFails in listOf(false, true)) {
var flushed = false
val result = AgentSyncCycle({ online }, { null }, { false },
{ if (recoverFails) error("disk") },
{ flushed = true; if (!recoverFails) throw api(500, "INTERNAL") }, { false }).run()
assertTrue(flushed)
assertEquals(online, result.heartbeat)
assertFalse(result.canClaim)
}
}
@Test fun `failed heartbeat still flushes but never claims and mismatch retries only once`() {
for (code in listOf("DEVICE_TASK_MISMATCH", "INTERNAL")) {
var heartbeats = 0
var flushed = false
val result = AgentSyncCycle({ heartbeats++; throw api(409, code) }, { null }, { false }, {},
{ flushed = true }, { false }).run()
assertTrue(flushed)
assertEquals(if (code == "DEVICE_TASK_MISMATCH") 2 else 1, heartbeats)
assertFalse(result.canClaim)
}
}
@Test fun `network failure leaves pending even if heartbeat succeeds`() {
var pending = true
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, { throw java.io.IOException("network") }, { pending }).run()
assertTrue(pending)
assertEquals(online, result.heartbeat)
assertFalse(result.canClaim)
}
@Test fun `active execution sends heartbeat without recovery or flush`() {
val result = AgentSyncCycle({ online }, { 42 }, { true }, { fail("recover") }, { fail("flush") }, { false }).run()
assertEquals(online, result.heartbeat)
assertFalse(result.canClaim)
}
@Test fun `retained irreversible local task prevents claim even without outbox`() {
val result = AgentSyncCycle({ online }, { 42 }, { false }, {}, {}, { false }).run()
assertFalse(result.canClaim)
}
@Test fun `authentication failure during recovery or flush stops round without heartbeat retry`() {
for (duringRecovery in listOf(true, false)) {
var heartbeats = 0
var flushes = 0
val result = AgentSyncCycle({ heartbeats++; throw api(409,"DEVICE_TASK_MISMATCH") }, { 42 }, { false },
{ if (duringRecovery) throw api(401,"OTHER") },
{ flushes++; throw api(403,"OTHER") }, { true }).run()
assertEquals(1, heartbeats)
assertEquals(if (duringRecovery) 0 else 1, flushes)
assertEquals("AUTH_ERROR", result.failureCode)
assertFalse(result.canClaim)
}
}
@Test fun `server failure is classified separately from transport failure`() {
val result = AgentSyncCycle({ throw api(503,"UNAVAILABLE") }, { null }, { false }, {}, {}, { false }).run()
assertEquals("SERVER_ERROR", result.failureCode)
}
@Test fun `claim failure cannot erase successful heartbeat except authentication`() {
assertNull(syncConnectionFailureCode(api(409,"PURCHASE_STATE_CONFLICT"), true))
assertNull(syncConnectionFailureCode(IllegalStateException("disk"), true))
assertNull(syncConnectionFailureCode(api(503,"INTERNAL"), true))
assertEquals("AUTH_ERROR", syncConnectionFailureCode(api(401,"OTHER"), true))
}
}
@@ -0,0 +1,59 @@
package cn.ilapage.goauto.agent.service
import org.junit.Assert.*
import org.junit.Test
class PurchaseRuntimeReconciliationTest {
@Test fun `manual current page cannot enter while sync owns recovery and flush`() {
val mutex = TaskExecutionMutex()
val syncing = java.util.concurrent.atomic.AtomicBoolean(false)
val syncEntered = java.util.concurrent.CountDownLatch(1)
val finishSync = java.util.concurrent.CountDownLatch(1)
val worker = java.util.concurrent.Executors.newSingleThreadExecutor()
val future = worker.submit {
synchronized(mutex) { assertTrue(syncing.compareAndSet(false, true)) }
syncEntered.countDown()
check(finishSync.await(3, java.util.concurrent.TimeUnit.SECONDS))
syncing.set(false)
}
try {
assertTrue(syncEntered.await(3, java.util.concurrent.TimeUnit.SECONDS))
assertFalse(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
assertNull(mutex.currentTaskId())
finishSync.countDown()
future.get(3, java.util.concurrent.TimeUnit.SECONDS)
assertTrue(tryAcquireCurrentPage(mutex, syncing, Long.MAX_VALUE))
} finally { finishSync.countDown(); worker.shutdownNow() }
}
@Test fun `reboot restored rejected task clears runtime so repurchase becomes idle`() {
var runtimeTaskId: Long? = 42
val currentDatabaseAttemptRejected = true
if (shouldClearRejectedPurchase(42, runtimeTaskId, "purchase", null, currentDatabaseAttemptRejected)) runtimeTaskId = null
assertNull(runtimeTaskId)
assertTrue(runtimeTaskId == null) // final runtime predicate in repurchaseLocalIdle
}
@Test fun `another task collection active executor or newer attempt cannot be cleared`() {
assertFalse(shouldClearRejectedPurchase(42, 43, "purchase", null, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "collection", null, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 42, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", 43, true))
assertFalse(shouldClearRejectedPurchase(42, 42, "purchase", null, false))
}
@Test fun `stopped service remains stopped after an in flight heartbeat completes`() {
val gate = AgentStatePublicationGate()
var connection = "CONNECTING"
gate.publish { connection = "ONLINE" }
assertEquals("ONLINE", connection)
gate.stop { connection = "STOPPED" }
gate.publish { connection = "ONLINE" }
assertEquals("STOPPED", connection)
val restartedService = AgentStatePublicationGate()
restartedService.publish { connection = "CONNECTING" }
assertEquals("CONNECTING", connection)
gate.publish { connection = "ONLINE" }
assertEquals("CONNECTING", connection)
}
}
@@ -0,0 +1,25 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.diagnostics.BrowserProbeClickPolicy
import org.junit.Assert.*
import org.junit.Test
class BrowserProbeClickPolicyTest {
@Test fun onlyExactNativeBrowserEntryIsAllowed() {
assertTrue(BrowserProbeClickPolicy.allowed("com.heytap.browser", "com.heytap.browser", "打开拼多多APP", true, true, true, 3, 3))
}
@Test fun rejectsPackageSwitchHiddenDisabledNonClickableAndStaleWindow() {
fun allowed(root: String? = "com.heytap.browser", node: String? = root, label: String = "打开拼多多APP",
visible: Boolean = true, enabled: Boolean = true, clickable: Boolean = true, window: Int = 3) =
BrowserProbeClickPolicy.allowed(root, node, label, visible, enabled, clickable, 3, window)
assertFalse(allowed(root = "com.xunmeng.pinduoduo"))
assertFalse(allowed(node = "com.xunmeng.pinduoduo"))
assertFalse(allowed(root = null))
assertFalse(allowed(label = "打开"))
assertFalse(allowed(label = "立即支付"))
assertFalse(allowed(visible = false))
assertFalse(allowed(enabled = false))
assertFalse(allowed(clickable = false))
assertFalse(allowed(window = 4))
}
}
@@ -0,0 +1,107 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.diagnostics.NavigationProbe
import org.junit.Assert.*
import org.junit.Test
class NavigationProbeTest {
@Test fun cancellationDuringInitialCapturePreventsLaunch() {
var active = true
var launches = 0
val page = UiSnapshot("com.xunmeng.pinduoduo", null, emptyList())
NavigationProbe(capture = { active = false; page }, launch = { launches++; true },
clickBrowserEntry = { error("no click") }, now = { 0 }, pause = {}, emit = {},
isActive = { active }).run()
assertEquals(0, launches)
}
@Test fun deadlineCrossedDuringCapturePreventsLaunch() {
var clock = 0L
var launches = 0
NavigationProbe(capture = { clock = 20_001; UiSnapshot(null, null, emptyList()) },
launch = { launches++; true }, clickBrowserEntry = { error("no click") }, now = { clock },
pause = {}, emit = {}).run()
assertEquals(0, launches)
}
@Test fun cancellationDuringBrowserCapturePreventsClick() {
var active = true
var reads = 0
var clock = 0L
var clicks = 0
val page = UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP")))
NavigationProbe(capture = { reads++; if (reads > 1) active = false; page }, launch = { true },
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
now = { clock }, pause = { clock += it }, emit = {}, isActive = { active }).run()
assertEquals(0, clicks)
}
@Test fun baselineUsesActualPackageRatherThanOptionalParserEvidence() {
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("发起拼单"))))
rig.run()
assertTrue(rig.lines.first().contains("pdd=false"))
assertFalse(rig.lines.any { it.contains("legacy_would_accept") })
}
private class Rig(var page: UiSnapshot) {
var clock = 0L
var launches = 0
var clicks = 0
val lines = mutableListOf<String>()
fun run(accepted: Boolean = true) = NavigationProbe(
capture = { page },
launch = { report -> launches++; report(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)); accepted },
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
now = { clock }, pause = { clock += it }, emit = lines::add,
).run()
}
private fun node(text: String, path: String = "0") = SnapshotNode(path, null, text, null, null,
"android.widget.TextView", NodeBounds(0, 0, 100, 100), true, false, false, false, true, true)
@Test fun pddIsReadOnlyAndSamplingStopsWithinBound() {
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("立即下单"))))
rig.run()
assertEquals(1, rig.launches)
assertEquals(0, rig.clicks)
assertEquals(20_000L, rig.clock)
assertTrue(rig.lines.last().contains("finished"))
}
@Test fun failedLaunchStopsWithoutClicking() {
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, emptyList()))
rig.run(false)
assertEquals(1, rig.launches)
assertEquals(0L, rig.clock)
assertEquals(0, rig.clicks)
assertTrue(rig.lines.last().contains("launch_failed"))
}
@Test fun browserRequiresUniqueExplicitPddEntryAndBoundedRetries() {
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"))))
rig.run()
assertEquals(3, rig.clicks)
}
@Test fun ambiguousBrowserEntryStopsWithoutGuessing() {
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"), node("打开拼多多APP", "1"))))
rig.run()
assertEquals(0, rig.clicks)
assertTrue(rig.lines.last().contains("browser_ambiguous"))
}
@Test fun genericOpenAndPddControlsAreNeverClicked() {
for (pkg in listOf("com.heytap.browser", "com.xunmeng.pinduoduo", "other.package")) {
val rig = Rig(UiSnapshot(pkg, null, listOf(node("打开"), node("立即支付"), node("确认订单"))))
rig.run()
assertEquals(0, rig.clicks)
assertTrue(rig.lines.isNotEmpty())
}
}
@Test fun loginOrCaptchaStopsBeforeLaunch() {
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("手机号登录"))))
rig.run()
assertEquals(0, rig.launches)
assertEquals(0, rig.clicks)
assertTrue(rig.lines.last().contains("page_problem"))
}
@Test fun pageTextAndExceptionMessagesNeverAppearInOutput() {
val secret = "PRIVATE_ACCOUNT_ADDRESS_DO_NOT_LOG"
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node(secret))))
rig.run()
assertTrue(rig.lines.isNotEmpty())
assertFalse(rig.lines.joinToString().contains(secret))
}
}
+34 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: f8a5ca861965f5da9bdec2235205f23fb9a00840
synchronized_at: 2026-10-09T07:45:19Z
wiki_revision: 7cdfe55383055e0f7611cd153e8d813d32f08d91
synchronized_at: 2026-10-09T10:28:15Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -891,7 +891,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## 采购页面一次性交接与导航稳定等待(#373 v4)
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`;2026-10-09 与 #374 依次合并并推送 main,集成提交 `555a954605a83c12457ed1ada86eb732fe3bb023`。集成APK仍标识 `0.9.69-373-fix1 / code82`,须以哈希区分;未安装或发布。此节不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。
- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。
@@ -905,7 +905,9 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## 下单前最终规格复核(#374 v3)
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(2026-10-09,工单分支 `fix/374-final-spec-confirmation`,基于 main `e26743c`);尚未合并、安装或发布,不含 #373 导航修复,真机采购未验收。
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
@@ -916,3 +918,31 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
## 采购改地址前复核与提交提示检查(#376 v3)
实现绑定工单分支 `fix/376-pre-address-spec-verification` 提交 `9c7357609cee7c73b57edd030136ed12ef9485b6`(2026-10-09),尚未合并 main 或发布;此规则仅适用于包含 #376 的 Android 构建,旧版 #374 行为不因此改变。没有 Server/Web、业务库迁移或 API 边界协议变化。
- 改地址前,verifySummary 在每个目标维度和既有重定位滑动后的当前面板中检查未选提示。面板摘要的“请选择/請選擇”或唯一提交按钮的选择提示导致 PURCHASE_SPEC_SELECTION_UNCONFIRMED、reason=selection_prompt,停止后续地址动作;无唯一提交按钮时仍可检查摘要,不增加改地址前缺少按钮的门禁。
- 本次选择历史证明仅用于既有可见性缺口;目标当前可见、可选却未选中,同维无其他选中项且没有已选摘要时,不再由历史证明路径放行,reason=target_visible_unselected。目标不可见时保留原历史证明行为;不暴露选中属性且无摘要的布局可能提前失败。此前规格选择点击、数量设置、改地址前价格/数量复核不变。
- 修改地址后的 finalConfirmation 不再比较颜色/尺码值,不要求选中证据,也不核对当前数量;保留地址、提交目标唯一性和单价区间检查。摘要未选提示在此只记录,不拦截。唯一提交按钮的完整文字去空白后匹配 `^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$` 才新增拒绝 PURCHASE_SPEC_NOT_MATCHED、finalSpec;stage=selection_prompt。
- 按钮来源沿用原面板底部行 row 和点击文字节点 chosen;提示只组合该行内可见、自身带文字且没有带文字子孙、最近可点击祖先恰为 row 的节点,按 top/left 排序,不重复拼父子汇总、不跨独立按钮/摘要/规格列表或不可见节点。实际点击目标、行/叶并列选择和支付排除保持不变;enabled=true 不是已选规格的证明。
- finalConfirmation 位于 beforeOrderSubmit 之前,拒绝时不写提交边界、不点击,普通失败可让重购继续。submitOrderOnce 重新取屏后的同类提示只作兜底,零点击但边界已写入,仍为 order_result_unknown,重购停止等待人工核对;不移动持久化边界、不增加重试或付款。
- 本地 final_confirmation_json 新记录以 targetQuantity 表示下发数量,追加 observedQuantity(唯一可见可用数字数量输入,否则 null)、selectedSummaryPresent 与 summaryPromptPresent。仅记录数字/布尔观察值,不新增页面文字或个人信息;不用于拦截、不上传,旧记录不改写。
- 用户明确接受剩余风险:修改地址后规格被换成其他值,或数量重置,但按钮仍正常时会放行;价格与地址检查不能替代规格/数量一致性。观察值仅供事后核对,不证明采购结果正确。缺少提示也不构成 PDD 已选正确的正向证明。
- 不新增分享、OCR/VLM、滑动取证、自动重新选规格或付款;合成测试与 APK 构建不代表真实订单验证。安装、真实采购、合并和发布须按相应授权执行。
验证:19 个相关套件共418项通过,0失败/错误/跳过;Debug APK与release单测源码编译通过。执行器测试模拟提交边界回调,并验证本地JSON序列化;未执行真实SQLite/服务端边界端到端或真实采购验证。不宣称全量Android测试通过(既有图搜返回忙循环用例不在本次回归范围)。
## Android 采购结果明确拒收恢复(#377)
实现位于工单分支 fix/377-rejected-purchase-outbox(实现提交 8b75a95,运行占位与互斥补齐 309dce3);仅 Android,未合并 main、未安装或发布,真机恢复尚待独立授权验证。
- 每轮先发心跳,再在无任务执行锁时恢复中断任务及补传。心跳任务不一致不能挡住拒收收敛;收尾后只额外重发一次心跳。认证错误停止补传,只有最终心跳成功、没有未完成本地任务/待上传结果且收尾正常才领取新任务。不放宽服务端任务身份校验,不修改心跳间隔或离线阈值。
- 只有 HTTP 409 且错误码精确属于 PURCHASE_STATE_CONFLICT、PURCHASE_LEASE_EXPIRED、PURCHASE_RESULT_CONFLICT 才视为明确拒收。未知 409、网络异常、5xx 及认证错误不按拒收处理;缺少 retryable 不构成拒收证据。
- 本地拒收使用 rejected,不伪装 sent。按 outbox 与 task/attempt 归属事务保存,保留原 payload;不再自动补传该条,继续处理后续结果。拒收不触发上传成功或规格探测交接,交接凭证失效。拒收记录不再充当待上传占位;不改变服务端采购结果和原重购停止边界。
- order_created、order_result_unknown 或带订单号的拒收结果在采购页持续提示人工核对。“已核对”只持久隐藏提醒,不删除结果、不调用服务端、不允许再次下单;非订单拒收仍可在设置及任务详情查看。
- goauto_purchase.db v2→v3 只追加拒收时间、白名单错误码及人工核对时间三个可空列,旧结果与记录保持不变;v1 升级仍经过原 v2 追加列。旧版 SQLiteOpenHelper 不保证能打开 v3,不能靠卸载清数据回退,优先使用保留 v3 schema 的修复版本。
- 设置页区分同步中、真实心跳失败、已连接但有拒收;新说明采用固定文案,不展示任意服务端错误消息。本单只修复明确拒收后的持续阻塞,不代表最初心跳中断的原因已查明,历史诊断仍属于 #378。
- 拒收后只在执行锁空闲、当前运行标记属于同一采购任务且本地当前 attempt 已拒收时清理恢复占位,不清除其他采集/采购或新 attempt;手动当前页采集与同步共用既有 working/任务锁门禁。服务停止写 STOPPED,原服务实例的迟到状态更新不覆盖它。
+20 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: 6b370764320d1f5148fbc31f0ef6ce999370173d
synchronized_at: 2026-10-09T02:06:57Z
wiki_revision: af209183f85200ce53f79f82ab682e17bb0aa942
synchronized_at: 2026-10-09T10:28:29Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -158,3 +158,21 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
```
排查须绑定同一设备、Agent/PDD 版本、任务与规则快照,并对照同次 App 布局。网页竖排与 App 横排不是同一复现场景;当前横排只能做横向兼容性回归。真机测试结果及待验证项以 #370 工单为准。
## 采购结果拒收后设备持续离线(#377)
修复绑定工单分支 fix/377-rejected-purchase-outbox 的 8b75a95、309dce3;尚未合并 main、安装或发布。自动化覆盖不等于真实设备验收,不能假定线上手机已具备本节恢复行为。
旧版同步在心跳之前补传,一条结果被服务端永久拒收就能让每轮跳过心跳及领任务。离线之后本地仍携带旧任务 ID,又可能被心跳的 DEVICE_TASK_MISMATCH 拒绝;仅把心跳前置不足以恢复。#377 先发心跳,仍进行有界拒收收尾,再在任务不一致时重发一次心跳;认证失败不会继续收尾,执行中的任务不会被恢复流程并发接管。
排查步骤:
1. 核实 APK 对应提交,不仅看版本名(本次未改 versionCode/versionName)。设置页“正在同步”不代表断线;真实心跳网络、服务端或认证失败使用固定原因说明;任务处理错误不再覆盖此前成功的连接状态。
2. “已连接 · 有 N 条采购结果被服务端拒收”表示本地已有 rejected 结果,不等于服务端接收成功。可在设置页“查看拒收结果”和对应采购详情查看;有订单证据的结果还在采购页顶部持续提醒。
3. 人工核对 PDD 实际订单及后台任务。需要补录订单时沿用 Admin 原有人工流程;Agent 的“已核对”只隐藏这条本机提醒,不能代替后台保存、恢复回填、解锁重试或完成任务。原结果/订单号与核对时间继续保存。
4. 未知 409、网络失败或 5xx 保持 pending;如果仍卡住,核对错误分类及原任务状态,不自动把 pending 改成 sent。不反复重试真实采购来试探是否已下单。
5. 早期现场曾经另获授权,通过同时修改 outbox 与本地任务 upload_status=sent 恢复设备,但这不代表服务端已收件,也会把订单号留在手机。它只是历史应急处置,不是新版运维步骤,不应复制到脚本或作为常规修复。
数据库升级仅限手机私有 goauto_purchase.db:v3 在 purchase_outbox 添加 rejected_at、rejection_error_code、acknowledged_at;保留原始 payload 和任务数据。不得把整库、订单/地址内容上传到工单、Wiki、普通日志或 SynapBus。旧 APK 不保证能够降级打开 v3;回退优先使用保留 v3 的兼容修复构建,不卸载清数据、不擅自降低库版本。
已验证 SQLite JDBC 执行同一生产迁移/拒收/核对 SQL、事务回滚及文件重开;尚未执行 Android SQLiteOpenHelper 仪器测试、真机界面或真实采购。最初心跳和结果同时中断的原因仍未查明,#378 的诊断计划不属于本修复。
+9 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
synchronized_at: 2026-10-09T07:45:33Z
wiki_revision: 3f69f2e39675752dfd43dc38de41cb7defb74a42
synchronized_at: 2026-10-09T07:58:00Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -365,3 +365,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。
- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。
- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。
## #373 + #374 合并 main 与集成构建(2026-10-09)
- 按用户授权的顺序先合并 #373(`1e924fa41195281a3223003a69c8c16ae37f8aa5`),再合并 #374(`555a954605a83c12457ed1ada86eb732fe3bb023`),保留两单提交历史;main已推送。源码无冲突,三份Wiki镜像选择包含两单内容的较新已导出版本,未手改规则正文解决冲突。
- 集成验证25套件478项通过,0失败/错误/跳过;compileReleaseUnitTestKotlin与assembleDebug通过。不是全量单测结论;#374记录的未修改图搜返回测试忙循环未在本次重跑。未进行真机采购。
- 集成APK位于本机 `D:/OPC/goauto-worktrees/release-373-374/android/app/build/outputs/apk/debug/app-debug.apk`,版本仍为 `0.9.69-373-fix1 / code82`,**已同时包含#374,不能只靠版本文字区分前一#373单独包**。SHA256 `6e2d0e0ed1eb943b3f168bb16d628d2c2bb4ce3b7c7c8b9e567dcdf8259150f8`;apksigner校验通过,沿用原签名证书 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。
- 本次仅合并、验证和推送,未覆盖安装、未执行真实采购/付款、未发布或重启服务;不需要业务库迁移。手机仍使用此前安装版本。后续装机须另行授权,保留原签名及用户数据。