diff --git a/packages/capture-kit/src/ios-snapshot-acquisition.ts b/packages/capture-kit/src/ios-snapshot-acquisition.ts index 8776dc16a7..e0d644ed66 100644 --- a/packages/capture-kit/src/ios-snapshot-acquisition.ts +++ b/packages/capture-kit/src/ios-snapshot-acquisition.ts @@ -22,7 +22,6 @@ const ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS = { viewportEvidence: 'available', hittabilityEvidence: 'unavailable', truncationEvidence: 'unavailable', - presentationOwner: 'ios-snapshot-engine', } as const; const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = { @@ -38,7 +37,6 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = { viewportEvidence: 'available', hittabilityEvidence: 'available', truncationEvidence: 'available', - presentationOwner: 'ios-snapshot-engine', }, 'simulator-ax-bridge': { ...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS, @@ -49,7 +47,6 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = { }, hittabilityEvidence: 'available', truncationEvidence: 'available', - presentationOwner: 'snapshot-state', }, 'appium-source': { ...ACQUIRED_PRODUCER_CAPABILITY_DEFAULTS, 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 4f53fd9a64..f2445b2760 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/engine.test.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/engine.test.ts @@ -13,9 +13,9 @@ import { } from '@agent-device/capture-kit/ios-snapshot-planning'; import { toIosSnapshotEngineErrorDetails } from './types.ts'; import { - compactIosInteractiveSnapshot, createIosSnapshotEngine, IosSnapshotEngineError, + presentIosInteractiveSnapshot, presentIosSnapshot, publishIosSnapshot, } from './index.ts'; @@ -326,7 +326,7 @@ test('unavailable hittability never becomes regular actionability', () => { test('interactive compaction stays available through the engine boundary', () => { const rowRect = { x: 16, y: 80, width: 288, height: 52 }; - const compacted = compactIosInteractiveSnapshot([ + const compacted = presentIosInteractiveSnapshot([ node(0, 'Application', 'App', viewport), node(1, 'Table', 'Settings', { x: 0, y: 40, width: 320, height: 200 }, 0), node(2, 'Cell', 'General', rowRect, 1, 2), diff --git a/packages/capture-kit/src/ios-snapshot-engine/engine.ts b/packages/capture-kit/src/ios-snapshot-engine/engine.ts index e853c22be0..4ef71b2c5f 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/engine.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/engine.ts @@ -71,10 +71,6 @@ export function presentIosSnapshot( return presentIosRunnerSnapshot(input, request, foldPolicy); } -export function compactIosInteractiveSnapshot(nodes: RawSnapshotNode[]): RawSnapshotNode[] { - return buildIosInteractiveSnapshotPresentation(nodes).nodes; -} - function presentAcquiredSnapshot( acquisition: IosSnapshotAcquisition, 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 dfafc4caf6..c04acf0ea8 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/index.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/index.ts @@ -1,9 +1,4 @@ -export { - compactIosInteractiveSnapshot, - createIosSnapshotEngine, - presentIosSnapshot, - publishIosSnapshot, -} from './engine.ts'; +export { createIosSnapshotEngine, 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 2842610365..c355e47cf4 100644 --- a/packages/capture-kit/src/ios-snapshot-planning.test.ts +++ b/packages/capture-kit/src/ios-snapshot-planning.test.ts @@ -252,7 +252,6 @@ function acquiredProducer( viewportEvidence: 'available', hittabilityEvidence: 'available', truncationEvidence: 'available', - presentationOwner: 'snapshot-state', ...overrides, }; } diff --git a/packages/contracts/src/ios-snapshot.ts b/packages/contracts/src/ios-snapshot.ts index de516c07eb..019a29ee43 100644 --- a/packages/contracts/src/ios-snapshot.ts +++ b/packages/contracts/src/ios-snapshot.ts @@ -15,7 +15,6 @@ export type IosAcquisitionIntent = 'full' | 'surface-observation'; export type IosSnapshotProjection = 'regular' | 'raw'; export type IosSnapshotCompleteness = 'complete' | 'incomplete'; export type IosSnapshotEvidenceAvailability = 'available' | 'unavailable'; -export type IosSnapshotPresentationOwner = 'ios-snapshot-engine' | 'snapshot-state'; export type IosSnapshotGeneration = string; @@ -86,7 +85,6 @@ type IosSnapshotProducerCapabilityFacts = Readonly<{ viewportEvidence: IosSnapshotEvidenceAvailability; hittabilityEvidence: IosSnapshotEvidenceAvailability; truncationEvidence: IosSnapshotEvidenceAvailability; - presentationOwner: IosSnapshotPresentationOwner; }>; export type IosSnapshotAcquisitionProducerCapabilities = IosSnapshotProducerCapabilityFacts & diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index c4c1dfed7c..d5d98ab713 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -198,14 +198,26 @@ type OptionalProducerProvenance = Pair extends { : never; /** - * The provenance carrier for {@link SnapshotState}: the producer may be absent (legacy states - * and fixtures predate it), but a present pair still has to come from the - * {@link SnapshotProvenance} table — the channel may not carry a foreign producer. + * The provenance carrier for {@link SnapshotState}: the producer may be absent (a client-side + * fallback that rebuilds a state from a bare backend result knows the channel and nothing more), + * but a present pair still has to come from the {@link SnapshotProvenance} table — the channel + * may not carry a foreign producer. */ export type SnapshotStateProvenance = | OptionalProducerProvenance | { backend?: undefined; producer?: undefined }; +/** + * The provenance a capture hands to the daemon snapshot assembly: either nothing is known about + * the origin, or the WHOLE pair is. A channel that arrived without its producer would leave the + * assembly guessing who presented the tree, which is exactly the backend-name presentation + * policy #2199 deleted — so it does not compile. Every production capture satisfies this: the + * interactor boundary (`SnapshotResult`) already carries {@link SnapshotProvenance}. + */ +export type SnapshotCaptureProvenance = + | SnapshotProvenance + | { backend?: undefined; producer?: undefined }; + /** * Narrows a provenance-carrying value to just its pair without decorrelating the two fields * (reading `backend` and `producer` separately would lose the pairing for the type system). diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 23a2a9db52..8a398f88e1 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -113,6 +113,7 @@ import { sessionResourceOwnershipViolations } from './session-resource-ownership import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts'; import { iosSnapshotEngineOwnershipViolations } from './ios-snapshot-engine-policy.ts'; import { providerSnapshotPresentationViolations } from './provider-snapshot-presentation-policy.ts'; +import { snapshotAssemblyPresentationViolations } from './snapshot-assembly-presentation-policy.ts'; import { RETIRED_PATH_RULES, retiredPathRuleViolations } from './retired-paths-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { @@ -435,6 +436,7 @@ export const LAYERING_RULE_IDS = [ 'replay-ownership', 'ios-snapshot-engine-ownership', 'provider-snapshot-presentation-ownership', + 'snapshot-assembly-presentation-neutrality', ] as const; export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number]; @@ -487,6 +489,8 @@ export const LAYERING_RULES: Readonly> = { ), 'provider-snapshot-presentation-ownership': (context) => providerSnapshotPresentationViolations(context.sources, context.edges), + 'snapshot-assembly-presentation-neutrality': (context) => + snapshotAssemblyPresentationViolations(context.sources, context.edges), }; export function main(): number { diff --git a/scripts/layering/snapshot-assembly-presentation-policy.test.ts b/scripts/layering/snapshot-assembly-presentation-policy.test.ts new file mode 100644 index 0000000000..0eb79731b9 --- /dev/null +++ b/scripts/layering/snapshot-assembly-presentation-policy.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'node:test'; +import { + SNAPSHOT_ASSEMBLY_FILES, + SNAPSHOT_ASSEMBLY_PRESENTATION_RULE, + snapshotAssemblyPresentationViolations, +} from './snapshot-assembly-presentation-policy.ts'; +import { resolveImportEdges } from './model.ts'; +import { workspaceSpecifierTargets } from './package-boundaries.ts'; +import { listTrackedProductionSources } from './tracked-sources.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const assemblyFile = 'src/core/snapshot-state.ts'; +const producerAdapter = 'packages/platform-apple/src/snapshot-source/adapter.ts'; + +function currentSources(overrides: ReadonlyMap = new Map()): Map { + const sources = new Map( + listTrackedProductionSources(repoRoot).map((file) => [ + file, + fs.readFileSync(path.join(repoRoot, file), 'utf8'), + ]), + ); + for (const [file, source] of overrides) sources.set(file, source); + return sources; +} + +function violations(overrides: ReadonlyMap = new Map()) { + const sources = currentSources(overrides); + return snapshotAssemblyPresentationViolations( + sources, + resolveImportEdges(sources, workspaceSpecifierTargets(repoRoot)), + ); +} + +function appended(file: string, addition: string): ReadonlyMap { + return new Map([[file, `${fs.readFileSync(path.join(repoRoot, file), 'utf8')}${addition}`]]); +} + +test('the snapshot assembly and the producer adapters are presentation-neutral', () => { + assert.deepEqual(violations(), []); +}); + +test('R74 rejects the assembly importing the iOS snapshot engine', () => { + const result = violations( + appended( + assemblyFile, + `\nimport { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';\nvoid presentIosInteractiveSnapshot;\n`, + ), + ); + + assert.ok( + result.some( + (entry) => + entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE && + entry.file === assemblyFile && + entry.message.includes('must not import iOS presentation'), + ), + JSON.stringify(result), + ); +}); + +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`, + ), + ); + + assert.ok( + result.some( + (entry) => + entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE && + entry.file === assemblyFile && + entry.message.includes('ios-snapshot-acquisition'), + ), + JSON.stringify(result), + ); +}); + +test('R74 rejects a backend-name presentation branch in the assembly', () => { + const result = violations( + appended( + assemblyFile, + `\nexport function presentsHere(backend: string): boolean {\n return backend === 'xctest';\n}\n`, + ), + ); + + assert.ok( + result.some( + (entry) => + entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE && + entry.file === assemblyFile && + entry.message.includes("('xctest')"), + ), + JSON.stringify(result), + ); +}); + +test('R74 rejects an assembly branch on a producer name', () => { + const result = violations( + appended(assemblyFile, `\nexport const legacyOwner = 'simulator-ax-bridge';\n`), + ); + + assert.ok( + result.some( + (entry) => + entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE && + entry.file === assemblyFile && + entry.message.includes("('simulator-ax-bridge')"), + ), + JSON.stringify(result), + ); +}); + +test('R74 rejects a producer adapter importing iOS presentation', () => { + const result = violations( + appended( + producerAdapter, + `\nimport { presentIosSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';\nvoid presentIosSnapshot;\n`, + ), + ); + + assert.ok( + result.some( + (entry) => + entry.rule === SNAPSHOT_ASSEMBLY_PRESENTATION_RULE && + entry.file === producerAdapter && + entry.message.includes('producers report acquisition facts'), + ), + JSON.stringify(result), + ); +}); + +test('R74 fails closed when the assembly moves out from under it', () => { + const sources = currentSources(); + for (const file of SNAPSHOT_ASSEMBLY_FILES) sources.delete(file); + + const result = snapshotAssemblyPresentationViolations( + sources, + resolveImportEdges(sources, workspaceSpecifierTargets(repoRoot)), + ); + + assert.deepEqual( + result.filter((entry) => entry.message.includes('is missing')).map((entry) => entry.file), + [...SNAPSHOT_ASSEMBLY_FILES], + ); +}); diff --git a/scripts/layering/snapshot-assembly-presentation-policy.ts b/scripts/layering/snapshot-assembly-presentation-policy.ts new file mode 100644 index 0000000000..e801e48732 --- /dev/null +++ b/scripts/layering/snapshot-assembly-presentation-policy.ts @@ -0,0 +1,141 @@ +// Catches: the daemon snapshot assembly presenting an iOS tree a second time, or a producer +// adapter presenting one at all — the shape #2199 deleted. Before it, `buildSnapshotState` +// imported the iOS snapshot engine and re-ran semantic compaction whenever a producer-name +// branch said the engine had not, so an engine-presented Simulator bridge tree was compacted +// twice (#2188 invariant 2, "geometric presentation occurs exactly once"). Nothing about that +// regrowth is visible at a call site: the second pass is a legal call to a legal export, and on +// a tree where compaction happens to be idempotent it produces no test failure at all. +// Evidence: 7ee1a5ded7 (#2233) carried provider acquisitions through one presentation owner and +// left the bridge behind; d26b0786fb (#2329) put the bridge on the engine without retiring the +// branch that assumed it was not; #2199 deleted the branch this policy now holds deleted. +// Cost: 292 LOC (147 rule + 145 test). +// Kill criterion: none enforced today; retire only by maintainer decision that the assembly +// staying presentation-neutral no longer matters. An exports map cannot replace it: the engine +// subpath is a legitimate public entrypoint for the engine's own callers, so a manifest can only +// ban it for everyone or no one, and no manifest sees a string literal at all. + +import { parseSync } from 'oxc-parser'; +import type { LayeringViolation, ResolvedImportEdge } from './model.ts'; +import { visitAst } from './layering-ast.ts'; + +export const SNAPSHOT_ASSEMBLY_PRESENTATION_RULE = 'R74 snapshot-assembly-presentation-neutrality'; + +/** + * The daemon assembly of a captured tree. It normalizes, prunes, annotates occlusion and attaches + * refs; it does not present, and it does not know which channel it is holding. + */ +export const SNAPSHOT_ASSEMBLY_FILES: readonly string[] = [ + 'src/core/snapshot-state.ts', + 'src/daemon/snapshot-capture.ts', +]; + +/** Producer adapters: they report acquisition facts and nothing else. */ +export const SNAPSHOT_PRODUCER_ADAPTER_ROOTS: readonly string[] = [ + 'packages/platform-apple/src/snapshot-source/', +]; + +/** + * iOS presentation. The engine owns projection, geometry, scope, depth, actionability narrowing + * and semantic compaction; the acquisition module owns the producer capability table that decides + * them. Reading either one from the assembly or from a producer adapter is how the deleted + * backend-name policy grows back. + */ +const IOS_PRESENTATION_TARGETS: readonly string[] = [ + 'packages/capture-kit/src/ios-snapshot-engine/', + 'packages/capture-kit/src/ios-snapshot-acquisition.ts', +]; + +/** + * The iOS channel and its producers. Naming one inside the assembly is a presentation or scope + * decision by backend name, which #2188 invariant 6 routes through typed capabilities instead. + */ +const IOS_PROVENANCE_LITERALS: ReadonlySet = new Set([ + 'xctest', + 'apple-runner', + 'simulator-ax-bridge', + 'appium-source', + 'limrun-ios-tree', +]); + +export function snapshotAssemblyPresentationViolations( + sources: ReadonlyMap, + edges: readonly ResolvedImportEdge[], +): LayeringViolation[] { + return [ + ...missingOwnerViolations(sources), + ...importViolations(edges), + ...provenanceLiteralViolations(sources), + ]; +} + +/** + * A rule that silently checks nothing is worse than no rule. If the assembly moves, this fails + * rather than passing over an empty file set. + */ +function missingOwnerViolations(sources: ReadonlyMap): LayeringViolation[] { + return SNAPSHOT_ASSEMBLY_FILES.filter((file) => !sources.has(file)).map((file) => ({ + rule: SNAPSHOT_ASSEMBLY_PRESENTATION_RULE, + file, + line: 1, + message: `${file} is missing, so the snapshot assembly's presentation neutrality cannot be checked; point SNAPSHOT_ASSEMBLY_FILES at the assembly's new home`, + })); +} + +function importViolations(edges: readonly ResolvedImportEdge[]): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const edge of edges) { + if (!isIosPresentationTarget(edge.target)) continue; + if (SNAPSHOT_ASSEMBLY_FILES.includes(edge.file)) { + violations.push({ + rule: SNAPSHOT_ASSEMBLY_PRESENTATION_RULE, + file: edge.file, + line: edge.line, + message: `the daemon snapshot assembly must not import iOS presentation (${edge.target}); an iOS capture is presented once, by the engine, before it reaches the assembly`, + }); + continue; + } + if (isProducerAdapter(edge.file)) { + violations.push({ + rule: SNAPSHOT_ASSEMBLY_PRESENTATION_RULE, + file: edge.file, + line: edge.line, + message: `a producer adapter must not import iOS presentation (${edge.target}); producers report acquisition facts and the engine presents them`, + }); + } + } + return violations; +} + +function provenanceLiteralViolations(sources: ReadonlyMap): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const file of SNAPSHOT_ASSEMBLY_FILES) { + const source = sources.get(file); + if (source === undefined) continue; + const program = parseSync(file, source).program; + visitAst(program, (node) => { + if (node.type !== 'Literal' || typeof node.value !== 'string') return; + if (!IOS_PROVENANCE_LITERALS.has(node.value)) return; + violations.push({ + rule: SNAPSHOT_ASSEMBLY_PRESENTATION_RULE, + file, + line: sourceLine(source, node.start as number | undefined), + message: `the daemon snapshot assembly must not branch on the iOS channel or producer name ('${node.value}'); producer differences enter through typed capabilities, read by the engine`, + }); + }); + } + return violations; +} + +function isIosPresentationTarget(target: string): boolean { + return IOS_PRESENTATION_TARGETS.some( + (owner) => target === owner || target.startsWith(owner) || target === owner.replace(/\/$/, ''), + ); +} + +function isProducerAdapter(file: string): boolean { + return SNAPSHOT_PRODUCER_ADAPTER_ROOTS.some((root) => file.startsWith(root)); +} + +function sourceLine(source: string, offset: number | undefined): number { + return source.slice(0, typeof offset === 'number' ? offset : 0).split('\n').length; +} diff --git a/src/__tests__/android-ui-hierarchy-scope.test.ts b/src/__tests__/android-ui-hierarchy-scope.test.ts index 5496ee73bc..3f76e14c53 100644 --- a/src/__tests__/android-ui-hierarchy-scope.test.ts +++ b/src/__tests__/android-ui-hierarchy-scope.test.ts @@ -121,7 +121,10 @@ test('a scoped Android snapshot is not re-scoped after the wire', () => { 'projection output: scoped root at depth 0, its children below it', ); - const state = buildSnapshotState({ nodes, backend: 'android' }, { snapshotScope: 'panel' }); + const state = buildSnapshotState( + { nodes, backend: 'android', producer: 'android-uiautomator' }, + { snapshotScope: 'panel' }, + ); assert.deepEqual( state.nodes.map((node) => node.label ?? node.identifier), ['panel', 'Save', 'Cancel'], diff --git a/src/__tests__/android-ui-hierarchy.test.ts b/src/__tests__/android-ui-hierarchy.test.ts index ae389d4695..dbb39d272e 100644 --- a/src/__tests__/android-ui-hierarchy.test.ts +++ b/src/__tests__/android-ui-hierarchy.test.ts @@ -128,7 +128,7 @@ test('interactive Android snapshots keep a fixed sibling outside filtered scroll const parsed = parseUiHierarchy(xml, 800, { interactiveOnly: true }); const snapshot = buildSnapshotState( - { nodes: parsed.nodes, backend: 'android' }, + { nodes: parsed.nodes, backend: 'android', producer: 'android-uiautomator' }, { snapshotInteractiveOnly: true }, ); const header = snapshot.nodes.find((node) => node.identifier === 'header-action'); diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index 42966bfd99..5ae5477f95 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -221,17 +221,16 @@ function snapshotAppFields(capture: SnapshotCapture): { }; } +/** + * A capture that reported nothing about truncation is only "not truncated" when its producer + * actually observes truncation. Producers that do not (Appium page source, the Limrun element + * tree) leave it unknown rather than having the absence upgraded to `false` (#2188 invariant 5). + */ 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]; - if (!capability) return false; - const acquisitionDepthUnknown = - capability.stage === 'acquired' && - capability.presentationOwner === 'ios-snapshot-engine' && - (capability.acquisitionDepth.rawTraversal.kind === 'incomplete' || - capability.acquisitionDepth.regularPresented.kind === 'incomplete'); - return acquisitionDepthUnknown ? undefined : false; + return capability.truncationEvidence === 'unavailable' ? undefined : false; } function buildSnapshotWarnings(params: { diff --git a/src/core/__tests__/ios-snapshot-presentation-once.test.ts b/src/core/__tests__/ios-snapshot-presentation-once.test.ts new file mode 100644 index 0000000000..8263ee477b --- /dev/null +++ b/src/core/__tests__/ios-snapshot-presentation-once.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from 'vitest'; +import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine'; +import type { + IosAcquisitionProducer, + IosSnapshotProducer, +} from '@agent-device/contracts/ios-snapshot'; +import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types'; +import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot'; +import { presentIosSnapshotAcquisition } from '../../snapshot/ios-snapshot-runtime.ts'; +import { buildSnapshotState } from '../snapshot-state.ts'; + +/** + * #2188 invariant 2: geometric presentation happens exactly ONCE per capture. Before #2199 the + * daemon assembly ran the iOS semantic presentation a second time for any producer whose + * capability table still claimed `presentationOwner: 'snapshot-state'`, so an engine-presented + * bridge tree was compacted twice. These tests pin both halves — the engine presents, and the + * assembly is the identity on an already-presented iOS tree — for every live producer. + */ + +const VIEWPORT: Rect = { x: 0, y: 0, width: 320, height: 480 }; +const ROW: Rect = { x: 16, y: 80, width: 288, height: 52 }; + +const ACQUISITION_PRODUCERS: readonly IosAcquisitionProducer[] = [ + 'simulator-ax-bridge', + 'appium-source', + 'limrun-ios-tree', +]; +const PRODUCERS: readonly IosSnapshotProducer[] = ['apple-runner', ...ACQUISITION_PRODUCERS]; + +/** A row whose Cell/Button/StaticText share one label: the shape iOS compaction folds. */ +function collapsibleNodes(): RawSnapshotNode[] { + return [ + node(0, 'Application', 'App', VIEWPORT), + node(1, 'Table', 'Settings', { x: 0, y: 40, width: 320, height: 300 }, 0, 1), + node(2, 'Cell', 'General', ROW, 1, 2), + node(3, 'Button', 'General', ROW, 2, 3), + node(4, 'StaticText', 'General', ROW, 3, 4), + ]; +} + +function node( + index: number, + type: string, + label: string, + rect: Rect, + parentIndex?: number, + depth?: number, +): RawSnapshotNode { + return { + index, + type, + label, + rect, + parentIndex, + depth: depth ?? 0, + enabled: true, + hittable: type === 'Button', + }; +} + +function acquired( + producer: IosAcquisitionProducer, + nodes: RawSnapshotNode[], +): SnapshotRuntimeAcquiredResult { + return { + stage: 'acquired', + acquisition: { + producer, + intent: 'full', + nodes, + truncated: false, + viewport: { kind: 'reported', rect: VIEWPORT }, + lineage: { targetId: 'ios-1:com.example.app', generation: 'launch-a' }, + residue: + producer === 'simulator-ax-bridge' + ? [{ kind: 'unavailable-fact', fact: 'acquisition-depth' }] + : [ + { kind: 'unavailable-fact', fact: 'hittability' }, + { kind: 'unavailable-fact', fact: 'acquisition-depth' }, + { kind: 'unavailable-fact', fact: 'truncation' }, + ], + }, + }; +} + +const shape = (nodes: readonly RawSnapshotNode[]) => + nodes.map((entry) => [entry.type, entry.label]); + +// The positive control for every fixed-point assertion below: the acquired tree genuinely needs +// compaction, so "already compacted" cannot pass by the rules having stopped matching. +test('the collapsible fixture is not already a presentation fixed point', () => { + const acquisitionNodes = collapsibleNodes(); + + expect(presentIosInteractiveSnapshot(acquisitionNodes).length).toBeLessThan( + acquisitionNodes.length, + ); +}); + +test.each(ACQUISITION_PRODUCERS)( + 'iOS presentation runs exactly once from acquisition to published state (%s)', + (producer) => { + const result = presentIosSnapshotAcquisition(acquired(producer, collapsibleNodes()), { + interactiveOnly: true, + }); + const published = result.nodes ?? []; + + // Presented at least once: the engine's output is a fixed point of the presentation rules. + expect(shape(presentIosInteractiveSnapshot([...published]))).toEqual(shape(published)); + // Presented at most once: the daemon assembly passes the engine's tree through untouched. + expect(shape(buildSnapshotState(result, { snapshotInteractiveOnly: true }).nodes)).toEqual( + shape(published), + ); + }, +); + +test.each(PRODUCERS)('the daemon assembly never presents an iOS tree (%s)', (producer) => { + const nodes = collapsibleNodes(); + + const state = buildSnapshotState( + { nodes, backend: 'xctest', producer }, + { snapshotInteractiveOnly: true }, + ); + + expect(shape(state.nodes)).toEqual(shape(nodes)); +}); + +test.each(PRODUCERS)( + 'the daemon assembly never scopes an iOS tree a second time (%s)', + (producer) => { + const nodes = collapsibleNodes(); + + // The engine owns iOS scope, so a scope the assembly cannot match must not reach the tree: a + // second post-wire pass would return the empty no-match slice instead of the presented tree. + const state = buildSnapshotState( + { nodes, backend: 'xctest', producer }, + { snapshotInteractiveOnly: true, snapshotScope: 'no-such-scope' }, + ); + + expect(shape(state.nodes)).toEqual(shape(nodes)); + }, +); diff --git a/src/core/__tests__/snapshot-state.test.ts b/src/core/__tests__/snapshot-state.test.ts index 1380e5a5d2..4094929d1d 100644 --- a/src/core/__tests__/snapshot-state.test.ts +++ b/src/core/__tests__/snapshot-state.test.ts @@ -26,6 +26,7 @@ test('buildSnapshotState carries structured snapshot quality verdicts', () => { { nodes: [{ index: 0, type: 'Application' }], backend: 'xctest', + producer: 'apple-runner', quality: { state: 'sparse', backend: 'private-ax', @@ -97,7 +98,10 @@ test('buildSnapshotState preserves Android effective geometry for post-wire cons `; const built = buildUiHierarchySnapshot(parseUiHierarchyTree(xml), undefined, {}); - const state = buildSnapshotState({ nodes: built.nodes, backend: 'android' }, undefined); + const state = buildSnapshotState( + { nodes: built.nodes, backend: 'android', producer: 'android-uiautomator' }, + undefined, + ); const target = state.nodes.find((node) => node.label === 'Partially visible'); expect(target).toMatchObject({ @@ -116,6 +120,7 @@ test('buildSnapshotState handles nodes with missing fields', () => { ], truncated: false, backend: 'android', + producer: 'android-uiautomator', }, undefined, ); @@ -128,58 +133,28 @@ test('buildSnapshotState marks comparisonSafe false for filtered Android snapsho const nodes = [{ index: 0, depth: 0, type: 'android.widget.TextView', label: 'A' }]; const interactiveOnly = buildSnapshotState( - { nodes, backend: 'android' }, + { nodes, backend: 'android', producer: 'android-uiautomator' }, { snapshotInteractiveOnly: true }, ); expect(interactiveOnly.comparisonSafe).toBe(false); - const withDepth = buildSnapshotState({ nodes, backend: 'android' }, { snapshotDepth: 2 }); + const withDepth = buildSnapshotState( + { nodes, backend: 'android', producer: 'android-uiautomator' }, + { snapshotDepth: 2 }, + ); expect(withDepth.comparisonSafe).toBe(false); - const withScope = buildSnapshotState({ nodes, backend: 'android' }, { snapshotScope: 'Header' }); + const withScope = buildSnapshotState( + { nodes, backend: 'android', producer: 'android-uiautomator' }, + { snapshotScope: 'Header' }, + ); expect(withScope.comparisonSafe).toBe(false); - const unfiltered = buildSnapshotState({ nodes, backend: 'android' }, {}); - expect(unfiltered.comparisonSafe).toBe(true); -}); - -test('buildSnapshotState leaves Apple runner presentation to the engine', () => { - const nodes = [ - { index: 0, depth: 0, type: 'Application', label: 'Settings' }, - { index: 1, depth: 1, parentIndex: 0, type: 'Table', label: 'Settings' }, - { index: 2, depth: 2, parentIndex: 1, type: 'Cell', label: 'General' }, - { index: 3, depth: 3, parentIndex: 2, type: 'Button', label: 'General' }, - ]; - - const state = buildSnapshotState( - { nodes, backend: 'xctest', producer: 'apple-runner' }, - { snapshotInteractiveOnly: true }, + const unfiltered = buildSnapshotState( + { nodes, backend: 'android', producer: 'android-uiautomator' }, + {}, ); - - expect(state.nodes.map((node) => node.type)).toEqual(['Application', 'Table', 'Cell', 'Button']); -}); - -test('buildSnapshotState uses the registered presentation owner for Appium results', () => { - const rowRect = { x: 16, y: 293, width: 370, height: 52 }; - const data = { - nodes: [ - { index: 0, depth: 0, type: 'Application', label: 'Settings' }, - { index: 1, depth: 1, parentIndex: 0, type: 'CollectionView' }, - { index: 2, depth: 2, parentIndex: 1, type: 'Cell', label: 'General', rect: rowRect }, - { index: 3, depth: 3, parentIndex: 2, type: 'Button', label: 'General', rect: rowRect }, - ], - backend: 'xctest' as const, - producer: 'appium-source' as const, - }; - - const state = buildSnapshotState(data, { snapshotInteractiveOnly: true }); - - expect(state.nodes.map((node) => [node.type, node.label])).toEqual([ - ['Application', 'Settings'], - ['CollectionView', undefined], - ['Cell', 'General'], - ['Button', 'General'], - ]); + expect(unfiltered.comparisonSafe).toBe(true); }); test('Appium presentation does not infer hittability from an enabled ancestor rectangle', () => { @@ -255,6 +230,7 @@ test('buildSnapshotState marks content covered by floating overlays as visible b }, ], backend: 'xctest', + producer: 'apple-runner', }, undefined, ); @@ -299,6 +275,7 @@ test('buildSnapshotState marks Android app content covered by IME overlays as bl }, ], backend: 'android', + producer: 'android-uiautomator', }, undefined, ); @@ -354,7 +331,7 @@ test('buildSnapshotState keeps a sparse Android overlay actionable above scrolla ]; const state = buildSnapshotState( attachSnapshotOcclusionContextEvidence( - { nodes, backend: 'android' as const }, + { nodes, backend: 'android', producer: 'android-uiautomator' as const }, { nodes, sourceIndexByNodeIndex: new Map(nodes.map((node) => [node.index, node.index])), @@ -405,7 +382,7 @@ test('buildSnapshotState handles a maximum-size deeply nested Android replacemen ]; const state = buildSnapshotState( attachSnapshotOcclusionContextEvidence( - { nodes, backend: 'android' as const }, + { nodes, backend: 'android', producer: 'android-uiautomator' as const }, { nodes, sourceIndexByNodeIndex: new Map(nodes.map((node) => [node.index, node.index])), @@ -463,6 +440,7 @@ test('buildSnapshotState treats large Android IME subtrees as one overlay root', ...imeChildren, ], backend: 'android', + producer: 'android-uiautomator', }, undefined, ); @@ -503,6 +481,7 @@ test('buildSnapshotState does not treat later generic hittable containers as cov }, ], backend: 'xctest', + producer: 'apple-runner', }, undefined, ); @@ -561,6 +540,7 @@ test('buildSnapshotState does not let covered overlays cover earlier targets', ( }, ], backend: 'xctest', + producer: 'apple-runner', }, undefined, ); @@ -608,6 +588,7 @@ test('buildSnapshotState leaves raw snapshot hittability untouched', () => { }, ], backend: 'xctest', + producer: 'apple-runner', }, { snapshotRaw: true }, ); @@ -639,6 +620,7 @@ test('buildSnapshotState preserves macOS helper scope behavior', () => { { index: 1, depth: 1, parentIndex: 0, type: 'Button', label: 'Target' }, ], backend: 'macos-helper', + producer: 'macos-helper', }, { snapshotScope: 'missing scope' }, ); diff --git a/src/core/snapshot-state.ts b/src/core/snapshot-state.ts index d81ee54706..2e6183d0c8 100644 --- a/src/core/snapshot-state.ts +++ b/src/core/snapshot-state.ts @@ -10,7 +10,7 @@ import { snapshotPresentationOptionsFromFlags, type RawSnapshotNode, type SnapshotBackend, - type SnapshotStateProvenance, + type SnapshotCaptureProvenance, snapshotStateProvenance, type SnapshotState, } from '@agent-device/kernel/snapshot'; @@ -21,8 +21,6 @@ import { import { coveredAndroidReplacementNodeIndexes } from '../snapshot/android-replacement-surface-occlusion.ts'; import { scopeSnapshotNodes } from '@agent-device/capture-kit/snapshot-desktop-projection'; import { normalizeSnapshotTree, pruneGroupNodes } from '../core/snapshot-tree-ingestion.ts'; -import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine'; -import { IOS_SNAPSHOT_PRODUCER_CAPABILITIES } from '@agent-device/capture-kit/ios-snapshot-acquisition'; import { iosSnapshotComparisonIdentityKey } from '@agent-device/capture-kit/ios-snapshot-planning'; import type { IosSnapshotComparisonIdentity } from '@agent-device/contracts/ios-snapshot'; @@ -33,6 +31,10 @@ import type { IosSnapshotComparisonIdentity } from '@agent-device/contracts/ios- * snapshot command, selector captures, settle observation, Android blocking-dialog recovery — * goes through here, so no two call sites can disagree about what a snapshot contains. * + * The assembly does not present. iOS acquisitions are presented exactly once, by the snapshot + * engine, before they reach here (#2199 / #2188 invariant 2), and a capture arrives with its + * whole provenance pair so no branch here can rediscover who presented it. + * * Kept below the daemon-server type cycle on purpose: it needs no session state. */ export function buildSnapshotState( @@ -41,7 +43,7 @@ export function buildSnapshotState( truncated?: boolean; quality?: unknown; comparisonIdentity?: IosSnapshotComparisonIdentity; - } & SnapshotStateProvenance, + } & SnapshotCaptureProvenance, flags: | (Pick & Partial>) @@ -57,13 +59,10 @@ export function buildSnapshotState( const normalizedNodes = normalizeSnapshotTree( snapshotRaw ? backendAnnotatedNodes : pruneGroupNodes(backendAnnotatedNodes), ); - const presentableNodes = shouldPresentLegacyIosInteractiveSnapshot(data, flags) - ? presentIosInteractiveSnapshot(normalizedNodes) - : normalizedNodes; const scopedNodes = flags?.snapshotScope && backendScopesAfterWire(data?.backend) - ? scopeSnapshotNodes(presentableNodes, flags.snapshotScope) - : presentableNodes; + ? scopeSnapshotNodes(normalizedNodes, flags.snapshotScope) + : normalizedNodes; const snapshotQuality = snapshotCaptureAnnotationsFrom(data).quality; const nodes = attachRefs( snapshotRaw @@ -117,56 +116,23 @@ function annotateAndroidReplacementSurfaces( } /** - * Scope resolves once per snapshot. Android and XCTest resolve it inside their projection (the - * platform matchers implement the shared scope specification, `@agent-device/contracts/snapshot`), - * and the macOS helper scopes at capture; a second pass here would re-match inside an already-scoped - * tree and hand the two layers different no-match semantics (#1832 C2). + * Scope resolves once per snapshot, and this names the channels that still need the post-wire + * pass. Every other channel scopes inside its own projection — Android and the macOS helper at + * capture, iOS in the snapshot engine — and a second pass would re-match inside an already-scoped + * tree and hand the two layers different no-match semantics (#1832 C2). Naming the channels that + * need the pass rather than the ones that do not keeps iOS out of post-wire scope planning + * entirely, so the list shrinks as a channel takes ownership instead of growing by exclusion + * (#2199). */ function backendScopesAfterWire(backend: SnapshotBackend | undefined): boolean { - return backend !== 'macos-helper' && backend !== 'android' && backend !== 'xctest'; -} - -function shouldPresentLegacyIosInteractiveSnapshot( - provenance: object & SnapshotStateProvenance, - flags: - | (Pick & - Partial>) - | undefined, -): boolean { return ( - provenance.backend === 'xctest' && - iosSnapshotPresentationStage(provenance) === 'acquired' && - iosSnapshotPresentationOwner(provenance) !== 'ios-snapshot-engine' && - flags?.snapshotInteractiveOnly === true && - flags.snapshotRaw !== true + backend === undefined || + backend === 'linux-atspi' || + backend === 'harmonyos-arkui' || + backend === 'web' ); } -function iosSnapshotPresentationStage( - provenance: SnapshotStateProvenance, -): 'acquired' | 'presented' | undefined { - if (provenance.backend !== 'xctest') return undefined; - if (provenance.producer === undefined) return 'acquired'; - return iosSnapshotCapabilities(provenance)?.stage; -} - -function iosSnapshotPresentationOwner( - provenance: SnapshotStateProvenance, -): 'ios-snapshot-engine' | 'snapshot-state' | undefined { - return iosSnapshotCapabilities(provenance)?.presentationOwner; -} - -function iosSnapshotCapabilities(provenance: SnapshotStateProvenance) { - if (provenance.backend !== 'xctest' || provenance.producer === undefined) return undefined; - return IOS_SNAPSHOT_PRODUCER_CAPABILITIES[ - provenance.producer as - | 'apple-runner' - | 'simulator-ax-bridge' - | 'appium-source' - | 'limrun-ios-tree' - ]; -} - function isAndroidComparisonSafeSnapshot( backend: SnapshotBackend | undefined, flags: diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 06898dd425..fb67b7463f 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -1,5 +1,4 @@ import { beforeEach, expect, test, vi } from 'vitest'; -import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; import type { CommandFlags } from '@agent-device/contracts/command'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -23,7 +22,8 @@ vi.mock('../interaction/index.ts', async (importOriginal) => { captureSnapshotForSession: vi.fn(async () => ({ nodes: [], createdAt: 0, - backend: 'xctest' as const, + backend: 'xctest', + producer: 'apple-runner' as const, })), }; }); @@ -86,10 +86,7 @@ async function emulateCaptureSnapshotForSession( postGestureStabilizationPending: session.postGestureStabilization !== undefined, }); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; - const snapshotData = (await mockDispatch('snapshot')) as { - nodes?: never[]; - backend?: SnapshotBackend; - }; + const snapshotData = (await mockDispatch('snapshot')) as Parameters[0]; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); setSessionSnapshot(session, snapshot); sessionStore.set(session.name, session); @@ -102,7 +99,7 @@ function mockCommandDispatch(snapshots: Array) { if (command === 'snapshot') { const nodes = snapshots[Math.min(snapshotCalls, snapshots.length - 1)]; snapshotCalls += 1; - return { nodes, backend: 'xctest' }; + return { nodes, backend: 'xctest', producer: 'apple-runner' }; } return {}; }); @@ -124,7 +121,10 @@ const mockDispatch = vi.fn<(command: string) => Promise> function seedSession(sessionName: string, sessionStore: SessionStore): SessionState { const session = makeIosSession(sessionName); - setSessionSnapshot(session, buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest' }, {})); + setSessionSnapshot( + session, + buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }, {}), + ); activateCompleteRefFrame(session); sessionStore.set(sessionName, session); return session; @@ -226,7 +226,8 @@ test('back --settle answers with the settled diff alongside the command result', const sessionName = 'generic-settle-back'; const session = seedSession(sessionName, sessionStore); mockDispatch.mockImplementation(async (command) => { - if (command === 'snapshot') return { nodes: AFTER_NODES, backend: 'xctest' }; + if (command === 'snapshot') + return { nodes: AFTER_NODES, backend: 'xctest', producer: 'apple-runner' }; return { action: 'back', mode: 'in-app', message: 'Back' }; }); @@ -300,7 +301,10 @@ test('a settle observation that cannot build a runtime degrades instead of faili // SESSION_NOT_FOUND, and the observation is best-effort: the scroll already // happened, so the response keeps its result and simply carries no settle. const session = makeIosSession(sessionName); - setSessionSnapshot(session, buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest' }, {})); + setSessionSnapshot( + session, + buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }, {}), + ); activateCompleteRefFrame(session); mockCommandDispatch([AFTER_NODES]); diff --git a/src/daemon/__tests__/snapshot-presentation-transitions.test.ts b/src/daemon/__tests__/snapshot-presentation-transitions.test.ts index f9123598dc..9b96e46abb 100644 --- a/src/daemon/__tests__/snapshot-presentation-transitions.test.ts +++ b/src/daemon/__tests__/snapshot-presentation-transitions.test.ts @@ -5,9 +5,13 @@ import { buildSnapshotState } from '../../core/snapshot-state.ts'; import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine'; import { navigationTitleWithAppProvidedDetailsAffordanceNodes } from '../../snapshot/snapshot-presentation/ios/transitions.fixtures.ts'; -test('iOS daemon presentation applies transitions without reapplying runner-owned scope', () => { +test('the daemon publishes engine transitions without reapplying runner-owned scope', () => { const snapshot = buildSnapshotState( - { nodes: navigationTitleWithAppProvidedDetailsAffordanceNodes, backend: 'xctest' }, + { + nodes: presentIosInteractiveSnapshot(navigationTitleWithAppProvidedDetailsAffordanceNodes), + backend: 'xctest', + producer: 'apple-runner', + }, { snapshotInteractiveOnly: true, snapshotScope: 'DisplayNameTextField' }, ); diff --git a/src/daemon/__tests__/snapshot-publication-membership.test.ts b/src/daemon/__tests__/snapshot-publication-membership.test.ts index 7cc1232f5d..6f7fcfca8f 100644 --- a/src/daemon/__tests__/snapshot-publication-membership.test.ts +++ b/src/daemon/__tests__/snapshot-publication-membership.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine'; import { buildSnapshotState } from '../../core/snapshot-state.ts'; // End-to-end publication-membership contract for the acquire/present design (#1797, external @@ -10,8 +11,15 @@ import { buildSnapshotState } from '../../core/snapshot-state.ts'; // Non-vacuity: with the collectIosStructuralIdentifierSuppression call disabled in noise.ts, the // suppression test below fails because promo-banner is published. It passes only when the // production suppression fires. +// +// The two stages run in the production order (#2199): the engine presents the acquired tree, +// then the daemon assembly publishes it. The assembly presents nothing of its own, so the +// engine's presenter has to be the one that decides membership. function publish(nodes: RawSnapshotNode[]) { - return buildSnapshotState({ nodes, backend: 'xctest' }, { snapshotInteractiveOnly: true }).nodes; + return buildSnapshotState( + { nodes: presentIosInteractiveSnapshot(nodes), backend: 'xctest', producer: 'apple-runner' }, + { snapshotInteractiveOnly: true }, + ).nodes; } const screen: RawSnapshotNode[] = [ diff --git a/src/daemon/interaction/internal/__tests__/interaction-get.test.ts b/src/daemon/interaction/internal/__tests__/interaction-get.test.ts index 11f81eb54c..205bc5f802 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-get.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-get.test.ts @@ -90,6 +90,7 @@ test('get text prefers underlying value for text surfaces and avoids recording g ]), createdAt: Date.now(), backend: 'xctest', + producer: 'apple-runner', }; sessionStore.set(sessionName, session); @@ -140,6 +141,7 @@ test('get text uses backend read expansion when the resolved node has a rect', a ]), createdAt: Date.now(), backend: 'xctest', + producer: 'apple-runner', }; sessionStore.set(sessionName, session); @@ -188,6 +190,7 @@ test('get text answers from the captured tree when the bound owner advertises no ]), createdAt: Date.now(), backend: 'xctest', + producer: 'apple-runner', }; sessionStore.set(sessionName, session); elementReadFixtureState.readTextAtPointAvailable = false; @@ -262,6 +265,7 @@ test('get text simple iOS id selector resolves through the bound capture, not a sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', + producer: 'apple-runner', nodes: [ { index: 0, @@ -319,6 +323,7 @@ test('get text iOS label selector uses snapshot disambiguation instead of runner sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', + producer: 'apple-runner', nodes: [ { index: 0, @@ -415,6 +420,7 @@ test('is visible preserves CLI snapshot flags during runtime snapshot capture', }, ], backend: 'xctest', + producer: 'apple-runner', }; }); @@ -446,7 +452,10 @@ test('is visible reuses fresh cached iOS snapshots with rects', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-visible-cached'; const session = makeSession(sessionName); - session.snapshot = makeVisibleButtonSnapshot('Cached action', 'xctest'); + session.snapshot = makeVisibleButtonSnapshot('Cached action', { + backend: 'xctest', + producer: 'apple-runner', + }); sessionStore.set(sessionName, session); legacyDispatchCapture.mockRejectedValue(new Error('unexpected fresh snapshot')); @@ -477,11 +486,17 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy { nodes: [{ index: 0, type: 'button', label: 'Submit order' }], backend: 'web', + producer: 'agent-browser', }, { snapshotInteractiveOnly: false }, ); sessionStore.set(sessionName, session); - legacyDispatchCapture.mockResolvedValue(makeVisibleButtonSnapshot('Submit order', 'web')); + legacyDispatchCapture.mockResolvedValue( + makeVisibleButtonSnapshot('Submit order', { + backend: 'web', + producer: 'agent-browser', + }), + ); const response = await handleInteractionCommands({ req: { @@ -578,6 +593,7 @@ test('is visible passes for list text that inherits viewport visibility from an }, ], backend: 'xctest', + producer: 'apple-runner', }; }); @@ -624,6 +640,7 @@ test('is visible fails for nodes outside the current viewport', async () => { }, ], backend: 'xctest', + producer: 'apple-runner', }; }); @@ -715,6 +732,7 @@ test('ADR 0014 evidence #17: get text @ref reads the retained frame tree, not a ] as never), createdAt: Date.now(), backend: 'xctest', + producer: 'apple-runner', }); sessionStore.set(sessionName, session); legacyDispatchCapture.mockRejectedValue(new Error('get text @ref must not recapture')); diff --git a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome-fixtures.ts b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome-fixtures.ts index 1120eef60c..4eae6be621 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome-fixtures.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome-fixtures.ts @@ -46,6 +46,7 @@ export function snapshot( { nodes, backend: 'xctest', + producer: 'apple-runner', quality: { state: 'healthy', backend }, }, { snapshotInteractiveOnly: false, ...(options.raw ? { snapshotRaw: true } : {}) }, @@ -57,7 +58,8 @@ export function snapshotPayload( backend: 'tree' | 'queries' | 'private-ax' = 'tree', ) { return { - backend: 'xctest' as const, + backend: 'xctest', + producer: 'apple-runner' as const, nodes, quality: { state: 'healthy' as const, backend }, }; diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index c89b9d78c7..3b07a0f2c8 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts @@ -4,7 +4,6 @@ import { test, expect, vi, beforeEach } from 'vitest'; import { createInteractionRuntime, handleInteractionCommands } from '../../index.ts'; import type { SessionStore } from '../../../session-store.ts'; import type { SessionState } from '../../../session-state.ts'; -import type { SnapshotBackend } from '@agent-device/kernel/snapshot'; import { buildSnapshotState } from '../../../../core/snapshot-state.ts'; import { setSessionSnapshot } from '../../../session-snapshot.ts'; import { activateCompleteRefFrame, expireRefFrame, refFrameState } from '../../../ref-frame.ts'; @@ -72,7 +71,7 @@ async function emulateCaptureSnapshotForSession( [], effectiveFlags.out, contextFromFlags(effectiveFlags, session.appBundleId, session.trace?.outPath), - )) as { nodes?: never[]; truncated?: boolean; backend?: SnapshotBackend }; + )) as Parameters[0]; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); setSessionSnapshot(session, snapshot); sessionStore.set(session.name, session); @@ -81,7 +80,10 @@ async function emulateCaptureSnapshotForSession( function seedSession(sessionName: string, sessionStore: ReturnType) { const session = makeIosSession(sessionName); - setSessionSnapshot(session, buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest' }, {})); + setSessionSnapshot( + session, + buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }, {}), + ); // The seed emulates a snapshot response that issued these refs: a complete, // active ref frame (ADR 0014). activateCompleteRefFrame(session); @@ -124,7 +126,7 @@ function mockCommandDispatch(params: { snapshots: Array }) if (command === 'snapshot') { const nodes = params.snapshots[Math.min(snapshotCalls, params.snapshots.length - 1)]; snapshotCalls += 1; - return { nodes, backend: 'xctest' }; + return { nodes, backend: 'xctest', producer: 'apple-runner' }; } return {}; }); @@ -348,7 +350,8 @@ test('a settle observation without a diff leaves ref staleness untouched', async legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { snapshotCalls += 1; - if (snapshotCalls === 1) return { nodes: BEFORE_NODES, backend: 'xctest' }; + if (snapshotCalls === 1) + return { nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }; throw new Error('AX bridge crashed'); } return {}; @@ -396,7 +399,10 @@ test('a stalled settle capture receives its deadline signal and leaves the inter ) => { captureCalls += 1; if (captureCalls === 1) { - return buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest' }, {}); + return buildSnapshotState( + { nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }, + {}, + ); } return await new Promise((_resolve, reject) => { const fallback = setTimeout( diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-fixtures.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-fixtures.ts index 2922e7fd71..a48f36973f 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-fixtures.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-fixtures.ts @@ -1,5 +1,5 @@ import type { CommandFlags } from '@agent-device/contracts/command'; -import { attachRefs, type SnapshotBackend } from '@agent-device/kernel/snapshot'; +import { attachRefs, type SnapshotCaptureProvenance } from '@agent-device/kernel/snapshot'; import { makeAndroidSession as makeBaseAndroidSession, makeIosAppSession, @@ -45,7 +45,7 @@ export function installTestScreenRecording( session.screenRecording = makeTestScreenRecordingResource(session, overrides); } -export function makeVisibleButtonSnapshot(label: string, backend: SnapshotBackend) { +export function makeVisibleButtonSnapshot(label: string, provenance: SnapshotCaptureProvenance) { return buildSnapshotState( { nodes: [ @@ -59,7 +59,7 @@ export function makeVisibleButtonSnapshot(label: string, backend: SnapshotBacken hittable: true, }, ], - backend, + ...provenance, }, { snapshotInteractiveOnly: false }, ); @@ -111,6 +111,7 @@ export function makeStaleRefSession(sessionName: string): SessionState { nodes: attachRefs(makeTwoButtonNodes() as never), createdAt: Date.now(), backend: 'xctest', + producer: 'apple-runner', }; // As if the snapshot command just returned these refs to the client: a // complete, active ref frame (ADR 0014). diff --git a/src/daemon/snapshot-capture.ts b/src/daemon/snapshot-capture.ts index efd2f5bc42..c764fed3d7 100644 --- a/src/daemon/snapshot-capture.ts +++ b/src/daemon/snapshot-capture.ts @@ -10,7 +10,7 @@ import { findNodeByRef, normalizeRef, type RawSnapshotNode, - type SnapshotStateProvenance, + type SnapshotCaptureProvenance, type SnapshotState, } from '@agent-device/kernel/snapshot'; import { resolveRefLabel } from '../core/snapshot-node-lookup.ts'; @@ -55,7 +55,7 @@ type SnapshotData = { truncated?: boolean; quality?: unknown; } & Omit & - SnapshotStateProvenance; + SnapshotCaptureProvenance; type SnapshotAttempt = { data: SnapshotData;