diff --git a/packages/capture-kit/src/ios-snapshot-acquisition.ts b/packages/capture-kit/src/ios-snapshot-acquisition.ts index e0d644ed66..75821130f7 100644 --- a/packages/capture-kit/src/ios-snapshot-acquisition.ts +++ b/packages/capture-kit/src/ios-snapshot-acquisition.ts @@ -1,69 +1,74 @@ 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; +} as const satisfies Record; -export const IOS_SNAPSHOT_PRODUCER_CAPABILITIES: Readonly< - Record -> = 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; + +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[] = []; @@ -71,13 +76,12 @@ function deriveIosSnapshotAcquisitionResidue( 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') { @@ -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: { diff --git a/packages/capture-kit/src/ios-snapshot-engine/engine.test.ts b/packages/capture-kit/src/ios-snapshot-engine/engine.test.ts index f2445b2760..59864a0c7c 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/engine.test.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/engine.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { IosSnapshotAcquisition, + IosSnapshotEngine, IosSnapshotInput, IosSnapshotRequest, IosSnapshotValidationFacts, @@ -13,7 +14,6 @@ import { } from '@agent-device/capture-kit/ios-snapshot-planning'; import { toIosSnapshotEngineErrorDetails } from './types.ts'; import { - createIosSnapshotEngine, IosSnapshotEngineError, presentIosInteractiveSnapshot, presentIosSnapshot, @@ -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); }); diff --git a/packages/capture-kit/src/ios-snapshot-engine/engine.ts b/packages/capture-kit/src/ios-snapshot-engine/engine.ts index 4ef71b2c5f..a8df22b5d0 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/engine.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/engine.ts @@ -2,11 +2,9 @@ import { buildIosSnapshotComparisonIdentity, buildIosSnapshotPresentationKey, deriveIosCaptureHint, - planIosSnapshot, } from '../ios-snapshot-planning.ts'; import type { IosSnapshotAcquisition, - IosSnapshotEngine, IosSnapshotInput, IosSnapshotPublication, IosSnapshotRequest, @@ -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, diff --git a/packages/capture-kit/src/ios-snapshot-engine/index.ts b/packages/capture-kit/src/ios-snapshot-engine/index.ts index c04acf0ea8..fe401fdae6 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/index.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/index.ts @@ -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, diff --git a/packages/capture-kit/src/ios-snapshot-planning.test.ts b/packages/capture-kit/src/ios-snapshot-planning.test.ts index c355e47cf4..d06e26d967 100644 --- a/packages/capture-kit/src/ios-snapshot-planning.test.ts +++ b/packages/capture-kit/src/ios-snapshot-planning.test.ts @@ -4,9 +4,10 @@ import path from 'node:path'; import { test } from 'vitest'; import type { CaptureHint, - IosSnapshotAcquisitionProducerCapabilities, IosSnapshotComparisonIdentity, + IosSnapshotEvidenceAvailability, IosSnapshotInput, + IosSnapshotProducer, IosSnapshotRequestInput, } from '@agent-device/contracts/ios-snapshot'; import { @@ -15,11 +16,10 @@ import { buildIosSnapshotPresentationKey, createIosSnapshotRequest, deriveIosCaptureHint, - planIosSnapshot, } from '@agent-device/capture-kit/ios-snapshot-planning'; import { createIosSnapshotAcquisition, - IOS_SNAPSHOT_PRODUCER_CAPABILITIES, + iosSnapshotTruncationEvidence, } from '@agent-device/capture-kit/ios-snapshot-acquisition'; type CaptureHintFixture = Readonly<{ @@ -72,107 +72,42 @@ test('normalization makes raw, scope, and absent values explicit', () => { }); }); -test('depth narrowing requires complete support for the requested projection', () => { - const rawRequest = createIosSnapshotRequest({ projection: 'raw', depth: 2 }); - const regularRequest = createIosSnapshotRequest({ projection: 'regular', depth: 2 }); - const rawComplete = acquiredProducer({ - acquisitionDepth: { - rawTraversal: { kind: 'complete' }, - regularPresented: { kind: 'incomplete' }, - }, - }); - const rawIncomplete = acquiredProducer({ - acquisitionDepth: { - rawTraversal: { kind: 'incomplete' }, - regularPresented: { kind: 'incomplete' }, - }, - }); - assert.equal(planIosSnapshot(rawRequest, rawComplete).narrowing.depth, 2); - assert.equal(planIosSnapshot(rawRequest, rawIncomplete).narrowing.depth, null); - - const regularComplete = acquiredProducer({ - acquisitionDepth: { - rawTraversal: { kind: 'complete' }, - regularPresented: { kind: 'complete', maxDepth: 2 }, - }, - }); - const regularTooShallow = acquiredProducer({ - acquisitionDepth: { - rawTraversal: { kind: 'complete' }, - regularPresented: { kind: 'complete', maxDepth: 1 }, - }, - }); - assert.equal(planIosSnapshot(regularRequest, regularComplete).narrowing.depth, 2); - assert.equal(planIosSnapshot(regularRequest, regularTooShallow).narrowing.depth, null); -}); - -test('scoped acquisition remains broad and reports scope completeness as evidence', () => { - const request = createIosSnapshotRequest({ projection: 'regular', depth: 2, scope: 'Card' }); - const complete = acquiredProducer({ scopeCompleteness: 'complete' }); - const incomplete = acquiredProducer({ scopeCompleteness: 'incomplete' }); - assert.deepEqual(planIosSnapshot(request, complete).narrowing, { - depth: null, - scope: null, - interactiveOnly: false, - }); - assert.equal(planIosSnapshot(request, complete).evidence.scope, 'complete'); - assert.equal(planIosSnapshot(request, incomplete).evidence.scope, 'incomplete'); -}); - -test('interactive narrowing requires complete queries and available hittability', () => { - const request = createIosSnapshotRequest({ interactiveOnly: true }); - const complete = acquiredProducer({ - interactiveQueryCompleteness: 'complete', - hittabilityEvidence: 'available', - }); - const incompleteQuery = acquiredProducer({ - interactiveQueryCompleteness: 'incomplete', - hittabilityEvidence: 'available', - }); - const missingHittability = acquiredProducer({ - interactiveQueryCompleteness: 'complete', - hittabilityEvidence: 'unavailable', - }); - assert.equal(planIosSnapshot(request, complete).narrowing.interactiveOnly, true); - assert.equal(planIosSnapshot(request, incompleteQuery).narrowing.interactiveOnly, false); - assert.equal(planIosSnapshot(request, missingHittability).narrowing.interactiveOnly, false); +test('acquisition derives unavailable provider facts from the registry', () => { + for (const producer of ['appium-source', 'limrun-ios-tree'] as const) { + assert.deepEqual( + createIosSnapshotAcquisition({ + producer, + nodes: [], + viewport: { kind: 'reported', rect: { x: 0, y: 0, width: 1, height: 1 } }, + lineage: {}, + }).acquisition.residue, + [ + { kind: 'unavailable-fact', fact: 'hittability' }, + { kind: 'unavailable-fact', fact: 'acquisition-depth' }, + { kind: 'unavailable-fact', fact: 'truncation' }, + ], + producer, + ); + } }); -test('presented producers cannot claim acquisition narrowing', () => { - const request = createIosSnapshotRequest({ projection: 'regular', depth: 2 }); - const plan = planIosSnapshot(request, IOS_SNAPSHOT_PRODUCER_CAPABILITIES['apple-runner']); - assert.deepEqual(plan.narrowing, { depth: null, scope: null, interactiveOnly: false }); - assert.deepEqual(plan.evidence, { - scope: 'complete', - interactiveQuery: 'complete', - viewport: 'available', - hittability: 'available', - truncation: 'available', - }); -}); +/** + * The producers that build their own residue have no row in the provider capability table, so + * this is the only place they declare a capability at all — and it must keep saying what it said + * before the table was narrowed, or `snapshotTruncationForResult` would start upgrading an absent + * `truncated` to `false` (or stop doing so) for a real capture (#2199). + */ +test('truncation evidence is declared for every iOS producer', () => { + const expected = { + 'apple-runner': 'available', + 'simulator-ax-bridge': 'available', + 'appium-source': 'unavailable', + 'limrun-ios-tree': 'unavailable', + } as const satisfies Record; -test('Appium source plan carries its viewport evidence capability', () => { - const plan = planIosSnapshot( - createIosSnapshotRequest(), - IOS_SNAPSHOT_PRODUCER_CAPABILITIES['appium-source'], - ); - assert.equal(plan.evidence.viewport, 'available'); -}); - -test('acquisition derives unavailable Appium facts from the registry', () => { - assert.deepEqual( - createIosSnapshotAcquisition({ - producer: 'appium-source', - nodes: [], - viewport: { kind: 'reported', rect: { x: 0, y: 0, width: 1, height: 1 } }, - lineage: {}, - }).acquisition.residue, - [ - { kind: 'unavailable-fact', fact: 'hittability' }, - { kind: 'unavailable-fact', fact: 'acquisition-depth' }, - { kind: 'unavailable-fact', fact: 'truncation' }, - ], - ); + for (const producer of Object.keys(expected) as IosSnapshotProducer[]) { + assert.equal(iosSnapshotTruncationEvidence(producer), expected[producer], producer); + } }); test('comparison identity rejects every identity axis and residue mismatch', () => { @@ -237,25 +172,6 @@ test('comparison identity builder follows the closed input stage', () => { }); }); -function acquiredProducer( - overrides: Partial> = {}, -): IosSnapshotAcquisitionProducerCapabilities { - return { - producer: 'simulator-ax-bridge', - stage: 'acquired', - acquisitionDepth: { - rawTraversal: { kind: 'complete' }, - regularPresented: { kind: 'complete' }, - }, - scopeCompleteness: 'incomplete', - interactiveQueryCompleteness: 'incomplete', - viewportEvidence: 'available', - hittabilityEvidence: 'available', - truncationEvidence: 'available', - ...overrides, - }; -} - function comparisonIdentity( overrides: Partial = {}, ): IosSnapshotComparisonIdentity { diff --git a/packages/capture-kit/src/ios-snapshot-planning.ts b/packages/capture-kit/src/ios-snapshot-planning.ts index b20a1be141..317cd695d7 100644 --- a/packages/capture-kit/src/ios-snapshot-planning.ts +++ b/packages/capture-kit/src/ios-snapshot-planning.ts @@ -1,12 +1,9 @@ import type { CaptureHint, IosAcquisitionResidue, - IosSnapshotAcquisitionDepthCapability, IosSnapshotComparisonIdentity, IosSnapshotInput, - IosSnapshotPlan, IosSnapshotPresentationKey, - IosSnapshotProducerCapabilities, IosSnapshotRequest, IosSnapshotRequestInput, } from '@agent-device/contracts/ios-snapshot'; @@ -47,36 +44,6 @@ export function deriveIosCaptureHint(request: IosSnapshotRequest): CaptureHint { }); } -export function planIosSnapshot( - request: IosSnapshotRequest, - producer: IosSnapshotProducerCapabilities, -): IosSnapshotPlan { - const hint = deriveIosCaptureHint(request); - const requestedDepth = hint.rawTraversalDepth ?? hint.regularPresentedDepth; - const depthSupport = depthSupportFor(request, producer); - const depth = - requestedDepth !== null && isCompleteFor(depthSupport, requestedDepth) ? requestedDepth : null; - const interactiveOnly = - producer.stage === 'acquired' && - hint.interactiveOnly && - producer.interactiveQueryCompleteness === 'complete' && - producer.hittabilityEvidence === 'available'; - - return Object.freeze({ - request, - producer: producer.producer, - hint, - narrowing: Object.freeze({ depth, scope: null, interactiveOnly }), - evidence: Object.freeze({ - scope: producer.scopeCompleteness, - interactiveQuery: producer.interactiveQueryCompleteness, - viewport: producer.viewportEvidence, - hittability: producer.hittabilityEvidence, - truncation: producer.truncationEvidence, - }), - }); -} - export function areIosSnapshotComparisonIdentitiesEqual( left: IosSnapshotComparisonIdentity, right: IosSnapshotComparisonIdentity, @@ -122,26 +89,6 @@ export function buildIosSnapshotComparisonIdentity( }); } -function depthSupportFor( - request: IosSnapshotRequest, - producer: IosSnapshotProducerCapabilities, -): IosSnapshotAcquisitionDepthCapability['rawTraversal'] { - if (producer.stage !== 'acquired') return { kind: 'not-applicable' }; - return request.projection === 'raw' - ? producer.acquisitionDepth.rawTraversal - : producer.acquisitionDepth.regularPresented; -} - -function isCompleteFor( - support: IosSnapshotAcquisitionDepthCapability['rawTraversal'], - requestedDepth: number, -): boolean { - return ( - support.kind === 'complete' && - (support.maxDepth === undefined || requestedDepth <= support.maxDepth) - ); -} - function lineagesEqual( left: IosSnapshotComparisonIdentity['lineage'], right: IosSnapshotComparisonIdentity['lineage'], diff --git a/packages/contracts/src/ios-snapshot.test.ts b/packages/contracts/src/ios-snapshot.test.ts index 450d5d7c30..6d3574c775 100644 --- a/packages/contracts/src/ios-snapshot.test.ts +++ b/packages/contracts/src/ios-snapshot.test.ts @@ -109,7 +109,6 @@ const publicationWithQualityPayload: IosSnapshotPublication = { }; const engineWithSecondPresentation: IosSnapshotEngine = { - plan: null as unknown as IosSnapshotEngine['plan'], publish: null as unknown as IosSnapshotEngine['publish'], // @ts-expect-error the engine surface has no second presentation operation present: null as never, @@ -118,7 +117,7 @@ const engineWithSecondPresentation: IosSnapshotEngine = { test('iOS snapshot stage and engine surfaces stay closed', () => { assert.equal(acquired.stage, 'acquired'); assert.equal(presented.stage, 'presented'); - assert.deepEqual(['plan', 'publish'] satisfies (keyof IosSnapshotEngine)[], ['plan', 'publish']); + assert.deepEqual(['publish'] satisfies (keyof IosSnapshotEngine)[], ['publish']); void doubleStage; void skippedPresentation; void nonRunnerPresentation; diff --git a/packages/contracts/src/ios-snapshot.ts b/packages/contracts/src/ios-snapshot.ts index 019a29ee43..ac3c0b88c9 100644 --- a/packages/contracts/src/ios-snapshot.ts +++ b/packages/contracts/src/ios-snapshot.ts @@ -13,7 +13,6 @@ export type IosProviderAcquisitionProducer = Extract< >; export type IosAcquisitionIntent = 'full' | 'surface-observation'; export type IosSnapshotProjection = 'regular' | 'raw'; -export type IosSnapshotCompleteness = 'complete' | 'incomplete'; export type IosSnapshotEvidenceAvailability = 'available' | 'unavailable'; export type IosSnapshotGeneration = string; @@ -61,8 +60,7 @@ export type CaptureHint = Readonly<{ export type IosSnapshotDepthSupport = | Readonly<{ kind: 'complete'; maxDepth?: number }> - | Readonly<{ kind: 'incomplete' }> - | Readonly<{ kind: 'not-applicable' }>; + | Readonly<{ kind: 'incomplete' }>; export type IosSnapshotAcquisitionDepthCapability = Readonly<{ rawTraversal: IosSnapshotDepthSupport; @@ -78,31 +76,28 @@ export type IosSnapshotFact = | 'generation' | 'truncation'; -type IosSnapshotProducerCapabilityFacts = Readonly<{ +/** + * What a *provider* acquisition leaves unproven, declared so capture-kit can derive the residue + * for a producer that hands over a bare tree and nothing else. + * + * The producer axis is `IosProviderAcquisitionProducer` — Appium page source and the Limrun + * element tree — and deliberately not every {@link IosSnapshotProducer}. `apple-runner` and + * `simulator-ax-bridge` build their own residue at the source, so a capability declared for + * them here would be a claim nothing consults: exactly the shape that let the table say the + * Simulator bridge had hittability evidence while the bridge adapter emitted + * `unavailable-fact: hittability` on every capture (#2199). Narrowing the producer makes that + * claim unrepresentable rather than merely wrong. + * + * Truncation is not a field here: it is the one fact the runner and the bridge also need + * answered, so it has a single owner over all four producers instead + * (`iosSnapshotTruncationEvidence`). + */ +export type IosProviderAcquisitionCapabilities = Readonly<{ + producer: IosProviderAcquisitionProducer; acquisitionDepth: IosSnapshotAcquisitionDepthCapability; - scopeCompleteness: IosSnapshotCompleteness; - interactiveQueryCompleteness: IosSnapshotCompleteness; - viewportEvidence: IosSnapshotEvidenceAvailability; hittabilityEvidence: IosSnapshotEvidenceAvailability; - truncationEvidence: IosSnapshotEvidenceAvailability; }>; -export type IosSnapshotAcquisitionProducerCapabilities = IosSnapshotProducerCapabilityFacts & - Readonly<{ - producer: IosAcquisitionProducer; - stage: 'acquired'; - }>; - -export type IosSnapshotPresentedProducerCapabilities = IosSnapshotProducerCapabilityFacts & - Readonly<{ - producer: 'apple-runner'; - stage: 'presented'; - }>; - -export type IosSnapshotProducerCapabilities = - | IosSnapshotAcquisitionProducerCapabilities - | IosSnapshotPresentedProducerCapabilities; - export type IosViewportEvidence = | Readonly<{ kind: 'reported'; rect: Rect }> | Readonly<{ kind: 'derived'; rect: Rect }> @@ -207,26 +202,6 @@ export type IosSnapshotInput = validation: IosSnapshotValidationFacts; }>; -export type IosSnapshotAcquisitionNarrowing = Readonly<{ - depth: number | null; - scope: null; - interactiveOnly: boolean; -}>; - -export type IosSnapshotPlan = Readonly<{ - request: IosSnapshotRequest; - producer: IosSnapshotProducer; - hint: CaptureHint; - narrowing: IosSnapshotAcquisitionNarrowing; - evidence: Readonly<{ - scope: IosSnapshotCompleteness; - interactiveQuery: IosSnapshotCompleteness; - viewport: IosSnapshotEvidenceAvailability; - hittability: IosSnapshotEvidenceAvailability; - truncation: IosSnapshotEvidenceAvailability; - }>; -}>; - export type IosSnapshotPublishedPayload = Readonly<{ nodes: readonly SnapshotNode[]; truncated?: boolean; @@ -248,6 +223,5 @@ export type IosSnapshotPublication = Readonly<{ }>; export type IosSnapshotEngine = Readonly<{ - plan(request: IosSnapshotRequest, producer: IosSnapshotProducerCapabilities): IosSnapshotPlan; publish(input: IosSnapshotInput, request: IosSnapshotRequest): IosSnapshotPublication; }>; diff --git a/scripts/layering/contracts-implementation-policy.test.ts b/scripts/layering/contracts-implementation-policy.test.ts index 4e58bad91e..d6764ebb80 100644 --- a/scripts/layering/contracts-implementation-policy.test.ts +++ b/scripts/layering/contracts-implementation-policy.test.ts @@ -101,7 +101,7 @@ test('iOS snapshot contracts reject planning implementation and provider imports assert.match( messages( [ - "import type { planIosSnapshot } from '@agent-device/capture-kit/ios-snapshot-planning';", + "import type { deriveIosCaptureHint } from '@agent-device/capture-kit/ios-snapshot-planning';", 'export type IosPlan = { readonly value: string };', ].join('\n'), contract, diff --git a/scripts/layering/snapshot-assembly-presentation-policy.test.ts b/scripts/layering/snapshot-assembly-presentation-policy.test.ts index 0eb79731b9..271addfdf9 100644 --- a/scripts/layering/snapshot-assembly-presentation-policy.test.ts +++ b/scripts/layering/snapshot-assembly-presentation-policy.test.ts @@ -65,7 +65,7 @@ test('R74 rejects the assembly reading the producer capability table', () => { const result = violations( appended( assemblyFile, - `\nimport { IOS_SNAPSHOT_PRODUCER_CAPABILITIES } from '@agent-device/capture-kit/ios-snapshot-acquisition';\nvoid IOS_SNAPSHOT_PRODUCER_CAPABILITIES;\n`, + `\nimport { iosSnapshotTruncationEvidence } from '@agent-device/capture-kit/ios-snapshot-acquisition';\nvoid iosSnapshotTruncationEvidence;\n`, ), ); diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index 5ac0df81d8..77860e8d64 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -5,6 +5,7 @@ import type { BackendSnapshotOptions, BackendSnapshotResult, } from '../../../backend.ts'; +import type { IosSnapshotProducer } from '@agent-device/contracts/ios-snapshot'; import { createLocalArtifactAdapter } from '../../../io.ts'; import { createAgentDevice, @@ -57,6 +58,30 @@ test('runtime snapshot preserves unknown hierarchy completeness for engine-owned assert.equal(result.truncated, undefined); }); +/** + * An absent `truncated` is only "not truncated" when the producer would have noticed (#2188 + * invariant 5). #2199 moved that answer out of the producer capability table into a truncation + * table of its own, so this pins the whole producer axis rather than the one Appium case above. + */ +test('runtime snapshot upgrades an absent truncation flag only for producers that observe it', async () => { + const expected = { + 'apple-runner': false, + 'simulator-ax-bridge': false, + 'appium-source': undefined, + 'limrun-ios-tree': undefined, + } as const satisfies Record; + + for (const producer of Object.keys(expected) as IosSnapshotProducer[]) { + const device = createSnapshotOnlyDevice({ + snapshot: { nodes: [], backend: 'xctest', producer, createdAt: 1 }, + }); + + const result = await device.capture.snapshot({ session: 'default' }); + + assert.equal(result.truncated, expected[producer], producer); + } +}); + test('runtime snapshot uses the Appium sparse-tree disclosure for Appium acquisition', async () => { const device = createSnapshotOnlyDevice({ snapshot: { diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index 5ae5477f95..1927ba0fbf 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -26,7 +26,7 @@ import { buildSnapshotVisibility } from '../../../snapshot/snapshot-visibility.t import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '../../../core/android-system-surface-disclosure.ts'; import { formatReactNativeOverlayWarning } from '../../react-native/overlay.ts'; import { now } from '../../runtime-common.ts'; -import { IOS_SNAPSHOT_PRODUCER_CAPABILITIES } from '@agent-device/capture-kit/ios-snapshot-acquisition'; +import { iosSnapshotTruncationEvidence } from '@agent-device/capture-kit/ios-snapshot-acquisition'; import type { DiffSnapshotCommandOptions, RuntimeCommand, @@ -229,8 +229,7 @@ function snapshotAppFields(capture: SnapshotCapture): { function snapshotTruncationForResult(snapshot: SnapshotState): boolean | undefined { if (snapshot.truncated !== undefined) return snapshot.truncated; if (snapshot.backend !== 'xctest' || snapshot.producer === undefined) return false; - const capability = IOS_SNAPSHOT_PRODUCER_CAPABILITIES[snapshot.producer]; - return capability.truncationEvidence === 'unavailable' ? undefined : false; + return iosSnapshotTruncationEvidence(snapshot.producer) === 'unavailable' ? undefined : false; } function buildSnapshotWarnings(params: {