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:
pj authored and GitHub committed 2026-06-06 13:59:08 +05:30
1 parent 410602d2e1
commit 94d9511312
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() {