Skip to content

Commit 490c83f

Browse files
committed
refactor(ios): implement snapshot engine
1 parent 09f247c commit 490c83f

35 files changed

Lines changed: 2179 additions & 566 deletions

‎.fallowrc.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@
280280
"createAppLogLiveHandle"
281281
]
282282
},
283+
{
284+
"comment": "The iOS snapshot engine is an explicit workspace-package façade; its default engine object is consumed by the later runner integration layer and by external package consumers rather than this layer's source graph.",
285+
"file": "packages/capture-kit/src/ios-snapshot-engine/index.ts",
286+
"exports": ["iosSnapshotEngine"]
287+
},
283288
{
284289
"comment": "Android mechanics facet: the package exports the complete named implementation seam for root/core/SDK consumers and package-owned tests; Fallow's production graph does not follow workspace subpath imports through the package exports map.",
285290
"file": "packages/platform-android/src/mechanics.ts",

‎packages/capture-kit/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"types": "./src/ios-snapshot-planning.ts",
1919
"default": "./src/ios-snapshot-planning.ts"
2020
},
21+
"./ios-snapshot-engine": {
22+
"types": "./src/ios-snapshot-engine/index.ts",
23+
"default": "./src/ios-snapshot-engine/index.ts"
24+
},
2125
"./mobile-snapshot-semantics": {
2226
"types": "./src/mobile-snapshot-semantics.ts",
2327
"default": "./src/mobile-snapshot-semantics.ts"

src/snapshot/snapshot-presentation/ios/action-shelf.ts renamed to packages/capture-kit/src/ios-snapshot-engine/action-shelf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
isSemanticActionNode,
99
isScrollableSnapshotType,
1010
type SnapshotTreeRuleContext,
11-
} from '../tree.ts';
11+
} from './tree.ts';
1212

1313
const ACTION_SHELF_MINIMUM_BUTTONS = 3;
1414
const ACTION_SHELF_EDGE_TOLERANCE = 2;

src/snapshot/snapshot-presentation/ios/actions.ts renamed to packages/capture-kit/src/ios-snapshot-engine/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
isMostlyViewportSizedRect,
77
mergeReplacement,
88
type SnapshotTreeRuleContext,
9-
} from '../tree.ts';
9+
} from './tree.ts';
1010

1111
export function collectIosImplicitScrollableActions(
1212
nodes: RawSnapshotNode[],
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type {
4+
IosSnapshotAcquisition,
5+
IosSnapshotInput,
6+
IosSnapshotRequest,
7+
IosSnapshotValidationFacts,
8+
} from '@agent-device/contracts/ios-snapshot';
9+
import {
10+
buildIosSnapshotPresentationKey,
11+
createIosSnapshotRequest,
12+
deriveIosCaptureHint,
13+
} from '@agent-device/capture-kit/ios-snapshot-planning';
14+
import {
15+
compactIosInteractiveSnapshot,
16+
createIosSnapshotEngine,
17+
IosSnapshotEngineError,
18+
presentIosSnapshot,
19+
publishIosSnapshot,
20+
} from './index.ts';
21+
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
22+
23+
const viewport: Rect = { x: 0, y: 0, width: 320, height: 240 };
24+
25+
test('regular presentation folds nested clips and keeps effective actionability', () => {
26+
const request = createIosSnapshotRequest();
27+
const result = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
28+
29+
assert.deepEqual(
30+
result.payload.nodes.map((node) => [node.label, node.rect, node.parentIndex]),
31+
[
32+
['App', viewport, undefined],
33+
['Outer', { x: 16, y: 20, width: 180, height: 180 }, 0],
34+
['Inner', { x: 120, y: 40, width: 76, height: 160 }, 1],
35+
['Partially visible', { x: 150, y: 80, width: 46, height: 40 }, 2],
36+
],
37+
);
38+
assert.equal(result.payload.nodes[3]?.hittable, true);
39+
assert.equal(result.payload.nodes[3]?.ref, 'e4');
40+
assert.equal(result.comparisonIdentity.lineage.targetId, 'simulator-1');
41+
});
42+
43+
test('raw projection preserves reported geometry while regular projection clips it', () => {
44+
const regularRequest = createIosSnapshotRequest();
45+
const rawRequest = createIosSnapshotRequest({ raw: true, interactiveOnly: true });
46+
const nodes = nestedNodes();
47+
const regular = publishIosSnapshot(acquiredInput(regularRequest, nodes), regularRequest);
48+
const raw = publishIosSnapshot(acquiredInput(rawRequest, nodes), rawRequest);
49+
50+
assert.equal(
51+
regular.payload.nodes.some((node) => node.label === 'Escaped child'),
52+
false,
53+
);
54+
assert.equal(raw.payload.nodes.find((node) => node.label === 'Escaped child')?.rect?.x, 210);
55+
assert.equal(raw.payload.nodes.find((node) => node.label === 'Escaped child')?.hittable, true);
56+
});
57+
58+
test('cursor projection keeps geometryless nodes neutral while plain viewport keeps child visibility independent', () => {
59+
const request = createIosSnapshotRequest();
60+
const nodes = [
61+
node(0, 'Application', 'App', viewport),
62+
{ ...node(1, 'Other', 'No frame', viewport, 0, 1), rect: undefined },
63+
node(2, 'Button', 'Child', { x: 20, y: 20, width: 40, height: 40 }, 1, 2),
64+
];
65+
const cursor = publishIosSnapshot(acquiredInput(request, nodes), request);
66+
const plain = publishIosSnapshot(acquiredInput(request, nodes), request, {
67+
foldPolicy: 'plain-viewport',
68+
});
69+
70+
assert.deepEqual(
71+
cursor.payload.nodes.map((entry) => entry.label),
72+
['App', 'No frame', 'Child'],
73+
);
74+
assert.deepEqual(
75+
plain.payload.nodes.map((entry) => entry.label),
76+
['App', 'Child'],
77+
);
78+
});
79+
80+
test('scope reparents wrappers and regular depth counts presented nodes', () => {
81+
const request = createIosSnapshotRequest({ scope: 'Target', depth: 1 });
82+
const result = publishIosSnapshot(acquiredInput(request, scopedNodes()), request);
83+
84+
assert.deepEqual(
85+
result.payload.nodes.map((node) => [node.type, node.label, node.depth, node.parentIndex]),
86+
[
87+
['Other', 'Target', 0, undefined],
88+
['Button', 'Target', 1, 0],
89+
],
90+
);
91+
});
92+
93+
test('plain viewport policy does not inherit cursor clipping', () => {
94+
const request = createIosSnapshotRequest();
95+
const result = publishIosSnapshot(
96+
acquiredInput(request, [
97+
node(0, 'Application', 'App', viewport),
98+
node(1, 'ScrollView', 'Scroll', { x: 0, y: 100, width: 320, height: 40 }, 0),
99+
node(2, 'StaticText', 'Outside scroll clip', { x: 10, y: 200, width: 80, height: 20 }, 1),
100+
]),
101+
request,
102+
{ foldPolicy: 'plain-viewport' },
103+
);
104+
105+
assert.deepEqual(
106+
result.payload.nodes.map((entry) => entry.label),
107+
['App', 'Scroll', 'Outside scroll clip'],
108+
);
109+
});
110+
111+
test('regular presentation fails typed when the viewport is missing or the graph is malformed', () => {
112+
const request = createIosSnapshotRequest();
113+
const missingViewport: IosSnapshotAcquisition = {
114+
...acquisition(request, nestedNodes()),
115+
viewport: { kind: 'missing', reason: 'not-provided' },
116+
};
117+
assert.throws(
118+
() => publishIosSnapshot({ stage: 'acquired', acquisition: missingViewport }, request),
119+
(error: unknown) =>
120+
error instanceof IosSnapshotEngineError && error.reason === 'missing-viewport',
121+
);
122+
123+
const malformed = acquisition(request, [
124+
node(0, 'Application', 'App', viewport),
125+
{ ...node(1, 'Button', 'Broken', { x: 1, y: 1, width: 10, height: 10 }, 0), parentIndex: 99 },
126+
]);
127+
assert.throws(
128+
() => publishIosSnapshot({ stage: 'acquired', acquisition: malformed }, request),
129+
(error: unknown) =>
130+
error instanceof IosSnapshotEngineError && error.reason === 'malformed-graph',
131+
);
132+
});
133+
134+
test('presented runner payloads and optional quality payloads cross the host invariant', () => {
135+
const request = createIosSnapshotRequest();
136+
const presentedCapture = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
137+
const input: IosSnapshotInput = {
138+
stage: 'presented',
139+
presentation: {
140+
producer: 'apple-runner',
141+
intent: 'full',
142+
payload: { nodes: presentedCapture.payload.nodes, truncated: false },
143+
qualityPayload: {
144+
nodes: presentedCapture.payload.nodes,
145+
truncated: false,
146+
scope: null,
147+
},
148+
},
149+
validation: validationFacts(request),
150+
};
151+
const result = publishIosSnapshot(input, request);
152+
153+
assert.equal(result.payload.nodes.length, 4);
154+
assert.equal(result.payload.nodes[3]?.ref, 'e4');
155+
assert.equal(result.residue.length, 1);
156+
});
157+
158+
test('scoped raw presentation retains an unscoped quality view', () => {
159+
const request = createIosSnapshotRequest({ raw: true, scope: 'Target' });
160+
const result = presentIosSnapshot(acquiredInput(request, scopedNodes()), request);
161+
162+
assert.deepEqual(
163+
result.nodes.map((entry) => entry.label),
164+
['Target', 'Target'],
165+
);
166+
assert.deepEqual(
167+
result.qualityNodes?.map((entry) => entry.label),
168+
['App', 'Wrapper', 'Target', 'Target'],
169+
);
170+
});
171+
172+
test('unavailable hittability never becomes regular actionability', () => {
173+
const request = createIosSnapshotRequest();
174+
const unavailable = {
175+
...acquisition(request, nestedNodes()),
176+
residue: [{ kind: 'unavailable-fact' as const, fact: 'hittability' as const }],
177+
} satisfies IosSnapshotAcquisition;
178+
const acquired = publishIosSnapshot({ stage: 'acquired', acquisition: unavailable }, request);
179+
assert.equal(
180+
acquired.payload.nodes.find((node) => node.label === 'Partially visible')?.hittable,
181+
false,
182+
);
183+
184+
const available = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
185+
const presented: IosSnapshotInput = {
186+
stage: 'presented',
187+
presentation: {
188+
producer: 'apple-runner',
189+
intent: 'full',
190+
payload: { nodes: available.payload.nodes, truncated: false },
191+
},
192+
validation: {
193+
...validationFacts(request),
194+
hittability: { kind: 'unavailable', reason: 'not-provided' },
195+
},
196+
};
197+
assert.throws(
198+
() => publishIosSnapshot(presented, request),
199+
(error: unknown) =>
200+
error instanceof IosSnapshotEngineError &&
201+
error.code === 'IOS_SNAPSHOT_ENGINE_FAILED' &&
202+
error.reason === 'invalid-presented-payload',
203+
);
204+
});
205+
206+
test('interactive compaction stays available through the engine boundary', () => {
207+
const rowRect = { x: 16, y: 80, width: 288, height: 52 };
208+
const compacted = compactIosInteractiveSnapshot([
209+
node(0, 'Application', 'App', viewport),
210+
node(1, 'Table', 'Settings', { x: 0, y: 40, width: 320, height: 200 }, 0),
211+
node(2, 'Cell', 'General', rowRect, 1, 2),
212+
node(3, 'Button', 'General', rowRect, 2, 3),
213+
node(4, 'StaticText', 'General', rowRect, 3, 4),
214+
]);
215+
216+
assert.deepEqual(
217+
compacted.map((entry) => entry.type),
218+
['Application', 'Table', 'Cell'],
219+
);
220+
});
221+
222+
test('the configured engine keeps its fold policy and exposes the contract operations', () => {
223+
const engine = createIosSnapshotEngine({ foldPolicy: 'plain-viewport' });
224+
const request = createIosSnapshotRequest();
225+
const presented = presentIosSnapshot(acquiredInput(request, nestedNodes()), request, {
226+
foldPolicy: 'plain-viewport',
227+
});
228+
229+
assert.equal(typeof engine.plan, 'function');
230+
assert.equal(typeof engine.publish, 'function');
231+
assert.equal(presented.stats.sourceNodeCount, nestedNodes().length);
232+
});
233+
234+
function acquisition(
235+
request: IosSnapshotRequest,
236+
nodes: RawSnapshotNode[],
237+
): IosSnapshotAcquisition {
238+
const hint = deriveIosCaptureHint(request);
239+
assert.equal(hint.acquisitionIntent, 'full');
240+
return {
241+
producer: 'simulator-ax-bridge',
242+
intent: 'full',
243+
hint: { ...hint, acquisitionIntent: 'full' },
244+
nodes,
245+
truncated: false,
246+
viewport: { kind: 'reported', rect: viewport },
247+
lineage: { targetId: 'simulator-1', generation: 'generation-1' },
248+
residue: [{ kind: 'truncated', dimension: 'payload', limit: 2000 }],
249+
};
250+
}
251+
252+
function acquiredInput(request: IosSnapshotRequest, nodes: RawSnapshotNode[]): IosSnapshotInput {
253+
return { stage: 'acquired', acquisition: acquisition(request, nodes) };
254+
}
255+
256+
function validationFacts(request: IosSnapshotRequest): IosSnapshotValidationFacts {
257+
return {
258+
presentationKey: buildIosSnapshotPresentationKey(request),
259+
viewport: { kind: 'reported', rect: viewport },
260+
hittability: { kind: 'available' },
261+
lineage: { targetId: 'simulator-1', generation: 'generation-1' },
262+
residue: [{ kind: 'truncated', dimension: 'payload', limit: 2000 }],
263+
};
264+
}
265+
266+
function nestedNodes(): RawSnapshotNode[] {
267+
return [
268+
node(0, 'Application', 'App', viewport),
269+
node(1, 'ScrollView', 'Outer', { x: 16, y: 20, width: 180, height: 180 }, 0, 1),
270+
node(2, 'ScrollView', 'Inner', { x: 120, y: 40, width: 180, height: 160 }, 1, 2),
271+
node(3, 'Button', 'Partially visible', { x: 150, y: 80, width: 100, height: 40 }, 2, 3),
272+
node(4, 'Button', 'Escaped child', { x: 210, y: 80, width: 100, height: 40 }, 2, 3),
273+
];
274+
}
275+
276+
function scopedNodes(): RawSnapshotNode[] {
277+
return [
278+
node(0, 'Application', 'App', viewport),
279+
node(1, 'Other', 'Wrapper', { x: 10, y: 10, width: 200, height: 100 }, 0, 1),
280+
node(2, 'Other', 'Target', { x: 10, y: 10, width: 200, height: 100 }, 1, 2),
281+
node(3, 'Button', 'Target', { x: 20, y: 20, width: 80, height: 40 }, 2, 3),
282+
];
283+
}
284+
285+
function node(
286+
index: number,
287+
type: string,
288+
label: string,
289+
rect: Rect,
290+
parentIndex?: number,
291+
depth?: number,
292+
): RawSnapshotNode {
293+
return {
294+
index,
295+
type,
296+
label,
297+
rect,
298+
parentIndex,
299+
depth: depth ?? (parentIndex === undefined ? 0 : 1),
300+
enabled: true,
301+
hittable: type === 'Button',
302+
};
303+
}

0 commit comments

Comments
 (0)