mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
test: full test-suite refactor sweep (#61)
* chore(test): start test-suite refactor sweep * test(ltl): pin exact multi-obligation residual AST * test(ltl): table-test finalize Kleene connective combinations * test(ltl): pin reduce over pending inner for bound, Or, Not * test(ltl): marshal bounded Always steps/duration/deadline * test(verifier): cover LTL combinator verdict transitions and within unit panic * test(verifier): table-test DecodeAction kinds and lastAction field exposure * test(verifier): assert WithPlatform(ios) reaches the picker host and key pool * test(verifier): widen weighted-selection assertion to a 5x skew margin * test(verifier): un-skip ax-find round trip with a committed tree fixture * test(runner): pin isWDADrop to sidecar reconnect-failed message origin * test(runner): assert PressKey/Wait trace encoding records kind-specific fields * test(runner): cover RenderSummary unsupported-verbs surfacing branch * test(trace): set Hierarchy in round-trip and lock lossy Tree contract Also add a -race concurrent WriteStep test that asserts N well-formed JSONL lines, catching torn lines if the writer mutex is dropped. * test(trace): round-trip witnesses/changes/metrics/exceptions, pin step-0 witness * test(trace): document ViolationsAreGreppable grep contract and lock-free WriteScreenshot * test(hierarchy): cover invalid-JSON and malformed-bounds parser paths * test(trace): guard writer mutex via WriteStep/Close race on w.file * test(replay): drop unfailable assets and devproxy assertions * test(replay): cache reuses on equal mtime, reparses after append * test(replay): violation marker falls back to detection step when attributed missing * test(replay): corrupt meta/trace dirs return 500 with error body * test(replay): SSE client receives runs.changed after a broadcast * test(replay): Run coalesces creates, ignores write/chmod, closes subs on cancel * fix(sidecar): synchronize health fixture writes and exercise healthError * test(sidecar): cover swipe/longpress/doubletap/erase/presskey/metrics/logs translations * test(sidecar): cover DoubleTapSelector composition and mid-gesture cancel * test(sidecar): assert gRPC error status surfaces from action RPC * fix(chrome): route action methods through runCtx so caller cancellation aborts CDP * fix(chrome): route hierarchy/screenshot/waitidle/metrics through runCtx * refactor(ios): extract pure simctl JSON parsers * refactor(ios): add command-runner seams for EnsureSimulator * test(ios): table-test simctl parsers and EnsureSimulator seams * test(sidecarassets): cover placeholder build path * test(sidecarassets): assert reuse via sentinel bytes not mtime * test(bundler): cover properties-only spec registration * refactor(testrun): extract prepareBundleInputs from Execute * test(testrun): cover prepareBundleInputs aliases and missing-runtime error * test(testrun): table-test resolveRuntimeSibling search edges * test(testrun): exact-output tests for progressHandler line format * fix(cmd): point bundle-check aliases at pkg/spec/src * test(cmd): smoke-test bundle-check resolves spec aliases * test(cmd): table-test hier-check parse and FindAll on fixture * test(cmd): unit-test buildBrowseURL deep-link vs root * test(cmd): drop flaky TestRun_Doctor that launched real Chromium * test(cmd): pin pipeline error to bundle resolution on web platform * test(replay-ui): add bun test script * ci(replay-ui): run bun test via make web-test target * ci(replay-ui): point bun cache key at replay-ui/bun.lock * test(replay-ui): exercise real URL encoding and non-ok throw in getJson * refactor(replay-ui): extract snapshot flatten/getAtPath into lib module * test(replay-ui): pin snapshot flatten/getAtPath path round-trip * refactor(replay-ui): extract action selector/format into lib module * test(replay-ui): pin action selector parse and row formatting * refactor(replay-ui): share one statusFor between panels * refactor(replay-ui): extract run-history derivation into lib module * test(replay-ui): pin shared statusFor precedence and ordering * test(replay-ui): pin run-history derivation alignment * refactor(replay-ui): export clampIndex for testing * refactor(replay-ui): extract keyboard-nav dispatch into pure module * refactor(replay-ui): extract metrics formatters into lib module * test(replay-ui): pin clampIndex step boundaries * test(replay-ui): pin keyboard-nav ownership and key routing * test(replay-ui): pin metrics formatters and path gap handling * refactor(sidecar): expose device-output parsers as internal for testing * test(sidecar): table-test device-output parsers against malformed input * test(sidecar): cover logcat parsing year inference and line skipping * test(sidecar): pin pressKey keycode mapping and unknown-key rejection * test(sidecar): metrics bundleId falls back to launched app and honors override * test(sidecar): loosen deadline upper bound to tolerate slow CI scheduling * test(web-runtime): export selector builders for unit tests * test(web-runtime): guard sanitize cycle, function, and depth limits * test(web-runtime): table-test selector builder quoting and escaping * test(sidecar): collapse scalar-forwarding RPC tests into a table * test(replay-ui): dedup step/summary fixtures into shared module * test(ios): collapse pickSimulator point-tests into a table
This commit is contained in:
66 files changed
+3419
-606
No files matched your search
@@ -281,7 +281,7 @@ private fun sampleCpuTwice(serial: String?, pid: Int): Double {
|
||||
return (deltaCpuNanos / 50_000_000.0) * 100.0
|
||||
}
|
||||
|
||||
private fun parseCpuTicks(statLine: String): Long? {
|
||||
internal fun parseCpuTicks(statLine: String): Long? {
|
||||
val afterComm = statLine.substringAfterLast(')').trim()
|
||||
val fields = afterComm.split(Regex("\\s+"))
|
||||
if (fields.size < 13) return null
|
||||
@@ -304,7 +304,7 @@ private fun sampleProcessMemory(serial: String?, pid: Int): Pair<Long, Long> {
|
||||
return Pair(rssKb * 1024L, vmSizeKb * 1024L)
|
||||
}
|
||||
|
||||
private fun parseKb(line: String): Long? {
|
||||
internal fun parseKb(line: String): Long? {
|
||||
val parts = line.split(Regex("\\s+"))
|
||||
if (parts.size < 2) return null
|
||||
return parts[1].toLongOrNull()
|
||||
@@ -638,7 +638,7 @@ private fun buildDadb(serial: String?): dadb.Dadb {
|
||||
}
|
||||
}
|
||||
|
||||
private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? {
|
||||
internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? {
|
||||
val colon = selector.indexOf(':')
|
||||
if (colon < 0) return null
|
||||
val kind = selector.substring(0, colon)
|
||||
@@ -646,7 +646,7 @@ private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntA
|
||||
return findBoundsInTree(root, kind, value)
|
||||
}
|
||||
|
||||
private fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? {
|
||||
internal fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? {
|
||||
val attrs = node.attributes
|
||||
val matches = when (kind) {
|
||||
"id" -> attrs["resource-id"]?.let { it == value || it.endsWith(":id/$value") } == true
|
||||
@@ -664,19 +664,19 @@ private fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseBounds(s: String): IntArray? {
|
||||
internal fun parseBounds(s: String): IntArray? {
|
||||
val pattern = Regex("^\\[(-?\\d+),(-?\\d+),(-?\\d+),(-?\\d+)\\]$")
|
||||
val m = pattern.matchEntire(s) ?: return null
|
||||
return IntArray(4) { m.groupValues[it + 1].toInt() }
|
||||
}
|
||||
|
||||
private fun pngWidth(bytes: ByteArray): Int {
|
||||
internal fun pngWidth(bytes: ByteArray): Int {
|
||||
if (bytes.size < 24) return 0
|
||||
return (bytes[16].toInt() and 0xFF shl 24) or (bytes[17].toInt() and 0xFF shl 16) or
|
||||
(bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
private fun pngHeight(bytes: ByteArray): Int {
|
||||
internal fun pngHeight(bytes: ByteArray): Int {
|
||||
if (bytes.size < 24) return 0
|
||||
return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or
|
||||
(bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package dev.sanderling.sidecar
|
||||
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DeviceOutputParserTest {
|
||||
|
||||
// Logcat's threadtime format carries no year; the parser infers the current
|
||||
// one. A line missing a field (or in another format) must be skipped, not
|
||||
// misparsed into a bogus entry.
|
||||
@Test fun parseLogcatExtractsLevelTagMessageAndInfersYear() {
|
||||
val output = """
|
||||
06-06 12:34:56.789 1000 1000 E AndroidRuntime: FATAL EXCEPTION: main
|
||||
not a logcat line at all
|
||||
06-06 12:34:57.000 1000 1000 I ActivityManager: Start proc
|
||||
""".trimIndent()
|
||||
|
||||
val lines = StubDriverBackend.parseLogcatOutput(output)
|
||||
assertEquals(2, lines.size)
|
||||
assertEquals("E", lines[0].level)
|
||||
assertEquals("AndroidRuntime", lines[0].tag)
|
||||
assertEquals("FATAL EXCEPTION: main", lines[0].message)
|
||||
|
||||
val year = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR)
|
||||
val cal = java.util.Calendar.getInstance().apply { timeInMillis = lines[0].unixMillis }
|
||||
assertEquals(year, cal.get(java.util.Calendar.YEAR))
|
||||
assertEquals(56, cal.get(java.util.Calendar.SECOND))
|
||||
assertEquals(789, cal.get(java.util.Calendar.MILLISECOND))
|
||||
}
|
||||
|
||||
@Test fun parseLogcatEmptyOutputYieldsNoLines() {
|
||||
assertTrue(StubDriverBackend.parseLogcatOutput("").isEmpty())
|
||||
assertTrue(StubDriverBackend.parseLogcatOutput(" \n ").isEmpty())
|
||||
}
|
||||
|
||||
// /proc/pid/stat: the comm field is parenthesized and may itself contain
|
||||
// spaces and a ')'. Splitting before substringAfterLast(')') would shift
|
||||
// every field index and read the wrong utime/stime ticks.
|
||||
@Test fun parseCpuTicksSumsUtimeAndStimeAfterComm() {
|
||||
val cases = listOf(
|
||||
statLine("(app)", utime = 100, stime = 23) to 123L,
|
||||
statLine("(com.foo (bar))", utime = 7, stime = 8) to 15L,
|
||||
statLine("(weird )name)", utime = 1, stime = 2) to 3L,
|
||||
)
|
||||
for ((line, expected) in cases) {
|
||||
assertEquals(expected, parseCpuTicks(line), line)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun parseCpuTicksReturnsNullOnTruncatedOrNonNumericStat() {
|
||||
assertNull(parseCpuTicks("1234 (app) S 1 2 3"))
|
||||
assertNull(parseCpuTicks("1234 (app) S " + (1..12).joinToString(" ") { "x" }))
|
||||
assertNull(parseCpuTicks(""))
|
||||
}
|
||||
|
||||
// VmRSS/VmSize lines are "Key:\t<number> kB"; the kB unit token must not be
|
||||
// read as the value, and a missing value must not crash the sampler.
|
||||
@Test fun parseKbReadsSecondFieldOrNull() {
|
||||
assertEquals(2048L, parseKb("VmRSS:\t 2048 kB"))
|
||||
assertEquals(900100L, parseKb("VmSize: 900100 kB"))
|
||||
assertNull(parseKb("VmRSS:"))
|
||||
assertNull(parseKb("VmRSS: notanumber kB"))
|
||||
}
|
||||
|
||||
@Test fun pngWidthAndHeightReadIhdrDimensions() {
|
||||
val png = ihdr(width = 1080, height = 2340)
|
||||
assertEquals(1080, pngWidth(png))
|
||||
assertEquals(2340, pngHeight(png))
|
||||
}
|
||||
|
||||
// A short/empty screencap (the device returned nothing) must report 0
|
||||
// rather than indexing past the buffer.
|
||||
@Test fun pngWidthAndHeightReturnZeroOnTruncatedInput() {
|
||||
assertEquals(0, pngWidth(ByteArray(23)))
|
||||
assertEquals(0, pngHeight(ByteArray(23)))
|
||||
assertEquals(0, pngWidth(ByteArray(0)))
|
||||
}
|
||||
|
||||
@Test fun parseBoundsAcceptsWellFormedAndRejectsMalformed() {
|
||||
assertEquals(listOf(0, 0, 1080, 2340), parseBounds("[0,0,1080,2340]")?.toList())
|
||||
assertEquals(listOf(-5, -10, 20, 30), parseBounds("[-5,-10,20,30]")?.toList())
|
||||
assertNull(parseBounds("[0,0,1080]"))
|
||||
assertNull(parseBounds("0,0,1,1"))
|
||||
assertNull(parseBounds("[0, 0, 1, 1]"))
|
||||
assertNull(parseBounds(""))
|
||||
}
|
||||
|
||||
@Test fun findBoundsBySelectorMatchesIdSuffixForm() {
|
||||
val tree = node(
|
||||
"resource-id" to "com.example:id/loginButton",
|
||||
"bounds" to "[10,20,110,80]",
|
||||
)
|
||||
assertEquals(listOf(10, 20, 110, 80), findBoundsBySelector(tree, "id:loginButton")?.toList())
|
||||
assertEquals(
|
||||
listOf(10, 20, 110, 80),
|
||||
findBoundsBySelector(tree, "id:com.example:id/loginButton")?.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun findBoundsBySelectorMatchesTextAndDescPrefixDeepInTree() {
|
||||
val tree = node(
|
||||
"resource-id" to "root",
|
||||
children = listOf(
|
||||
node("text" to "Sign in", "bounds" to "[1,2,3,4]"),
|
||||
node("content-desc" to "AccountCardRow-7", "bounds" to "[5,6,7,8]"),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf(1, 2, 3, 4), findBoundsBySelector(tree, "text:Sign in")?.toList())
|
||||
assertEquals(listOf(5, 6, 7, 8), findBoundsBySelector(tree, "descPrefix:AccountCard")?.toList())
|
||||
}
|
||||
|
||||
@Test fun findBoundsBySelectorReturnsNullForBadSelectorOrNoMatch() {
|
||||
val tree = node("resource-id" to "com.example:id/x", "bounds" to "[0,0,1,1]")
|
||||
assertNull(findBoundsBySelector(tree, "id"))
|
||||
assertNull(findBoundsBySelector(tree, "id:missing"))
|
||||
}
|
||||
|
||||
@Test fun findBoundsBySelectorReturnsNullWhenMatchHasMalformedBounds() {
|
||||
val tree = node("resource-id" to "com.example:id/x", "bounds" to "not-bounds")
|
||||
assertNull(findBoundsBySelector(tree, "id:x"))
|
||||
}
|
||||
|
||||
private fun statLine(comm: String, utime: Int, stime: Int): String {
|
||||
// After comm, parseCpuTicks reads index 11 (utime) and 12 (stime), so
|
||||
// the state field plus ten placeholders must precede them.
|
||||
val before = "1234 $comm S " + (1..10).joinToString(" ")
|
||||
return "$before $utime $stime 0 0 0 0"
|
||||
}
|
||||
|
||||
private fun ihdr(width: Int, height: Int): ByteArray {
|
||||
val b = ByteArray(33)
|
||||
for (i in 0 until 8) b[8 + i] = 0
|
||||
b[12] = 'I'.code.toByte(); b[13] = 'H'.code.toByte()
|
||||
b[14] = 'D'.code.toByte(); b[15] = 'R'.code.toByte()
|
||||
b[16] = (width ushr 24).toByte(); b[17] = (width ushr 16).toByte()
|
||||
b[18] = (width ushr 8).toByte(); b[19] = width.toByte()
|
||||
b[20] = (height ushr 24).toByte(); b[21] = (height ushr 16).toByte()
|
||||
b[22] = (height ushr 8).toByte(); b[23] = height.toByte()
|
||||
return b
|
||||
}
|
||||
|
||||
private fun node(
|
||||
vararg attrs: Pair<String, String>,
|
||||
children: List<maestro.TreeNode> = emptyList(),
|
||||
): maestro.TreeNode = maestro.TreeNode(attributes = attrs.toMap().toMutableMap(), children = children)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import dev.sanderling.driver.v1.Duration
|
||||
import dev.sanderling.driver.v1.Empty
|
||||
import dev.sanderling.driver.v1.EraseTextRequest
|
||||
import dev.sanderling.driver.v1.LaunchRequest
|
||||
import dev.sanderling.driver.v1.MetricsRequest
|
||||
import dev.sanderling.driver.v1.Point
|
||||
import dev.sanderling.driver.v1.PressKeyRequest
|
||||
import dev.sanderling.driver.v1.RecentLogsRequest
|
||||
@@ -338,6 +339,40 @@ class DriverServiceTest {
|
||||
assertEquals("boom", response.getEntries(0).message)
|
||||
}
|
||||
|
||||
// With no explicit bundle in the request, metrics must sample the app the
|
||||
// service launched; otherwise CPU/memory are read from the wrong process.
|
||||
@Test fun metricsFallsBackToLaunchedBundleWhenRequestOmitsIt() {
|
||||
var sampled: String? = null
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun metrics(bundleId: String): MetricsSample {
|
||||
sampled = bundleId
|
||||
return MetricsSample(0.0, 0L, 0L)
|
||||
}
|
||||
}
|
||||
val client = newClient(backend)
|
||||
|
||||
client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build())
|
||||
client.metrics(MetricsRequest.getDefaultInstance())
|
||||
|
||||
assertEquals("com.launched", sampled)
|
||||
}
|
||||
|
||||
@Test fun metricsRequestBundleOverridesLaunchedBundle() {
|
||||
var sampled: String? = null
|
||||
val backend = object : DriverBackend by StubDriverBackend("android") {
|
||||
override fun metrics(bundleId: String): MetricsSample {
|
||||
sampled = bundleId
|
||||
return MetricsSample(0.0, 0L, 0L)
|
||||
}
|
||||
}
|
||||
val client = newClient(backend)
|
||||
|
||||
client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build())
|
||||
client.metrics(MetricsRequest.newBuilder().setBundleId("com.other").build())
|
||||
|
||||
assertEquals("com.other", sampled)
|
||||
}
|
||||
|
||||
@Test fun healthReportsPlatformAndVersion() {
|
||||
val backend = StubDriverBackend("android")
|
||||
val client = newClient(backend)
|
||||
|
||||
@@ -2,10 +2,33 @@ package dev.sanderling.sidecar
|
||||
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class InputTextTest {
|
||||
|
||||
// A logical key name must map to the right Android keycode; a typo'd table
|
||||
// entry would silently dispatch the wrong key (e.g. 'back' issuing HOME).
|
||||
@Test fun pressKeyDispatchesMappedKeycode() {
|
||||
val cases = mapOf(
|
||||
"back" to "KEYCODE_BACK",
|
||||
"enter" to "KEYCODE_ENTER",
|
||||
"up" to "KEYCODE_DPAD_UP",
|
||||
)
|
||||
for ((key, keycode) in cases) {
|
||||
val commands = mutableListOf<List<String>>()
|
||||
StubDriverBackend("android") { commands.add(it) }.pressKey(key)
|
||||
assertEquals(listOf(listOf("shell", "input", "keyevent", keycode)), commands, key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun pressKeyRejectsUnknownKeyInsteadOfSilentlyDoingNothing() {
|
||||
val commands = mutableListOf<List<String>>()
|
||||
val backend = StubDriverBackend("android") { commands.add(it) }
|
||||
assertFailsWith<IllegalArgumentException> { backend.pressKey("zorp") }
|
||||
assertTrue(commands.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun inputTextTypesAtCursorWithoutClearing() {
|
||||
val commands = mutableListOf<List<String>>()
|
||||
val backend = StubDriverBackend("android") { commands.add(it) }
|
||||
|
||||
@@ -65,7 +65,7 @@ class StabilityPollTest {
|
||||
"frame-$calls"
|
||||
}
|
||||
val elapsed = System.currentTimeMillis() - start
|
||||
assertTrue(elapsed in budget..(budget + 200L), "expected to hit cap, elapsed=$elapsed")
|
||||
assertTrue(elapsed in budget..(budget + 1000L), "expected to hit cap, elapsed=$elapsed")
|
||||
}
|
||||
|
||||
@Test fun zeroBudgetReturnsImmediately() {
|
||||
|
||||
Reference in new issue
Block a user