Skip to content

Commit 80c1b3e

Browse files
committed
fix(replay): unify the id-uniqueness predicate across both writer sites (#1269 review)
Address the maintainer review on #1272: 1. ONE shared uniqueness predicate. The two demotion sites were counting id matches with DIFFERENT semantics — `demoteNonUniqueId` via `filterIdentitySet` (NFC + 256-byte cap, and a broken-parent-walk exclusion), `selectableId` via a raw `normalizeSelectorText` scan (trim, no NFC/cap, no exclusion) — so the identity tuple and the selector chain could disagree and half-demote (id gone from one, kept in the other). Extract `idMatchCountInTree(nodes, id)` in target-identity-node.ts, counting over the canonical `readNodeLocalIdentity` id the replay verifier keys on, with no ancestry/parent-walk exclusion. Both `demoteNonUniqueId` and `selectableId` now call it. Corrects the inaccurate "vacuously-true / plain id scan" comment. Cross-invariant test (build.test.ts): for the same node+tree, evidence.id === undefined iff the built chain has no id= clause — across demoted, unique, and a non-NFC (decomposed vs precomposed) edge case. Verified it fails under the old raw-scan and passes under the unified predicate. 2. End-to-end reorder proof (session-replay-target-classification.test.ts): record against a tree whose rows share android:id/title, then classify against a DIFFERENT replay tree where the shared-id rows reorder — the demoted role+label identity rebinds the correct row (verified, matchCount 1) while `id="android:id/title"` resolves ambiguously (null). This pins the FDR 1.0 -> 0 mechanism, not just record-time demotion. 3. Removed the conflated "20/20 clean" live-number comment from the unit test; it now states the mechanism (role+label selectivity) instead. Behavior for the already-clean unique-id path is unchanged: for ordinary ascii ids the canonical count equals the old raw count. The only outcomes that change are the edge cases the old split mishandled (non-NFC, broken parent walk) — where demotion is the correct result. The kept clause still emits the chain's own normalizeSelectorText id string, so unique ids lead the chain exactly as before.
1 parent 44050b4 commit 80c1b3e

6 files changed

Lines changed: 289 additions & 30 deletions

File tree

src/daemon/__tests__/session-target-evidence.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,8 +532,8 @@ test('computeTargetEvidence: a shared Android framework id (matchCount 3 > 1) is
532532
assert.equal(evidence.label, 'Network & internet');
533533
assert.deepEqual(evidence.ancestry[0], { role: 'linearlayout' });
534534
// With the id demoted, role+label uniquely isolates this row among the
535-
// three sharing the id, so the record-time self-check still verifies
536-
// this is the measured "get-only stripped-id: 20/20 clean" fix.
535+
// three sharing the id, so the record-time self-check still verifies via
536+
// the now-selective label rather than the non-selective id.
537537
assert.equal(evidence.verification, 'verified');
538538
});
539539

src/daemon/handlers/__tests__/session-replay-target-classification.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { test } from 'vitest';
22
import assert from 'node:assert/strict';
3-
import type { SnapshotNode } from '../../../kernel/snapshot.ts';
3+
import type { RawSnapshotNode, SnapshotNode } from '../../../kernel/snapshot.ts';
44
import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts';
5+
import { computeTargetEvidence } from '../../session-target-evidence.ts';
6+
import { buildSelectorChainForNode } from '../../../selectors/build.ts';
7+
import { parseSelectorChain, resolveSelectorChain } from '../../../selectors/index.ts';
58
import { classifyReplayTarget } from '../session-replay-target-classification.ts';
69
import {
710
bottomTabsRealCaptureFixture,
@@ -464,3 +467,97 @@ test('classifyReplayTarget: document-order determinism for equal rect centers',
464467
});
465468

466469
// ---------------------------------------------------------------------------
470+
// #1269 end-to-end mechanism (Ask 2): the load-bearing proof that id-demotion
471+
// makes replay clean — not just that the id is dropped at record time. Record
472+
// against a tree whose rows share `android:id/title`, then replay against a
473+
// DIFFERENT tree where those shared-id rows REORDER (positions drift). The
474+
// demoted role+label identity binds the correct row; the id path would not.
475+
// ---------------------------------------------------------------------------
476+
477+
function androidSharedIdListFixture(
478+
rows: { id: string; label: string; y: number }[],
479+
): SnapshotNode[] {
480+
const raw: RawSnapshotNode[] = [
481+
{ index: 0, type: 'FrameLayout', depth: 0 },
482+
{ index: 1, type: 'RecyclerView', depth: 1, parentIndex: 0 },
483+
];
484+
rows.forEach((row, position) => {
485+
const wrapperIndex = 2 + position * 2;
486+
const titleIndex = wrapperIndex + 1;
487+
raw.push({ index: wrapperIndex, type: 'LinearLayout', depth: 2, parentIndex: 1 });
488+
raw.push({
489+
index: titleIndex,
490+
type: 'TextView',
491+
identifier: row.id,
492+
label: row.label,
493+
rect: { x: 0, y: row.y, width: 300, height: 48 },
494+
depth: 3,
495+
parentIndex: wrapperIndex,
496+
});
497+
});
498+
return toSnapshotNodes(raw);
499+
}
500+
501+
test('#1269 e2e: a demoted shared-id row rebinds by role+label after the shared-id rows reorder on replay', () => {
502+
const ANDROID = 'android' as const;
503+
// Record-time Settings root: three rows all carrying the framework id.
504+
const recordNodes = androidSharedIdListFixture([
505+
{ id: 'android:id/title', label: 'Network & internet', y: 100 },
506+
{ id: 'android:id/title', label: 'Connected devices', y: 148 },
507+
{ id: 'android:id/title', label: 'Apps', y: 196 },
508+
]);
509+
const target = recordNodes.find((node) => node.label === 'Connected devices')!;
510+
511+
// The writer demotes the non-unique id in BOTH the tuple and the chain.
512+
const recorded = computeTargetEvidence({ node: target, preActionNodes: recordNodes })!;
513+
assert.equal(recorded.id, undefined);
514+
assert.equal(recorded.verification, 'verified');
515+
const chain = buildSelectorChainForNode(target, ANDROID, { action: 'get', nodes: recordNodes });
516+
assert.ok(!chain.some((entry) => entry.startsWith('id=')));
517+
const token = chain.join(' || '); // the recorded selector positional the replay loop re-resolves
518+
519+
// Replay-time tree: a new conditional row appears at the top and the
520+
// shared-id rows are in a DIFFERENT document/viewport order. The recorded
521+
// row is now third, at a drifted position.
522+
const replayNodes = androidSharedIdListFixture([
523+
{ id: 'android:id/title', label: 'Wi-Fi', y: 100 },
524+
{ id: 'android:id/title', label: 'Apps', y: 148 },
525+
{ id: 'android:id/title', label: 'Connected devices', y: 196 },
526+
{ id: 'android:id/title', label: 'Network & internet', y: 244 },
527+
]);
528+
const expected = replayNodes.find((node) => node.label === 'Connected devices')!;
529+
530+
const result = classifyReplayTarget({
531+
recorded,
532+
token,
533+
nodes: replayNodes,
534+
platform: ANDROID,
535+
refLabel: undefined,
536+
requireRect: true,
537+
allowDisambiguation: true,
538+
});
539+
assertVerified(result, { winnerRef: expected.ref, matchCount: 1 });
540+
541+
// Contrast — the reorder is genuinely hostile to the id path: the shared id
542+
// matches all four rows (no unique bind → resolution refuses), while the
543+
// demoted role+label resolves the correct row uniquely. This is the
544+
// FDR 1.0 → 0 difference the demotion buys.
545+
const idResolved = resolveSelectorChain(
546+
replayNodes,
547+
parseSelectorChain('id="android:id/title"'),
548+
{
549+
platform: ANDROID,
550+
requireRect: true,
551+
requireUnique: true,
552+
},
553+
);
554+
assert.equal(idResolved, null); // non-unique: refuses to bind
555+
const labelResolved = resolveSelectorChain(
556+
replayNodes,
557+
parseSelectorChain('role="textview" label="Connected devices"'),
558+
{ platform: ANDROID, requireRect: true, requireUnique: true },
559+
);
560+
assert.equal(labelResolved?.node.ref, expected.ref);
561+
});
562+
563+
// ---------------------------------------------------------------------------

src/daemon/session-target-evidence.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
import type { SnapshotNode } from '../kernel/snapshot.ts';
1818
import { resolveRectCenter } from '../utils/rect-center.ts';
1919
import { findNearestScrollableContainer } from './snapshot-presentation/tree.ts';
20-
import { readNodeLocalIdentity, siblingOrdinal } from '../replay/target-identity-node.ts';
20+
import {
21+
idMatchCountInTree,
22+
readNodeLocalIdentity,
23+
siblingOrdinal,
24+
} from '../replay/target-identity-node.ts';
2125
import {
2226
classifyTargetBindingMatch,
2327
matchesAncestryPrefix,
@@ -45,7 +49,7 @@ export function computeTargetEvidence(
4549
const { node, preActionNodes: nodes } = capture;
4650
if (typeof node.index !== 'number') return undefined;
4751
const byIndex = buildIndexMap(nodes);
48-
const identity = demoteNonUniqueId(boundedLocalIdentity(node), nodes, byIndex);
52+
const identity = demoteNonUniqueId(boundedLocalIdentity(node), nodes);
4953
const ancestryWalk = buildAncestryChain(node, byIndex, TARGET_ANNOTATION_MAX_ANCESTRY);
5054
const fullAncestry = ancestryWalk.chain;
5155
const sibling = computeSiblingOrdinal(nodes, node);
@@ -244,23 +248,18 @@ export function filterIdentitySet(
244248
* (Android's `android:id/title` matching every list row is the measured
245249
* case — #1269) is not selective: on replay the id-led identity set spans
246250
* every row, position drifts, and verification correctly refuses a
247-
* confident bind. Passing an empty `ancestry` to `filterIdentitySet`
248-
* degrades its ancestry-prefix check to vacuously-true, so it returns
249-
* exactly the nodes sharing this id anywhere in the tree — the id's own
250-
* capture-time match count, independent of structural context. When that
251-
* count is greater than one, fall back to role+label, exactly the identity
252-
* an unrecorded id already computes. The rule is capture-time uniqueness,
253-
* not an id-namespace heuristic: a reused RN `FlatList` `testID` hits the
254-
* same demotion on iOS.
251+
* confident bind. `idMatchCountInTree` — the SAME predicate
252+
* `buildSelectorChainForNode` uses for the selector chain — counts nodes
253+
* sharing this canonical id across the whole tree; when more than one, fall
254+
* back to role+label, exactly the identity an unrecorded id already computes.
255+
* Both sites sharing one predicate is what keeps the tuple and the chain from
256+
* disagreeing (demoting one but not the other). The rule is capture-time
257+
* uniqueness, not an id-namespace heuristic: a reused RN `FlatList` `testID`
258+
* hits the same demotion on iOS.
255259
*/
256-
function demoteNonUniqueId(
257-
identity: LocalIdentity,
258-
nodes: readonly SnapshotNode[],
259-
byIndex: Map<number, SnapshotNode>,
260-
): LocalIdentity {
260+
function demoteNonUniqueId(identity: LocalIdentity, nodes: readonly SnapshotNode[]): LocalIdentity {
261261
if (identity.id === undefined) return identity;
262-
const idMatchCount = filterIdentitySet(nodes, byIndex, identity, []).length;
263-
if (idMatchCount <= 1) return identity;
262+
if (idMatchCountInTree(nodes, identity.id) <= 1) return identity;
264263
const { role, label } = identity;
265264
return { role, ...(label !== undefined ? { label } : {}) };
266265
}

src/replay/target-identity-node.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,33 @@ export function localIdentitiesEqual(a: LocalIdentity, b: LocalIdentity): boolea
4141
return a.id === b.id && a.role === b.role && a.label === b.label;
4242
}
4343

44+
/**
45+
* ADR 0012 decision 3 amendment (#1269): how many nodes in `nodes` carry the
46+
* canonical identity `id`. THE single uniqueness predicate behind both
47+
* id-demotion sites — `computeTargetEvidence`'s `target-v1` identity tuple
48+
* and `buildSelectorChainForNode`'s selector chain. Sharing it is the
49+
* correctness guarantee: a non-unique id is dropped from BOTH or NEITHER,
50+
* never half-demoted (dropped from identity while still leading the chain, or
51+
* the reverse — the exact split #1269's fix exists to close).
52+
*
53+
* `id` is a canonical identity id (`readNodeLocalIdentity(node).id`: NFC +
54+
* 256-byte field cap, no trimming), and every candidate is measured through
55+
* the SAME reader, so the count is over exactly the identities the replay
56+
* verifier keys on. There is deliberately NO ancestry / parent-walk
57+
* exclusion: an id is non-selective the moment two nodes anywhere in the tree
58+
* carry it, independent of structural context or a broken parent linkage.
59+
*/
60+
export function idMatchCountInTree(
61+
nodes: readonly Pick<RawSnapshotNode, 'type' | 'identifier' | 'label'>[],
62+
id: string,
63+
): number {
64+
let count = 0;
65+
for (const node of nodes) {
66+
if (readNodeLocalIdentity(node).id === id) count += 1;
67+
}
68+
return count;
69+
}
70+
4471
/**
4572
* ADR 0012 decision 3: the STRUCTURAL denotation of a node within its capture
4673
* — the discriminators path 6 uses to isolate ONE member among several nodes

src/selectors/build.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import type { SnapshotNode } from '../kernel/snapshot.ts';
44
import { buildNodes } from '../__tests__/test-utils/snapshot-builders.ts';
5+
import { computeTargetEvidence } from '../daemon/session-target-evidence.ts';
56
import { buildSelectorChainForNode } from './build.ts';
67

78
function findByLabel(nodes: SnapshotNode[], label: string): SnapshotNode {
@@ -10,6 +11,24 @@ function findByLabel(nodes: SnapshotNode[], label: string): SnapshotNode {
1011
return found;
1112
}
1213

14+
/**
15+
* #1269 cross-invariant: the two writers demote through ONE shared
16+
* uniqueness predicate, so for the same node+tree the `target-v1` identity
17+
* tuple carries an `id` iff the built selector chain leads with an `id=`
18+
* clause — never a half-demoted split (id in one, gone from the other).
19+
*/
20+
function assertIdParityAcrossWriters(nodes: SnapshotNode[], node: SnapshotNode): void {
21+
const evidence = computeTargetEvidence({ node, preActionNodes: nodes });
22+
const chain = buildSelectorChainForNode(node, 'android', { action: 'get', nodes });
23+
const evidenceHasId = evidence?.id !== undefined;
24+
const chainHasId = chain.some((entry) => entry.startsWith('id='));
25+
assert.equal(
26+
evidenceHasId,
27+
chainHasId,
28+
`id parity broken: evidence.id=${JSON.stringify(evidence?.id)} chain=${JSON.stringify(chain)}`,
29+
);
30+
}
31+
1332
// ---------------------------------------------------------------------------
1433
// #1269: a recorded id only leads the chain when it is unique in the
1534
// record-time tree. `android:id/title` — a shared Android framework
@@ -145,3 +164,111 @@ test('buildSelectorChainForNode: without a record-time tree, an id is trusted as
145164
const chain = buildSelectorChainForNode(nodes[0]!, 'ios', { action: 'get' });
146165
assert.deepEqual(chain, ['id="save"', 'role="button" label="Save"', 'label="Save"']);
147166
});
167+
168+
// ---------------------------------------------------------------------------
169+
// #1269 cross-invariant (Ask 1): the identity tuple and the selector chain go
170+
// through ONE shared uniqueness predicate (`idMatchCountInTree`), so they
171+
// demote in lockstep. The pre-fix bug was two predicates with different
172+
// normalization/exclusions that could disagree — the non-NFC case below is
173+
// exactly where a raw `normalizeSelectorText` chain scan kept an id the
174+
// NFC-normalized identity tuple demoted.
175+
// ---------------------------------------------------------------------------
176+
177+
test('#1269 cross-invariant: evidence.id present iff chain leads with id= — demoted, unique, and non-NFC edge cases', () => {
178+
// Demoted: a shared framework id across 3 rows → both writers drop it.
179+
const demoted = buildNodes([
180+
{ index: 0, type: 'FrameLayout', depth: 0 },
181+
{ index: 1, type: 'RecyclerView', depth: 1, parentIndex: 0 },
182+
{ index: 2, type: 'LinearLayout', depth: 2, parentIndex: 1 },
183+
{
184+
index: 3,
185+
type: 'TextView',
186+
identifier: 'android:id/title',
187+
label: 'Network & internet',
188+
rect: { x: 0, y: 100, width: 300, height: 48 },
189+
depth: 3,
190+
parentIndex: 2,
191+
},
192+
{ index: 4, type: 'LinearLayout', depth: 2, parentIndex: 1 },
193+
{
194+
index: 5,
195+
type: 'TextView',
196+
identifier: 'android:id/title',
197+
label: 'Apps',
198+
rect: { x: 0, y: 148, width: 300, height: 48 },
199+
depth: 3,
200+
parentIndex: 4,
201+
},
202+
{ index: 6, type: 'LinearLayout', depth: 2, parentIndex: 1 },
203+
{
204+
index: 7,
205+
type: 'TextView',
206+
identifier: 'android:id/title',
207+
label: 'Battery',
208+
rect: { x: 0, y: 196, width: 300, height: 48 },
209+
depth: 3,
210+
parentIndex: 6,
211+
},
212+
]);
213+
assertIdParityAcrossWriters(demoted, findByLabel(demoted, 'Network & internet'));
214+
215+
// Unique: a distinct id → both writers keep it.
216+
const unique = buildNodes([
217+
{ index: 0, type: 'Window', depth: 0 },
218+
{
219+
index: 1,
220+
type: 'Button',
221+
identifier: 'save',
222+
label: 'Save',
223+
rect: { x: 10, y: 10, width: 40, height: 20 },
224+
depth: 1,
225+
parentIndex: 0,
226+
},
227+
]);
228+
assertIdParityAcrossWriters(unique, findByLabel(unique, 'Save'));
229+
230+
// Non-NFC edge: two ids equal under NFC but different raw bytes (decomposed
231+
// vs precomposed "é"). The identity tuple always NFC-normalizes, so it sees
232+
// one shared id and demotes; the pre-fix raw chain scan compared the raw
233+
// bytes, saw two DIFFERENT ids, and kept the id in the chain — the exact
234+
// half-demoted split this cross-invariant now forbids.
235+
const decomposed = 'cafe\u0301'; // c a f e + U+0301 combining acute accent
236+
const precomposed = 'caf\u00e9'; // c a f é (single U+00E9)
237+
assert.notEqual(decomposed, precomposed); // distinct raw code-point sequences
238+
assert.equal(decomposed.normalize('NFC'), precomposed.normalize('NFC')); // equal under NFC
239+
const nonNfc = buildNodes([
240+
{ index: 0, type: 'RecyclerView', depth: 0 },
241+
{ index: 1, type: 'LinearLayout', depth: 1, parentIndex: 0 },
242+
{
243+
index: 2,
244+
type: 'TextView',
245+
identifier: decomposed,
246+
label: 'Coffee',
247+
rect: { x: 0, y: 100, width: 300, height: 48 },
248+
depth: 2,
249+
parentIndex: 1,
250+
},
251+
{ index: 3, type: 'LinearLayout', depth: 1, parentIndex: 0 },
252+
{
253+
index: 4,
254+
type: 'TextView',
255+
identifier: precomposed,
256+
label: 'Espresso',
257+
rect: { x: 0, y: 148, width: 300, height: 48 },
258+
depth: 2,
259+
parentIndex: 3,
260+
},
261+
]);
262+
assertIdParityAcrossWriters(nonNfc, findByLabel(nonNfc, 'Coffee'));
263+
// And concretely: the id is dropped from BOTH sides (not kept in the chain).
264+
const evidence = computeTargetEvidence({
265+
node: findByLabel(nonNfc, 'Coffee'),
266+
preActionNodes: nonNfc,
267+
});
268+
assert.equal(evidence?.id, undefined);
269+
const chain = buildSelectorChainForNode(findByLabel(nonNfc, 'Coffee'), 'android', {
270+
action: 'get',
271+
nodes: nonNfc,
272+
});
273+
assert.ok(!chain.some((entry) => entry.startsWith('id=')));
274+
});

0 commit comments

Comments
 (0)