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
343 changes: 343 additions & 0 deletions contracts/fixtures/ios-presented-surface.json

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions packages/capture-kit/src/ios-snapshot-engine/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,77 @@ function acquisition(
};
}

test("the published presented-surface fact is the acquisition's, for every projection", () => {
const requests = [
createIosSnapshotRequest(),
createIosSnapshotRequest({ interactiveOnly: true }),
createIosSnapshotRequest({ raw: true, depth: 1 }),
createIosSnapshotRequest({ scope: 'Automation lab' }),
];
const published = requests.map((request) =>
publishIosSnapshot(acquiredInput(request, presentedTreeNodes()), request),
);

// One screen answers one value: the fact is read on the acquired tree, so a projection that dropped
// the presentation from what it shows still reports the presentation the producer saw.
assert.deepEqual(
published.map((result) => result.presentedSurface),
['presented', 'presented', 'presented', 'presented'],
);

// The depth-1 cut here projects a full acquisition: the presentation sits at depth 2 and 3, so what
// that capture publishes holds no trace of it. (A live `--raw --depth 1` bounds the acquisition
// instead and answers `unknown`.) Were the fact read off the published nodes, this projection would
// have to answer `absent` — which is the failure mode the fact exists to close.
const depthCut = published[2]!;
const publishedRoles = (depthCut.payload.nodes ?? []).map((node) => node.role);
assert.ok(
!publishedRoles.includes('UIDimmingView') && !publishedRoles.includes('UITransitionView'),
`expected the depth-1 cut to drop the presentation, saw ${JSON.stringify(publishedRoles)}`,
);
assert.equal(depthCut.presentedSurface, 'presented');
});

test('an acquisition that states no completeness cannot be read as un-presented', () => {
const request = createIosSnapshotRequest();
const unstated: IosSnapshotAcquisition = {
...acquisition(request, ordinaryTreeNodes()),
truncated: undefined,
};

assert.equal(
publishIosSnapshot({ stage: 'acquired', acquisition: unstated }, request).presentedSurface,
'unknown',
);
assert.equal(
publishIosSnapshot(acquiredInput(request, ordinaryTreeNodes()), request).presentedSurface,
'absent',
);
});

test('a runner-declared publication states no presented-surface fact of its own', () => {
const request = createIosSnapshotRequest();
const input: IosSnapshotInput = {
stage: 'presented',
presentation: {
producer: 'apple-runner',
intent: 'full',
payload: { nodes: presentedTreeNodes(), truncated: false },
},
validation: validationFacts(request),
};

// The runner classifies its own pre-presentation acquisition and publishes the reading beside the
// payload; re-deriving one from the presented tree here would be a second, weaker answer. The same tree
// answers at the acquired stage, so this pins the stage gate and not a classifier that reads neither:
// a publication path that stopped classifying at all would fail the second assertion, not this one.
assert.equal(publishIosSnapshot(input, request).presentedSurface, undefined);
assert.equal(
publishIosSnapshot(acquiredInput(request, presentedTreeNodes()), request).presentedSurface,
'presented',
);
});

function acquiredInput(request: IosSnapshotRequest, nodes: RawSnapshotNode[]): IosSnapshotInput {
return { stage: 'acquired', acquisition: acquisition(request, nodes) };
}
Expand All @@ -421,6 +492,53 @@ function nestedNodes(): RawSnapshotNode[] {
];
}

/** A UIKit-shaped tree over which a presentation is up: a second container carrying a dimming view. */
function presentedTreeNodes(): RawSnapshotNode[] {
return [
appNode(0, 'UIApplication', 'Application', viewport, undefined),
appNode(1, 'UIWindow', 'Window', viewport, 0),
appNode(2, 'UITransitionView', 'Other', viewport, 1, 2),
node(3, 'StaticText', 'Automation lab', { x: 10, y: 20, width: 200, height: 20 }, 2, 3),
appNode(4, 'UITransitionView', 'Other', viewport, 1, 2),
{
...appNode(5, 'UIDimmingView', 'Other', viewport, 4, 3),
userInteractionEnabled: true,
},
node(6, 'StaticText', 'Automation sheet', { x: 10, y: 120, width: 200, height: 20 }, 4, 3),
];
}

/** A UIKit-shaped tree with nothing presenting: one window, its own single container. */
function ordinaryTreeNodes(): RawSnapshotNode[] {
return [
appNode(0, 'UIApplication', 'Application', viewport, undefined),
appNode(1, 'UIWindow', 'Window', viewport, 0),
appNode(2, 'UITransitionView', 'Other', viewport, 1),
appNode(
3,
'RCTParagraphComponentView',
'StaticText',
{ x: 10, y: 20, width: 200, height: 20 },
2,
),
];
}

/** A node shaped like the simulator AX bridge reports one: UIKit class name in `role`. */
function appNode(
index: number,
role: string,
type: string,
rect: Rect,
parentIndex: number | undefined,
depth?: number,
): RawSnapshotNode {
return {
...node(index, type, undefined, rect, parentIndex, depth),
role,
};
}

function scopedNodes(): RawSnapshotNode[] {
return [
node(0, 'Application', 'App', viewport),
Expand Down
21 changes: 21 additions & 0 deletions packages/capture-kit/src/ios-snapshot-engine/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'
import { buildIosInteractiveSnapshotPresentation } from './semantic-index.ts';
import { validateIosSnapshotGraph } from './graph.ts';
import { foldIosSnapshot } from './geometry.ts';
import { collectPresentedSurface, type PresentedSurfaceCompleteness } from './geometry-policy.ts';
import { resolveIosViewport, validateIosPayload } from './invariants.ts';
import { projectIosQualitySnapshot, projectIosSnapshot } from './projection.ts';
import { presentIosRunnerSnapshot } from './runner-presentation.ts';
Expand Down Expand Up @@ -49,9 +50,29 @@ export function publishIosSnapshot(
...(presentation.validatedViewport
? { validatedViewport: presentation.validatedViewport }
: {}),
...(input.stage === 'acquired'
? {
presentedSurface: collectPresentedSurface(
input.acquisition.nodes,
acquisitionCompleteness(input.acquisition),
),
}
: {}),
};
}

/**
* What an acquisition claims about its own completeness, from the producer's truncation statement and
* never from the tree: a producer that reports nothing about truncation has stated nothing, and a
* presented-surface classification may not read a silent field as "complete" (#2188 invariant 5).
*/
function acquisitionCompleteness(
acquisition: IosSnapshotAcquisition,
): PresentedSurfaceCompleteness {
if (acquisition.truncated === undefined) return 'unstated';
return acquisition.truncated ? 'capped' : 'complete';
}

export function presentIosSnapshot(
input: IosSnapshotInput,
request: IosSnapshotRequest,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test } from 'vitest';
import type { PresentedSurface, RawSnapshotNode } from '@agent-device/kernel/snapshot';
import {
ALERT_ROOT_CLASS_PREFIX,
PRESENTATION_CONTAINER_CLASS,
PRESENTATION_DIMMING_VIEW_CLASS,
PRESENTING_ELEMENT_TYPES,
SYSTEM_WINDOW_CLASSES,
collectPresentedSurface,
type PresentedSurfaceCompleteness,
} from './geometry-policy.ts';

const FIXTURE_PATH = path.resolve(
import.meta.dirname,
'..',
'..',
'..',
'..',
'contracts',
'fixtures',
'ios-presented-surface.json',
);

type FixtureNode = {
index: number;
parentIndex?: number;
type: string;
role?: string;
label?: string;
userInteractionEnabled?: boolean;
};

type FixtureCase = {
name: string;
truncated: boolean | null;
expected: PresentedSurface;
classBlindExpected: PresentedSurface;
nodes: FixtureNode[];
};

type Fixture = {
vocabulary: {
presentationContainerClass: string;
dimmingViewClass: string;
alertRootClassPrefix: string;
systemWindowClasses: string[];
presentingElementTypes: Array<{ name: string; elementType: number }>;
};
cases: FixtureCase[];
};

function readFixture(): Fixture {
return JSON.parse(fs.readFileSync(FIXTURE_PATH, 'utf8')) as Fixture;
}

// Cross-language parity: the classifier must mirror the canonical table the Swift
// SnapshotPresentedSurface also reads, and the vocabulary it mirrors by hand. A drift on either side
// fails here or in the Swift test.
test('the classifier mirrors the canonical vocabulary exactly', () => {
const vocabulary = readFixture().vocabulary;
expect(vocabulary.presentationContainerClass).toBe(PRESENTATION_CONTAINER_CLASS);
expect(vocabulary.dimmingViewClass).toBe(PRESENTATION_DIMMING_VIEW_CLASS);
expect(vocabulary.alertRootClassPrefix).toBe(ALERT_ROOT_CLASS_PREFIX);
expect(new Set(vocabulary.systemWindowClasses)).toEqual(SYSTEM_WINDOW_CLASSES);
expect(Object.fromEntries(PRESENTING_ELEMENT_TYPES)).toEqual(
Object.fromEntries(
vocabulary.presentingElementTypes.map((entry) => [entry.name, entry.elementType]),
),
);
});

// Every case runs twice: once with the UIKit class names the table spells, once with every class name
// stripped, which is what a capture from a producer that reports none — every XCTest runner tier —
// actually looks like. That reading is what `classBlindExpected` states, and it is why the table can
// hold a case a bridge capture answers `presented` and a runner capture answers `unknown`.
test('every golden case classifies as the table states', () => {
for (const testCase of readFixture().cases) {
const completeness = completenessOf(testCase);
expect(collectPresentedSurface(nodesOf(testCase), completeness)).toBe(testCase.expected);
expect(collectPresentedSurface(classBlindNodesOf(testCase), completeness)).toBe(
testCase.classBlindExpected,
);
}
});

/**
* The producer's own completeness claim, which the table states as `truncated`: a null is a producer
* that observes nothing about truncation, and cannot be read as complete.
*/
function completenessOf(testCase: FixtureCase): PresentedSurfaceCompleteness {
if (testCase.truncated === null) return 'unstated';
return testCase.truncated ? 'capped' : 'complete';
}

function nodesOf(testCase: FixtureCase): RawSnapshotNode[] {
return testCase.nodes.map((node) => ({ ...node }));
}

function classBlindNodesOf(testCase: FixtureCase): RawSnapshotNode[] {
return testCase.nodes.map(({ role: _role, ...node }) => ({ ...node }));
}
Loading
Loading