Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 54 additions & 50 deletions packages/capture-kit/src/ios-snapshot-acquisition.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,87 @@
import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types';
import type {
IosAcquisitionResidue,
IosProviderAcquisitionCapabilities,
IosProviderAcquisitionProducer,
IosSnapshotProducer,
IosSnapshotProducerCapabilities,
IosSnapshotEvidenceAvailability,
IosSnapshotLineage,
IosSnapshotProducer,
IosViewportEvidence,
} from '@agent-device/contracts/ios-snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';

const ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS = {
stage: 'acquired',
acquisitionDepth: {
rawTraversal: { kind: 'incomplete' },
regularPresented: { kind: 'incomplete' },
},
scopeCompleteness: 'incomplete',
interactiveQueryCompleteness: 'incomplete',
viewportEvidence: 'available',
hittabilityEvidence: 'unavailable',
truncationEvidence: 'unavailable',
} as const;

const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
'apple-runner': {
producer: 'apple-runner',
stage: 'presented',
acquisitionDepth: {
rawTraversal: { kind: 'not-applicable' },
regularPresented: { kind: 'not-applicable' },
},
scopeCompleteness: 'complete',
interactiveQueryCompleteness: 'complete',
viewportEvidence: 'available',
hittabilityEvidence: 'available',
truncationEvidence: 'available',
},
'simulator-ax-bridge': {
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
producer: 'simulator-ax-bridge',
/**
* The facts a *provider* acquisition leaves unproven, keyed only by the producers whose residue
* this module derives.
*
* `apple-runner` and `simulator-ax-bridge` are absent by construction, not by omission: each
* builds its own residue at the source (the bridge adapter emits `unavailable-fact: hittability`
* on every capture; the runner presents and reports its own facts), so a row here would be a
* declaration with no reader — which is how the table came to claim the bridge had hittability
* evidence while every bridge-served `snapshot -i` printed the opposite (#2199). Keying the
* record on {@link IosProviderAcquisitionProducer} makes that claim not compile.
*/
const IOS_PROVIDER_ACQUISITION_CAPABILITY_VALUES = {
'appium-source': {
producer: 'appium-source',
acquisitionDepth: {
rawTraversal: { kind: 'complete' },
rawTraversal: { kind: 'incomplete' },
regularPresented: { kind: 'incomplete' },
},
hittabilityEvidence: 'available',
truncationEvidence: 'available',
},
'appium-source': {
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
producer: 'appium-source',
hittabilityEvidence: 'unavailable',
},
'limrun-ios-tree': {
...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS,
producer: 'limrun-ios-tree',
acquisitionDepth: {
rawTraversal: { kind: 'incomplete' },
regularPresented: { kind: 'incomplete' },
},
hittabilityEvidence: 'unavailable',
},
} as const satisfies Record<IosSnapshotProducer, IosSnapshotProducerCapabilities>;
} as const satisfies Record<IosProviderAcquisitionProducer, IosProviderAcquisitionCapabilities>;

export const IOS_SNAPSHOT_PRODUCER_CAPABILITIES: Readonly<
Record<IosSnapshotProducer, IosSnapshotProducerCapabilities>
> = Object.freeze(IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES);
/**
* Whether a producer observes truncation at all, for every iOS producer.
*
* This is the one capability the runner and the bridge genuinely need declared: a capture that
* reported nothing about truncation is only "not truncated" when its producer would have noticed
* (#2188 invariant 5), and `snapshotTruncationForResult` has to answer that for all four. It is a
* table of its own rather than a column of the provider capabilities so that each producer states
* this fact exactly once, in the only place that asks.
*
* The runner payload carries a required `truncated` boolean, and the bridge adapter rejects an
* envelope without one and turns a true into a `truncated` residue — so both observe it.
*/
const IOS_SNAPSHOT_TRUNCATION_EVIDENCE = {
'apple-runner': 'available',
'simulator-ax-bridge': 'available',
'appium-source': 'unavailable',
'limrun-ios-tree': 'unavailable',
} as const satisfies Record<IosSnapshotProducer, IosSnapshotEvidenceAvailability>;

export function iosSnapshotTruncationEvidence(
producer: IosSnapshotProducer,
): IosSnapshotEvidenceAvailability {
return IOS_SNAPSHOT_TRUNCATION_EVIDENCE[producer];
}

function deriveIosSnapshotAcquisitionResidue(
producer: IosSnapshotProducerCapabilities,
producer: IosProviderAcquisitionCapabilities,
viewport: IosViewportEvidence,
): readonly IosAcquisitionResidue[] {
const residue: IosAcquisitionResidue[] = [];
if (producer.hittabilityEvidence === 'unavailable') {
residue.push({ kind: 'unavailable-fact', fact: 'hittability' });
}
if (
producer.stage === 'acquired' &&
(producer.acquisitionDepth.rawTraversal.kind === 'incomplete' ||
producer.acquisitionDepth.regularPresented.kind === 'incomplete')
producer.acquisitionDepth.rawTraversal.kind === 'incomplete' ||
producer.acquisitionDepth.regularPresented.kind === 'incomplete'
) {
residue.push({ kind: 'unavailable-fact', fact: 'acquisition-depth' });
}
if (producer.truncationEvidence === 'unavailable') {
if (iosSnapshotTruncationEvidence(producer.producer) === 'unavailable') {
residue.push({ kind: 'unavailable-fact', fact: 'truncation' });
}
if (viewport.kind === 'missing') {
Expand All @@ -94,7 +98,7 @@ export function createIosSnapshotAcquisition(
lineage: IosSnapshotLineage;
}>,
): SnapshotRuntimeAcquiredResult {
const producer = IOS_SNAPSHOT_PRODUCER_CAPABILITIES[input.producer];
const producer = IOS_PROVIDER_ACQUISITION_CAPABILITY_VALUES[input.producer];
return {
stage: 'acquired',
acquisition: {
Expand Down
21 changes: 16 additions & 5 deletions packages/capture-kit/src/ios-snapshot-engine/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import type {
IosSnapshotAcquisition,
IosSnapshotEngine,
IosSnapshotInput,
IosSnapshotRequest,
IosSnapshotValidationFacts,
Expand All @@ -13,7 +14,6 @@ import {
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { toIosSnapshotEngineErrorDetails } from './types.ts';
import {
createIosSnapshotEngine,
IosSnapshotEngineError,
presentIosInteractiveSnapshot,
presentIosSnapshot,
Expand Down Expand Up @@ -340,15 +340,26 @@ test('interactive compaction stays available through the engine boundary', () =>
);
});

test('the configured engine keeps its fold policy and exposes the contract operations', () => {
const engine = createIosSnapshotEngine({ foldPolicy: 'plain-viewport' });
/**
* `IosSnapshotEngine` has one operation because presentation happens once (#2188 invariant 2).
* Pinning `publishIosSnapshot` to it keeps the contract a description of the export production
* actually calls, rather than a shape only a factory ever satisfied (#2199).
*/
test('publishing satisfies the whole engine contract under an explicit fold policy', () => {
const engine: IosSnapshotEngine = {
publish: (input, request) =>
publishIosSnapshot(input, request, { foldPolicy: 'plain-viewport' }),
};
const request = createIosSnapshotRequest();
const presented = presentIosSnapshot(acquiredInput(request, nestedNodes()), request, {
foldPolicy: 'plain-viewport',
});

assert.equal(typeof engine.plan, 'function');
assert.equal(typeof engine.publish, 'function');
assert.deepEqual(Object.keys(engine), ['publish']);
assert.equal(
engine.publish(acquiredInput(request, nestedNodes()), request).payload.nodes.length,
presented.nodes.length,
);
assert.equal(presented.stats.sourceNodeCount, nestedNodes().length);
});

Expand Down
10 changes: 0 additions & 10 deletions packages/capture-kit/src/ios-snapshot-engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import {
buildIosSnapshotComparisonIdentity,
buildIosSnapshotPresentationKey,
deriveIosCaptureHint,
planIosSnapshot,
} from '../ios-snapshot-planning.ts';
import type {
IosSnapshotAcquisition,
IosSnapshotEngine,
IosSnapshotInput,
IosSnapshotPublication,
IosSnapshotRequest,
Expand All @@ -27,14 +25,6 @@ import { IosSnapshotEngineError } from './types.ts';

const DEFAULT_FOLD_POLICY: IosSnapshotFoldPolicy = 'cursor-projected';

export function createIosSnapshotEngine(options: IosSnapshotEngineOptions = {}): IosSnapshotEngine {
const foldPolicy = options.foldPolicy ?? DEFAULT_FOLD_POLICY;
return Object.freeze({
plan: planIosSnapshot,
publish: (input, request) => publishIosSnapshot(input, request, { foldPolicy }),
});
}

export function publishIosSnapshot(
input: IosSnapshotInput,
request: IosSnapshotRequest,
Expand Down
2 changes: 1 addition & 1 deletion packages/capture-kit/src/ios-snapshot-engine/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { createIosSnapshotEngine, presentIosSnapshot, publishIosSnapshot } from './engine.ts';
export { presentIosSnapshot, publishIosSnapshot } from './engine.ts';
export { presentIosRunnerSnapshot } from './runner-presentation.ts';
export {
buildIosInteractiveSnapshotPresentation,
Expand Down
Loading
Loading