fix(android): demote shared android:id/* framework ids below a discriminating label (#1269)#1279
Open
thymikee wants to merge 1 commit into
Open
fix(android): demote shared android:id/* framework ids below a discriminating label (#1269)#1279thymikee wants to merge 1 commit into
thymikee wants to merge 1 commit into
Conversation
…minating label Fixes #1269. On Android, `android:id/title` (and other `android:id/*` framework ids) are assigned by the OS to widget roles it supplies, so unrelated sibling rows share the exact same id (11 matches on a Settings root). The record-time annotator was choosing that shared id as PRIMARY identity for both the human-facing selector chain (`id=... || role=... label=... || label=...`) and the target-v1 binding metadata, so replay resolved the ambiguous id clause first via silent position-based disambiguation, and the identity verifier correctly (but falsely) refused whenever a row shifted position — measured FDR 1.0 on Settings flows. Demote a non-discriminating `android:id/*` id below a recorded label at record time instead of loosening the verifier: - `readNodeLocalIdentity` (src/replay/target-identity-node.ts) drops such an id from the recorded LocalIdentity when a label is available, so target-v1 verification narrows the identity/disambiguation set by label instead of by shared id. - `buildSelectorChainForNode` (src/selectors/build.ts) reorders the human-facing selector chain so the discriminating role+label/label clauses lead, keeping the shared id only as a last-resort fallback entry. Both changes share the same `isNonDiscriminatingAndroidId` predicate so the recorded expression and its target-v1 metadata stay consistent. A unique (app-namespaced) resource id, or a framework id with no label to fall back on, is unaffected. Verified with unit tests pinning: a shared framework id (matchCount 11) is demoted when a label exists; a unique id is still preferred; the recorded fallback chain still resolves the correct row after a position shift with the id demoted. Full unit suite passes (4064/4066; the 2 failures are the known session-replay-vars.test.ts Maestro-http timing flake under full-suite CPU contention — confirmed passing in isolation). Live-device FDR re-measurement on Android is still pending (not attempted per instructions — no live device in this environment).
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #1269.
On Android, replay of native list UIs produced systematic false divergences (FDR 1.0 measured on Settings flows) because the record-time annotator chose the non-unique framework resource-id
android:id/titleas primary identity for list-row text — it matches every titled row on a screen (11 matches on the Settings root in the reported repro), and the label-less generic container ancestry (linearlayout/recyclerview/framelayout) gives disambiguation nothing else to work with but sibling/viewportOrder position.Tracing the recorded expression end to end confirmed the exact mechanism:
id="android:id/title", which at replay matched all 11 rows.resolveSelectorChain's own in-clause disambiguation (depth/area heuristic) resolved a winner from that same ambiguous clause and never even reached the laterrole=... label=...clause that would have resolved uniquely.target-v1binding metadata recordedid: "android:id/title"as primary identity, somatchesLocalIdentitytreated any node sharing that id as sharing identity (ignoring label), leaving only sibling/viewportOrder to disambiguate.identity-mismatch.Fix
This is a record-time identity-selection fix, not a verifier change — the verifier's refusal was doing its job (a safe failure, not a silent mis-bind), so it stays untouched.
A shared
android:id/*id is now demoted below a recorded label whenever a label is available to discriminate instead:src/replay/target-identity-node.ts—readNodeLocalIdentity(the ONE local-identity reader shared by the record-time writer, replay-time verification, and the dispatch post-resolution guard) drops a non-discriminatingandroid:id/*id from the recordedLocalIdentitywhen a label exists, sotarget-v1verification narrows its identity/disambiguation domain by label instead of by the shared id. Adds theisNonDiscriminatingAndroidIdpredicate.src/selectors/build.ts—buildSelectorChainForNodereorders the recorded, human-facing selector chain (id=... || role=... label=... || label=...) so the discriminatingrole=... label=.../label=...clauses lead; the shared id survives only as a last-resort fallback entry at the end of the chain, so replay resolves via the (unique) label instead of the ambiguous id.Both call sites share the one predicate so the recorded expression and its
target-v1metadata stay consistent. A unique, app-namespaced resource id (e.g.com.android.settings:id/network_and_internet_row) is unaffected and still leads the chain/identity. A shared framework id with no label to fall back on is also left untouched (nothing else to discriminate with).Tests
New unit tests pin:
src/replay/__tests__/target-identity-node.test.ts,src/daemon/__tests__/session-target-evidence.test.ts,src/selectors/build.test.ts)android:id/title, insert a conditional row ahead of it (the position shift from the issue), and confirm the recorded selector-chain expression still resolves the correct row via its label clause with the id demoted (src/daemon/handlers/__tests__/session-replay-target-classification.test.ts)Test plan
pnpm test(full unit suite): 4064/4066 passing. The 2 failures (session-replay-vars.test.tsMaestrorunScripthttp tests) are a known timing flake under full-suite CPU contention — confirmed passing in isolation (npx vitest run --project unit-core src/daemon/handlers/__tests__/session-replay-vars.test.ts→ 75/75 passing).pnpm check:layering— the newselectors -> replayimport edge (an unranked zone) is clean, no cycle.npx tsc --noEmit— clean.oxlinton changed files — clean.