diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt index 5c6286e..b3f15d4 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt +++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/account/AddAccountScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import app.folio.data.Repository import app.folio.navigation.Navigator @@ -59,6 +60,7 @@ fun AddAccountScreen() { } Screen( + modifier = Modifier.testTag("AddAccountScreen"), header = { Header(title = "New account", left = { BackButton(onClick = { Navigator.back(Route.Home) }) }) }, diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt index b5066bc..bde7a33 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt +++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/auth/LoginScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import app.folio.data.Repository @@ -59,7 +60,7 @@ fun LoginScreen(onLoggedIn: (String) -> Unit) { onLoggedIn(user) } - Screen { + Screen(modifier = Modifier.testTag("LoginScreen")) { Spacer(Modifier.height(16.dp)) Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt index cbe68bc..a5aa165 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt +++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/home/HomeScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -49,6 +50,7 @@ fun HomeScreen(user: String, onLogout: () -> Unit) { val total = balanceOf(txns) Screen( + modifier = Modifier.testTag("HomeScreen"), header = { Header( title = "Accounts", diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt index cef4b05..83ee08f 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt +++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/AddTransactionScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -90,6 +91,7 @@ fun AddTransactionScreen(accountId: String) { } Screen( + modifier = Modifier.testTag("AddTransactionScreen"), header = { Header( title = "Add transaction", diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt index 4f34905..633ce95 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt +++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/feature/ledger/LedgerScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.draw.clip import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -74,6 +75,7 @@ fun LedgerScreen(accountId: String) { val balance = balanceOf(txns) Screen( + modifier = Modifier.testTag("LedgerScreen"), header = { Header( title = account.name, diff --git a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt index 222e56b..c5b18a6 100644 --- a/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt +++ b/examples/folio/composeApp/src/commonMain/kotlin/app/folio/ui/component/Screen.kt @@ -19,12 +19,13 @@ import app.folio.ui.theme.ScreenPad @Composable fun Screen( + modifier: Modifier = Modifier, header: @Composable (() -> Unit)? = null, footer: @Composable (() -> Unit)? = null, content: @Composable () -> Unit, ) { val t = LocalTokens.current - Column(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize().then(modifier)) { if (header != null) header() Column( modifier = Modifier diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index ba9ccd5..2244795 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -29,15 +29,15 @@ const ledgerBalance = extract(s => (s.snapshots.ledger_balance as number const activeAccountId = extract(s => (s.snapshots.active_account_id as string | null) ?? null); const focusedInput = extract(s => (s.snapshots.focused_input as string | null) ?? null); -const loginEmailField = extract(s => s.ax.find("desc:login_email")); -const loginPasswordField = extract(s => s.ax.find("desc:login_password")); -const loginSubmit = extract(s => s.ax.find("desc:login_submit")); -const addAccountButton = extract(s => s.ax.find("desc:add_account_button")); -const accountNameField = extract(s => s.ax.find("desc:account_name_field")); -const addAccountSubmit = extract(s => s.ax.find("desc:add_account_submit")); -const addTxnButton = extract(s => s.ax.find("desc:add_txn_button")); -const txnAmountField = extract(s => s.ax.find("desc:txn_amount")); -const txnSubmit = extract(s => s.ax.find("desc:txn_submit")); +const loginEmailField = extract(s => s.ax.find("id:LoginScreen > desc:login_email")); +const loginPasswordField = extract(s => s.ax.find("id:LoginScreen > desc:login_password")); +const loginSubmit = extract(s => s.ax.find("id:LoginScreen > desc:login_submit")); +const addAccountButton = extract(s => s.ax.find("id:HomeScreen > desc:add_account_button")); +const accountNameField = extract(s => s.ax.find("id:AddAccountScreen > desc:account_name_field")); +const addAccountSubmit = extract(s => s.ax.find("id:AddAccountScreen > desc:add_account_submit")); +const addTxnButton = extract(s => s.ax.find("id:LedgerScreen > desc:add_txn_button")); +const txnAmountField = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_amount")); +const txnSubmit = extract(s => s.ax.find("id:AddTransactionScreen > desc:txn_submit")); const accountCards = extract(s => s.ax.findAll("descPrefix:account_card:")); const backButton = extract(s => s.ax.find("desc:Back")); diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 60f5317..902b7f3 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -1,12 +1,18 @@ // Package hierarchy parses the TreeNode JSON produced by the Maestro sidecar // and resolves selectors against it. // -// Selector grammar (v0.1): +// Selector grammar (v1.0): // -// id: — match resource-id ending with ":id/" or equal to -// text: — exact match on the node's text -// desc: — exact match on the node's content-desc -// descPrefix: — startsWith match on the node's content-desc (e.g. Compose testTag + UUID) +// Single selectors (global scan): +// id: - resource-id == suffix or ends with ":id/" +// text: - exact text match +// desc: - exact content-desc match +// descPrefix: - content-desc starts with prefix +// +// Path queries (segments separated by " > "): +// > > ... - each segment is matched within the subtree of the +// previous match (any descendant, not just direct child) +// example: id:LoginScreen > desc:EmailInput package hierarchy import ( @@ -54,8 +60,15 @@ type Element struct { Bounds Bounds `json:"bounds"` } +// Node is one node in the hierarchy tree. +type Node struct { + Element + Children []*Node `json:"-"` +} + // Tree is a flat collection of every node in a hierarchy dump, in pre-order. type Tree struct { + Root *Node `json:"-"` Elements []*Element `json:"elements"` } @@ -81,16 +94,17 @@ func Parse(text string) (*Tree, error) { return nil, fmt.Errorf("hierarchy: %w", err) } tree := &Tree{} - walkNode(&root, tree) + tree.Root = walkNode(&root, tree) return tree, nil } -func walkNode(node *treeNodeJSON, tree *Tree) { - element := elementFromNode(node) - tree.Elements = append(tree.Elements, element) +func walkNode(node *treeNodeJSON, tree *Tree) *Node { + n := &Node{Element: *elementFromNode(node)} + tree.Elements = append(tree.Elements, &n.Element) for i := range node.Children { - walkNode(&node.Children[i], tree) + n.Children = append(n.Children, walkNode(&node.Children[i], tree)) } + return n } func elementFromNode(node *treeNodeJSON) *Element { @@ -138,6 +152,9 @@ func elementFromNode(node *treeNodeJSON) *Element { // Find returns the first element matching the selector, or nil. func (t *Tree) Find(selector string) *Element { + if strings.Contains(selector, " > ") { + return findPath(t.Root, strings.Split(selector, " > ")) + } kind, value, ok := parseSelector(selector) if !ok { return nil @@ -152,6 +169,9 @@ func (t *Tree) Find(selector string) *Element { // FindAll returns every element matching the selector. func (t *Tree) FindAll(selector string) []*Element { + if strings.Contains(selector, " > ") { + return findPathAll(t.Root, strings.Split(selector, " > ")) + } kind, value, ok := parseSelector(selector) if !ok { return nil @@ -165,6 +185,95 @@ func (t *Tree) FindAll(selector string) []*Element { return matches } +func findPath(root *Node, segments []string) *Element { + if root == nil || len(segments) == 0 { + return nil + } + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + for _, node := range searchSubtree(root, kind, value) { + if len(segments) == 1 { + return &node.Element + } + if result := findPathDescendants(node, segments[1:]); result != nil { + return result + } + } + return nil +} + +func findPathDescendants(root *Node, segments []string) *Element { + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + for _, child := range root.Children { + for _, node := range searchSubtree(child, kind, value) { + if len(segments) == 1 { + return &node.Element + } + if result := findPathDescendants(node, segments[1:]); result != nil { + return result + } + } + } + return nil +} + +func findPathAll(root *Node, segments []string) []*Element { + if root == nil || len(segments) == 0 { + return nil + } + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + var result []*Element + for _, node := range searchSubtree(root, kind, value) { + if len(segments) == 1 { + result = append(result, &node.Element) + continue + } + result = append(result, findPathAllDescendants(node, segments[1:])...) + } + return result +} + +func findPathAllDescendants(root *Node, segments []string) []*Element { + kind, value, ok := parseSelector(segments[0]) + if !ok { + return nil + } + var result []*Element + for _, child := range root.Children { + for _, node := range searchSubtree(child, kind, value) { + if len(segments) == 1 { + result = append(result, &node.Element) + continue + } + result = append(result, findPathAllDescendants(node, segments[1:])...) + } + } + return result +} + +// searchSubtree returns all nodes under root (inclusive) matching kind:value. +func searchSubtree(root *Node, kind, value string) []*Node { + if root == nil { + return nil + } + var result []*Node + if match(&root.Element, kind, value) { + result = append(result, root) + } + for _, child := range root.Children { + result = append(result, searchSubtree(child, kind, value)...) + } + return result +} + func parseSelector(selector string) (string, string, bool) { index := strings.IndexByte(selector, ':') if index <= 0 { diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index efd6284..bbbd8b1 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -181,6 +181,114 @@ func TestIOSMergedLabel(t *testing.T) { } } +const pathDump = `{ + "attributes": {"resource-id": "root", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"resource-id": "A", "content-desc": "screen_a", "bounds": "[0,0,540,2340]"}, + "children": [ + { + "attributes": {"resource-id": "B", "content-desc": "label_b", "bounds": "[0,0,100,100]"}, + "children": [ + { + "attributes": {"resource-id": "C", "content-desc": "label_c", "bounds": "[0,0,50,50]"}, + "children": [] + } + ] + } + ] + }, + { + "attributes": {"resource-id": "A2", "content-desc": "screen_a2", "bounds": "[540,0,1080,2340]"}, + "children": [ + { + "attributes": {"resource-id": "B2", "content-desc": "label_b", "bounds": "[540,0,640,100]"}, + "children": [] + } + ] + } + ] +}` + +func TestPathQuerySingleLevel(t *testing.T) { + tree, _ := Parse(pathDump) + el := tree.Find("id:A > id:B") + if el == nil { + t.Fatal("expected to find B under A") + } + if el.ResourceID != "B" { + t.Fatalf("got %q, want B", el.ResourceID) + } +} + +func TestPathQueryNotBUnderOtherRoot(t *testing.T) { + tree, _ := Parse(pathDump) + // B2 is under A2, not A; path from A should not reach B2 + el := tree.Find("id:A > id:B2") + if el != nil { + t.Fatalf("expected nil, got element with id %q", el.ResourceID) + } +} + +func TestPathQueryMultiLevel(t *testing.T) { + tree, _ := Parse(pathDump) + el := tree.Find("id:A > id:B > id:C") + if el == nil { + t.Fatal("expected to find C under A > B") + } + if el.ResourceID != "C" { + t.Fatalf("got %q, want C", el.ResourceID) + } +} + +func TestPathQueryMixedTypes(t *testing.T) { + tree, _ := Parse(pathDump) + el := tree.Find("desc:screen_a > desc:label_b") + if el == nil { + t.Fatal("expected to find label_b under screen_a") + } + if el.Description != "label_b" { + t.Fatalf("got %q, want label_b", el.Description) + } +} + +func TestPathQueryNotFound(t *testing.T) { + tree, _ := Parse(pathDump) + if tree.Find("id:A > id:NoSuch") != nil { + t.Fatal("expected nil for missing second segment") + } +} + +func TestPathQueryFirstMatchesSecondDoesNot(t *testing.T) { + tree, _ := Parse(pathDump) + // B2 is under A2, so A > B2 should return nil (B2 is not a descendant of A) + if tree.Find("id:A > id:B2") != nil { + t.Fatal("B2 is not a descendant of A, expected nil") + } +} + +func TestPathQueryFindAllAcrossRoots(t *testing.T) { + tree, _ := Parse(pathDump) + // label_b appears under A (as B) and under A2 (as B2) + // FindAll("desc:screen_a > desc:label_b") should only find B under A, not B2 under A2 + matches := tree.FindAll("desc:screen_a > desc:label_b") + if len(matches) != 1 { + t.Fatalf("want 1 match, got %d", len(matches)) + } + if matches[0].ResourceID != "B" { + t.Fatalf("got %q, want B", matches[0].ResourceID) + } +} + +func TestPathQueryFindAllMultipleRootMatches(t *testing.T) { + tree, _ := Parse(pathDump) + // Both A and A2 are children of root, both have a child with content-desc "label_b" + matches := tree.FindAll("id:root > desc:label_b") + if len(matches) != 2 { + t.Fatalf("want 2 matches (B and B2), got %d", len(matches)) + } +} + func TestIOSBoundsFormat(t *testing.T) { input := `{ "attributes": {"accessibilityText": "account_card:abc123, Tim, $100", "bounds": "[20,130][382,202]"}, diff --git a/sidecar/build.gradle.kts b/sidecar/build.gradle.kts index d39585d..f8a5ef7 100644 --- a/sidecar/build.gradle.kts +++ b/sidecar/build.gradle.kts @@ -21,7 +21,25 @@ kotlin { val grpcVersion = "1.68.0" val protobufVersion = "3.25.5" -val maestroVersion = "1.40.0" +val maestroVersion = "2.4.0" + +// Maestro 2.4.0 still declares grpc-netty:1.50.2 and grpc-okhttp:1.50.2 which +// were compiled against AbstractManagedChannelImplBuilder removed in grpc-core 1.64+. +// Force them to the version we pin so they stay binary-compatible. +// Also exclude GraalVM JS — not used by our sidecar and causes shadow JAR expansion errors. +configurations.all { + resolutionStrategy.eachDependency { + if (requested.group == "io.grpc" && + (requested.name == "grpc-netty" || requested.name == "grpc-okhttp")) { + useVersion(grpcVersion) + because("align grpc transport with grpc-core version") + } + } + exclude(group = "org.graalvm.js") + exclude(group = "org.graalvm.sdk") + exclude(group = "org.graalvm.regex") + exclude(group = "org.graalvm.truffle") +} dependencies { implementation("dev.mobile:maestro-client:$maestroVersion") @@ -81,6 +99,7 @@ application { // depend on `version`. tasks.named("shadowJar") { archiveFileName.set("sidecar-all.jar") + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") } // JUnit 4 (vintage) for the gRPC GrpcCleanupRule + JUnit 5 (jupiter) diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index c3c6762..580e03a 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -382,13 +382,14 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend { init { dadb = buildDadb(serial) - driver = maestro.drivers.AndroidDriver(dadb, 7001, "localhost") + val hostPort = java.net.ServerSocket(0).use { it.localPort } + driver = maestro.drivers.AndroidDriver(dadb, hostPort) driver.open() } override fun launch(bundleId: String, clearState: Boolean, env: Map) { if (clearState) driver.clearAppState(bundleId) - driver.launchApp(bundleId, env, java.util.UUID.randomUUID()) + driver.launchApp(bundleId, env) } override fun terminate(bundleId: String) = driver.stopApp(bundleId) @@ -491,24 +492,45 @@ class IosDriverBackend(private val udid: String) : DriverBackend { private val reconnectLock = java.util.concurrent.locks.ReentrantLock() init { - val httpClient = xcuitest.api.OkHttpClientInstance.get() - val metrics = maestro.utils.NoOpMetrics() val wdaPort = maestro.utils.SocketUtils.nextFreePort(22000, 23000) - val installer = xcuitest.installer.LocalXCTestInstaller( - udid, - "localhost", - false, - wdaPort, - metrics, - httpClient, - false, - false, + val tempFileHandler = maestro.utils.TempFileHandler() + val simctlDevice = device.SimctlIOSDevice( + deviceId = udid, + tempFileHandler = tempFileHandler, ) - val xcTestDriverClient = xcuitest.XCTestDriverClient(installer, httpClient, false) - val xcTestDevice = ios.xctest.XCTestIOSDevice(udid, xcTestDriverClient) { emptySet() } - val simctlDevice = ios.simctl.SimctlIOSDevice(udid) - val device = ios.LocalIOSDevice(udid, xcTestDevice, simctlDevice, maestro.utils.NoopInsights) - driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics) + val driverConfig = xcuitest.installer.LocalXCTestInstaller.IOSDriverConfig( + prebuiltRunner = false, + sourceDirectory = "driver-iPhoneSimulator", + context = xcuitest.installer.Context.CLI, + snapshotKeyHonorModalViews = null, + ) + val installer = xcuitest.installer.LocalXCTestInstaller( + deviceId = udid, + host = "127.0.0.1", + deviceType = util.IOSDeviceType.SIMULATOR, + defaultPort = wdaPort, + iOSDriverConfig = driverConfig, + deviceController = simctlDevice, + tempFileHandler = tempFileHandler, + ) + val xcTestClient = xcuitest.XCTestClient("127.0.0.1", wdaPort) + val xcTestDriverClient = xcuitest.XCTestDriverClient( + installer = installer, + client = xcTestClient, + ) + val xcRunnerUtils = util.XCRunnerCLIUtils(tempFileHandler) + val xcTestDevice = ios.xctest.XCTestIOSDevice( + deviceId = udid, + client = xcTestDriverClient, + getInstalledApps = { xcRunnerUtils.listApps(udid) }, + ) + val device = ios.LocalIOSDevice( + deviceId = udid, + xcTestDevice = xcTestDevice, + deviceController = simctlDevice, + insights = maestro.utils.NoopInsights, + ) + driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights) driver.open() warmup() } @@ -551,7 +573,7 @@ class IosDriverBackend(private val udid: String) : DriverBackend { override fun launch(bundleId: String, clearState: Boolean, env: Map) = withReconnect { runCatching { driver.stopApp(bundleId) } if (clearState) driver.clearAppState(bundleId) - driver.launchApp(bundleId, env, java.util.UUID.randomUUID()) + driver.launchApp(bundleId, env) } override fun terminate(bundleId: String) = withReconnect { driver.stopApp(bundleId) }