Skip to content

Commit b251a97

Browse files
committed
refactor(ios): centralize provider snapshot presentation
1 parent 6c8c050 commit b251a97

44 files changed

Lines changed: 1203 additions & 1003 deletions

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/src/ios-snapshot-engine/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
publishIosSnapshot,
66
resolveIosViewportEvidenceFromRoots,
77
} from './engine.ts';
8+
export type { IosSnapshotViewportRoot } from './engine.ts';
89
export { presentIosRunnerSnapshot } from './runner-presentation.ts';
910
export {
1011
buildIosInteractiveSnapshotPresentation,

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ test('presented producers cannot claim acquisition narrowing', () => {
145145
interactiveQuery: 'complete',
146146
viewport: 'available',
147147
hittability: 'available',
148+
truncation: 'available',
148149
});
149150
});
150151

@@ -162,10 +163,25 @@ test('capability residue derives unavailable Appium facts from the registry', ()
162163
[
163164
{ kind: 'unavailable-fact', fact: 'hittability' },
164165
{ kind: 'unavailable-fact', fact: 'acquisition-depth' },
166+
{ kind: 'unavailable-fact', fact: 'truncation' },
165167
],
166168
);
167169
});
168170

171+
test('capability residue reports truncation independently from acquisition depth', () => {
172+
const producer = acquiredProducer({
173+
acquisitionDepth: {
174+
rawTraversal: { kind: 'complete' },
175+
regularPresented: { kind: 'complete' },
176+
},
177+
truncationEvidence: 'unavailable',
178+
});
179+
180+
assert.deepEqual(deriveIosSnapshotCapabilityResidue(producer), [
181+
{ kind: 'unavailable-fact', fact: 'truncation' },
182+
]);
183+
});
184+
169185
test('comparison identity rejects every identity axis and residue mismatch', () => {
170186
const base = comparisonIdentity();
171187
const mismatches: IosSnapshotComparisonIdentity[] = [
@@ -242,6 +258,7 @@ function acquiredProducer(
242258
interactiveQueryCompleteness: 'incomplete',
243259
viewportEvidence: 'available',
244260
hittabilityEvidence: 'available',
261+
truncationEvidence: 'available',
245262
presentationOwner: 'snapshot-state',
246263
...overrides,
247264
};

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
IosSnapshotProducerCapabilities,
1111
IosSnapshotRequest,
1212
IosSnapshotRequestInput,
13+
IosViewportEvidence,
1314
} from '@agent-device/contracts/ios-snapshot';
1415

1516
const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
@@ -24,6 +25,7 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
2425
interactiveQueryCompleteness: 'complete',
2526
viewportEvidence: 'available',
2627
hittabilityEvidence: 'available',
28+
truncationEvidence: 'available',
2729
presentationOwner: 'ios-snapshot-engine',
2830
},
2931
'simulator-ax-bridge': {
@@ -37,6 +39,7 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
3739
interactiveQueryCompleteness: 'incomplete',
3840
viewportEvidence: 'available',
3941
hittabilityEvidence: 'available',
42+
truncationEvidence: 'available',
4043
presentationOwner: 'snapshot-state',
4144
},
4245
'appium-source': {
@@ -50,6 +53,7 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
5053
interactiveQueryCompleteness: 'incomplete',
5154
viewportEvidence: 'available',
5255
hittabilityEvidence: 'unavailable',
56+
truncationEvidence: 'unavailable',
5357
presentationOwner: 'ios-snapshot-engine',
5458
},
5559
'limrun-ios-tree': {
@@ -63,6 +67,7 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
6367
interactiveQueryCompleteness: 'incomplete',
6468
viewportEvidence: 'available',
6569
hittabilityEvidence: 'unavailable',
70+
truncationEvidence: 'unavailable',
6671
presentationOwner: 'ios-snapshot-engine',
6772
},
6873
} as const satisfies Record<IosSnapshotProducer, IosSnapshotProducerCapabilities>;
@@ -85,9 +90,24 @@ export function deriveIosSnapshotCapabilityResidue(
8590
) {
8691
residue.push({ kind: 'unavailable-fact', fact: 'acquisition-depth' });
8792
}
93+
if (producer.truncationEvidence === 'unavailable') {
94+
residue.push({ kind: 'unavailable-fact', fact: 'truncation' });
95+
}
8896
return Object.freeze(residue);
8997
}
9098

99+
export function deriveIosSnapshotAcquisitionResidue(
100+
producer: IosSnapshotProducerCapabilities,
101+
viewport: IosViewportEvidence,
102+
): readonly IosAcquisitionResidue[] {
103+
return Object.freeze([
104+
...deriveIosSnapshotCapabilityResidue(producer),
105+
...(viewport.kind === 'missing'
106+
? [{ kind: 'missing-viewport' as const, reason: viewport.reason }]
107+
: []),
108+
]);
109+
}
110+
91111
export function createIosSnapshotRequest(input: IosSnapshotRequestInput = {}): IosSnapshotRequest {
92112
return Object.freeze({
93113
projection: input.projection ?? (input.raw === true ? 'raw' : 'regular'),
@@ -149,6 +169,7 @@ export function planIosSnapshot(
149169
interactiveQuery: producer.interactiveQueryCompleteness,
150170
viewport: producer.viewportEvidence,
151171
hittability: producer.hittabilityEvidence,
172+
truncation: producer.truncationEvidence,
152173
}),
153174
});
154175
}

packages/contracts/src/interactor-types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import type { SettingOptions } from './settings.ts';
88
import type { SessionSurface } from './session-surface.ts';
99
import type { BackendSnapshotResult } from './snapshot-types.ts';
1010
import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';
11+
import type {
12+
IosProviderAcquisitionProducer,
13+
IosSnapshotAcquisitionFacts,
14+
} from './ios-snapshot.ts';
1115
import type {
1216
RawSnapshotNode,
1317
Point,
@@ -249,6 +253,13 @@ export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> &
249253
nodes?: RawSnapshotNode[];
250254
} & SnapshotProvenance;
251255

256+
export type SnapshotRuntimeAcquiredResult = Readonly<{
257+
stage: 'acquired';
258+
acquisition: IosSnapshotAcquisitionFacts & Readonly<{ producer: IosProviderAcquisitionProducer }>;
259+
}>;
260+
261+
export type SnapshotRuntimeResult = SnapshotResult | SnapshotRuntimeAcquiredResult;
262+
252263
export type Interactor = {
253264
open(
254265
app: string,
@@ -305,7 +316,7 @@ export type Interactor = {
305316
): Promise<Record<string, unknown> | void>;
306317
screenshot(outPath: string, options?: ScreenshotOptions): Promise<void>;
307318
setViewport?(width: number, height: number): Promise<Record<string, unknown> | void>;
308-
snapshot(options?: SnapshotOptions): Promise<SnapshotResult>;
319+
snapshot(options?: SnapshotOptions): Promise<SnapshotRuntimeResult>;
309320
/**
310321
* Native reading of the live text at a point, when the backend has one. Answers the text the
311322
* owner can see right now, which can exceed what an already-captured node carries (an editable

packages/contracts/src/ios-snapshot.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export type IosSnapshotProducer =
77
| 'limrun-ios-tree';
88

99
export type IosAcquisitionProducer = Exclude<IosSnapshotProducer, 'apple-runner'>;
10+
export type IosProviderAcquisitionProducer = Extract<
11+
IosAcquisitionProducer,
12+
'appium-source' | 'limrun-ios-tree'
13+
>;
1014
export type IosAcquisitionIntent = 'full' | 'surface-observation';
1115
export type IosSnapshotProjection = 'regular' | 'raw';
1216
export type IosSnapshotCompleteness = 'complete' | 'incomplete';
@@ -81,6 +85,7 @@ type IosSnapshotProducerCapabilityFacts = Readonly<{
8185
interactiveQueryCompleteness: IosSnapshotCompleteness;
8286
viewportEvidence: IosSnapshotEvidenceAvailability;
8387
hittabilityEvidence: IosSnapshotEvidenceAvailability;
88+
truncationEvidence: IosSnapshotEvidenceAvailability;
8489
presentationOwner: IosSnapshotPresentationOwner;
8590
}>;
8691

@@ -161,6 +166,8 @@ export type IosSnapshotAcquisition =
161166
| IosSnapshotAcquisitionForIntent<'full'>
162167
| IosSnapshotAcquisitionForIntent<'surface-observation'>;
163168

169+
export type IosSnapshotAcquisitionFacts = Omit<IosSnapshotAcquisition, 'hint'>;
170+
164171
export type IosRunnerPayloadFacts = Readonly<{
165172
nodes: readonly RawSnapshotNode[];
166173
truncated: boolean;
@@ -214,6 +221,7 @@ export type IosSnapshotPlan = Readonly<{
214221
interactiveQuery: IosSnapshotCompleteness;
215222
viewport: IosSnapshotEvidenceAvailability;
216223
hittability: IosSnapshotEvidenceAvailability;
224+
truncation: IosSnapshotEvidenceAvailability;
217225
}>;
218226
}>;
219227

packages/contracts/src/snapshot-runtime.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import type { DeviceInfo } from '@agent-device/kernel/device';
4-
import type { Interactor, RunnerContext } from './interactor-types.ts';
4+
import type {
5+
Interactor,
6+
RunnerContext,
7+
SnapshotRuntimeAcquiredResult,
8+
SnapshotResult,
9+
} from './interactor-types.ts';
510
import {
611
bindLocalSnapshotInteractor,
712
bindProviderSnapshotInteractor,
@@ -71,6 +76,39 @@ test('provider snapshot binding fails closed when its selected owner loses the i
7176
);
7277
});
7378

79+
test('provider snapshot binding presents acquired facts through the supplied host owner', async () => {
80+
const acquired: SnapshotRuntimeAcquiredResult = {
81+
stage: 'acquired',
82+
acquisition: {
83+
producer: 'appium-source',
84+
intent: 'full',
85+
nodes: [],
86+
viewport: { kind: 'reported', rect: { x: 0, y: 0, width: 390, height: 844 } },
87+
lineage: { targetId: 'ios-1' },
88+
residue: [{ kind: 'unavailable-fact', fact: 'truncation' }],
89+
},
90+
};
91+
const presented: SnapshotResult = {
92+
backend: 'xctest',
93+
producer: 'appium-source',
94+
nodes: [],
95+
};
96+
let presentedInput: SnapshotRuntimeAcquiredResult | undefined;
97+
const operations = bindProviderSnapshotInteractor({
98+
device,
99+
signal: new AbortController().signal,
100+
resolveInteractor: () => ({ snapshot: async () => acquired }) as unknown as Interactor,
101+
presentIosAcquisition: async (input) => {
102+
presentedInput = input;
103+
return presented;
104+
},
105+
});
106+
107+
const result = await operations.captureSnapshot({ options: { raw: true } });
108+
assert.equal(result, presented);
109+
assert.equal(presentedInput, acquired);
110+
});
111+
74112
// ---------------------------------------------------------------------------
75113
// Per-capture cancellation (`CaptureSnapshotInput.signal`). A `DeviceBinding`'s
76114
// signal is fixed at bind time, but `wait` binds once and polls many times, so

packages/contracts/src/snapshot-runtime.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import type {
44
Interactor,
55
RunnerContext,
66
SnapshotOptions,
7+
SnapshotRuntimeAcquiredResult,
8+
SnapshotRuntimeResult,
79
SnapshotResult,
810
} from './interactor-types.ts';
911
import type { RuntimeOperationFact } from './platform-runtime.ts';
1012

11-
export type { SnapshotResult } from './interactor-types.ts';
13+
export type { SnapshotRuntimeAcquiredResult, SnapshotResult } from './interactor-types.ts';
1214

1315
/** Runner metadata needed by the selected snapshot implementation, without request-owned state. */
1416
export type SnapshotRuntimeExecution = Readonly<Omit<RunnerContext, 'appBundleId' | 'signal'>>;
@@ -75,8 +77,17 @@ export type SnapshotRuntimeHost = Readonly<{
7577
options: CaptureSnapshotInput['options'],
7678
signal: AbortSignal,
7779
): Promise<SnapshotResult>;
80+
presentIosAcquisition(
81+
input: SnapshotRuntimeAcquiredResult,
82+
options: CaptureSnapshotInput['options'],
83+
): SnapshotResult | Promise<SnapshotResult>;
7884
}>;
7985

86+
export type SnapshotRuntimePresenter = (
87+
input: SnapshotRuntimeAcquiredResult,
88+
options: CaptureSnapshotInput['options'],
89+
) => SnapshotResult | Promise<SnapshotResult>;
90+
8091
export type LocalSnapshotInteractorResolver = (
8192
device: DeviceInfo,
8293
runner: RunnerContext,
@@ -90,12 +101,14 @@ type SnapshotInteractorBindingParams =
90101
signal: AbortSignal;
91102
ownership: 'local';
92103
resolveInteractor: LocalSnapshotInteractorResolver;
104+
presentIosAcquisition?: never;
93105
}>
94106
| Readonly<{
95107
device: DeviceInfo;
96108
signal: AbortSignal;
97109
ownership: 'provider';
98110
resolveInteractor: ProviderSnapshotInteractorResolver;
111+
presentIosAcquisition?: SnapshotRuntimePresenter;
99112
}>;
100113

101114
/** Captures one selected owner's interactor authority for the lifetime of a request binding. */
@@ -120,7 +133,16 @@ function bindSnapshotInteractor(
120133
{ reason: 'provider-runtime-interactor-missing', deviceId: params.device.id },
121134
);
122135
}
123-
return await interactor.snapshot({ ...input.options, signal });
136+
const result: SnapshotRuntimeResult = await interactor.snapshot({ ...input.options, signal });
137+
if (!isSnapshotRuntimeAcquiredResult(result)) return result;
138+
if (!params.presentIosAcquisition) {
139+
throw new AppError(
140+
'COMMAND_FAILED',
141+
'Acquired iOS snapshot has no host presentation owner.',
142+
{ reason: 'ios-snapshot-presenter-missing', deviceId: params.device.id },
143+
);
144+
}
145+
return await params.presentIosAcquisition(result, input.options);
124146
};
125147
return Object.freeze({
126148
captureSnapshot,
@@ -145,7 +167,14 @@ export function bindProviderSnapshotInteractor(
145167
device: DeviceInfo;
146168
signal: AbortSignal;
147169
resolveInteractor: ProviderSnapshotInteractorResolver;
170+
presentIosAcquisition?: SnapshotRuntimePresenter;
148171
}>,
149172
): SnapshotRuntimeOperations {
150173
return bindSnapshotInteractor({ ...params, ownership: 'provider' });
151174
}
175+
176+
function isSnapshotRuntimeAcquiredResult(
177+
result: SnapshotRuntimeResult,
178+
): result is SnapshotRuntimeAcquiredResult {
179+
return 'stage' in result && result.stage === 'acquired';
180+
}

packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types';
2-
import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types';
2+
import type {
3+
Interactor,
4+
RunnerContext,
5+
SnapshotResult,
6+
SnapshotRuntimeResult,
7+
} from '@agent-device/contracts/interactor-types';
38
import { AppError } from '@agent-device/kernel/errors';
49
import assert from 'node:assert/strict';
510
import { test } from 'vitest';
@@ -13,6 +18,11 @@ import { createAppleInteractor } from '../interactor.ts';
1318

1419
type RecordedRunnerCall = { command: RunnerCommand; options: AppleRunnerCommandOptions };
1520

21+
function presentedSnapshot(result: SnapshotRuntimeResult): SnapshotResult {
22+
if ('stage' in result) throw new Error('Apple runner snapshot must be presented');
23+
return result;
24+
}
25+
1626
// Every Interactor method must either ride the injected runner transport or
1727
// fail fast as a local-tooling method the provider composes itself. The two
1828
// tables below partition the surface; the partition test keeps them total, so
@@ -175,7 +185,7 @@ test('snapshot merges its per-call cancellation signal with the interaction cont
175185

176186
test('snapshot over the injected transport keeps the shared xctest result shape', async () => {
177187
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([]));
178-
const result = await interactor.snapshot();
188+
const result = presentedSnapshot(await interactor.snapshot());
179189
assert.equal(result.backend, 'xctest');
180190
assert.equal(result.nodes?.length, 2);
181191
});
@@ -246,7 +256,7 @@ test('snapshot publishes runner presentation through the engine and drops its qu
246256
},
247257
);
248258

249-
const result = await interactor.snapshot({ interactiveOnly: true });
259+
const result = presentedSnapshot(await interactor.snapshot({ interactiveOnly: true }));
250260

251261
assert.deepEqual(
252262
result.nodes?.map((node) => node.type),
@@ -263,7 +273,7 @@ test('macOS app snapshots preserve runner nodes outside the iOS presentation eng
263273
{ runCommand: async () => ({ nodes }) },
264274
);
265275

266-
const result = await interactor.snapshot({ interactiveOnly: true });
276+
const result = presentedSnapshot(await interactor.snapshot({ interactiveOnly: true }));
267277

268278
assert.deepEqual(result.nodes, nodes);
269279
});
@@ -350,7 +360,7 @@ test('snapshot accepts only structured healthy empty scope results', async () =>
350360
};
351361
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, healthyEmptyProvider);
352362

353-
const scoped = await interactor.snapshot({ scope: 'missing' });
363+
const scoped = presentedSnapshot(await interactor.snapshot({ scope: 'missing' }));
354364
assert.deepEqual(scoped.nodes, []);
355365
assert.equal(scoped.backend, 'xctest');
356366
assert.equal(scoped.quality?.state, 'healthy');

0 commit comments

Comments
 (0)