fix(folio): testTagsAsResourceId at App root + JS-bridge regression test

App.kt sets testTagsAsResourceId=true on the root Box semantics so
Compose's testTag surfaces as Android resource-id (and equivalent on
iOS via accessibilityIdentifier). Without this, testTag stays in the
Compose semantics tree but never reaches the runtime hierarchy that
UIAutomator and Sanderling read.

Also adds TestStateAxObjectSelectorTestTagAlias as a regression
test for the {testTag: ...} object selector resolving through the
SDK alias to resource-id at the JS bridge layer.
This commit is contained in:
pj committed 2026-04-26 15:14:54 +07:00
1 parent 9293deb858
commit 63708b21a0
2 files changed
+58 -1

No files matched your search

@@ -16,7 +16,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import app.folio.core.data.DriverFactory import app.folio.core.data.DriverFactory
import app.folio.core.data.Repository import app.folio.core.data.Repository
import app.folio.core.data.SqlLedgerStore import app.folio.core.data.SqlLedgerStore
@@ -32,6 +35,7 @@ import app.folio.ui.theme.LedgerTheme
import app.folio.ui.theme.LocalTokens import app.folio.ui.theme.LocalTokens
import app.folio.ui.theme.Tokens import app.folio.ui.theme.Tokens
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
fun App(driverFactory: DriverFactory) { fun App(driverFactory: DriverFactory) {
var component by remember { mutableStateOf<AppComponent?>(null) } var component by remember { mutableStateOf<AppComponent?>(null) }
@@ -50,7 +54,8 @@ fun App(driverFactory: DriverFactory) {
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(t.bg) .background(t.bg)
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing)
.semantics { testTagsAsResourceId = true },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val c = component val c = component
+52
View File
@@ -7,6 +7,58 @@ import (
"github.com/priyanshujain/sanderling/internal/hierarchy" "github.com/priyanshujain/sanderling/internal/hierarchy"
) )
// TestStateAxObjectSelectorTestTagAlias verifies that an object selector
// `{ testTag: "X" }` resolves through the testTag alias to match an element
// whose source attributes carry resource-id="X" (the Compose
// testTagsAsResourceId=true case on Android).
func TestStateAxObjectSelectorTestTagAlias(t *testing.T) {
src := `{
"attributes": {"class": "android.widget.LinearLayout"},
"children": [
{
"attributes": {"resource-id": "LoginScreen", "class": "android.view.View"},
"children": [
{
"attributes": {"resource-id": "LoginEmail", "class": "android.widget.EditText"},
"children": []
}
]
}
]
}`
tree, err := hierarchy.Parse(src)
if err != nil {
t.Fatal(err)
}
verifier := newVerifier(t)
mustLoad(t, verifier, `
globalThis.loginRoot = __sanderling__.extract(state => {
const r = state.ax.find({ testTag: "LoginScreen" });
return r ? "matched" : "miss";
});
globalThis.loginEmailViaChain = __sanderling__.extract(state => {
const r = state.ax.find({ testTag: "LoginScreen" });
if (!r) return "outer-miss";
const inner = r.find({ testTag: "LoginEmail" });
return inner ? "inner-matched" : "inner-miss";
});
`)
if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, Tree: tree}); err != nil {
t.Fatal(err)
}
root := verifier.runtime.GlobalObject().Get("loginRoot").ToObject(verifier.runtime).Get("current").String()
if root != "matched" {
t.Fatalf("loginRoot = %q, want matched", root)
}
chain := verifier.runtime.GlobalObject().Get("loginEmailViaChain").ToObject(verifier.runtime).Get("current").String()
if chain != "inner-matched" {
t.Fatalf("loginEmailViaChain = %q, want inner-matched", chain)
}
}
// TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip // TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip
// actually lets the spec resolve selectors through state.ax.find. // actually lets the spec resolve selectors through state.ax.find.
// Reads /tmp/live-dump.json (Maestro TreeNode JSON format); skipped if absent. // Reads /tmp/live-dump.json (Maestro TreeNode JSON format); skipped if absent.