Skip to content

Commit 0fd991f

Browse files
committed
fix(ios): preserve unavailable snapshot facts
1 parent db242d3 commit 0fd991f

8 files changed

Lines changed: 99 additions & 55 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ test('unavailable hittability never becomes regular actionability', () => {
236236
const acquired = publishIosSnapshot({ stage: 'acquired', acquisition: unavailable }, request);
237237
assert.equal(
238238
acquired.payload.nodes.find((node) => node.label === 'Partially visible')?.hittable,
239+
undefined,
240+
);
241+
assert.equal(
242+
'hittable' in (acquired.payload.nodes.find((node) => node.label === 'Partially visible') ?? {}),
239243
false,
240244
);
241245

packages/capture-kit/src/ios-snapshot-engine/geometry.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,17 +107,21 @@ function appendFoldedNode(
107107

108108
const index = kept.length;
109109
keptDepth += 1;
110+
const { hittable: sourceHittable, ...sourceNode } = node;
110111
kept.push({
111112
raw: {
112-
...node,
113+
...sourceNode,
113114
index,
114115
depth: keptDepth,
115116
parentIndex: keptIndex,
116-
hittable:
117-
node.parentIndex !== undefined &&
118-
options.hittabilityAvailable !== false &&
119-
node.hittable === true &&
120-
isGeometricallyActionable(node.enabled !== false, decision.effectiveRect, viewport),
117+
...foldedHittability(
118+
sourceHittable,
119+
node.parentIndex !== undefined,
120+
decision.effectiveRect,
121+
node.enabled !== false,
122+
viewport,
123+
options,
124+
),
121125
},
122126
sourceIndex: node.index,
123127
...(decision.effectiveRect ? { effectiveRect: decision.effectiveRect } : {}),
@@ -126,6 +130,25 @@ function appendFoldedNode(
126130
return { keptIndex, keptDepth };
127131
}
128132

133+
function foldedHittability(
134+
sourceHittable: RawSnapshotNode['hittable'],
135+
hasParent: boolean,
136+
effectiveRect: Rect | undefined,
137+
enabled: boolean,
138+
viewport: Rect,
139+
options: IosSnapshotFoldOptions,
140+
): Partial<Pick<RawSnapshotNode, 'hittable'>> {
141+
if (options.hittabilityAvailable === false) {
142+
return sourceHittable === false ? { hittable: false } : {};
143+
}
144+
return {
145+
hittable:
146+
hasParent &&
147+
sourceHittable === true &&
148+
isGeometricallyActionable(enabled, effectiveRect, viewport),
149+
};
150+
}
151+
129152
function nextScrollAnchor(
130153
decision: GeometryDecision,
131154
parentAnchor: BranchState['anchor'],

packages/capture-kit/src/ios-snapshot-engine/projection.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,17 +142,19 @@ function createRegularProjectedNode(
142142
if (!isEligibleForIosRegularPresentation(node.raw)) return undefined;
143143
const depth = parent ? (parent.depth ?? 0) + 1 : 0;
144144
if (maximumDepth !== null && depth > maximumDepth) return undefined;
145+
const hittable = isProjectedNodeHittable(node);
145146
return {
146147
...node.raw,
147148
index,
148149
depth,
149150
parentIndex: parent?.index,
150151
...(node.effectiveRect ? { rect: node.effectiveRect } : { rect: undefined }),
151-
hittable: isProjectedNodeHittable(node),
152+
...(hittable === undefined ? {} : { hittable }),
152153
};
153154
}
154155

155-
function isProjectedNodeHittable(node: IosSnapshotPresentationNode): boolean {
156+
function isProjectedNodeHittable(node: IosSnapshotPresentationNode): RawSnapshotNode['hittable'] {
157+
if (node.raw.hittable === undefined) return undefined;
156158
return Boolean(
157159
node.raw.hittable === true &&
158160
node.effectiveRect &&

packages/capture-kit/src/ios-snapshot-planning.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,12 @@ test('presented producers cannot claim acquisition narrowing', () => {
147147
});
148148
});
149149

150-
test('Appium source advertises viewport evidence when its root reports geometry', () => {
151-
assert.equal(IOS_SNAPSHOT_PRODUCER_CAPABILITIES['appium-source'].viewportEvidence, 'available');
150+
test('Appium source plan carries its viewport evidence capability', () => {
151+
const plan = planIosSnapshot(
152+
createIosSnapshotRequest(),
153+
IOS_SNAPSHOT_PRODUCER_CAPABILITIES['appium-source'],
154+
);
155+
assert.equal(plan.evidence.viewport, 'available');
152156
});
153157

154158
test('comparison identity rejects every identity axis and residue mismatch', () => {

packages/provider-webdriver/src/webdriver-ios-snapshot.test.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ test('Appium iOS snapshots acquire facts and publish regular output through the
3131
['XCUIElementTypeButton', 'Continue', 1],
3232
],
3333
);
34-
assert.equal(result.nodes?.find((node) => node.label === 'Continue')?.hittable, false);
34+
assert.equal(result.nodes?.find((node) => node.label === 'Continue')?.hittable, undefined);
3535
assert.equal(source.mock.calls.length, 1);
3636
assert.ok(result.warnings?.some((warning) => warning.includes('hittability evidence')));
3737
assert.deepEqual(Object.keys(result).sort(), [
@@ -85,6 +85,22 @@ test('Appium iOS options become an engine plan and engine-owned projection', ()
8585
assert.deepEqual(published.publication.comparisonIdentity.lineage, {});
8686
});
8787

88+
test('Appium regular presentation omits unavailable hittability while raw preserves supplied facts', async () => {
89+
const source =
90+
'<AppiumAUT><XCUIElementTypeApplication x="0" y="0" width="390" height="844">' +
91+
'<XCUIElementTypeButton name="Continue" x="24" y="700" width="160" height="48" hittable="true" />' +
92+
'</XCUIElementTypeApplication></AppiumAUT>';
93+
94+
const regular = await captureWebDriverIosSnapshot({ source: async () => source });
95+
const regularButton = regular.nodes?.find((node) => node.label === 'Continue');
96+
assert.equal(regularButton?.hittable, undefined);
97+
assert.equal('hittable' in (regularButton ?? {}), false);
98+
99+
const raw = await captureWebDriverIosSnapshot({ source: async () => source }, { raw: true });
100+
assert.equal(raw.nodes?.find((node) => node.label === 'Continue')?.hittable, true);
101+
assert.ok(raw.warnings?.some((warning) => warning.includes('does not guarantee')));
102+
});
103+
88104
test('Appium iOS interactive requests stay provider-unpruned and use engine presentation', () => {
89105
const acquired = acquireWebDriverIosSnapshot(SOURCE, { interactiveOnly: true }, 'ios-1');
90106

@@ -95,7 +111,10 @@ test('Appium iOS interactive requests stay provider-unpruned and use engine pres
95111
published.result.nodes?.some((node) => node.label === 'Continue'),
96112
true,
97113
);
98-
assert.equal(published.result.nodes?.find((node) => node.label === 'Continue')?.hittable, false);
114+
assert.equal(
115+
published.result.nodes?.find((node) => node.label === 'Continue')?.hittable,
116+
undefined,
117+
);
99118
});
100119

101120
test('Appium iOS regular presentation fails typed when page source has no viewport', () => {
@@ -109,6 +128,7 @@ test('Appium iOS regular presentation fails typed when page source has no viewpo
109128
});
110129
assert.deepEqual(acquired.input.acquisition.residue, [
111130
{ kind: 'unavailable-fact', fact: 'hittability' },
131+
{ kind: 'unavailable-fact', fact: 'acquisition-depth' },
112132
{ kind: 'missing-viewport', reason: 'not-provided' },
113133
]);
114134
assert.throws(
@@ -147,15 +167,12 @@ test('Appium iOS regular presentation fails typed when root geometry is invalid'
147167
);
148168
});
149169

150-
test('Appium iOS truncation is typed and disclosed at response level', async () => {
151-
const result = await captureWebDriverIosSnapshot(
152-
{ source: async () => SOURCE.replace('<AppiumAUT>', '<AppiumAUT truncated="true">') },
153-
{ raw: true },
154-
);
170+
test('Appium iOS hierarchy limits are typed and disclosed at response level', async () => {
171+
const result = await captureWebDriverIosSnapshot({ source: async () => SOURCE }, { raw: true });
155172

156-
assert.equal(result.truncated, true);
173+
assert.equal(result.truncated, false);
157174
assert.deepEqual(result.warnings, [
158-
'Appium page source does not provide hittability evidence; the capture carries no hittability fact.',
159-
'Appium page source is truncated; the snapshot hierarchy may be incomplete.',
175+
'Appium page source does not guarantee hittability evidence; regular presentation treats it as unavailable.',
176+
'Appium page source does not report hierarchy completeness; depth- or child-limited nodes may be absent.',
160177
]);
161178
});

packages/provider-webdriver/src/webdriver-ios-snapshot.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
IosSnapshotPlan,
1717
IosSnapshotPublication,
1818
IosSnapshotRequest,
19+
IosSnapshotFact,
1920
IosViewportEvidence,
2021
} from '@agent-device/contracts/ios-snapshot';
2122
import type { SnapshotOptions, SnapshotResult } from '@agent-device/contracts/interactor-types';
@@ -29,8 +30,13 @@ const iosSnapshotEngine = createIosSnapshotEngine();
2930
const RESIDUE_WARNINGS = {
3031
'missing-viewport':
3132
'Appium page source does not provide a valid viewport; regular snapshot presentation is unavailable.',
32-
truncated: 'Appium page source is truncated; the snapshot hierarchy may be incomplete.',
33-
} satisfies Pick<Record<IosAcquisitionResidue['kind'], string>, 'missing-viewport' | 'truncated'>;
33+
} satisfies Pick<Record<IosAcquisitionResidue['kind'], string>, 'missing-viewport'>;
34+
const UNAVAILABLE_FACT_WARNINGS: Partial<Record<IosSnapshotFact, string>> = {
35+
'acquisition-depth':
36+
'Appium page source does not report hierarchy completeness; depth- or child-limited nodes may be absent.',
37+
hittability:
38+
'Appium page source does not guarantee hittability evidence; regular presentation treats it as unavailable.',
39+
};
3440

3541
export type WebDriverIosSnapshotAcquisition = Readonly<{
3642
request: IosSnapshotRequest;
@@ -71,11 +77,11 @@ export function acquireWebDriverIosSnapshot(
7177
kind: 'missing' as const,
7278
reason: 'not-provided' as const,
7379
};
74-
const residue = residueForSource(sourceFacts.truncated, viewport);
80+
const residue = residueForSource(viewport);
7581
const common = {
7682
producer: 'appium-source' as const,
7783
nodes: sourceFacts.nodes,
78-
truncated: sourceFacts.truncated,
84+
truncated: false,
7985
viewport,
8086
lineage: targetId ? { targetId } : {},
8187
residue,
@@ -110,15 +116,14 @@ export function publishWebDriverIosSnapshot(
110116
return { acquisition, publication, result };
111117
}
112118

113-
function residueForSource(
114-
truncated: boolean,
115-
viewport: IosViewportEvidence,
116-
): readonly IosAcquisitionResidue[] {
117-
const residue: IosAcquisitionResidue[] = [{ kind: 'unavailable-fact', fact: 'hittability' }];
119+
function residueForSource(viewport: IosViewportEvidence): readonly IosAcquisitionResidue[] {
120+
const residue: IosAcquisitionResidue[] = [
121+
{ kind: 'unavailable-fact', fact: 'hittability' },
122+
{ kind: 'unavailable-fact', fact: 'acquisition-depth' },
123+
];
118124
if (viewport.kind === 'missing') {
119125
residue.push({ kind: 'missing-viewport', reason: viewport.reason });
120126
}
121-
if (truncated) residue.push({ kind: 'truncated', dimension: 'nodes' });
122127
return residue;
123128
}
124129

@@ -133,11 +138,9 @@ function warningsForResidue(residue: readonly IosAcquisitionResidue[]): { warnin
133138

134139
function warningForResidue(entry: IosAcquisitionResidue): string | undefined {
135140
if (entry.kind === 'unavailable-fact') {
136-
return entry.fact === 'hittability'
137-
? 'Appium page source does not provide hittability evidence; the capture carries no hittability fact.'
138-
: undefined;
141+
return UNAVAILABLE_FACT_WARNINGS[entry.fact];
139142
}
140-
if (entry.kind === 'missing-viewport' || entry.kind === 'truncated') {
143+
if (entry.kind === 'missing-viewport') {
141144
return RESIDUE_WARNINGS[entry.kind];
142145
}
143146
return undefined;

packages/provider-webdriver/src/webdriver-source.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ test('WebDriver source facts do not fill absent provider attributes', () => {
2828
assert.equal(node?.label, 'Continue');
2929
assert.equal(node?.identifier, 'Continue');
3030
assert.equal('enabled' in (node ?? {}), false);
31+
assert.equal('selected' in (node ?? {}), false);
32+
assert.equal('focused' in (node ?? {}), false);
3133
assert.equal('visibleToUser' in (node ?? {}), false);
3234
assert.equal('hittable' in (node ?? {}), false);
3335
});
@@ -64,12 +66,12 @@ test('legacy WebDriver parsing keeps Android source booleans and ignores iOS hin
6466
assert.equal(node?.hittable, false);
6567
});
6668

67-
test('WebDriver source facts expose provider truncation without publishing wrapper nodes', () => {
69+
test('WebDriver source facts preserve roots without claiming hierarchy completeness', () => {
6870
const facts = parseWebDriverSourceFacts(
6971
'<AppiumAUT truncated="true"><XCUIElementTypeApplication x="0" y="0" width="390" height="844" /></AppiumAUT>',
7072
);
7173

72-
assert.equal(facts.truncated, true);
74+
assert.equal('truncated' in facts, false);
7375
assert.deepEqual(facts.roots, [
7476
{
7577
type: 'XCUIElementTypeApplication',

packages/provider-webdriver/src/webdriver-source.ts

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ export type WebDriverSourceParseMode = 'facts' | 'legacy-derived';
88
export type WebDriverSourceFacts = Readonly<{
99
nodes: RawSnapshotNode[];
1010
roots: readonly WebDriverSourceRootFact[];
11-
/** True only when the source explicitly marks the hierarchy as truncated. */
12-
truncated: boolean;
1311
}>;
1412

1513
export type WebDriverSourceRootFact = Readonly<{
@@ -36,7 +34,7 @@ export function parseWebDriverSourceFacts(
3634
for (const root of roots) {
3735
appendSourceNodes(nodes, root, undefined, 0, mode, sourceRoots);
3836
}
39-
return { nodes, roots: sourceRoots, truncated: hasExplicitTruncationMarker(roots) };
37+
return { nodes, roots: sourceRoots };
4038
}
4139

4240
function appendSourceNodes(
@@ -150,20 +148,19 @@ function sourceStateFacts(
150148
const enabled = booleanAttribute(attrs.enabled);
151149
const visibleToUser = booleanAttribute(attrs.displayed ?? attrs.visible);
152150
return {
153-
...optionalBooleanFact('enabled', enabled, false),
154-
selected: booleanAttribute(attrs.selected),
155-
focused: booleanAttribute(attrs.focused),
156-
...optionalBooleanFact('visibleToUser', visibleToUser, false),
151+
...optionalBooleanFact('enabled', enabled),
152+
...optionalBooleanFact('selected', booleanAttribute(attrs.selected)),
153+
...optionalBooleanFact('focused', booleanAttribute(attrs.focused)),
154+
...optionalBooleanFact('visibleToUser', visibleToUser),
157155
...reportedHittabilityFact(attrs.hittable),
158156
};
159157
}
160158

161159
function optionalBooleanFact(
162-
key: 'enabled' | 'visibleToUser',
160+
key: 'enabled' | 'selected' | 'focused' | 'visibleToUser',
163161
value: boolean | undefined,
164-
defaultWhenAbsent: boolean,
165-
): Partial<RawSnapshotNode> {
166-
return defaultWhenAbsent || value !== undefined ? { [key]: value ?? true } : {};
162+
): Partial<Pick<RawSnapshotNode, 'enabled' | 'selected' | 'focused' | 'visibleToUser'>> {
163+
return value === undefined ? {} : { [key]: value };
167164
}
168165

169166
function reportedHittabilityFact(
@@ -173,14 +170,6 @@ function reportedHittabilityFact(
173170
return reportedHittable === undefined ? {} : { hittable: reportedHittable };
174171
}
175172

176-
function hasExplicitTruncationMarker(nodes: readonly XmlNode[]): boolean {
177-
return nodes.some(
178-
(node) =>
179-
booleanAttribute(node.attributes.truncated) === true ||
180-
hasExplicitTruncationMarker(node.children),
181-
);
182-
}
183-
184173
function rectFromAttributes(attrs: Record<string, string>): RawSnapshotNode['rect'] | undefined {
185174
const bounds = parseBounds(attrs.bounds ?? null);
186175
if (bounds) return bounds;

0 commit comments

Comments
 (0)