Skip to content

Commit 35d1a35

Browse files
committed
fix(ios): keep an identifier hook the fold cannot prove inert
Suppressing a childless structural identifier wrapper deletes its identifier from every canonical view, and the interactive fold did it on a missing fact: the Apple runner declares hittable:false for those wrappers, the Simulator AX bridge declares nothing, and the same testID hook resolved on one producer and vanished on the other. Suppression now needs the node's own declared verdict. Refs #2638
1 parent 2ad819c commit 35d1a35

7 files changed

Lines changed: 128 additions & 1 deletion

File tree

‎docs/adr/0004-ios-snapshot-backend-strategy.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,15 @@ nodes belong in the regular presentation, while publication adds refs and emits
164164
payload. An optional unscoped quality payload is validated for classification evidence and is never
165165
published.
166166

167+
Semantic compaction may move an identifier; it may not un-make one. A structural `Other` wrapper
168+
carrying an identifier and nothing else is suppressed in favour of its content, which is a
169+
delegation: the identifier goes on living in whatever the wrapper stood for. A wrapper with no
170+
content has nothing to delegate to, so suppressing it deletes the identifier from every canonical
171+
view while `is`, `get`, and `click` still resolve it from the same capture. That deletion needs the
172+
node's own declared `hittable: false`, because it is the only verdict in the capture that says the
173+
wrapper is inert; a producer that reports no hittability for any node declares nothing, and an
174+
absent fact is not a negative answer (#2638).
175+
167176
## Regression Notes
168177

169178
PR #639 made XCTest AX serialization failures explicit instead of swallowing them as empty

‎packages/capture-kit/src/ios-snapshot-engine/mapping.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ test('publishes an exact representative for every semantic source index', () =>
4444
parentIndex: 4,
4545
type: 'Other',
4646
identifier: 'shared-but-not-a-representative',
47+
hittable: false,
4748
rect: { x: 20, y: 240, width: 120, height: 40 },
4849
},
4950
];

‎packages/capture-kit/src/ios-snapshot-engine/noise-structural.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ export function collectIosStructuralIdentifierSuppression(
1717
if (!node.identifier?.trim()) {
1818
continue;
1919
}
20-
context.suppressNode(node, collectSubtreeByParentLinks(node, childrenByParent));
20+
const content = collectSubtreeByParentLinks(node, childrenByParent);
21+
// Suppression delegates the identifier to the wrapper's content. With no content there is
22+
// nothing to delegate to, and only the node's own declared `hittable: false` says the wrapper
23+
// is inert; a producer that reports no hittability at all declares nothing (#2638).
24+
if (content.length === 0 && node.hittable !== false) {
25+
continue;
26+
}
27+
context.suppressNode(node, content);
2128
}
2229
}
2330

‎packages/capture-kit/src/ios-snapshot-engine/noise.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,39 @@ describe('collectIosStructuralIdentifierSuppression', () => {
7777
expect(suppressed.sort((a, b) => a - b)).toEqual([1, 4, 7]);
7878
});
7979

80+
test('keeps a childless identifier wrapper whose capture reported no hittability', () => {
81+
const nodes: RawSnapshotNode[] = [
82+
{ index: 0, type: 'Application', label: 'App' },
83+
{ index: 1, parentIndex: 0, type: 'Other', identifier: 'members.count' },
84+
];
85+
86+
const unreported: number[] = [];
87+
collectIosStructuralIdentifierSuppression(nodes, {
88+
...makeRuleContext(nodes),
89+
suppressNode: (source) => unreported.push(source.index),
90+
});
91+
expect(unreported).toEqual([]);
92+
93+
const declared: number[] = [];
94+
collectIosStructuralIdentifierSuppression(
95+
[{ ...nodes[0]! }, { ...nodes[1]!, hittable: false }],
96+
{ ...makeRuleContext(nodes), suppressNode: (source) => declared.push(source.index) },
97+
);
98+
expect(declared).toEqual([1]);
99+
});
100+
101+
test('delegates an identifier wrapper to its content even without a hittability verdict', () => {
102+
const { nodes } = makeStructuralTree(1, 2);
103+
const suppressed: number[] = [];
104+
105+
collectIosStructuralIdentifierSuppression(nodes, {
106+
...makeRuleContext(nodes),
107+
suppressNode: (source) => suppressed.push(source.index),
108+
});
109+
110+
expect(suppressed).toEqual([1]);
111+
});
112+
80113
test('builds the child index once instead of per structural candidate', () => {
81114
const candidateCount = 40;
82115
const descendantsPerCandidate = 25;

‎packages/capture-kit/src/ios-snapshot-engine/presentation.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ test('buildSnapshotState keeps React Native warning banner instead of full-scree
498498
parentIndex: 1,
499499
type: 'Other',
500500
identifier: 'SearchRouterPage',
501+
hittable: false,
501502
rect: { x: 0, y: 0, width: 402, height: 874 },
502503
},
503504
{
@@ -719,6 +720,7 @@ test('buildSnapshotState suppresses structural iOS identifier-only nodes', () =>
719720
parentIndex: 0,
720721
type: 'Other',
721722
identifier: 'SearchRouterPage',
723+
hittable: false,
722724
rect: { x: 0, y: 0, width: 402, height: 874 },
723725
},
724726
{
@@ -735,6 +737,7 @@ test('buildSnapshotState suppresses structural iOS identifier-only nodes', () =>
735737
parentIndex: 2,
736738
type: 'Other',
737739
identifier: 'ReportActionAvatars-SingleAvatar',
740+
hittable: false,
738741
rect: { x: 20, y: 252, width: 40, height: 40 },
739742
},
740743
];

‎src/daemon/__tests__/snapshot-publication-membership.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const screen: RawSnapshotNode[] = [
4646
parentIndex: 0,
4747
type: 'Other',
4848
identifier: 'promo-banner',
49+
hittable: false,
4950
rect: { x: 110, y: 130, width: 180, height: 44 },
5051
},
5152
// No label, identifier, or value: survival proves the interactive branch, not content.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { expect, test } from 'vitest';
2+
import { executeMaestroFlow, inspectMaestroFlow } from '@agent-device/maestro';
3+
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
4+
import { noMaestroIncludeSources } from '../../../../__tests__/test-utils/replay-script-source.ts';
5+
import { createDaemonMaestroRuntimePort } from '../daemon-runtime-port.ts';
6+
import { makeBaseRequest, makeDependencies, makeSnapshot } from './daemon-runtime-port-fixtures.ts';
7+
8+
const ON_SCREEN = { x: 18, y: 62, width: 366, height: 144 };
9+
const BELOW_VIEWPORT = { x: 18, y: 2000, width: 366, height: 144 };
10+
11+
// React Native keeps a `testID` wrapper's text beside it instead of inside it, so the wrapper
12+
// reaches the fold childless with nothing to delegate its identifier to. Whether that is fatal then
13+
// depends on the wrapper's own declared hittability: the Apple runner answers for every node and
14+
// the Simulator AX bridge answers for none, and #2638 is the flow that passed on one producer and
15+
// failed on the other.
16+
function hookCapture(
17+
rect: typeof ON_SCREEN,
18+
hittable: boolean | undefined,
19+
): Array<Omit<SnapshotNode, 'ref'>> {
20+
return [
21+
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 402, height: 874 } },
22+
{
23+
index: 1,
24+
parentIndex: 0,
25+
type: 'Other',
26+
identifier: 'members.count',
27+
rect,
28+
...(hittable === undefined ? {} : { hittable }),
29+
},
30+
];
31+
}
32+
33+
async function replay(
34+
assertion: 'assertVisible' | 'assertNotVisible',
35+
hittable: boolean | undefined,
36+
rect: typeof ON_SCREEN,
37+
) {
38+
const port = createDaemonMaestroRuntimePort({
39+
baseReq: makeBaseRequest({ flags: { platform: 'ios', replayBackend: 'maestro' } }),
40+
invoke: async (request) =>
41+
request.command === 'snapshot'
42+
? { ok: true, data: makeSnapshot(hookCapture(rect, hittable)) }
43+
: { ok: true, data: {} },
44+
dependencies: makeDependencies(),
45+
platform: 'ios',
46+
});
47+
const flow = inspectMaestroFlow(
48+
['appId: com.example.app', '---', `- ${assertion}:`, ' id: members.count'].join('\n'),
49+
'/flows/identifier-hook.yaml',
50+
);
51+
return await executeMaestroFlow(flow, port, { readSource: noMaestroIncludeSources });
52+
}
53+
54+
test('resolves an on-screen identifier hook whose capture reported no hittability', async () => {
55+
expect(await replay('assertVisible', undefined, ON_SCREEN)).toMatchObject({
56+
ok: true,
57+
replayed: 1,
58+
});
59+
});
60+
61+
test('keeps a declared non-hittable identifier hook out of the visible set', async () => {
62+
expect(await replay('assertNotVisible', false, ON_SCREEN)).toMatchObject({
63+
ok: true,
64+
replayed: 1,
65+
});
66+
});
67+
68+
test('does not report a kept identifier hook as visible past the viewport', async () => {
69+
expect(await replay('assertNotVisible', undefined, BELOW_VIEWPORT)).toMatchObject({
70+
ok: true,
71+
replayed: 1,
72+
});
73+
});

0 commit comments

Comments
 (0)