From 2c58d5817cda45243d2f4f5925fe7e6e87598e21 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 9 Oct 2026 14:44:47 +0800 Subject: [PATCH 1/5] feat(android): add bounded navigation diagnostics without purchase actions (#373) --- android/app/build.gradle.kts | 2 +- android/app/src/debug/AndroidManifest.xml | 5 +- .../diagnostics/BrowserProbeClickPolicy.kt | 9 ++ .../agent/diagnostics/NavigationProbe.kt | 94 +++++++++++++++ .../diagnostics/NavigationProbeReceiver.kt | 109 ++++++++++++++++++ .../agent/automation/PddLaunchFallback.kt | 24 +++- .../goauto/agent/automation/PddNavigation.kt | 8 +- .../agent/persistence/AgentDiagnosticStore.kt | 7 ++ .../agent/service/AgentForegroundService.kt | 40 ++++++- .../goauto/agent/PddLaunchFallbackTest.kt | 33 ++++++ .../agent/BrowserProbeClickPolicyTest.kt | 25 ++++ .../goauto/agent/NavigationProbeTest.kt | 107 +++++++++++++++++ 12 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt create mode 100644 android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt create mode 100644 android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt create mode 100644 android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt create mode 100644 android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4effa7a..af2f678 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,7 +31,7 @@ android { buildTypes { debug { - versionNameSuffix = "-370-diag1" + versionNameSuffix = "-373-nav-diag3" } release { isMinifyEnabled = false diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 36f3d2c..7059f45 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,4 +1,7 @@ - + + + diff --git a/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt new file mode 100644 index 0000000..ac3192f --- /dev/null +++ b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/BrowserProbeClickPolicy.kt @@ -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 +} diff --git a/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt new file mode 100644 index 0000000..1491597 --- /dev/null +++ b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbe.kt @@ -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") + } +} diff --git a/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt new file mode 100644 index 0000000..77d5ba0 --- /dev/null +++ b/android/app/src/debug/java/cn/ilapage/goauto/agent/diagnostics/NavigationProbeReceiver.kt @@ -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() + 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" } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt index 533bdf5..31825fc 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddLaunchFallback.kt @@ -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) } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt index a8b9625..6248208 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddNavigation.kt @@ -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 = diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 0b18a06..27224a3 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -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, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index a38c531..c84644a 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -154,6 +154,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 +198,7 @@ class AgentForegroundService : Service() { } override fun onDestroy() { + if (diagnosticInstance === this) diagnosticInstance = null repurchaseClosed.set(true) repurchase.requestStop(repurchase.state.roundId) repurchaseExecutor.shutdownNow() @@ -220,6 +222,29 @@ 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 + 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 { @@ -783,7 +808,18 @@ class AgentForegroundService : Service() { } else { PurchaseRehearsalExecutor( driver = accessibility, - openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, + openLink = { url -> + val navigationStarted = SystemClock.elapsedRealtime() + PddLinkLauncher(this).open(url, preferDirect = true) { event -> + probeDiagnostic(AgentDiagnosticEvent( + taskId = task.taskId, + stage = AgentDiagnosticStage.PRODUCT_NAVIGATION, + reason = AgentDiagnosticReason.valueOf("NAV_${event.path}_${event.status}"), + elapsedMs = SystemClock.elapsedRealtime() - navigationStarted, + targetClassName = event.exceptionClass, + )) + } + }, probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) }, probeClickDiagnostic = { stage, target, outcome -> probeDiagnostic(specClickDiagnostic(task.taskId, stage, target, outcome)) @@ -1586,6 +1622,8 @@ 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" diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt index 31811bc..3bedb1c 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddLaunchFallbackTest.kt @@ -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() + assertTrue(PddLaunchFallback.open(true, { true }, { error("unexpected") }, events::add)) + assertEquals(listOf(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)), events) + } + @Test fun reportsOnlyExceptionClassThenBrowserAcceptance() { + val events = mutableListOf() + 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() + 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() + 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") })) } diff --git a/android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt new file mode 100644 index 0000000..8cee10d --- /dev/null +++ b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/BrowserProbeClickPolicyTest.kt @@ -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)) + } +} diff --git a/android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt new file mode 100644 index 0000000..6838950 --- /dev/null +++ b/android/app/src/testDebug/java/cn/ilapage/goauto/agent/NavigationProbeTest.kt @@ -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() + 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)) + } +} From 8f07a19512d374ac25e39591eee44d0e212d9d77 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 9 Oct 2026 14:51:00 +0800 Subject: [PATCH 2/5] docs: mirror navigation diagnostic operations (#373) --- docs/13-deployment-and-operations.md | 43 ++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index 4e4a54f..3aec8b6 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -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: 6ca0b35cb1e8058b2930a9a01029822af8b571d2 -synchronized_at: 2026-10-08T07:10:52Z +wiki_revision: 69c80ae55159abbdb98c0f4acc68322cd320739d +synchronized_at: 2026-10-09T06:46:27Z @@ -312,7 +312,7 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 ## 采购失败现场诊断部署与保留(#364) -实现绑定 `4581ee5`;本节为新版本部署要求,不表示线上已迁移/发布。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。 +实现绑定 `4581ee5`;2026-10-09 已随 main `e26743c` 完成线上迁移及发布,见下方发布记录。#365/#366 为Android局部行为,#366仅②编辑框就绪/hint保护已交付,①地址列表识别仍待现场证据。 1. 先取得目标环境的追加迁移及发布授权,核对唯一待执行版本 `1791400000000_purchase_failure_snapshot.go`,建立独立私有诊断表,再启动配套Server/Web。专用摘要/ZIP下载注册到既有启动权限对账,只允许管理员;对账会维护完整API目录及采购员既有权限,不是只写两条接口。 2. Server接收与权限验证就绪后再安装新Agent。旧Server不支持新诊断接口;不要把“本机保存成功”当作“已上传”。仅合并main不启动服务、不执行线上迁移、不自动安装APK。 @@ -320,3 +320,40 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 4. 管理员从采购详情执行记录下载ZIP;无现场、未截取、partial、上传中/失败应按实际结果区分。排错日志仅固定错误分类,禁止复制ZIP内容到日志、工单、Wiki或消息;分析只摘必要脱敏结构。沿用现有Agent HTTPS/显式HTTP例外,HTTP上传有个人数据明文风险。 5. 代码回滚时保留追加表和已存数据,不降级或删除手机数据库来恢复;回滚到尚不识别schema v4的旧Agent须先单独评估,不承诺可直接覆盖安装。保留期限清理须有仍在运行的相应新版维护组件。 6. 本机换机后的数据库经用户授权初始化于 `127.0.0.1:3308/goauto`,配置仍来自仓库根目录已忽略的config.yaml;已完成baseline迁移及#364幂等、容量、权限验证,未启动业务服务或连接线上。不是将历史3307运行说明套用于所有环境;实际Supervisor目录/发布按目标环境另核对。 + + +## 2026-10-08 换机后本地 Supervisor 联调配置(#364 后续运行准备) + +本节是当前电脑的配置,优先于上文历史 D:/supervisor、3307 和 main-runtime 路径;不改变线上拓扑。 + +- Supervisor 位于 `D:/supervisord`,主配置 `supervisord.conf` 通过 `[include] files=programs/goauto.conf` 引入两个实例:`goauto-admin-api` 和 `goauto-admin-ui`。两者 `autostart=false`、`autorestart=true`,添加/重读配置不等于批准启动所有服务;只对目标实例操作,不重启整个Supervisor。 +- 独立运行目录 `D:/OPC/goauto-runtime` 干净地固定在已合并main的 `e450ac28989de6827328b411e4bdc050dded9ab3`,用detached HEAD避免与开发分支混淆。根仓库 `D:/OPC/goauto` 仍保留用户旧分支和未提交资料,不在其中切main或覆盖。 +- 两实例读取 `D:/OPC/goauto/config.yaml`;本地MySQL为127.0.0.1:3308/goauto,API端口8010,Web端口9527。后端调用运行目录的 `scripts/start-server.ps1 -ConfigPath D:/OPC/goauto/config.yaml -SkipMigration`,前端调用 `scripts/start-web.ps1 -ConfigPath D:/OPC/goauto/config.yaml`。后端仍会校验/确保数据库存在并执行既有启动权限对账;SkipMigration不是只读启动。 +- 当前电脑默认go为1.24且GOTOOLCHAIN=local,继承的GOSUMDB=off又会阻止自动下载1.26.5;仅后端实例的PowerShell命令把已安装 `C:/Users/Qiu/go/pkg/mod/golang.org/toolchain@v0.0.1-go1.26.5.windows-amd64/bin` 前置到进程PATH,并设置GOTOOLCHAIN=local。已用该环境构建验证;未更改全局环境、校验策略或项目go.mod。此绝对路径是本机配置,其他电脑须核实已安装工具链,不照抄。 +- 前端依赖属于运行目录自身,按锁文件安装,不链接其他worktree的node_modules。若旧残留依赖出现ERR_MODULE_NOT_FOUND且普通安装未补齐,先用 `node -e "import('vite')"` 复现,再在本目录按锁文件重新安装;不通过修改业务代码或升级依赖绕过。 +- 手动启动顺序为API就绪后Web;Web原脚本最多等待API健康60秒,并将/api同源代理到本机8010。浏览器本机入口 `http://127.0.0.1:9527/`,Agent使用电脑实际局域网IP的8010端口,不把127.0.0.1配置给手机。 +- 日志为 `D:/supervisord/goauto-admin-api.log`、`D:/supervisord/goauto-admin-ui.log`。检查依赖、端口和实际子进程环境;不能只因Supervisor显示Running就宣称HTTP就绪。 +- 新库预置SYB每小时同步与Yeeke同步status=2(启用);蝦皮规格自动匹配、异常规格AI解析status=1(关闭)。本轮只读检查,未改任务状态;用户手动启动API后既有调度会运行,真实外部访问须结合本地凭据/配置确认,不能把本地环境当作自动隔离的沙箱。 +- 配置动作未新增迁移、未部署线上、未安装APK;用户随后确认亲自点击了启动。健康/页面验证记录在工单,不将健康检查冒充登录、诊断下载或真机端到端验收。 + + +## 2026-10-09 集成发布与 Android 签名连续性(#364/#365/#366/#367/#368/#370) + +- 集成源码 main `e26743c817275137f7dc8a3659461d552371b9e3`。122.228.200.167 当前发布目录 `/home/goauto/releases/20261009-e26743c-integrated`,旧目录 `/home/goauto/releases/20261007-0138da8-359` 保留供代码回退。用户明确要求线上有采集/采购执行时不得重启;迁移前及切换前相关运行计数均为0,没有取消、重置或发起业务。 +- 已授权追加迁移 `1791400000000_purchase_failure_snapshot.go`、启动权限对账及发布。私有备份 `/home/goauto/backups/20261009-integrated/database.sql` 目录0700/文件0600;只新增诊断表,回读LONGTEXT/LONGBLOB及attempt唯一索引通过。定时任务启停、Cron与参数摘要前后一致。 +- Server SHA256 `ae7e479e94db0485661665306d0d0306ca5c3cd4019694991c29b64d113eb637`,运行进程二进制一致;新Web入口SHA256 `242158568fb3ee9c51e2e16574abf0a1b6b5b692ae167ec78bcb957cdf5e7434`。根入口、index、login、syb-products入口、10项JS/CSS、健康与验证码通过。Nginx实际读取用户可读性切换前确认;保留旧hashed资源与config/static/temp/var。没有更改Nginx配置;未认证诊断接口401、现有非管理员凭据403。 +- 本次自动化验证为Android 274项受影响测试、Server任务/采购/商品/规格/权限/中间件/迁移相关包、Web 6项模拟API页面测试及三端构建。未执行真实采购或付款;管理员真实诊断下载及完整失败现场上传仍待业务验收。#366仅环节②,#370仅第一阶段诊断,不能视为剩余根因修复已完成。 +- Android集成APK保持 `0.9.69-370-diag1 / versionCode 82`,不能只凭版本号区分旧包;本次最终APK SHA256 `68d7caf9ae7ee5808783056f4f1591266746197b520048fa64c89365723edef7`。2026-10-09已覆盖安装到用户指定手机,安装后文件哈希一致、配置及设备身份前后摘要一致、进程启动存在,没有卸载或清数据。 +- 换机生成的调试签名与旧机不同会导致 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`;不可为更新擅自卸载。用户提供的原调试签名已与手机证书核对一致,私有文件位于 `D:/OPC/goauto/debug.keystore`,仅在本地Git排除规则中忽略,绝不提交。应另行私密备份,不能重新生成替代。 +- 当前Windows用户级 `C:/Users/Qiu/.gradle/init.d/goauto-debug-signing.gradle` 使用Android Components的 `finalizeDsl`,仅对applicationId `cn.ilapage.goauto.agent` 的debug签名指定原文件;其他项目及release配置不变。普通Gradle调试构建自动生效,文件缺失则明确失败,不退回新签名。更换电脑或GRADLE_USER_HOME时需带入相同私有文件及配置;不要把本机设置当作仓库内或其他电脑已配置。构建后用signingReport与apksigner复核,再以install -r更新。 + + +## Android 商品导航诊断(#373,诊断阶段) + +- 分支实现 `2c58d58`,调试包 `0.9.69-373-nav-diag3`;尚未合并 main,不代表旧商品页复用或旧页排除已修复。正式采购打开路径保持原样,新增 `PRODUCT_NAVIGATION` 结构诊断至既有 SQLite `agent_diagnostic`,区分 DIRECT/BROWSER 的 ACCEPTED/REJECTED/EXCEPTION,仅记录异常类名及耗时,沿用任务、attempt、device、phase、rule hash 关联和原50条/7天保留规则,无数据库升级。ACCEPTED只表示startActivity返回,不表示目标商品已加载。 +- 仅debug包包含 `cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver`,要求发送者具有 `android.permission.DUMP`(ADB shell/system),release清单不含该入口。输入仅数字goodsId及direct/browser,不接受任意URL、脚本或购买动作。必须先获得指定设备覆盖安装及只打开商品页的授权,确认无业务任务运行;保留原签名用install -r,禁止为安装清数据或卸载。 +- Agent主界面启动前台服务且无障碍就绪后,通过ADB执行 `adb -s <设备地址> shell am broadcast -n cn.ilapage.goauto.agent/.diagnostics.NavigationProbeReceiver --es goodsId <数字商品ID> --es mode direct`;浏览器对照使用 `--es mode browser`。忙碌/服务未就绪时拒绝、不排队,不能用取消或重置业务任务强行取得设备。 +- 探针占用现有设备互斥锁,预算20秒/最多201轮;耗时Binder读取可能越过观察预算,但其后不再派发导航/点击,不能声称能强制中断系统读取。服务或无障碍实例失效、线程中断、登录/风控/验证码时停止。只打开商品链接,在浏览器中最多尝试3次唯一的精确“打开拼多多APP”/“打开拼多多 App”且节点自身可点击的入口;实际派发前再次校验包、窗口、节点与存活条件,无祖先/坐标/手势兜底。PDD内只读,不调用采购执行器、不进入规格选择/改地址/下单/付款,不创建/重试服务器任务。 +- 临时探针输出通过 `adb -s <设备地址> logcat -d -s GoAutoNavProbe:I '*:S'` 读取;只包含随机runId、路径/固定结果、相对毫秒、节点计数、窗口id、布尔证据及仅在内存比较所得标题same/different/unknown,不保存标题/哈希/URL/页面原文/原始树/截图,不写业务数据库。别用logcat -t的全局最后几行冒充完整诊断;若缓冲已覆盖须如实记录。禁止导出完整无过滤系统日志。 +- `legacy_would_accept`模拟现有两次商品结构命中,始终标注identityVerified=false;窗口变化、标题变化、商品结构成立都不是目标goodsId证明。shell触发探针与后台任务的系统启动许可可能不同;人工指定browser只验证浏览器路径,不证明当时自动回退的异常原因。正常/失败对照及真实后台时序仍须以任务绑定诊断和人工确认核验。 +- 自动化验证应包含debug专用探针单测与 `:app:compileReleaseUnitTestKotlin`;探针测试放 `src/testDebug`,不得在共享 `src/test` 引用debug-only类。构建使用原私有签名,安装后回读包版本和APK哈希,并比较设置/设备身份摘要。 From dbf9a5f4606529189e57b6c266449070d5001340 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 9 Oct 2026 15:14:50 +0800 Subject: [PATCH 3/5] fix(android): gate probe handoff and wait for navigation readiness #373 --- android/app/build.gradle.kts | 2 +- .../automation/GoAutoAccessibilityService.kt | 13 +- .../automation/PurchaseNavigationReadiness.kt | 55 +++ .../agent/automation/PurchaseProbeHandoff.kt | 59 +++ .../automation/PurchaseRehearsalExecutor.kt | 83 ++-- .../goauto/agent/network/AgentApiClient.kt | 15 +- .../persistence/PurchaseOutboxUploader.kt | 2 + .../agent/service/AgentForegroundService.kt | 73 +++- .../agent/PurchaseNavigationReadinessTest.kt | 75 ++++ .../goauto/agent/PurchaseOutboxHandoffTest.kt | 34 ++ .../goauto/agent/PurchaseProbeHandoffTest.kt | 78 ++++ .../agent/PurchaseRehearsalExecutorTest.kt | 401 +++++++++++++----- .../PurchaseResultAcknowledgementTest.kt | 19 + .../goauto/agent/SpecOptionDedupTest.kt | 1 + .../goauto/agent/SpecPanelRecognitionTest.kt | 1 + .../goauto/agent/TruncatedSpecCardTest.kt | 3 +- 16 files changed, 756 insertions(+), 158 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseNavigationReadiness.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseProbeHandoff.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseNavigationReadinessTest.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseOutboxHandoffTest.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseProbeHandoffTest.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultAcknowledgementTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index af2f678..22d2379 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,7 +31,7 @@ android { buildTypes { debug { - versionNameSuffix = "-373-nav-diag3" + versionNameSuffix = "-373-fix1" } release { isMinifyEnabled = false diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index 4ff2c6f..974a321 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -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() 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 { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseNavigationReadiness.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseNavigationReadiness.kt new file mode 100644 index 0000000..253c7c2 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseNavigationReadiness.kt @@ -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") + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseProbeHandoff.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseProbeHandoff.kt new file mode 100644 index 0000000..2ae9e02 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseProbeHandoff.kt @@ -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 + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index 4fe0b51..9231143 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -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() 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 diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index 6654330..28cf914 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -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 { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt index d735f30..58f2003 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt @@ -5,11 +5,13 @@ class PurchaseOutboxUploader( private val pending: () -> List, private val submit: (PendingPurchaseOutbox) -> Unit, private val markUploaded: (PendingPurchaseOutbox) -> Unit, + private val afterUploaded: (PendingPurchaseOutbox) -> Unit = {}, ) { fun flush() { pending().forEach { item -> submit(item) markUploaded(item) + afterUploaded(item) } } } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index c84644a..8e737fa 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -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>() private val taskMutex = TaskExecutionMutex() + private val probeHandoff = PurchaseProbeHandoff() private val backfillGuard = OrderBackfillGuard(taskMutex) private val runningTaskId = AtomicReference(null) private val working = AtomicBoolean(false) @@ -198,6 +204,7 @@ class AgentForegroundService : Service() { } override fun onDestroy() { + probeHandoff.invalidate() if (diagnosticInstance === this) diagnosticInstance = null repurchaseClosed.set(true) repurchase.requestStop(repurchase.state.roundId) @@ -231,6 +238,7 @@ class AgentForegroundService : Service() { activeCollectionCooldown() == null && taskMutex.tryAcquire(reservation) } if (!acquired) return false + probeHandoff.beginExecution() cancelIdleReturn("导航诊断") try { taskExecutor.execute { @@ -509,6 +517,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("请先启用无障碍服务") @@ -603,6 +612,7 @@ class AgentForegroundService : Service() { return } cancelIdleReturn("正在创建当前页面采集任务") + probeHandoff.beginExecution() taskExecutor.execute { var taskId = 0L try { @@ -744,6 +754,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 @@ -809,8 +825,11 @@ class AgentForegroundService : Service() { PurchaseRehearsalExecutor( driver = accessibility, 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, @@ -820,7 +839,20 @@ class AgentForegroundService : Service() { )) } }, - probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule, probeDiagnostic) }, + 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)) }, @@ -858,6 +890,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, @@ -885,7 +921,22 @@ class AgentForegroundService : Service() { GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation) } beginIdleReturnCooldown() - flushPurchaseOutbox(api, token) + // 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 message = 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} 已提交") @@ -949,6 +1000,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 { @@ -1000,11 +1052,20 @@ 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) } + }, ).flush() runningTaskId.set(purchaseStore.activeTaskId()) } @@ -1067,6 +1128,7 @@ class AgentForegroundService : Service() { initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String, ): TaskExecutionSummary { + probeHandoff.beginExecution() GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble() acquireTaskWakeLock() return try { @@ -1440,6 +1502,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 @@ -1594,6 +1657,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) @@ -1608,6 +1672,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) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseNavigationReadinessTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseNavigationReadinessTest.kt new file mode 100644 index 0000000..bb548c0 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseNavigationReadinessTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseOutboxHandoffTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseOutboxHandoffTest.kt new file mode 100644 index 0000000..30fec3d --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseOutboxHandoffTest.kt @@ -0,0 +1,34 @@ +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 `handoff callback runs only after successful submit and local upload mark`() { + for (failure in listOf("submit", "mark", "none")) { + val events = mutableListOf() + 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) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseProbeHandoffTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseProbeHandoffTest.kt new file mode 100644 index 0000000..5cfc6b4 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseProbeHandoffTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 43de665..4c32e93 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -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() + 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() + 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() val clicks = mutableListOf() 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() 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() - 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() - 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() 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() - 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() 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() - 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() - 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() - 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() - 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() - 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() val pauses = mutableListOf() 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() 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() 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", diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultAcknowledgementTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultAcknowledgementTest.kt new file mode 100644 index 0000000..da0aecd --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseResultAcknowledgementTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecOptionDedupTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecOptionDedupTest.kt index 5170280..e9e38db 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecOptionDedupTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecOptionDedupTest.kt @@ -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", diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt index 58f5506..ce4f55b 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt @@ -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", diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt index 10aada9..7b0757f 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt @@ -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"}]}"""), From c9b7a2dc0dbb539271e52276a224c4682cee9d6c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 9 Oct 2026 15:21:03 +0800 Subject: [PATCH 4/5] docs: close out previously published integration wiki mirrors (#365 #367 #368 #370) --- docs/02-architecture-and-code-map.md | 8 ++++---- docs/03-business-rules-and-glossary.md | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index c546a40..08fec7b 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 579f053988e3f67f287ff130f9c24a692d897191 -synchronized_at: 2026-10-09T02:06:38Z +wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31 +synchronized_at: 2026-10-09T06:46:07Z @@ -656,7 +656,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 ## Android 重购编排(#367) -实现绑定 `5430dc1`,工单分支尚未合并/安装/发布;无Server/Web、数据库或接口迁移。 +实现绑定 `5430dc1`,2026-10-09 已合并 main `e26743c` 并安装集成APK;无Server/Web、数据库或接口迁移,真机批量采购仍待验收。 - `TaskHistoryFragment` 把采购搜索栏入口改为“重购”,发送 PREPARE/CONFIRM/STOP、显示 Service 内存状态;一次确认与逐条摘要由 `RepurchaseState` 提供,不由 Fragment 驱动采购生命周期。 - `RepurchaseBatchDiscovery` 注入既有设备历史查询和服务端HTTP Date时钟,负责全状态/50条页/30天窗口、相邻60秒边界、去重、完整性及重读首页;最多200页,不使用本地历史缓存执行重购。 @@ -667,7 +667,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 ## Android 颜色发现诊断追加(#370 第一阶段) -实现绑定 `9e5f09a`,独立工单分支,尚未合并 main;本阶段不修改颜色遍历和采购结果判定。 +实现绑定 `9e5f09a`,已合并 main,2026-10-09 随集成版本 `e26743c` 安装;本阶段不修改颜色遍历和采购结果判定。 - `AgentDiagnosticSchema` 升至 v5,在 `agent_diagnostic` 追加可空 INTEGER `color_vertical_swipe_count`、`color_trailing_empty_read_count`、`color_horizontal_incomplete`;对应事件中的两个 Int? 和一个 Boolean?,Boolean 按 0/1 写入。新库包含列,v1–v4 逐级补齐并跳过已存在列,旧行新列为 NULL;不清表,不改独立采购失败快照、队列和 context。 - `PddProductDetailCollector.collectColors` 只基于既有解析帧记录颜色阶段 UP 派发次数、空发现轮次和横向探索三态;不增加抓取、手势或等待。标签及签名只在内存使用,持久化仍为安全聚合。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index f251c06..922319f 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: c4870fd01a4569059fc0afc77179d14fbd286384 -synchronized_at: 2026-10-08T10:15:21Z +wiki_revision: 9e7887874f3972f75fc33113b2d916b34f48be41 +synchronized_at: 2026-10-09T06:46:13Z @@ -676,7 +676,7 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 ## 支付页安全返回后的核单观察(#365) -实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。 +实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,已合并 main 并随2026-10-09集成APK安装;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。 - 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。 - 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。 @@ -862,7 +862,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 ## 图搜采集部分完成的自动关联资格(#368) -实现基线:工单分支 `fix/368-image-search-partial-link` 提交 `15ccab3`(2026-10-08);尚未合并 main 或发布,生产是否启用以发布记录为准。 +实现基线:`15ccab3`(2026-10-08);2026-10-09 已合并 main `e26743c` 并部署122.228.200.167,真实图搜采集业务效果仍待验收。 - 此规则仅作用于 `source=image_search` 的结果后置自动关联。正常 `completed` 沿用原行为,包括 `missing_json=[]`。 - `completed_partial` 仅在 `missing_json` 为可解析、非空字符串数组且每一项精确属于 `reviewCount`、`salesText`、`shopName` 时允许自动关联。三项均为可选描述信息。 @@ -875,7 +875,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 ## Android 本设备最后一批重购(#367) -实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);未合并 main、未安装或发布,真机批量采购仍待独立授权验收。 +实现绑定 `5430dc1f64fc0a4d8ef124ada7356e21a975151f`(2026-10-08,工单分支 `feat/367-last-batch-repurchase`);2026-10-09 已合并 main `e26743c` 并安装集成APK,真机批量采购仍待独立授权验收。 - 采购页搜索栏的“回填”入口替换为“重购”。原回填弹窗、Service 动作和实现保留但不再由该按钮触发;原单条重试、继续采购不改。重购使用原任务的 `/reset`,保留 CG 编号,不使用创建替代任务的 `/retry`。 - 读取本设备近30天全部状态、不继承页面编号/状态筛选;按创建时间倒序、同刻任务ID倒序,从最新记录逐项比较相邻时间,不超过60秒归入同一推算批次。跨50条分页去重,最多200页,结束复核首页;头部或总量变化、日期/页结构异常、查询截断无法证明完整时不启动,不拿部分列表执行。批次时间边界使用独享API客户端的 HTTP Date,不使用手机时钟,缺少有效 Date 拒绝;此轻量方案不保证任意并发删除/归属变动的数据库快照。 From 92aeab8d04627401d27ec154f2877bd53a2f81f5 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 9 Oct 2026 15:26:55 +0800 Subject: [PATCH 5/5] docs: record verified navigation readiness contract #373 --- docs/03-business-rules-and-glossary.md | 21 +++++++++++++++++++-- docs/13-deployment-and-operations.md | 12 ++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 922319f..ce015fc 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: 9e7887874f3972f75fc33113b2d916b34f48be41 -synchronized_at: 2026-10-09T06:46:13Z +wiki_revision: 6c1a317ef67a0b015bb16a615b8c490b384188a0 +synchronized_at: 2026-10-09T07:21:39Z @@ -375,6 +375,8 @@ Admin 的「创建时间」范围按 SYB 货运单列表的 `t_stock.created`( ## SYB 采购强制当次规格探测(#215) +以下为 #373 之前的复用行为;#373 分支的限定复用和等待规则见“采购页面一次性交接与导航稳定等待”章节,未安装新版不能按新规则判断。 + - 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。 - 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。 - 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。 @@ -885,3 +887,18 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装 - Service 独立串行后台调度观察,真正领取、执行、上传仍由原执行器和租约/任务锁负责。终态且执行锁、活跃记录和Outbox均收尾后才继续。检测新采集或采购工作停止剩余,本轮当前task本身不算新工作。 - 页面和通知栏“停止重购”只停止后续,已提交/执行的任务沿用原流程;切Tab、切PDD、界面重建不中断。单条单调时钟等待上限15分钟,超时只停止编排,不取消任务、不重置或再次下单。进程死亡/服务重建不恢复整轮,原单任务恢复不改。 - 本轮摘要与最小attempt关联仅在内存,不新增地址/树采集或数据库。接口、服务端、Web、权限及 #364 诊断契约不变。自动化回归和APK构建通过不代表真机页面/批量下单已验收。 + + +## 采购页面一次性交接与导航稳定等待(#373 v4) + +实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。 + +- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。 +- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。 +- 采集、图搜、当前页采集、回填、恢复执行、导航诊断及已知主动离页操作都会使原交接不可用;服务/无障碍重建不恢复。复用仍要求新鲜PDD商品结构,读取后再次核对同一次已消费凭证的期限、实例、前台与代次,避免慢Binder读取期间过期。人工在同一PDD包内切页可能没有可观察前台变化,不能声称完全识别。 +- 不符合复用资格时打开本任务链接,沿用直达NEW_TASK|CLEAR_TASK及异常回退浏览器。等待从该次打开开始,不是给整个重购批次加固定间隔。执行器取得实际ACCEPTED路径;未知路径不当作直达。 +- 浏览器路径先观察到受支持浏览器,再允许PDD商品结构进入就绪判断,浏览器出现前的旧PDD帧不放行。直达接受后至少3秒缓冲;浏览器返回PDD后至少3秒缓冲。商品结构还需连续稳定至少1秒、至少3次读取,缓冲与稳定时间可以重叠。就绪后立即继续,不无条件等30秒。 +- 商品稳定特征只在内存比较标题和规格入口/面板结构,不以销量、价格或滚动拼单变化为依据。非PDD、空窗口、缺少商品证据或结构变化会打断稳定计时;登录、验证码、风控和无效商品保持明确失败。 +- 导航总预算为单调时钟30秒、最多300轮、轮间100ms;耗时系统读取可能超过预算,但返回后检查时限,不再继续点击。浏览器入口重试从点击调用返回后至少间隔1秒;原生点击前在新鲜节点重新校验浏览器与剩余预算。超时返回PDD_DETAIL_ENTRY_FAILED,不继续规格、地址或创建订单步骤。 +- 用户明确拒绝新增分享/复制链接操作,并接受本版仅解决跨任务无条件复用与导航抢跑。**稳定商品页不是goodsId身份核验;PDD忽略深链而稳定停在旧页、或无可观察变化的同包人工换页,仍可能无法识别。** 不得将任务携带goodsId当作页面回读,不能用本版宣称绝对排除错误商品。 +- 不改服务端/API线协议/数据库、采集器、规格选择、地址与下单逻辑、重购批次发现/停止逻辑;不涉及任何付款动作。仅增加Android对既有结果响应status/replayed的本地解析(replayed=false在服务端可省略)。普通流程不新增原始树、截图、分享URL或页面文本落盘。 diff --git a/docs/13-deployment-and-operations.md b/docs/13-deployment-and-operations.md index 3aec8b6..35a77e2 100644 --- a/docs/13-deployment-and-operations.md +++ b/docs/13-deployment-and-operations.md @@ -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: 69c80ae55159abbdb98c0f4acc68322cd320739d -synchronized_at: 2026-10-09T06:46:27Z +wiki_revision: 01a3a9cd968510c9435ec1fb2be0040fe17d0282 +synchronized_at: 2026-10-09T07:21:53Z @@ -357,3 +357,11 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、 - 临时探针输出通过 `adb -s <设备地址> logcat -d -s GoAutoNavProbe:I '*:S'` 读取;只包含随机runId、路径/固定结果、相对毫秒、节点计数、窗口id、布尔证据及仅在内存比较所得标题same/different/unknown,不保存标题/哈希/URL/页面原文/原始树/截图,不写业务数据库。别用logcat -t的全局最后几行冒充完整诊断;若缓冲已覆盖须如实记录。禁止导出完整无过滤系统日志。 - `legacy_would_accept`模拟现有两次商品结构命中,始终标注identityVerified=false;窗口变化、标题变化、商品结构成立都不是目标goodsId证明。shell触发探针与后台任务的系统启动许可可能不同;人工指定browser只验证浏览器路径,不证明当时自动回退的异常原因。正常/失败对照及真实后台时序仍须以任务绑定诊断和人工确认核验。 - 自动化验证应包含debug专用探针单测与 `:app:compileReleaseUnitTestKotlin`;探针测试放 `src/testDebug`,不得在共享 `src/test` 引用debug-only类。构建使用原私有签名,安装后回读包版本和APK哈希,并比较设置/设备身份摘要。 + +## #373 v4 修复版构建与验证(2026-10-09) + +- 源码分支 `fix/373-navigation-diagnostics`,实现提交 `dbf9a5f4606529189e57b6c266449070d5001340`。APK版本 `0.9.69-373-fix1 / code82`,与前一导航诊断版不同;未安装修复版、未合并main、未发布线上,不需要Admin迁移或服务重启。 +- APK SHA256 `171f4b12395e2940ce873b62ea394799f521d1a0997f6aaf7582a123c24795e8`;仍使用用户原签名,证书SHA256 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。后续经安装授权用install -r保留数据,不能为覆盖擅自卸载。相同versionCode不能证明文件相同,应核对完整版本和哈希。 +- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。 +- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。 +- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。