Skip to content

fix(android): demote shared android:id/* framework ids below a discriminating label (#1269)#1279

Open
thymikee wants to merge 1 commit into
mainfrom
fix/android-replay-shared-id-identity
Open

fix(android): demote shared android:id/* framework ids below a discriminating label (#1269)#1279
thymikee wants to merge 1 commit into
mainfrom
fix/android-replay-shared-id-identity

Conversation

@thymikee

Copy link
Copy Markdown
Member

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/title as 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:

  • The human-facing selector chain led with 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 later role=... label=... clause that would have resolved uniquely.
  • The target-v1 binding metadata recorded id: "android:id/title" as primary identity, so matchesLocalIdentity treated any node sharing that id as sharing identity (ignoring label), leaving only sibling/viewportOrder to disambiguate.
  • When a row shifted (a conditional row inserted, or the shared-id set reordered), both mechanisms picked the wrong element, and the identity verifier correctly — but falsely — refused with 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.tsreadNodeLocalIdentity (the ONE local-identity reader shared by the record-time writer, replay-time verification, and the dispatch post-resolution guard) drops a non-discriminating android:id/* id from the recorded LocalIdentity when a label exists, so target-v1 verification narrows its identity/disambiguation domain by label instead of by the shared id. Adds the isNonDiscriminatingAndroidId predicate.
  • src/selectors/build.tsbuildSelectorChainForNode reorders the recorded, human-facing selector chain (id=... || role=... label=... || label=...) so the discriminating role=... 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-v1 metadata 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:

  • a shared framework id (matchCount 11, matching the reported repro) is not selected as primary identity when a label exists (src/replay/__tests__/target-identity-node.test.ts, src/daemon/__tests__/session-target-evidence.test.ts, src/selectors/build.test.ts)
  • a unique (non-framework) resource id is still preferred (same files)
  • a shared framework id with no label is kept, since nothing else can discriminate
  • an end-to-end regression reproducing the issue's exact scenario: record a row among 11 sharing 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.ts Maestro runScript http 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 new selectors -> replay import edge (an unranked zone) is clean, no cycle.
  • npx tsc --noEmit — clean.
  • oxlint on changed files — clean.
  • Live-device FDR re-measurement on Android (wave-3 E1-style repro) — pending, not attempted in this environment per the issue's verification guidance (no live device/emulator available here).

…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).
@github-actions

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB -26.3 kB
JS gzip 557.5 kB 550.7 kB -6.8 kB
npm tarball 673.0 kB 665.1 kB -7.9 kB
npm unpacked 2.4 MB 2.3 MB -31.0 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.3 ms 28.8 ms +1.5 ms
CLI --help 59.6 ms 59.0 ms -0.6 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/sdk-selectors.js +22.0 kB +7.2 kB
dist/src/session.js -25.6 kB -6.6 kB
dist/src/tv-remote.js +14.1 kB +5.1 kB
dist/src/internal/daemon.js -3.7 kB -1.1 kB
dist/src/registry.js -1.6 kB -541 B

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android replay: non-unique framework id android:id/title used as primary identity → systematic false divergences (FDR 1.0)

1 participant