Skip to content

Commit 7ee1a5d

Browse files
authored
refactor(ios): carry provider acquisitions through one presentation owner (#2233)
* refactor(ios): centralize provider snapshot presentation * fix(ios): close provider snapshot ownership gaps * fix(ios): enforce provider snapshot ownership boundary * fix(capture-kit): preserve snapshot engine lazy closure
1 parent 1f0eedf commit 7ee1a5d

51 files changed

Lines changed: 1421 additions & 1128 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/capture-kit/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"types": "./src/index.ts",
1515
"default": "./src/index.ts"
1616
},
17+
"./ios-snapshot-acquisition": {
18+
"types": "./src/ios-snapshot-acquisition.ts",
19+
"default": "./src/ios-snapshot-acquisition.ts"
20+
},
1721
"./ios-snapshot-planning": {
1822
"types": "./src/ios-snapshot-planning.ts",
1923
"default": "./src/ios-snapshot-planning.ts"
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types';
2+
import type {
3+
IosAcquisitionResidue,
4+
IosProviderAcquisitionProducer,
5+
IosSnapshotProducer,
6+
IosSnapshotProducerCapabilities,
7+
IosSnapshotLineage,
8+
IosViewportEvidence,
9+
} from '@agent-device/contracts/ios-snapshot';
10+
import { normalizeType } from '@agent-device/contracts/snapshot';
11+
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
12+
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
13+
14+
const ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS = {
15+
stage: 'acquired',
16+
acquisitionDepth: {
17+
rawTraversal: { kind: 'incomplete' },
18+
regularPresented: { kind: 'incomplete' },
19+
},
20+
scopeCompleteness: 'incomplete',
21+
interactiveQueryCompleteness: 'incomplete',
22+
viewportEvidence: 'available',
23+
hittabilityEvidence: 'unavailable',
24+
truncationEvidence: 'unavailable',
25+
presentationOwner: 'ios-snapshot-engine',
26+
} as const;
27+
28+
const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
29+
'apple-runner': {
30+
producer: 'apple-runner',
31+
stage: 'presented',
32+
acquisitionDepth: {
33+
rawTraversal: { kind: 'not-applicable' },
34+
regularPresented: { kind: 'not-applicable' },
35+
},
36+
scopeCompleteness: 'complete',
37+
interactiveQueryCompleteness: 'complete',
38+
viewportEvidence: 'available',
39+
hittabilityEvidence: 'available',
40+
truncationEvidence: 'available',
41+
presentationOwner: 'ios-snapshot-engine',
42+
},
43+
'simulator-ax-bridge': {
44+
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
45+
producer: 'simulator-ax-bridge',
46+
acquisitionDepth: {
47+
rawTraversal: { kind: 'complete' },
48+
regularPresented: { kind: 'incomplete' },
49+
},
50+
hittabilityEvidence: 'available',
51+
truncationEvidence: 'available',
52+
presentationOwner: 'snapshot-state',
53+
},
54+
'appium-source': {
55+
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
56+
producer: 'appium-source',
57+
},
58+
'limrun-ios-tree': {
59+
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
60+
producer: 'limrun-ios-tree',
61+
},
62+
} as const satisfies Record<IosSnapshotProducer, IosSnapshotProducerCapabilities>;
63+
64+
export const IOS_SNAPSHOT_PRODUCER_CAPABILITIES: Readonly<
65+
Record<IosSnapshotProducer, IosSnapshotProducerCapabilities>
66+
> = Object.freeze(IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES);
67+
68+
function deriveIosSnapshotAcquisitionResidue(
69+
producer: IosSnapshotProducerCapabilities,
70+
viewport: IosViewportEvidence,
71+
): readonly IosAcquisitionResidue[] {
72+
const residue: IosAcquisitionResidue[] = [];
73+
if (producer.hittabilityEvidence === 'unavailable') {
74+
residue.push({ kind: 'unavailable-fact', fact: 'hittability' });
75+
}
76+
if (
77+
producer.stage === 'acquired' &&
78+
(producer.acquisitionDepth.rawTraversal.kind === 'incomplete' ||
79+
producer.acquisitionDepth.regularPresented.kind === 'incomplete')
80+
) {
81+
residue.push({ kind: 'unavailable-fact', fact: 'acquisition-depth' });
82+
}
83+
if (producer.truncationEvidence === 'unavailable') {
84+
residue.push({ kind: 'unavailable-fact', fact: 'truncation' });
85+
}
86+
if (viewport.kind === 'missing') {
87+
residue.push({ kind: 'missing-viewport', reason: viewport.reason });
88+
}
89+
return Object.freeze(residue);
90+
}
91+
92+
export function createIosSnapshotAcquisition(
93+
input: Readonly<{
94+
producer: IosProviderAcquisitionProducer;
95+
nodes: readonly RawSnapshotNode[];
96+
viewport: IosViewportEvidence;
97+
lineage: IosSnapshotLineage;
98+
}>,
99+
): SnapshotRuntimeAcquiredResult {
100+
const producer = IOS_SNAPSHOT_PRODUCER_CAPABILITIES[input.producer];
101+
return {
102+
stage: 'acquired',
103+
acquisition: {
104+
producer: input.producer,
105+
intent: 'full',
106+
nodes: input.nodes,
107+
viewport: input.viewport,
108+
lineage: input.lineage,
109+
residue: deriveIosSnapshotAcquisitionResidue(producer, input.viewport),
110+
},
111+
};
112+
}
113+
114+
type IosSnapshotViewportRoot = Readonly<{
115+
type?: string;
116+
rect?: Rect;
117+
rectStatus?: 'reported' | 'invalid' | 'not-provided';
118+
}>;
119+
120+
export function resolveIosViewportEvidenceFromRoots(
121+
roots: readonly IosSnapshotViewportRoot[],
122+
options: Readonly<{ fallbackToLargestRoot?: boolean }> = {},
123+
): IosViewportEvidence | undefined {
124+
const viewportRoots = roots.filter(isViewportRoot);
125+
const candidates =
126+
viewportRoots.length > 0 || options.fallbackToLargestRoot !== true ? viewportRoots : roots;
127+
const root = [...candidates].sort(compareViewportRoots)[0];
128+
if (!root) return undefined;
129+
if (isPositiveFiniteRect(root.rect)) return { kind: 'reported', rect: root.rect };
130+
return {
131+
kind: 'missing',
132+
reason:
133+
root.rectStatus === 'invalid' || (root.rectStatus === undefined && root.rect !== undefined)
134+
? 'invalid'
135+
: 'not-provided',
136+
};
137+
}
138+
139+
function isViewportRoot(root: IosSnapshotViewportRoot): boolean {
140+
const type = normalizeType(root.type ?? '');
141+
return type === 'application' || type === 'window';
142+
}
143+
144+
function compareViewportRoots(
145+
left: IosSnapshotViewportRoot,
146+
right: IosSnapshotViewportRoot,
147+
): number {
148+
const status = rootGeometryRank(right.rectStatus) - rootGeometryRank(left.rectStatus);
149+
return status || rectArea(right.rect) - rectArea(left.rect);
150+
}
151+
152+
function rootGeometryRank(status: IosSnapshotViewportRoot['rectStatus']): number {
153+
return status === 'reported' ? 2 : status === 'invalid' ? 1 : 0;
154+
}
155+
156+
function rectArea(rect: Rect | undefined): number {
157+
return rect && isPositiveFiniteRect(rect) ? rect.width * rect.height : 0;
158+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {
1818
IosSnapshotEngineError,
1919
presentIosSnapshot,
2020
publishIosSnapshot,
21-
resolveIosViewportEvidenceFromRoots,
2221
} from './index.ts';
22+
import { resolveIosViewportEvidenceFromRoots } from '@agent-device/capture-kit/ios-snapshot-acquisition';
2323
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
2424

2525
const viewport: Rect = { x: 0, y: 0, width: 320, height: 240 };

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

Lines changed: 2 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
buildIosSnapshotComparisonIdentity,
33
buildIosSnapshotPresentationKey,
44
deriveIosCaptureHint,
5-
IOS_SNAPSHOT_PRODUCER_CAPABILITIES,
65
planIosSnapshot,
76
} from '../ios-snapshot-planning.ts';
87
import type {
@@ -11,11 +10,8 @@ import type {
1110
IosSnapshotInput,
1211
IosSnapshotPublication,
1312
IosSnapshotRequest,
14-
IosViewportEvidence,
1513
} from '@agent-device/contracts/ios-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';
14+
import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot';
1915
import { buildIosInteractiveSnapshotPresentation } from './semantic-index.ts';
2016
import { validateIosSnapshotGraph } from './graph.ts';
2117
import { foldIosSnapshot } from './geometry.ts';
@@ -31,52 +27,6 @@ import { IosSnapshotEngineError } from './types.ts';
3127

3228
const DEFAULT_FOLD_POLICY: IosSnapshotFoldPolicy = 'cursor-projected';
3329

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-
8030
export function createIosSnapshotEngine(options: IosSnapshotEngineOptions = {}): IosSnapshotEngine {
8131
const foldPolicy = options.foldPolicy ?? DEFAULT_FOLD_POLICY;
8232
return Object.freeze({
@@ -170,9 +120,7 @@ function presentAcquiredSnapshot(
170120
}
171121

172122
const viewport = resolveIosViewport(acquisition);
173-
const hittabilityAvailable =
174-
IOS_SNAPSHOT_PRODUCER_CAPABILITIES[acquisition.producer].hittabilityEvidence === 'available' &&
175-
!hasUnavailableHittability(acquisition.residue);
123+
const hittabilityAvailable = !hasUnavailableHittability(acquisition.residue);
176124
const folded = foldIosSnapshot(acquisition.nodes, viewport, request.interactiveOnly, foldPolicy, {
177125
hittabilityAvailable,
178126
});

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ export {
33
createIosSnapshotEngine,
44
presentIosSnapshot,
55
publishIosSnapshot,
6-
resolveIosViewportEvidenceFromRoots,
76
} from './engine.ts';
87
export { presentIosRunnerSnapshot } from './runner-presentation.ts';
98
export {

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ import type {
1010
IosSnapshotRequestInput,
1111
} from '@agent-device/contracts/ios-snapshot';
1212
import {
13-
IOS_SNAPSHOT_PRODUCER_CAPABILITIES,
1413
areIosSnapshotComparisonIdentitiesEqual,
1514
buildIosSnapshotComparisonIdentity,
1615
buildIosSnapshotPresentationKey,
1716
createIosSnapshotRequest,
1817
deriveIosCaptureHint,
19-
deriveIosSnapshotCapabilityResidue,
2018
planIosSnapshot,
2119
} from '@agent-device/capture-kit/ios-snapshot-planning';
20+
import {
21+
createIosSnapshotAcquisition,
22+
IOS_SNAPSHOT_PRODUCER_CAPABILITIES,
23+
} from '@agent-device/capture-kit/ios-snapshot-acquisition';
2224

2325
type CaptureHintFixture = Readonly<{
2426
name: string;
@@ -145,6 +147,7 @@ test('presented producers cannot claim acquisition narrowing', () => {
145147
interactiveQuery: 'complete',
146148
viewport: 'available',
147149
hittability: 'available',
150+
truncation: 'available',
148151
});
149152
});
150153

@@ -156,12 +159,18 @@ test('Appium source plan carries its viewport evidence capability', () => {
156159
assert.equal(plan.evidence.viewport, 'available');
157160
});
158161

159-
test('capability residue derives unavailable Appium facts from the registry', () => {
162+
test('acquisition derives unavailable Appium facts from the registry', () => {
160163
assert.deepEqual(
161-
deriveIosSnapshotCapabilityResidue(IOS_SNAPSHOT_PRODUCER_CAPABILITIES['appium-source']),
164+
createIosSnapshotAcquisition({
165+
producer: 'appium-source',
166+
nodes: [],
167+
viewport: { kind: 'reported', rect: { x: 0, y: 0, width: 1, height: 1 } },
168+
lineage: {},
169+
}).acquisition.residue,
162170
[
163171
{ kind: 'unavailable-fact', fact: 'hittability' },
164172
{ kind: 'unavailable-fact', fact: 'acquisition-depth' },
173+
{ kind: 'unavailable-fact', fact: 'truncation' },
165174
],
166175
);
167176
});
@@ -242,6 +251,7 @@ function acquiredProducer(
242251
interactiveQueryCompleteness: 'incomplete',
243252
viewportEvidence: 'available',
244253
hittabilityEvidence: 'available',
254+
truncationEvidence: 'available',
245255
presentationOwner: 'snapshot-state',
246256
...overrides,
247257
};

0 commit comments

Comments
 (0)