mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat: LTL operators, sampling, and default generators (#17)
* feat(ltl): add Now/Next/Eventually/Implies/Or/And/Not formulas Replace the fold-with-latch evaluator with a residual-formula reducer. Each Observe() instantiates a fresh obligation from the root (stripping an outer Always), reduces each pending obligation against current state, latches Violated on first failure, and surfaces Pending verdicts for deferred obligations. Existing Always/Pure/Thunk tests continue to pass. * feat(ltl): support relative duration for eventually().within() * feat(proto): add Swipe, PressKey, RecentLogs RPCs * feat(verifier,runner): formula handles, new action kinds, rich state - verifier: add formula-spec registry; bindNow/bindNext/bindEventually with chainable .implies/.or/.and/.not and .within(n,unit) on eventually; bindFrom for uniform sampling. bindAlways keeps accepting plain predicates. - verifier: store lastTree, lastAction, step time, logs, exceptions on the Verifier; SnapshotInput replaces the (snapshots, tree) pair. stateObject now produces state.lastAction/time/logs/exceptions matching the TS State type. - verifier: make taps/swipes/waitOnce/pressKey built-in generators actually fire; taps picks a clickable, enabled element from the last hierarchy. - agent: add exceptions field to Message wire format. - driver: add Swipe/PressKey/RecentLogs to Driver interface; wire maestro client and mock driver. LogEntry exposed for runner consumption. - runner: apply Swipe/PressKey/Wait actions; collect logcat and exceptions; pass lastAction and step time into PushSnapshot. * feat(spec-api): LTL operators, new actions, richer State - ltl.ts exports now/next/eventually; always overload accepts a Formula - types.ts: Formula gains implies/or/and/not; EventuallyFormula adds .within; State gains lastAction/time/logs/exceptions; Swipe/PressKey/Wait action types - actions.ts: Swipe/PressKey/Wait/from constructors; waitOnce + pressKey default generators - tests exercise the chaining, sampling, and new actions through a recorded fake runtime * feat(sidecar): add swipe, pressKey, recentLogs RPC handlers * feat(sdk-android): capture uncaught exceptions Install a default uncaught handler on Uatu.start, chained with any existing handler so Android's crash reporter still runs. Expose Uatu.reportError for callers to forward caught throwables. A bounded circular buffer (default 50) drains into each STATE message's new exceptions field. Protocol.kt serializes/deserializes the field, matching the Go wire format added to internal/agent/protocol.go. * feat(spec-api): add @uatu/spec/defaults/properties bundle * feat(sample-app): exercise new LTL operators + defaults spec.ts now imports eventually/next/now/from from @uatu/spec and noUncaughtExceptions from @uatu/spec/defaults/properties. It declares three properties that exercise the new surface: - accountCountNonNegative: plain always() safety - addAccountAdvances: always(now(x).implies(next(y))) - eventuallyLoggedIn: eventually(p).within(30, "seconds") - noUncaughtExceptions: imported default The weighted actions root uses from() for random phone/name sampling and entries for taps/swipes/waitOnce/pressKey built-ins. SampleApplication gains a debug hook gated on the system property uatu.inject_error so the e2e run can synthesize an Uatu.reportError and verify noUncaughtExceptions violates. cmd/uatu/test_run.go adds a subpath alias so specs importing "@uatu/spec/defaults/properties" resolve against the in-tree source when running from the uatu checkout. The spec-integration tests swap the old click-counter fixtures for the new login hierarchy. * feat(trace): record swipe/key/wait details + exceptions trace.Step gains an Exceptions array so the trace captures the class/message/stackTrace for each SDK-reported throwable in a step. trace.Action gains FromX/FromY/ToX/ToY/Key/DurationMillis so the full payload of Swipe/PressKey/Wait actions is visible in trace.jsonl. sample-app's debug error hook now gates on ApplicationInfo.DEBUGGABLE instead of a system property (adb setprop fails on non-rooted emulators).
This commit is contained in:
40 files changed
+2980
-262
No files matched your search
@@ -6,12 +6,22 @@ interface DriverBackend {
|
||||
fun tap(x: Int, y: Int)
|
||||
fun tapSelector(selector: String)
|
||||
fun inputText(text: String)
|
||||
fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long)
|
||||
fun pressKey(key: String)
|
||||
fun screenshot(): Triple<ByteArray, Int, Int>
|
||||
fun hierarchy(): String
|
||||
fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine>
|
||||
fun waitForIdle(durationMillis: Long)
|
||||
fun healthy(): Boolean
|
||||
}
|
||||
|
||||
data class LogLine(
|
||||
val unixMillis: Long,
|
||||
val level: String,
|
||||
val tag: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* StubDriverBackend records calls but takes no real device action. Real
|
||||
* Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing
|
||||
@@ -78,6 +88,56 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal val KEY_MAP: Map<String, String> = mapOf(
|
||||
"back" to "KEYCODE_BACK",
|
||||
"home" to "KEYCODE_HOME",
|
||||
"enter" to "KEYCODE_ENTER",
|
||||
"tab" to "KEYCODE_TAB",
|
||||
"up" to "KEYCODE_DPAD_UP",
|
||||
"down" to "KEYCODE_DPAD_DOWN",
|
||||
"left" to "KEYCODE_DPAD_LEFT",
|
||||
"right" to "KEYCODE_DPAD_RIGHT",
|
||||
)
|
||||
|
||||
internal fun formatAdbLogcatTimestamp(unixMillis: Long): String {
|
||||
val seconds = unixMillis / 1000
|
||||
val millis = unixMillis % 1000
|
||||
return "$seconds.${millis.toString().padStart(3, '0')}"
|
||||
}
|
||||
|
||||
// Logcat default threadtime format:
|
||||
// MM-dd HH:mm:ss.SSS PID TID L TAG: message
|
||||
// The leading date is the local year-inferred date; we convert to a
|
||||
// unix-millis best-effort using the current year.
|
||||
private val LOGCAT_LINE = Regex(
|
||||
"^(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{3})" +
|
||||
"\\s+\\d+\\s+\\d+\\s+([VDIWEFS])\\s+([^:]+?):\\s?(.*)$",
|
||||
)
|
||||
|
||||
internal fun parseLogcatOutput(output: String): List<LogLine> {
|
||||
if (output.isBlank()) return emptyList()
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
val year = calendar.get(java.util.Calendar.YEAR)
|
||||
val result = mutableListOf<LogLine>()
|
||||
for (line in output.lines()) {
|
||||
val match = LOGCAT_LINE.matchEntire(line) ?: continue
|
||||
val month = match.groupValues[1].toInt() - 1
|
||||
val day = match.groupValues[2].toInt()
|
||||
val hour = match.groupValues[3].toInt()
|
||||
val minute = match.groupValues[4].toInt()
|
||||
val second = match.groupValues[5].toInt()
|
||||
val millis = match.groupValues[6].toInt()
|
||||
val level = match.groupValues[7]
|
||||
val tag = match.groupValues[8].trim()
|
||||
val message = match.groupValues[9]
|
||||
calendar.clear()
|
||||
calendar.set(year, month, day, hour, minute, second)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, millis)
|
||||
result.add(LogLine(calendar.timeInMillis, level, tag, message))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
override fun terminate(bundleId: String) {
|
||||
@@ -101,6 +161,52 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
|
||||
runAdb(listOf("shell", "input", "text", text.replace(" ", "%s")))
|
||||
}
|
||||
|
||||
@Volatile var lastSwipe: SwipeRecord? = null
|
||||
private set
|
||||
@Volatile var lastKey: String? = null
|
||||
private set
|
||||
|
||||
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {
|
||||
lastSwipe = SwipeRecord(fromX, fromY, toX, toY, durationMillis)
|
||||
val effectiveDuration = if (durationMillis > 0) durationMillis else 250L
|
||||
runAdb(
|
||||
listOf(
|
||||
"shell", "input", "swipe",
|
||||
fromX.toString(), fromY.toString(),
|
||||
toX.toString(), toY.toString(),
|
||||
effectiveDuration.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun pressKey(key: String) {
|
||||
lastKey = key
|
||||
val keyCode = KEY_MAP[key.lowercase()]
|
||||
?: throw IllegalArgumentException("unsupported pressKey value: $key")
|
||||
runAdb(listOf("shell", "input", "keyevent", keyCode))
|
||||
}
|
||||
|
||||
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> {
|
||||
val level = if (minLevel.isEmpty()) "E" else minLevel
|
||||
val since = if (sinceUnixMillis > 0) formatAdbLogcatTimestamp(sinceUnixMillis) else null
|
||||
val arguments = mutableListOf("logcat", "-d", "*:$level")
|
||||
if (since != null) {
|
||||
arguments.add("-T")
|
||||
arguments.add(since)
|
||||
}
|
||||
return try {
|
||||
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(false).start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
parseLogcatOutput(output)
|
||||
} catch (cause: Exception) {
|
||||
println("adb logcat failed: $cause")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
data class SwipeRecord(val fromX: Int, val fromY: Int, val toX: Int, val toY: Int, val durationMillis: Long)
|
||||
|
||||
private fun runAdb(arguments: List<String>) {
|
||||
try {
|
||||
val command = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
|
||||
|
||||
@@ -8,8 +8,13 @@ import dev.uatu.driver.v1.HealthStatus
|
||||
import dev.uatu.driver.v1.HierarchyJSON
|
||||
import dev.uatu.driver.v1.Image
|
||||
import dev.uatu.driver.v1.LaunchRequest
|
||||
import dev.uatu.driver.v1.LogEntries
|
||||
import dev.uatu.driver.v1.LogEntry
|
||||
import dev.uatu.driver.v1.Point
|
||||
import dev.uatu.driver.v1.PressKeyRequest
|
||||
import dev.uatu.driver.v1.RecentLogsRequest
|
||||
import dev.uatu.driver.v1.Selector
|
||||
import dev.uatu.driver.v1.SwipeRequest
|
||||
import dev.uatu.driver.v1.Text
|
||||
import io.grpc.stub.StreamObserver
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
@@ -59,6 +64,40 @@ class DriverService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun swipe(request: SwipeRequest, responseObserver: StreamObserver<Empty>) {
|
||||
runRpc(responseObserver) {
|
||||
val from = request.from
|
||||
val to = request.to
|
||||
backend.swipe(from.x, from.y, to.x, to.y, request.durationMillis)
|
||||
Empty.getDefaultInstance()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pressKey(request: PressKeyRequest, responseObserver: StreamObserver<Empty>) {
|
||||
runRpc(responseObserver) {
|
||||
backend.pressKey(request.key)
|
||||
Empty.getDefaultInstance()
|
||||
}
|
||||
}
|
||||
|
||||
override fun recentLogs(request: RecentLogsRequest, responseObserver: StreamObserver<LogEntries>) {
|
||||
runRpc(responseObserver) {
|
||||
val entries = backend.recentLogs(request.sinceUnixMillis, request.levelAtLeast)
|
||||
val builder = LogEntries.newBuilder()
|
||||
for (entry in entries) {
|
||||
builder.addEntries(
|
||||
LogEntry.newBuilder()
|
||||
.setUnixMillis(entry.unixMillis)
|
||||
.setLevel(entry.level)
|
||||
.setTag(entry.tag)
|
||||
.setMessage(entry.message)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
override fun screenshot(request: Empty, responseObserver: StreamObserver<Image>) {
|
||||
runRpc(responseObserver) {
|
||||
val (png, width, height) = backend.screenshot()
|
||||
|
||||
@@ -5,6 +5,9 @@ import dev.uatu.driver.v1.Duration
|
||||
import dev.uatu.driver.v1.Empty
|
||||
import dev.uatu.driver.v1.LaunchRequest
|
||||
import dev.uatu.driver.v1.Point
|
||||
import dev.uatu.driver.v1.PressKeyRequest
|
||||
import dev.uatu.driver.v1.RecentLogsRequest
|
||||
import dev.uatu.driver.v1.SwipeRequest
|
||||
import dev.uatu.driver.v1.Text
|
||||
import io.grpc.ManagedChannel
|
||||
import io.grpc.inprocess.InProcessChannelBuilder
|
||||
@@ -15,6 +18,8 @@ import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
private data class Quintuple<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
|
||||
|
||||
class DriverServiceTest {
|
||||
|
||||
@get:Rule val grpcCleanup: GrpcCleanupRule = GrpcCleanupRule()
|
||||
@@ -100,6 +105,54 @@ class DriverServiceTest {
|
||||
assertEquals(123L, observed)
|
||||
}
|
||||
|
||||
@Test fun swipeForwardsEndpointsAndDuration() {
|
||||
var observed: Quintuple<Int, Int, Int, Int, Long>? = null
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) {
|
||||
observed = Quintuple(fromX, fromY, toX, toY, durationMillis)
|
||||
}
|
||||
}
|
||||
val client = newClient(backend)
|
||||
|
||||
client.swipe(
|
||||
SwipeRequest.newBuilder()
|
||||
.setFrom(Point.newBuilder().setX(10).setY(20).build())
|
||||
.setTo(Point.newBuilder().setX(30).setY(40).build())
|
||||
.setDurationMillis(250)
|
||||
.build(),
|
||||
)
|
||||
assertEquals(Quintuple(10, 20, 30, 40, 250L), observed)
|
||||
}
|
||||
|
||||
@Test fun pressKeyForwardsValue() {
|
||||
var observed: String? = null
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun pressKey(key: String) {
|
||||
observed = key
|
||||
}
|
||||
}
|
||||
val client = newClient(backend)
|
||||
|
||||
client.pressKey(PressKeyRequest.newBuilder().setKey("back").build())
|
||||
assertEquals("back", observed)
|
||||
}
|
||||
|
||||
@Test fun recentLogsReturnsBackendEntries() {
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> {
|
||||
return listOf(LogLine(1, "E", "AndroidRuntime", "boom"))
|
||||
}
|
||||
}
|
||||
val client = newClient(backend)
|
||||
|
||||
val response = client.recentLogs(
|
||||
RecentLogsRequest.newBuilder().setSinceUnixMillis(0).setLevelAtLeast("E").build(),
|
||||
)
|
||||
assertEquals(1, response.entriesCount)
|
||||
assertEquals("AndroidRuntime", response.getEntries(0).tag)
|
||||
assertEquals("boom", response.getEntries(0).message)
|
||||
}
|
||||
|
||||
@Test fun healthReportsPlatformAndVersion() {
|
||||
val backend = StubDriverBackend("android")
|
||||
val client = newClient(backend)
|
||||
|
||||
Reference in new issue
Block a user