feat(android): add bounded navigation diagnostics without purchase actions (#373)
This commit is contained in:
@@ -31,7 +31,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = "-370-diag1"
|
||||
versionNameSuffix = "-373-nav-diag3"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
<application android:usesCleartextTraffic="true">
|
||||
<receiver android:name=".diagnostics.NavigationProbeReceiver"
|
||||
android:exported="true" android:permission="android.permission.DUMP" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
internal object BrowserProbeClickPolicy {
|
||||
fun allowed(rootPackage: String?, nodePackage: String?, label: String, visible: Boolean,
|
||||
enabled: Boolean, clickable: Boolean, rootWindow: Int, nodeWindow: Int): Boolean =
|
||||
rootPackage in setOf("com.heytap.browser", "com.android.chrome", "com.android.browser") &&
|
||||
nodePackage == rootPackage && label in setOf("打开拼多多APP", "打开拼多多 App") &&
|
||||
visible && enabled && clickable && rootWindow >= 0 && nodeWindow == rootWindow
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode as UiSnapshotNode
|
||||
|
||||
internal class NavigationProbe(
|
||||
private val capture: () -> UiSnapshot,
|
||||
private val launch: ((PddLaunchEvent) -> Unit) -> Boolean,
|
||||
private val clickBrowserEntry: (UiSnapshotNode) -> FreshClickOutcome,
|
||||
private val now: () -> Long,
|
||||
private val pause: (Long) -> Unit,
|
||||
private val emit: (String) -> Unit,
|
||||
private val windowId: () -> Int? = { null },
|
||||
private val isActive: () -> Boolean = { true },
|
||||
) {
|
||||
fun run() {
|
||||
val started = now()
|
||||
fun canAct() = isActive() && !Thread.currentThread().isInterrupted && now() - started < 20_000
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
val before = capture()
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
fun problem(page: UiSnapshot) = PddPageClassifier.classify(page.packageName, page.activityName,
|
||||
page.nodes.filter { it.visible }.map { it.label }) != null
|
||||
if (problem(before)) { emit("stopped=page_problem"); return }
|
||||
val initial = PddScreenParser.parse(before, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
// Values remain in memory. Neither text nor its hash is logged/persisted.
|
||||
val oldTitle = initial.summary.title
|
||||
val oldWindow = windowId()
|
||||
val initiallyPdd = before.packageName == "com.xunmeng.pinduoduo"
|
||||
emit("baseline;pdd=$initiallyPdd;root=${initial.rootAvailable};product=${initiallyPdd && initial.hasPurchaseProductEvidence()};title=${oldTitle != null};window=$oldWindow")
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
if (!launch { event ->
|
||||
emit("launch;t=${now() - started};path=${event.path};status=${event.status};exception=${event.exceptionClass ?: "none"}")
|
||||
}) { emit("stopped=launch_failed"); return }
|
||||
var stable = 0
|
||||
var legacyAccepted = false
|
||||
var browserClicks = 0
|
||||
var nextBrowserClick = 0L
|
||||
var polls = 0
|
||||
var lastSignal: String? = null
|
||||
while (canAct() && polls < 201) {
|
||||
val readStarted = now()
|
||||
val page = capture()
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired;t=${now() - started}"); return }
|
||||
if (problem(page)) { emit("stopped=page_problem;t=${now() - started}"); return }
|
||||
val screen = PddScreenParser.parse(page, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
val product = page.packageName == "com.xunmeng.pinduoduo" && screen.hasPurchaseProductEvidence()
|
||||
stable = if (product) stable + 1 else 0
|
||||
val titleComparison = when {
|
||||
oldTitle == null || screen.summary.title == null -> "unknown"
|
||||
oldTitle == screen.summary.title -> "same"
|
||||
else -> "different"
|
||||
}
|
||||
val category = when (page.packageName) {
|
||||
"com.xunmeng.pinduoduo" -> "pdd"
|
||||
in BROWSERS -> "browser"
|
||||
null -> "none"
|
||||
else -> "other"
|
||||
}
|
||||
val signal = "pkg=$category;root=${screen.rootAvailable};product=$product;panel=${screen.specPanelOpen};entry=${screen.specEntry != null};nodes=${page.nodes.size};title=$titleComparison;window=${windowId()}"
|
||||
if (signal != lastSignal || polls % 10 == 0) {
|
||||
emit("sample;t=${now() - started};readMs=${now() - readStarted};$signal")
|
||||
lastSignal = signal
|
||||
}
|
||||
// Observation only: never treats this as proof of goods identity or calls the executor.
|
||||
if (stable >= 2 && !legacyAccepted) {
|
||||
legacyAccepted = true
|
||||
emit("legacy_would_accept;t=${now() - started};title=$titleComparison;identityVerified=false")
|
||||
}
|
||||
if (category == "browser") {
|
||||
val entries = page.nodes.filter { it.visible && it.enabled && it.label in OPEN_PDD_ALIASES }
|
||||
if (entries.size > 1) { emit("stopped=browser_ambiguous"); return }
|
||||
if (entries.size == 1 && browserClicks < 3 && now() >= nextBrowserClick) {
|
||||
if (!canAct()) { emit("stopped=inactive_or_expired"); return }
|
||||
val result = clickBrowserEntry(entries.single())
|
||||
browserClicks++
|
||||
emit("browser_click;t=${now() - started};attempt=$browserClicks;result=${result.result};reason=${result.reason}")
|
||||
if (result.result == FreshActionResult.AMBIGUOUS || result.result == FreshActionResult.BLOCKED) {
|
||||
emit("stopped=browser_click_unsafe"); return
|
||||
}
|
||||
nextBrowserClick = now() + 1_000
|
||||
}
|
||||
}
|
||||
polls++
|
||||
pause(100)
|
||||
}
|
||||
emit("finished;t=${now() - started};samples=$polls;legacyAccepted=$legacyAccepted;identityVerified=false")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val BROWSERS = setOf("com.heytap.browser", "com.android.chrome", "com.android.browser")
|
||||
private val OPEN_PDD_ALIASES = setOf("打开拼多多APP", "打开拼多多 App")
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package cn.ilapage.goauto.agent.diagnostics
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
|
||||
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import cn.ilapage.goauto.agent.service.AgentForegroundService
|
||||
import java.util.UUID
|
||||
|
||||
/** Debug APK only; manifest requires the shell/system DUMP permission. No arbitrary URL or action. */
|
||||
class NavigationProbeReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val goodsId = intent.getStringExtra("goodsId").orEmpty()
|
||||
val mode = intent.getStringExtra("mode").orEmpty()
|
||||
if (!goodsId.matches(Regex("[1-9][0-9]{0,19}")) || mode !in setOf("direct", "browser")) {
|
||||
Log.i(TAG, "rejected=invalid_arguments")
|
||||
return
|
||||
}
|
||||
val service = AgentForegroundService.diagnosticInstance
|
||||
val accessibility = GoAutoAccessibilityService.instance
|
||||
if (service == null || accessibility == null) {
|
||||
Log.i(TAG, "rejected=service_unavailable")
|
||||
return
|
||||
}
|
||||
val runId = UUID.randomUUID().toString()
|
||||
val accepted = service.runNavigationProbe { serviceContext ->
|
||||
// Delayed execution must not use a destroyed accessibility instance.
|
||||
if (GoAutoAccessibilityService.instance !== accessibility) {
|
||||
Log.i(TAG, "run=$runId;stopped=accessibility_changed")
|
||||
return@runNavigationProbe
|
||||
}
|
||||
Log.i(TAG, "run=$runId;started;mode=$mode;synthetic=true;noBusinessTask=true")
|
||||
val deadline = SystemClock.elapsedRealtime() + 20_000
|
||||
val canAct = { AgentForegroundService.diagnosticInstance === service &&
|
||||
GoAutoAccessibilityService.instance === accessibility && !Thread.currentThread().isInterrupted &&
|
||||
SystemClock.elapsedRealtime() < deadline }
|
||||
val guardedContext = object : ContextWrapper(serviceContext) {
|
||||
override fun startActivity(intent: Intent) {
|
||||
// Includes direct-failure fallback and package-manager Binder delays.
|
||||
check(canAct()) { "probe_inactive" }
|
||||
super.startActivity(intent)
|
||||
}
|
||||
}
|
||||
NavigationProbe(
|
||||
capture = {
|
||||
check(canAct()) { "probe_inactive" }
|
||||
accessibility.capture()
|
||||
},
|
||||
launch = { report -> canAct() && PddLinkLauncher(guardedContext).open(
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=$goodsId", mode == "direct", report,
|
||||
) },
|
||||
clickBrowserEntry = { safeBrowserClick(accessibility, canAct) },
|
||||
now = SystemClock::elapsedRealtime,
|
||||
pause = { Thread.sleep(it) },
|
||||
emit = { Log.i(TAG, "run=$runId;$it") },
|
||||
windowId = { accessibility.rootInActiveWindow?.let { root ->
|
||||
try { root.windowId } finally { @Suppress("DEPRECATION") root.recycle() }
|
||||
} },
|
||||
isActive = canAct,
|
||||
).run()
|
||||
}
|
||||
if (!accepted) Log.i(TAG, "run=$runId;rejected=device_busy")
|
||||
}
|
||||
|
||||
/** Fresh native node only: no ancestors, gestures, coordinates, or PDD-package actions. */
|
||||
private fun safeBrowserClick(service: GoAutoAccessibilityService, canAct: () -> Boolean): FreshClickOutcome {
|
||||
fun blocked() = FreshClickOutcome(FreshActionResult.BLOCKED, FreshClickReason.TARGET_NOT_FOUND)
|
||||
if (!canAct()) return blocked()
|
||||
val root = service.rootInActiveWindow ?: return blocked()
|
||||
val nodes = mutableListOf<AccessibilityNodeInfo>()
|
||||
fun visit(node: AccessibilityNodeInfo) {
|
||||
nodes += node
|
||||
if (nodes.size >= 2_000 || !canAct()) return
|
||||
for (i in 0 until node.childCount) {
|
||||
if (nodes.size >= 2_000 || !canAct()) break
|
||||
node.getChild(i)?.let(::visit)
|
||||
}
|
||||
}
|
||||
try {
|
||||
visit(root)
|
||||
if (nodes.size >= 2_000 || !canAct()) return blocked()
|
||||
val candidates = nodes.filter { node ->
|
||||
val label = node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() }
|
||||
?: node.contentDescription?.toString()?.trim().orEmpty()
|
||||
BrowserProbeClickPolicy.allowed(root.packageName?.toString(), node.packageName?.toString(), label,
|
||||
node.isVisibleToUser, node.isEnabled, node.isClickable, root.windowId, node.windowId)
|
||||
}
|
||||
if (candidates.size != 1) return blocked()
|
||||
val current = service.rootInActiveWindow ?: return blocked()
|
||||
val sameWindow = try { current.windowId == root.windowId && current.packageName == root.packageName }
|
||||
finally { @Suppress("DEPRECATION") current.recycle() }
|
||||
if (!sameWindow || !canAct()) return blocked()
|
||||
val clicked = candidates.single().performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
return FreshClickOutcome(if (clicked) FreshActionResult.SUCCESS else FreshActionResult.FAILED,
|
||||
if (clicked) FreshClickReason.SUCCESS else FreshClickReason.ACTION_CLICK_FALSE)
|
||||
} finally {
|
||||
nodes.forEach { @Suppress("DEPRECATION") it.recycle() }
|
||||
}
|
||||
}
|
||||
private companion object { const val TAG = "GoAutoNavProbe" }
|
||||
}
|
||||
@@ -1,8 +1,26 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
enum class PddLaunchPath { DIRECT, BROWSER }
|
||||
enum class PddLaunchStatus { ACCEPTED, REJECTED, EXCEPTION }
|
||||
|
||||
/** No URL, exception message, or page content may enter this event. */
|
||||
data class PddLaunchEvent(val path: PddLaunchPath, val status: PddLaunchStatus, val exceptionClass: String? = null)
|
||||
|
||||
internal object PddLaunchFallback {
|
||||
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
|
||||
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
|
||||
return runCatching(browser).getOrDefault(false)
|
||||
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean,
|
||||
diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
|
||||
fun attempt(path: PddLaunchPath, launch: () -> Boolean): Boolean {
|
||||
val result = runCatching(launch)
|
||||
val event = PddLaunchEvent(path, when {
|
||||
result.isFailure -> PddLaunchStatus.EXCEPTION
|
||||
result.getOrDefault(false) -> PddLaunchStatus.ACCEPTED
|
||||
else -> PddLaunchStatus.REJECTED
|
||||
}, result.exceptionOrNull()?.javaClass?.name)
|
||||
// Observability must never change routing or turn an accepted launch into a retry.
|
||||
runCatching { diagnostic(event) }
|
||||
return result.getOrDefault(false)
|
||||
}
|
||||
if (preferDirect && attempt(PddLaunchPath.DIRECT, direct)) return true
|
||||
return attempt(PddLaunchPath.BROWSER, browser)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ object PddPageClassifier {
|
||||
}
|
||||
|
||||
class PddLinkLauncher(private val context: Context) {
|
||||
fun open(url: String, preferDirect: Boolean = false): Boolean {
|
||||
fun open(url: String, preferDirect: Boolean = false, diagnostic: (PddLaunchEvent) -> Unit = {}): Boolean {
|
||||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
|
||||
return false
|
||||
@@ -42,7 +42,7 @@ class PddLinkLauncher(private val context: Context) {
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
context.startActivity(direct)
|
||||
true
|
||||
}, browser = { openBrowser(uri) })
|
||||
}, browser = { openBrowser(uri) }, diagnostic = diagnostic)
|
||||
}
|
||||
|
||||
private fun openBrowser(uri: Uri): Boolean {
|
||||
@@ -51,7 +51,9 @@ class PddLinkLauncher(private val context: Context) {
|
||||
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
|
||||
}
|
||||
if (browser != null) base.setPackage(browser)
|
||||
return runCatching { context.startActivity(base); true }.getOrDefault(false)
|
||||
// The outer fallback boundary retains the previous false-on-error behavior and records only the class.
|
||||
context.startActivity(base)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isPddHost(host: String?): Boolean =
|
||||
|
||||
@@ -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,
|
||||
|
||||
+39
-1
@@ -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"
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchEvent
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchPath
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchStatus
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PddLaunchFallbackTest {
|
||||
@Test fun reportsDirectAcceptanceWithoutCallingBrowser() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { error("unexpected") }, events::add))
|
||||
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)), events)
|
||||
}
|
||||
@Test fun reportsOnlyExceptionClassThenBrowserAcceptance() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertTrue(PddLaunchFallback.open(true, { throw SecurityException("secret URL and address") }, { true }, events::add))
|
||||
assertEquals(listOf(
|
||||
PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.EXCEPTION, "java.lang.SecurityException"),
|
||||
PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.ACCEPTED),
|
||||
), events)
|
||||
assertFalse(events.toString().contains("secret"))
|
||||
}
|
||||
@Test fun reportsFalseAndBrowserExceptionWithoutChangingResult() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertFalse(PddLaunchFallback.open(true, { false }, { throw IllegalStateException("private") }, events::add))
|
||||
assertEquals(PddLaunchStatus.REJECTED, events[0].status)
|
||||
assertEquals(PddLaunchStatus.EXCEPTION, events[1].status)
|
||||
}
|
||||
@Test fun diagnosticFailureDoesNotCauseBrowserFallbackOrChangeResult() {
|
||||
var browserCalls = 0
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { browserCalls++; true }, { error("storage failed") }))
|
||||
assertEquals(0, browserCalls)
|
||||
}
|
||||
@Test fun browserOnlyReportsNoDirectAttempt() {
|
||||
val events = mutableListOf<PddLaunchEvent>()
|
||||
assertFalse(PddLaunchFallback.open(false, { error("unexpected") }, { false }, events::add))
|
||||
assertEquals(listOf(PddLaunchEvent(PddLaunchPath.BROWSER, PddLaunchStatus.REJECTED)), events)
|
||||
}
|
||||
@Test fun directSuccessDoesNotStartBrowser() {
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.diagnostics.BrowserProbeClickPolicy
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class BrowserProbeClickPolicyTest {
|
||||
@Test fun onlyExactNativeBrowserEntryIsAllowed() {
|
||||
assertTrue(BrowserProbeClickPolicy.allowed("com.heytap.browser", "com.heytap.browser", "打开拼多多APP", true, true, true, 3, 3))
|
||||
}
|
||||
@Test fun rejectsPackageSwitchHiddenDisabledNonClickableAndStaleWindow() {
|
||||
fun allowed(root: String? = "com.heytap.browser", node: String? = root, label: String = "打开拼多多APP",
|
||||
visible: Boolean = true, enabled: Boolean = true, clickable: Boolean = true, window: Int = 3) =
|
||||
BrowserProbeClickPolicy.allowed(root, node, label, visible, enabled, clickable, 3, window)
|
||||
assertFalse(allowed(root = "com.xunmeng.pinduoduo"))
|
||||
assertFalse(allowed(node = "com.xunmeng.pinduoduo"))
|
||||
assertFalse(allowed(root = null))
|
||||
assertFalse(allowed(label = "打开"))
|
||||
assertFalse(allowed(label = "立即支付"))
|
||||
assertFalse(allowed(visible = false))
|
||||
assertFalse(allowed(enabled = false))
|
||||
assertFalse(allowed(clickable = false))
|
||||
assertFalse(allowed(window = 4))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.diagnostics.NavigationProbe
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class NavigationProbeTest {
|
||||
@Test fun cancellationDuringInitialCapturePreventsLaunch() {
|
||||
var active = true
|
||||
var launches = 0
|
||||
val page = UiSnapshot("com.xunmeng.pinduoduo", null, emptyList())
|
||||
NavigationProbe(capture = { active = false; page }, launch = { launches++; true },
|
||||
clickBrowserEntry = { error("no click") }, now = { 0 }, pause = {}, emit = {},
|
||||
isActive = { active }).run()
|
||||
assertEquals(0, launches)
|
||||
}
|
||||
@Test fun deadlineCrossedDuringCapturePreventsLaunch() {
|
||||
var clock = 0L
|
||||
var launches = 0
|
||||
NavigationProbe(capture = { clock = 20_001; UiSnapshot(null, null, emptyList()) },
|
||||
launch = { launches++; true }, clickBrowserEntry = { error("no click") }, now = { clock },
|
||||
pause = {}, emit = {}).run()
|
||||
assertEquals(0, launches)
|
||||
}
|
||||
@Test fun cancellationDuringBrowserCapturePreventsClick() {
|
||||
var active = true
|
||||
var reads = 0
|
||||
var clock = 0L
|
||||
var clicks = 0
|
||||
val page = UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP")))
|
||||
NavigationProbe(capture = { reads++; if (reads > 1) active = false; page }, launch = { true },
|
||||
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
|
||||
now = { clock }, pause = { clock += it }, emit = {}, isActive = { active }).run()
|
||||
assertEquals(0, clicks)
|
||||
}
|
||||
@Test fun baselineUsesActualPackageRatherThanOptionalParserEvidence() {
|
||||
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("发起拼单"))))
|
||||
rig.run()
|
||||
assertTrue(rig.lines.first().contains("pdd=false"))
|
||||
assertFalse(rig.lines.any { it.contains("legacy_would_accept") })
|
||||
}
|
||||
private class Rig(var page: UiSnapshot) {
|
||||
var clock = 0L
|
||||
var launches = 0
|
||||
var clicks = 0
|
||||
val lines = mutableListOf<String>()
|
||||
fun run(accepted: Boolean = true) = NavigationProbe(
|
||||
capture = { page },
|
||||
launch = { report -> launches++; report(PddLaunchEvent(PddLaunchPath.DIRECT, PddLaunchStatus.ACCEPTED)); accepted },
|
||||
clickBrowserEntry = { clicks++; FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS) },
|
||||
now = { clock }, pause = { clock += it }, emit = lines::add,
|
||||
).run()
|
||||
}
|
||||
private fun node(text: String, path: String = "0") = SnapshotNode(path, null, text, null, null,
|
||||
"android.widget.TextView", NodeBounds(0, 0, 100, 100), true, false, false, false, true, true)
|
||||
|
||||
@Test fun pddIsReadOnlyAndSamplingStopsWithinBound() {
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("立即下单"))))
|
||||
rig.run()
|
||||
assertEquals(1, rig.launches)
|
||||
assertEquals(0, rig.clicks)
|
||||
assertEquals(20_000L, rig.clock)
|
||||
assertTrue(rig.lines.last().contains("finished"))
|
||||
}
|
||||
@Test fun failedLaunchStopsWithoutClicking() {
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, emptyList()))
|
||||
rig.run(false)
|
||||
assertEquals(1, rig.launches)
|
||||
assertEquals(0L, rig.clock)
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.last().contains("launch_failed"))
|
||||
}
|
||||
@Test fun browserRequiresUniqueExplicitPddEntryAndBoundedRetries() {
|
||||
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"))))
|
||||
rig.run()
|
||||
assertEquals(3, rig.clicks)
|
||||
}
|
||||
@Test fun ambiguousBrowserEntryStopsWithoutGuessing() {
|
||||
val rig = Rig(UiSnapshot("com.heytap.browser", null, listOf(node("打开拼多多APP"), node("打开拼多多APP", "1"))))
|
||||
rig.run()
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.last().contains("browser_ambiguous"))
|
||||
}
|
||||
@Test fun genericOpenAndPddControlsAreNeverClicked() {
|
||||
for (pkg in listOf("com.heytap.browser", "com.xunmeng.pinduoduo", "other.package")) {
|
||||
val rig = Rig(UiSnapshot(pkg, null, listOf(node("打开"), node("立即支付"), node("确认订单"))))
|
||||
rig.run()
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.isNotEmpty())
|
||||
}
|
||||
}
|
||||
@Test fun loginOrCaptchaStopsBeforeLaunch() {
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node("手机号登录"))))
|
||||
rig.run()
|
||||
assertEquals(0, rig.launches)
|
||||
assertEquals(0, rig.clicks)
|
||||
assertTrue(rig.lines.last().contains("page_problem"))
|
||||
}
|
||||
@Test fun pageTextAndExceptionMessagesNeverAppearInOutput() {
|
||||
val secret = "PRIVATE_ACCOUNT_ADDRESS_DO_NOT_LOG"
|
||||
val rig = Rig(UiSnapshot("com.xunmeng.pinduoduo", null, listOf(node(secret))))
|
||||
rig.run()
|
||||
assertTrue(rig.lines.isNotEmpty())
|
||||
assertFalse(rig.lines.joinToString().contains(secret))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user