Skip to content

Commit b8ce09d

Browse files
authored
refactor(ios): route Appium snapshots through engine (#2224)
* refactor(ios): route Appium snapshots through engine * perf(ios): keep Appium snapshot adapter lazy * fix(ios): preserve legacy snapshot presentation boundary * perf(ios): tighten Appium snapshot facts * test(ios): cover legacy snapshot presentation boundary * test(ios): cover WebDriver snapshot seams * fix(ios): centralize WebDriver snapshot evidence * fix(ios): preserve unavailable snapshot facts * fix(ios): preserve snapshot error context * fix(ios): centralize snapshot presentation ownership * fix(ios): disclose Appium snapshot limits accurately * fix(ios): disclose Appium snapshot evidence limits * fix(ios): harden Appium evidence disclosure * fix(ios): tighten Appium snapshot disclosures * fix(ios): close Appium audit gaps
1 parent b15121f commit b8ce09d

31 files changed

Lines changed: 1225 additions & 134 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
- Breaking (0.21): iOS Appium/WebDriver snapshots now expose engine-owned acquisition facts and
6+
typed fidelity warnings. The SDK snapshot `truncated` field is optional when Appium cannot report
7+
hierarchy completeness; regular snapshots fail closed without valid viewport evidence, while
8+
`snapshot --raw` remains available for diagnostics (#2195).
59
- Security (daemon, remote/proxy HTTP only): when `AGENT_DEVICE_HTTP_AUTH_HOOK` is configured and a
610
request's hook result does not attest a `tenantId`, the request is now refused (401) outright — the
711
daemon no longer runs it as whichever tenant the client declared (RPC body `meta.tenantId` or

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
createIosSnapshotRequest,
88
deriveIosCaptureHint,
99
} from '@agent-device/capture-kit/ios-snapshot-planning';
10-
import { IosSnapshotEngineError, presentIosSnapshot } from './index.ts';
10+
import { IosSnapshotEngineError, presentIosSnapshot, publishIosSnapshot } from './index.ts';
1111
import { runTypeScriptCase, writeDifferentialFailureArtifact } from './conformance-harness.ts';
1212
import {
1313
acquisitionForGoldenCase,
@@ -86,7 +86,7 @@ test('the independent iOS snapshot goldens match the TypeScript engine', () => {
8686
| {
8787
outcome: 'success';
8888
nodes: ReturnType<typeof normalizeGoldenNodes>;
89-
truncated: boolean;
89+
truncated?: boolean;
9090
residue: typeof acquisition.residue;
9191
qualityLabels?: readonly (string | null)[];
9292
}
@@ -97,16 +97,21 @@ test('the independent iOS snapshot goldens match the TypeScript engine', () => {
9797
};
9898

9999
try {
100-
const result = presentIosSnapshot({ stage: 'acquired', acquisition }, request, {
100+
const presentation = presentIosSnapshot({ stage: 'acquired', acquisition }, request, {
101+
foldPolicy: testCase.foldPolicy,
102+
});
103+
const publication = publishIosSnapshot({ stage: 'acquired', acquisition }, request, {
101104
foldPolicy: testCase.foldPolicy,
102105
});
103106
actual = {
104107
outcome: 'success',
105-
nodes: normalizeGoldenNodes(result.nodes),
106-
truncated: acquisition.truncated,
107-
residue: acquisition.residue,
108+
nodes: normalizeGoldenNodes(publication.payload.nodes),
109+
...(publication.payload.truncated === undefined
110+
? {}
111+
: { truncated: publication.payload.truncated }),
112+
residue: publication.residue,
108113
...(testCase.qualityLabels
109-
? { qualityLabels: result.qualityNodes?.map((node) => node.label ?? null) }
114+
? { qualityLabels: presentation.qualityNodes?.map((node) => node.label ?? null) }
110115
: {}),
111116
};
112117
} catch (error) {
@@ -125,6 +130,21 @@ test('the independent iOS snapshot goldens match the TypeScript engine', () => {
125130
}
126131
});
127132

133+
test('published payload omits unknown truncation instead of defaulting it', () => {
134+
const fixture = readIosSnapshotEngineFixture();
135+
const testCase = fixture.cases[0]!;
136+
const request = requestForGoldenCase(testCase);
137+
const acquisition = {
138+
...acquisitionForGoldenCase(fixture, testCase),
139+
truncated: undefined,
140+
};
141+
142+
const publication = publishIosSnapshot({ stage: 'acquired', acquisition }, request);
143+
144+
assert.equal(publication.payload.truncated, undefined);
145+
assert.equal('truncated' in publication.payload, false);
146+
});
147+
128148
test('the differential TypeScript runner preserves typed failures', () => {
129149
const fixture = readIosSnapshotEngineFixture();
130150
const source = fixture.cases.find(

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

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import {
1111
createIosSnapshotRequest,
1212
deriveIosCaptureHint,
1313
} from '@agent-device/capture-kit/ios-snapshot-planning';
14+
import { toIosSnapshotEngineErrorDetails } from './types.ts';
1415
import {
1516
compactIosInteractiveSnapshot,
1617
createIosSnapshotEngine,
1718
IosSnapshotEngineError,
1819
presentIosSnapshot,
1920
publishIosSnapshot,
21+
resolveIosViewportEvidenceFromRoots,
2022
} from './index.ts';
2123
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
2224

@@ -189,6 +191,65 @@ test('regular presentation fails typed when the viewport is missing or the graph
189191
);
190192
});
191193

194+
test('engine error details preserve typed public context', () => {
195+
const error = new IosSnapshotEngineError('invalid-viewport', 'invalid viewport', {
196+
field: 'viewport',
197+
index: 4,
198+
parentIndex: 2,
199+
frame: { x: 0, y: 0, width: 10, height: 10 },
200+
clip: { x: 1, y: 2, width: 3, height: 4 },
201+
projection: 'regular',
202+
});
203+
204+
assert.deepEqual(toIosSnapshotEngineErrorDetails(error), {
205+
reason: 'invalid-viewport',
206+
field: 'viewport',
207+
index: 4,
208+
parentIndex: 2,
209+
frame: { x: 0, y: 0, width: 10, height: 10 },
210+
clip: { x: 1, y: 2, width: 3, height: 4 },
211+
projection: 'regular',
212+
});
213+
});
214+
215+
test('viewport evidence prefers reported application and window roots', () => {
216+
assert.deepEqual(
217+
resolveIosViewportEvidenceFromRoots([
218+
{ type: 'XCUIElementTypeApplication', rectStatus: 'invalid' },
219+
{
220+
type: 'XCUIElementTypeWindow',
221+
rect: { x: 0, y: 0, width: 390, height: 844 },
222+
rectStatus: 'reported',
223+
},
224+
]),
225+
{ kind: 'reported', rect: { x: 0, y: 0, width: 390, height: 844 } },
226+
);
227+
});
228+
229+
test('viewport evidence can fall back to the largest top-level root', () => {
230+
assert.deepEqual(
231+
resolveIosViewportEvidenceFromRoots(
232+
[
233+
{ type: 'Other', rect: { x: 0, y: 0, width: 100, height: 100 } },
234+
{ type: 'Other', rect: { x: 0, y: 0, width: 200, height: 300 } },
235+
],
236+
{ fallbackToLargestRoot: true },
237+
),
238+
{ kind: 'reported', rect: { x: 0, y: 0, width: 200, height: 300 } },
239+
);
240+
});
241+
242+
test('viewport evidence preserves explicit missing geometry reasons', () => {
243+
assert.deepEqual(
244+
resolveIosViewportEvidenceFromRoots([{ type: 'Application', rectStatus: 'invalid' }]),
245+
{ kind: 'missing', reason: 'invalid' },
246+
);
247+
assert.deepEqual(
248+
resolveIosViewportEvidenceFromRoots([{ type: 'Application', rectStatus: 'not-provided' }]),
249+
{ kind: 'missing', reason: 'not-provided' },
250+
);
251+
});
252+
192253
test('presented runner payloads and optional quality payloads cross the host invariant', () => {
193254
const request = createIosSnapshotRequest();
194255
const presentedCapture = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
@@ -234,10 +295,12 @@ test('unavailable hittability never becomes regular actionability', () => {
234295
residue: [{ kind: 'unavailable-fact' as const, fact: 'hittability' as const }],
235296
} satisfies IosSnapshotAcquisition;
236297
const acquired = publishIosSnapshot({ stage: 'acquired', acquisition: unavailable }, request);
237-
assert.equal(
238-
acquired.payload.nodes.find((node) => node.label === 'Partially visible')?.hittable,
239-
false,
298+
const partiallyVisible = acquired.payload.nodes.find(
299+
(node) => node.label === 'Partially visible',
240300
);
301+
assert.ok(partiallyVisible);
302+
assert.equal(partiallyVisible.hittable, undefined);
303+
assert.equal('hittable' in partiallyVisible, false);
241304

242305
const available = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
243306
const presented: IosSnapshotInput = {

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

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import type {
1111
IosSnapshotInput,
1212
IosSnapshotPublication,
1313
IosSnapshotRequest,
14+
IosViewportEvidence,
1415
} from '@agent-device/contracts/ios-snapshot';
15-
import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot';
16+
import { normalizeType } from '@agent-device/contracts/snapshot';
17+
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
18+
import { attachRefs, type RawSnapshotNode, type Rect } from '@agent-device/kernel/snapshot';
1619
import { buildIosInteractiveSnapshotPresentation } from './semantic-index.ts';
1720
import { validateIosSnapshotGraph } from './graph.ts';
1821
import { foldIosSnapshot } from './geometry.ts';
@@ -28,6 +31,52 @@ import { IosSnapshotEngineError } from './types.ts';
2831

2932
const DEFAULT_FOLD_POLICY: IosSnapshotFoldPolicy = 'cursor-projected';
3033

34+
export type IosSnapshotViewportRoot = Readonly<{
35+
type?: string;
36+
rect?: Rect;
37+
rectStatus?: 'reported' | 'invalid' | 'not-provided';
38+
}>;
39+
40+
export function resolveIosViewportEvidenceFromRoots(
41+
roots: readonly IosSnapshotViewportRoot[],
42+
options: Readonly<{ fallbackToLargestRoot?: boolean }> = {},
43+
): IosViewportEvidence | undefined {
44+
const viewportRoots = roots.filter(isViewportRoot);
45+
const candidates =
46+
viewportRoots.length > 0 || options.fallbackToLargestRoot !== true ? viewportRoots : roots;
47+
const root = [...candidates].sort(compareViewportRoots)[0];
48+
if (!root) return undefined;
49+
if (isPositiveFiniteRect(root.rect)) return { kind: 'reported', rect: root.rect };
50+
return {
51+
kind: 'missing',
52+
reason:
53+
root.rectStatus === 'invalid' || (root.rectStatus === undefined && root.rect !== undefined)
54+
? 'invalid'
55+
: 'not-provided',
56+
};
57+
}
58+
59+
function isViewportRoot(root: IosSnapshotViewportRoot): boolean {
60+
const type = normalizeType(root.type ?? '');
61+
return type === 'application' || type === 'window';
62+
}
63+
64+
function compareViewportRoots(
65+
left: IosSnapshotViewportRoot,
66+
right: IosSnapshotViewportRoot,
67+
): number {
68+
const status = rootGeometryRank(right.rectStatus) - rootGeometryRank(left.rectStatus);
69+
return status || rectArea(right.rect) - rectArea(left.rect);
70+
}
71+
72+
function rootGeometryRank(status: IosSnapshotViewportRoot['rectStatus']): number {
73+
return status === 'reported' ? 2 : status === 'invalid' ? 1 : 0;
74+
}
75+
76+
function rectArea(rect: Rect | undefined): number {
77+
return rect && isPositiveFiniteRect(rect) ? rect.width * rect.height : 0;
78+
}
79+
3180
export function createIosSnapshotEngine(options: IosSnapshotEngineOptions = {}): IosSnapshotEngine {
3281
const foldPolicy = options.foldPolicy ?? DEFAULT_FOLD_POLICY;
3382
return Object.freeze({
@@ -46,13 +95,12 @@ export function publishIosSnapshot(
4695
input.stage === 'presented'
4796
? input.validation.presentationKey
4897
: buildIosSnapshotPresentationKey(request);
98+
const truncated =
99+
input.stage === 'acquired' ? input.acquisition.truncated : input.presentation.payload.truncated;
49100
return {
50101
payload: {
51102
nodes: attachRefs(presentation.nodes),
52-
truncated:
53-
input.stage === 'acquired'
54-
? input.acquisition.truncated
55-
: input.presentation.payload.truncated,
103+
...(truncated === undefined ? {} : { truncated }),
56104
},
57105
presentationKey,
58106
comparisonIdentity: buildIosSnapshotComparisonIdentity(input, request),

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/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {
33
createIosSnapshotEngine,
44
presentIosSnapshot,
55
publishIosSnapshot,
6+
resolveIosViewportEvidenceFromRoots,
67
} from './engine.ts';
78
export { presentIosRunnerSnapshot } from './runner-presentation.ts';
89
export {
@@ -11,5 +12,5 @@ export {
1112
} from './semantic-index.ts';
1213
export { collectIosStructuralIdentifierSuppression } from './noise-structural.ts';
1314
export { findNearestScrollableContainer, mergeReplacement, updateReplacement } from './tree.ts';
14-
export { IosSnapshotEngineError } from './types.ts';
15+
export { IosSnapshotEngineError, toIosSnapshotEngineErrorDetails } from './types.ts';
1516
export type { SnapshotTreeRuleContext } from './tree.ts';

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-engine/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,13 @@ export class IosSnapshotEngineError extends Error {
6464
this.details = details;
6565
}
6666
}
67+
68+
export type IosSnapshotEnginePublicErrorDetails = Readonly<
69+
IosSnapshotEngineFailureDetails & { reason: IosSnapshotEngineFailureReason }
70+
>;
71+
72+
export function toIosSnapshotEngineErrorDetails(
73+
error: IosSnapshotEngineError,
74+
): IosSnapshotEnginePublicErrorDetails {
75+
return { reason: error.reason, ...error.details };
76+
}

0 commit comments

Comments
 (0)