refactor(ios): implement snapshot engine (#2211)

* refactor(ios): implement snapshot engine

* fix(ios): finish snapshot engine ownership move
This commit is contained in:
Michał Pierzchała
2026-09-01 15:59:30 +02:00
committed by GitHub
parent 868f8f90ee
commit 02116ccdd8
43 changed files with 2047 additions and 463 deletions
+4
View File
@@ -18,6 +18,10 @@
"types": "./src/ios-snapshot-planning.ts",
"default": "./src/ios-snapshot-planning.ts"
},
"./ios-snapshot-engine": {
"types": "./src/ios-snapshot-engine/index.ts",
"default": "./src/ios-snapshot-engine/index.ts"
},
"./mobile-snapshot-semantics": {
"types": "./src/mobile-snapshot-semantics.ts",
"default": "./src/mobile-snapshot-semantics.ts"
@@ -8,7 +8,7 @@ import {
isSemanticActionNode,
isScrollableSnapshotType,
type SnapshotTreeRuleContext,
} from '../tree.ts';
} from './tree.ts';
const ACTION_SHELF_MINIMUM_BUTTONS = 3;
const ACTION_SHELF_EDGE_TOLERANCE = 2;
@@ -6,7 +6,7 @@ import {
isMostlyViewportSizedRect,
mergeReplacement,
type SnapshotTreeRuleContext,
} from '../tree.ts';
} from './tree.ts';
export function collectIosImplicitScrollableActions(
nodes: RawSnapshotNode[],
@@ -0,0 +1,303 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type {
IosSnapshotAcquisition,
IosSnapshotInput,
IosSnapshotRequest,
IosSnapshotValidationFacts,
} from '@agent-device/contracts/ios-snapshot';
import {
buildIosSnapshotPresentationKey,
createIosSnapshotRequest,
deriveIosCaptureHint,
} from '@agent-device/capture-kit/ios-snapshot-planning';
import {
compactIosInteractiveSnapshot,
createIosSnapshotEngine,
IosSnapshotEngineError,
presentIosSnapshot,
publishIosSnapshot,
} from './index.ts';
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
const viewport: Rect = { x: 0, y: 0, width: 320, height: 240 };
test('regular presentation folds nested clips and keeps effective actionability', () => {
const request = createIosSnapshotRequest();
const result = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
assert.deepEqual(
result.payload.nodes.map((node) => [node.label, node.rect, node.parentIndex]),
[
['App', viewport, undefined],
['Outer', { x: 16, y: 20, width: 180, height: 180 }, 0],
['Inner', { x: 120, y: 40, width: 76, height: 160 }, 1],
['Partially visible', { x: 150, y: 80, width: 46, height: 40 }, 2],
],
);
assert.equal(result.payload.nodes[3]?.hittable, true);
assert.equal(result.payload.nodes[3]?.ref, 'e4');
assert.equal(result.comparisonIdentity.lineage.targetId, 'simulator-1');
});
test('raw projection preserves reported geometry while regular projection clips it', () => {
const regularRequest = createIosSnapshotRequest();
const rawRequest = createIosSnapshotRequest({ raw: true, interactiveOnly: true });
const nodes = nestedNodes();
const regular = publishIosSnapshot(acquiredInput(regularRequest, nodes), regularRequest);
const raw = publishIosSnapshot(acquiredInput(rawRequest, nodes), rawRequest);
assert.equal(
regular.payload.nodes.some((node) => node.label === 'Escaped child'),
false,
);
assert.equal(raw.payload.nodes.find((node) => node.label === 'Escaped child')?.rect?.x, 210);
assert.equal(raw.payload.nodes.find((node) => node.label === 'Escaped child')?.hittable, true);
});
test('cursor projection keeps geometryless nodes neutral while plain viewport keeps child visibility independent', () => {
const request = createIosSnapshotRequest();
const nodes = [
node(0, 'Application', 'App', viewport),
{ ...node(1, 'Other', 'No frame', viewport, 0, 1), rect: undefined },
node(2, 'Button', 'Child', { x: 20, y: 20, width: 40, height: 40 }, 1, 2),
];
const cursor = publishIosSnapshot(acquiredInput(request, nodes), request);
const plain = publishIosSnapshot(acquiredInput(request, nodes), request, {
foldPolicy: 'plain-viewport',
});
assert.deepEqual(
cursor.payload.nodes.map((entry) => entry.label),
['App', 'No frame', 'Child'],
);
assert.deepEqual(
plain.payload.nodes.map((entry) => entry.label),
['App', 'Child'],
);
});
test('scope reparents wrappers and regular depth counts presented nodes', () => {
const request = createIosSnapshotRequest({ scope: 'Target', depth: 1 });
const result = publishIosSnapshot(acquiredInput(request, scopedNodes()), request);
assert.deepEqual(
result.payload.nodes.map((node) => [node.type, node.label, node.depth, node.parentIndex]),
[
['Other', 'Target', 0, undefined],
['Button', 'Target', 1, 0],
],
);
});
test('plain viewport policy does not inherit cursor clipping', () => {
const request = createIosSnapshotRequest();
const result = publishIosSnapshot(
acquiredInput(request, [
node(0, 'Application', 'App', viewport),
node(1, 'ScrollView', 'Scroll', { x: 0, y: 100, width: 320, height: 40 }, 0),
node(2, 'StaticText', 'Outside scroll clip', { x: 10, y: 200, width: 80, height: 20 }, 1),
]),
request,
{ foldPolicy: 'plain-viewport' },
);
assert.deepEqual(
result.payload.nodes.map((entry) => entry.label),
['App', 'Scroll', 'Outside scroll clip'],
);
});
test('regular presentation fails typed when the viewport is missing or the graph is malformed', () => {
const request = createIosSnapshotRequest();
const missingViewport: IosSnapshotAcquisition = {
...acquisition(request, nestedNodes()),
viewport: { kind: 'missing', reason: 'not-provided' },
};
assert.throws(
() => publishIosSnapshot({ stage: 'acquired', acquisition: missingViewport }, request),
(error: unknown) =>
error instanceof IosSnapshotEngineError && error.reason === 'missing-viewport',
);
const malformed = acquisition(request, [
node(0, 'Application', 'App', viewport),
{ ...node(1, 'Button', 'Broken', { x: 1, y: 1, width: 10, height: 10 }, 0), parentIndex: 99 },
]);
assert.throws(
() => publishIosSnapshot({ stage: 'acquired', acquisition: malformed }, request),
(error: unknown) =>
error instanceof IosSnapshotEngineError && error.reason === 'malformed-graph',
);
});
test('presented runner payloads and optional quality payloads cross the host invariant', () => {
const request = createIosSnapshotRequest();
const presentedCapture = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
const input: IosSnapshotInput = {
stage: 'presented',
presentation: {
producer: 'apple-runner',
intent: 'full',
payload: { nodes: presentedCapture.payload.nodes, truncated: false },
qualityPayload: {
nodes: presentedCapture.payload.nodes,
truncated: false,
scope: null,
},
},
validation: validationFacts(request),
};
const result = publishIosSnapshot(input, request);
assert.equal(result.payload.nodes.length, 4);
assert.equal(result.payload.nodes[3]?.ref, 'e4');
assert.equal(result.residue.length, 1);
});
test('scoped raw presentation retains an unscoped quality view', () => {
const request = createIosSnapshotRequest({ raw: true, scope: 'Target' });
const result = presentIosSnapshot(acquiredInput(request, scopedNodes()), request);
assert.deepEqual(
result.nodes.map((entry) => entry.label),
['Target', 'Target'],
);
assert.deepEqual(
result.qualityNodes?.map((entry) => entry.label),
['App', 'Wrapper', 'Target', 'Target'],
);
});
test('unavailable hittability never becomes regular actionability', () => {
const request = createIosSnapshotRequest();
const unavailable = {
...acquisition(request, nestedNodes()),
residue: [{ kind: 'unavailable-fact' as const, fact: 'hittability' as const }],
} satisfies IosSnapshotAcquisition;
const acquired = publishIosSnapshot({ stage: 'acquired', acquisition: unavailable }, request);
assert.equal(
acquired.payload.nodes.find((node) => node.label === 'Partially visible')?.hittable,
false,
);
const available = publishIosSnapshot(acquiredInput(request, nestedNodes()), request);
const presented: IosSnapshotInput = {
stage: 'presented',
presentation: {
producer: 'apple-runner',
intent: 'full',
payload: { nodes: available.payload.nodes, truncated: false },
},
validation: {
...validationFacts(request),
hittability: { kind: 'unavailable', reason: 'not-provided' },
},
};
assert.throws(
() => publishIosSnapshot(presented, request),
(error: unknown) =>
error instanceof IosSnapshotEngineError &&
error.code === 'IOS_SNAPSHOT_ENGINE_FAILED' &&
error.reason === 'invalid-presented-payload',
);
});
test('interactive compaction stays available through the engine boundary', () => {
const rowRect = { x: 16, y: 80, width: 288, height: 52 };
const compacted = compactIosInteractiveSnapshot([
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),
node(3, 'Button', 'General', rowRect, 2, 3),
node(4, 'StaticText', 'General', rowRect, 3, 4),
]);
assert.deepEqual(
compacted.map((entry) => entry.type),
['Application', 'Table', 'Cell'],
);
});
test('the configured engine keeps its fold policy and exposes the contract operations', () => {
const engine = createIosSnapshotEngine({ 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.equal(presented.stats.sourceNodeCount, nestedNodes().length);
});
function acquisition(
request: IosSnapshotRequest,
nodes: RawSnapshotNode[],
): IosSnapshotAcquisition {
const hint = deriveIosCaptureHint(request);
assert.equal(hint.acquisitionIntent, 'full');
return {
producer: 'simulator-ax-bridge',
intent: 'full',
hint: { ...hint, acquisitionIntent: 'full' },
nodes,
truncated: false,
viewport: { kind: 'reported', rect: viewport },
lineage: { targetId: 'simulator-1', generation: 'generation-1' },
residue: [{ kind: 'truncated', dimension: 'payload', limit: 2000 }],
};
}
function acquiredInput(request: IosSnapshotRequest, nodes: RawSnapshotNode[]): IosSnapshotInput {
return { stage: 'acquired', acquisition: acquisition(request, nodes) };
}
function validationFacts(request: IosSnapshotRequest): IosSnapshotValidationFacts {
return {
presentationKey: buildIosSnapshotPresentationKey(request),
viewport: { kind: 'reported', rect: viewport },
hittability: { kind: 'available' },
lineage: { targetId: 'simulator-1', generation: 'generation-1' },
residue: [{ kind: 'truncated', dimension: 'payload', limit: 2000 }],
};
}
function nestedNodes(): RawSnapshotNode[] {
return [
node(0, 'Application', 'App', viewport),
node(1, 'ScrollView', 'Outer', { x: 16, y: 20, width: 180, height: 180 }, 0, 1),
node(2, 'ScrollView', 'Inner', { x: 120, y: 40, width: 180, height: 160 }, 1, 2),
node(3, 'Button', 'Partially visible', { x: 150, y: 80, width: 100, height: 40 }, 2, 3),
node(4, 'Button', 'Escaped child', { x: 210, y: 80, width: 100, height: 40 }, 2, 3),
];
}
function scopedNodes(): RawSnapshotNode[] {
return [
node(0, 'Application', 'App', viewport),
node(1, 'Other', 'Wrapper', { x: 10, y: 10, width: 200, height: 100 }, 0, 1),
node(2, 'Other', 'Target', { x: 10, y: 10, width: 200, height: 100 }, 1, 2),
node(3, 'Button', 'Target', { x: 20, y: 20, width: 80, height: 40 }, 2, 3),
];
}
function node(
index: number,
type: string,
label: string,
rect: Rect,
parentIndex?: number,
depth?: number,
): RawSnapshotNode {
return {
index,
type,
label,
rect,
parentIndex,
depth: depth ?? (parentIndex === undefined ? 0 : 1),
enabled: true,
hittable: type === 'Button',
};
}
@@ -0,0 +1,196 @@
import {
buildIosSnapshotComparisonIdentity,
buildIosSnapshotPresentationKey,
deriveIosCaptureHint,
IOS_SNAPSHOT_PRODUCER_CAPABILITIES,
planIosSnapshot,
} from '../ios-snapshot-planning.ts';
import type {
IosSnapshotAcquisition,
IosSnapshotEngine,
IosSnapshotInput,
IosSnapshotPublication,
IosSnapshotRequest,
} from '@agent-device/contracts/ios-snapshot';
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 { resolveIosViewport, validateIosPayload } from './invariants.ts';
import { projectIosQualitySnapshot, projectIosSnapshot } from './projection.ts';
import { presentIosRunnerSnapshot } from './runner-presentation.ts';
import type {
IosSnapshotEngineOptions,
IosSnapshotEnginePresentation,
IosSnapshotFoldPolicy,
} from './types.ts';
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,
options: IosSnapshotEngineOptions = {},
): IosSnapshotPublication {
const presentation = presentIosSnapshot(input, request, options);
const presentationKey =
input.stage === 'presented'
? input.validation.presentationKey
: buildIosSnapshotPresentationKey(request);
return {
payload: {
nodes: attachRefs(presentation.nodes),
truncated:
input.stage === 'acquired'
? input.acquisition.truncated
: input.presentation.payload.truncated,
},
presentationKey,
comparisonIdentity: buildIosSnapshotComparisonIdentity(input, request),
residue:
input.stage === 'acquired' ? [...input.acquisition.residue] : [...input.validation.residue],
};
}
export function presentIosSnapshot(
input: IosSnapshotInput,
request: IosSnapshotRequest,
options: IosSnapshotEngineOptions = {},
): IosSnapshotEnginePresentation {
const foldPolicy = options.foldPolicy ?? DEFAULT_FOLD_POLICY;
if (input.stage === 'acquired') {
return presentAcquiredSnapshot(input.acquisition, request, foldPolicy);
}
return presentIosRunnerSnapshot(input, request, foldPolicy);
}
export function compactIosInteractiveSnapshot(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
return buildIosInteractiveSnapshotPresentation(nodes).nodes;
}
function presentAcquiredSnapshot(
acquisition: IosSnapshotAcquisition,
request: IosSnapshotRequest,
foldPolicy: IosSnapshotFoldPolicy,
): IosSnapshotEnginePresentation {
const expectedHint = deriveIosCaptureHint(request);
assertCaptureHintMatches(acquisition, expectedHint);
if (request.projection === 'raw') {
validateIosSnapshotGraph(acquisition.nodes);
const projected = projectIosSnapshot({
nodes: acquisition.nodes.map((raw) => ({ raw })),
projection: 'raw',
scope: request.scope,
depth: request.depth,
foldPolicy,
});
const qualityNodes =
request.scope === null
? undefined
: projectIosQualitySnapshot({
nodes: acquisition.nodes.map((raw) => ({ raw })),
projection: 'raw',
depth: null,
foldPolicy,
}).nodes;
return {
nodes: projected.nodes,
...(qualityNodes ? { qualityNodes } : {}),
presentedIndexesBySourceIndex: identityMapping(projected.nodes),
stats: {
presentedNodeCount: projected.nodes.length,
sourceNodeCount: acquisition.nodes.length,
parentClipLookups: 0,
},
};
}
const viewport = resolveIosViewport(acquisition);
const hittabilityAvailable =
IOS_SNAPSHOT_PRODUCER_CAPABILITIES[acquisition.producer].hittabilityEvidence === 'available' &&
!hasUnavailableHittability(acquisition.residue);
const folded = foldIosSnapshot(acquisition.nodes, viewport, request.interactiveOnly, foldPolicy, {
hittabilityAvailable,
});
const foldedInput = {
nodes: folded.nodes,
projection: 'regular' as const,
scope: request.scope,
depth: request.depth,
foldPolicy,
};
const projected = projectIosSnapshot(foldedInput);
const compacted = request.interactiveOnly
? buildIosInteractiveSnapshotPresentation(projected.nodes)
: {
nodes: projected.nodes,
presentedIndexesBySourceIndex: identityMapping(projected.nodes),
};
const validation = validateIosPayload(
compacted.nodes,
'regular',
viewport,
foldPolicy,
hittabilityAvailable,
);
const qualityNodes =
request.scope === null
? undefined
: projectIosQualitySnapshot({
nodes: folded.nodes,
projection: 'regular',
depth: null,
foldPolicy,
}).nodes;
return {
nodes: compacted.nodes,
...(qualityNodes ? { qualityNodes } : {}),
presentedIndexesBySourceIndex: compacted.presentedIndexesBySourceIndex,
stats: {
presentedNodeCount: compacted.nodes.length,
sourceNodeCount: acquisition.nodes.length,
parentClipLookups: folded.stats.parentClipLookups + validation.parentClipLookups,
},
};
}
function hasUnavailableHittability(residue: IosSnapshotAcquisition['residue']): boolean {
return residue.some((entry) => entry.kind === 'unavailable-fact' && entry.fact === 'hittability');
}
function assertCaptureHintMatches(
acquisition: IosSnapshotAcquisition,
expected: ReturnType<typeof deriveIosCaptureHint>,
): void {
const actual = acquisition.hint;
if (
actual.projection !== expected.projection ||
actual.rawTraversalDepth !== expected.rawTraversalDepth ||
actual.regularPresentedDepth !== expected.regularPresentedDepth ||
actual.interactiveOnly !== expected.interactiveOnly ||
actual.customActions !== expected.customActions ||
actual.acquisitionIntent !== expected.acquisitionIntent
) {
throw new IosSnapshotEngineError(
'projection-mismatch',
'acquired iOS snapshot does not match the requested capture hint',
{ projection: actual.projection },
);
}
}
function identityMapping(
nodes: readonly RawSnapshotNode[],
): ReadonlyMap<number, readonly number[]> {
return new Map(nodes.map((node) => [node.index, [node.index]]));
}
@@ -0,0 +1,237 @@
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { containsPoint, isPositiveFiniteRect } from '@agent-device/kernel/rect';
import { normalizeType } from '@agent-device/contracts/snapshot';
import type { IosSnapshotFoldPolicy } from './types.ts';
const SCROLL_CONTAINER_TYPES = new Set(['collectionview', 'scrollview', 'table']);
const VISIBILITY_CARRIER_TYPES = new Set(['application', 'window']);
const NEGLIGIBLE_DECORATION_TOLERANCE = 1;
export type TraversalState = Readonly<{
projectedOut: boolean;
ancestorClip?: Rect;
}>;
export type BranchState = Readonly<{
traversal: TraversalState;
anchor?: { index: number; rect: Rect };
keptIndex?: number;
keptDepth: number;
}>;
export type GeometryDecision = Readonly<{
isIncluded: boolean;
descendants: TraversalState;
effectiveRect?: Rect;
hiddenContentFrame?: Rect;
establishesScrollAnchor: boolean;
}>;
export function isGeometricallyActionable(
enabled: boolean,
rect: Rect | undefined,
viewport: Rect,
): boolean {
return Boolean(
enabled &&
isPositiveFiniteRect(rect) &&
containsPoint(viewport, rect.x + rect.width / 2, rect.y + rect.height / 2),
);
}
export function rootTraversal(): TraversalState {
return { projectedOut: false };
}
export function traversalDecision(
node: RawSnapshotNode,
parentTraversal: TraversalState,
viewport: Rect,
interactiveOnly: boolean,
hasChildren: boolean,
policy: IosSnapshotFoldPolicy,
): GeometryDecision {
const ancestorClip = policy === 'cursor-projected' ? parentTraversal.ancestorClip : undefined;
const effectiveRect = effectiveSnapshotRect(node.rect, viewport, ancestorClip);
const hasFrame = isPositiveFiniteRect(normalizeRect(node.rect));
const intersectsClip = isPositiveFiniteRect(effectiveRect);
const type = normalizeType(node.type ?? '');
const projectedOut = descendantsProjectedOut(
policy,
parentTraversal.projectedOut,
hasFrame,
intersectsClip,
type,
hasChildren,
);
const visible =
presentationVisible(policy, parentTraversal.projectedOut, hasFrame, intersectsClip) &&
!isNegligibleDecoration(node, hasFrame, policy);
const isIncluded = shouldInclude(node, visible, interactiveOnly, policy);
const establishesScrollAnchor = ownsScrollAnchor(type, isIncluded, intersectsClip, hasChildren);
const hiddenFrame = hiddenContentFrame(
policy,
parentTraversal.projectedOut,
hasFrame,
intersectsClip,
node,
);
return {
isIncluded,
descendants: descendantTraversal(
policy,
projectedOut,
ancestorClip,
effectiveRect,
establishesScrollAnchor,
),
effectiveRect,
...(hiddenFrame ? { hiddenContentFrame: hiddenFrame } : {}),
establishesScrollAnchor,
};
}
function effectiveSnapshotRect(
reportedRect: Rect | undefined,
viewport: Rect,
ancestorClip?: Rect,
): Rect | undefined {
const normalized = normalizeRect(reportedRect);
if (!normalized) return undefined;
let effective = intersectRect(normalized, viewport);
if (ancestorClip) effective = intersectRect(effective, ancestorClip);
return effective;
}
function descendantsProjectedOut(
policy: IosSnapshotFoldPolicy,
parentProjectedOut: boolean,
hasFrame: boolean,
intersectsClip: boolean,
type: string,
hasChildren: boolean,
): boolean {
if (policy === 'plain-viewport') return !intersectsClip;
return parentProjectedOut || (hasFrame && !intersectsClip && ownsDescendants(type, hasChildren));
}
function presentationVisible(
policy: IosSnapshotFoldPolicy,
parentProjectedOut: boolean,
hasFrame: boolean,
intersectsClip: boolean,
): boolean {
if (policy === 'plain-viewport') return intersectsClip;
return !parentProjectedOut && (!hasFrame || intersectsClip);
}
function isNegligibleDecoration(
node: RawSnapshotNode,
hasFrame: boolean,
policy: IosSnapshotFoldPolicy,
): boolean {
if (
policy !== 'cursor-projected' ||
node.parentIndex === undefined ||
hasSemanticContent(node) ||
!hasFrame
) {
return false;
}
return (
normalizedRectWidth(node.rect) <= NEGLIGIBLE_DECORATION_TOLERANCE ||
normalizedRectHeight(node.rect) <= NEGLIGIBLE_DECORATION_TOLERANCE
);
}
function descendantTraversal(
policy: IosSnapshotFoldPolicy,
projectedOut: boolean,
ancestorClip: Rect | undefined,
effectiveRect: Rect | undefined,
establishesScrollAnchor: boolean,
): TraversalState {
return {
projectedOut,
...(policy === 'cursor-projected' && establishesScrollAnchor
? { ancestorClip: effectiveRect }
: { ancestorClip }),
};
}
function hiddenContentFrame(
policy: IosSnapshotFoldPolicy,
parentProjectedOut: boolean,
hasFrame: boolean,
intersectsClip: boolean,
node: RawSnapshotNode,
): Rect | undefined {
if (policy !== 'cursor-projected' || parentProjectedOut || !hasFrame || intersectsClip) {
return undefined;
}
return normalizeRect(node.rect);
}
function ownsDescendants(type: string, hasChildren: boolean): boolean {
return hasChildren && (type === 'cell' || SCROLL_CONTAINER_TYPES.has(type));
}
function ownsScrollAnchor(
type: string,
isIncluded: boolean,
intersectsClip: boolean,
hasChildren: boolean,
): boolean {
return isIncluded && intersectsClip && hasChildren && SCROLL_CONTAINER_TYPES.has(type);
}
function shouldInclude(
node: RawSnapshotNode,
visible: boolean,
interactiveOnly: boolean,
policy: IosSnapshotFoldPolicy,
): boolean {
if (node.parentIndex === undefined) return true;
const type = normalizeType(node.type ?? '');
if (policy === 'plain-viewport' && interactiveOnly && !visible && type !== 'application') {
return false;
}
return VISIBILITY_CARRIER_TYPES.has(type) || visible;
}
function hasSemanticContent(node: RawSnapshotNode): boolean {
return [node.label, node.identifier, node.value].some(
(value) => typeof value === 'string' && value.trim().length > 0,
);
}
function normalizeRect(rect: Rect | undefined): Rect | undefined {
if (!rect) return undefined;
if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) return undefined;
if (rect.width < 0 || rect.height < 0) return undefined;
return { ...rect, width: Math.max(0, rect.width), height: Math.max(0, rect.height) };
}
function normalizedRectWidth(rect: Rect | undefined): number {
return normalizeRect(rect)?.width ?? 0;
}
function normalizedRectHeight(rect: Rect | undefined): number {
return normalizeRect(rect)?.height ?? 0;
}
function intersectRect(left: Rect, right: Rect): Rect {
const x = Math.max(left.x, right.x);
const y = Math.max(left.y, right.y);
const rightEdge = Math.min(left.x + left.width, right.x + right.width);
const bottomEdge = Math.min(left.y + left.height, right.y + right.height);
if (rightEdge <= x || bottomEdge <= y) {
return { x: left.x, y: left.y, width: 0, height: 0 };
}
return {
x: rightEdge > x ? x : left.x,
y: bottomEdge > y ? y : left.y,
width: rightEdge - x,
height: bottomEdge - y,
};
}
@@ -0,0 +1,167 @@
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { validateIosSnapshotGraph } from './graph.ts';
import {
isGeometricallyActionable,
rootTraversal,
traversalDecision,
type BranchState,
type GeometryDecision,
} from './geometry-policy.ts';
import type {
IosSnapshotFoldOptions,
IosSnapshotFoldPolicy,
IosSnapshotPresentationNode,
IosSnapshotPresentationStats,
} from './types.ts';
export function foldIosSnapshot(
nodes: readonly RawSnapshotNode[],
viewport: Rect,
interactiveOnly: boolean,
policy: IosSnapshotFoldPolicy,
options: IosSnapshotFoldOptions = {},
): { nodes: IosSnapshotPresentationNode[]; stats: IosSnapshotPresentationStats } {
validateIosSnapshotGraph(nodes);
const hasChildren = buildChildPresence(nodes);
const states = new Map<number, BranchState>();
const kept: IosSnapshotPresentationNode[] = [];
const hints = new Map<number, { above: boolean; below: boolean }>();
let parentClipLookups = 0;
for (const node of nodes) {
const parentState = readParentState(node, states, () => {
parentClipLookups += 1;
});
const parentTraversal = parentState?.traversal ?? rootTraversal();
const parentAnchor = policy === 'cursor-projected' ? parentState?.anchor : undefined;
const decision = traversalDecision(
node,
parentTraversal,
viewport,
interactiveOnly,
hasChildren.has(node.index),
policy,
);
recordHiddenContentHint(decision, parentAnchor, hints);
const foldedNode = appendFoldedNode(node, decision, parentState, kept, viewport, options);
const anchor = nextScrollAnchor(decision, parentAnchor, foldedNode.keptIndex);
states.set(node.index, {
traversal: decision.descendants,
anchor,
keptIndex: foldedNode.keptIndex,
keptDepth: foldedNode.keptDepth,
});
}
const presented = applyHiddenContentHints(hints, kept);
return {
nodes: presented,
stats: {
presentedNodeCount: presented.length,
sourceNodeCount: nodes.length,
parentClipLookups,
},
};
}
function buildChildPresence(nodes: readonly RawSnapshotNode[]): Set<number> {
const parents = new Set<number>();
for (const node of nodes) {
if (node.parentIndex !== undefined) parents.add(node.parentIndex);
}
return parents;
}
function readParentState(
node: RawSnapshotNode,
states: ReadonlyMap<number, BranchState>,
onLookup: () => void,
): BranchState | undefined {
if (node.parentIndex === undefined) return undefined;
onLookup();
return states.get(node.parentIndex);
}
function recordHiddenContentHint(
decision: GeometryDecision,
parentAnchor: BranchState['anchor'],
hints: Map<number, { above: boolean; below: boolean }>,
): void {
if (decision.hiddenContentFrame && parentAnchor) {
rememberHiddenContentHint(decision.hiddenContentFrame, parentAnchor, hints);
}
}
function appendFoldedNode(
node: RawSnapshotNode,
decision: GeometryDecision,
parentState: BranchState | undefined,
kept: IosSnapshotPresentationNode[],
viewport: Rect,
options: IosSnapshotFoldOptions,
): { keptIndex?: number; keptDepth: number } {
let keptIndex = parentState?.keptIndex;
let keptDepth = parentState?.keptDepth ?? -1;
if (!decision.isIncluded) return { keptIndex, keptDepth };
const index = kept.length;
keptDepth += 1;
kept.push({
raw: {
...node,
index,
depth: keptDepth,
parentIndex: keptIndex,
hittable:
node.parentIndex !== undefined &&
options.hittabilityAvailable !== false &&
node.hittable === true &&
isGeometricallyActionable(node.enabled !== false, decision.effectiveRect, viewport),
},
...(decision.effectiveRect ? { effectiveRect: decision.effectiveRect } : {}),
});
keptIndex = index;
return { keptIndex, keptDepth };
}
function nextScrollAnchor(
decision: GeometryDecision,
parentAnchor: BranchState['anchor'],
keptIndex: number | undefined,
): BranchState['anchor'] {
if (decision.establishesScrollAnchor && keptIndex !== undefined && decision.effectiveRect) {
return { index: keptIndex, rect: decision.effectiveRect };
}
return parentAnchor;
}
function rememberHiddenContentHint(
frame: Rect,
anchor: { index: number; rect: Rect },
hints: Map<number, { above: boolean; below: boolean }>,
): void {
const hint = hints.get(anchor.index) ?? { above: false, below: false };
if (frame.y + frame.height <= anchor.rect.y) hint.above = true;
else if (frame.y >= anchor.rect.y + anchor.rect.height) hint.below = true;
hints.set(anchor.index, hint);
}
function applyHiddenContentHints(
hints: ReadonlyMap<number, { above: boolean; below: boolean }>,
nodes: IosSnapshotPresentationNode[],
): IosSnapshotPresentationNode[] {
return nodes.map((presentation) => {
const hint = hints.get(presentation.raw.index);
if (!hint) return presentation;
const node = presentation.raw;
return {
...presentation,
raw: {
...node,
hiddenContentAbove: node.hiddenContentAbove === true || hint.above ? true : undefined,
hiddenContentBelow: node.hiddenContentBelow === true || hint.below ? true : undefined,
},
};
});
}
@@ -0,0 +1,35 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { IosSnapshotEngineError } from './types.ts';
export function validateIosSnapshotGraph(nodes: readonly RawSnapshotNode[]): void {
const positions = new Map<number, number>();
for (const [position, node] of nodes.entries()) {
if (!Number.isInteger(node.index) || node.index < 0 || positions.has(node.index)) {
throw new IosSnapshotEngineError(
'malformed-graph',
'iOS snapshot graph contains a duplicate or invalid node index',
{ index: node.index },
);
}
positions.set(node.index, position);
if (node.depth !== undefined && (!Number.isInteger(node.depth) || node.depth < 0)) {
throw new IosSnapshotEngineError(
'malformed-graph',
'iOS snapshot graph contains an invalid node depth',
{ index: node.index, field: 'depth' },
);
}
}
for (const [position, node] of nodes.entries()) {
if (node.parentIndex === undefined) continue;
const parentPosition = positions.get(node.parentIndex);
if (parentPosition === undefined || parentPosition >= position) {
throw new IosSnapshotEngineError(
'malformed-graph',
'iOS snapshot graph parent must precede its child',
{ index: node.index, parentIndex: node.parentIndex },
);
}
}
}
@@ -0,0 +1,14 @@
export {
compactIosInteractiveSnapshot,
createIosSnapshotEngine,
presentIosSnapshot,
publishIosSnapshot,
} from './engine.ts';
export {
buildIosInteractiveSnapshotPresentation,
presentIosInteractiveSnapshot,
} from './semantic-index.ts';
export { collectIosStructuralIdentifierSuppression } from './noise-structural.ts';
export { findNearestScrollableContainer, mergeReplacement, updateReplacement } from './tree.ts';
export { IosSnapshotEngineError } from './types.ts';
export type { SnapshotTreeRuleContext } from './tree.ts';
@@ -0,0 +1,189 @@
import type {
IosSnapshotAcquisition,
IosViewportEvidence,
} from '@agent-device/contracts/ios-snapshot';
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { containsPoint, rectContains, isPositiveFiniteRect } from '@agent-device/kernel/rect';
import { normalizeType } from '@agent-device/contracts/snapshot';
import { validateIosSnapshotGraph } from './graph.ts';
import { IosSnapshotEngineError } from './types.ts';
import type { IosSnapshotFoldPolicy } from './types.ts';
const SCROLL_CONTAINER_TYPES = new Set(['collectionview', 'scrollview', 'table']);
export function resolveIosViewport(acquisition: IosSnapshotAcquisition): Rect {
return resolveViewportEvidence(acquisition.viewport);
}
export function resolveViewportEvidence(evidence: IosViewportEvidence): Rect {
if (evidence.kind === 'missing') {
throw new IosSnapshotEngineError(
evidence.reason === 'invalid' ? 'invalid-viewport' : 'missing-viewport',
'regular iOS snapshot presentation requires a valid viewport',
{ field: 'viewport' },
);
}
if (!isPositiveFiniteRect(evidence.rect)) {
throw new IosSnapshotEngineError(
'invalid-viewport',
'regular iOS snapshot presentation requires a positive finite viewport',
{ field: 'viewport' },
);
}
return evidence.rect;
}
function validateIosRegularInvariant(
nodes: readonly RawSnapshotNode[],
viewport: Rect,
policy: IosSnapshotFoldPolicy,
hittabilityAvailable = true,
): { parentClipLookups: number } {
validateIosSnapshotGraph(nodes);
const clipByIndex = new Map<number, Rect>();
let parentClipLookups = 0;
for (const node of nodes) {
const ancestorClip = resolveAncestorClip(node, clipByIndex, viewport, () => {
parentClipLookups += 1;
});
const frame = normalizeRect(node.rect);
validateHittabilityEvidence(node, hittabilityAvailable);
if (!frame || !isPositiveFiniteRect(frame)) {
validateDegenerateActionability(node, frame);
clipByIndex.set(node.index, ancestorClip);
continue;
}
validateContainedFrame(node, frame, ancestorClip);
validateActionability(node, frame, viewport);
clipByIndex.set(node.index, clipForNode(node, frame, ancestorClip, policy));
}
return { parentClipLookups };
}
function resolveAncestorClip(
node: RawSnapshotNode,
clipByIndex: ReadonlyMap<number, Rect>,
viewport: Rect,
onParentLookup: () => void,
): Rect {
if (node.parentIndex === undefined) return viewport;
onParentLookup();
const parentClip = clipByIndex.get(node.parentIndex);
if (!parentClip) {
throw new IosSnapshotEngineError(
'invalid-presented-payload',
'regular iOS snapshot payload refers to a parent outside the payload',
{ index: node.index, parentIndex: node.parentIndex },
);
}
return parentClip;
}
function validateHittabilityEvidence(node: RawSnapshotNode, available: boolean): void {
if (node.hittable !== true || available) return;
throw new IosSnapshotEngineError(
'invalid-presented-payload',
'iOS snapshot payload marked a node actionable without hittability evidence',
{ index: node.index },
);
}
function validateDegenerateActionability(node: RawSnapshotNode, frame: Rect | undefined): void {
if (node.hittable !== true) return;
throw new IosSnapshotEngineError(
'regular-degenerate-actionable-node',
'regular iOS snapshot node with a missing or degenerate frame is actionable',
{ index: node.index, frame: frame ?? node.rect },
);
}
function validateContainedFrame(node: RawSnapshotNode, frame: Rect, clip: Rect): void {
if (containsWithTolerance(frame, clip)) return;
throw new IosSnapshotEngineError(
'regular-node-outside-cumulative-clip',
'regular iOS snapshot node escaped its cumulative clip',
{ index: node.index, frame, clip },
);
}
function validateActionability(node: RawSnapshotNode, frame: Rect, viewport: Rect): void {
if (
node.hittable !== true ||
(node.enabled !== false &&
containsPoint(viewport, frame.x + frame.width / 2, frame.y + frame.height / 2))
) {
return;
}
throw new IosSnapshotEngineError(
'invalid-presented-payload',
'regular iOS snapshot payload marked a disabled or off-viewport node actionable',
{ index: node.index, frame },
);
}
function clipForNode(
node: RawSnapshotNode,
frame: Rect,
ancestorClip: Rect,
policy: IosSnapshotFoldPolicy,
): Rect {
return policy === 'cursor-projected' && SCROLL_CONTAINER_TYPES.has(normalizeType(node.type ?? ''))
? frame
: ancestorClip;
}
export function validateIosPayload(
nodes: readonly RawSnapshotNode[],
projection: 'regular' | 'raw',
viewport: Rect | undefined,
policy: IosSnapshotFoldPolicy,
hittabilityAvailable = true,
): { parentClipLookups: number } {
if (projection === 'raw') {
validateIosSnapshotGraph(nodes);
return { parentClipLookups: 0 };
}
if (!viewport) {
throw new IosSnapshotEngineError(
'missing-viewport',
'regular iOS snapshot payload cannot be validated without a viewport',
{ field: 'viewport' },
);
}
try {
return validateIosRegularInvariant(nodes, viewport, policy, hittabilityAvailable);
} catch (error) {
if (error instanceof IosSnapshotEngineError && error.code === 'IOS_SNAPSHOT_ENGINE_FAILED') {
if (error.reason === 'malformed-graph') {
throw new IosSnapshotEngineError('invalid-presented-payload', error.message, error.details);
}
throw error;
}
throw error;
}
}
function normalizeRect(rect: Rect | undefined): Rect | undefined {
if (!rect || ![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) {
return undefined;
}
if (rect.width < 0 || rect.height < 0) return undefined;
return { ...rect, width: Math.max(0, rect.width), height: Math.max(0, rect.height) };
}
function containsWithTolerance(frame: Rect, clip: Rect): boolean {
const tolerance = 0.0001;
return rectContains(
{
x: clip.x - tolerance,
y: clip.y - tolerance,
width: clip.width + tolerance * 2,
height: clip.height + tolerance * 2,
},
frame,
);
}
@@ -0,0 +1,15 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
export function forEachOtherNodeWithLabel(
nodes: RawSnapshotNode[],
visitor: (node: RawSnapshotNode, label: string, position: number) => void,
): void {
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
const label = node?.label?.trim();
if (node && label && normalizeType(node.type ?? '') === 'other') {
visitor(node, label, position);
}
}
}
@@ -0,0 +1,111 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { rectArea, rectContains } from '@agent-device/kernel/rect';
import {
isReactNativeCollapsedWarningWrapperCandidate,
isReactNativeCollapsedWarningWrapperWithVisibleBanner,
isReactNativeOverlayDismissLabel,
isReactNativeOverlayMinimizeLabel,
} from '@agent-device/contracts/react-native-overlay';
import {
areRectsApproximatelyEqual,
findDescendant,
forEachDescendant,
mergeReplacement,
type SnapshotTreeRuleContext,
} from './tree.ts';
import { forEachOtherNodeWithLabel } from './noise-helpers.ts';
export function collectIosReactNativeOverlayActionPresentation(
nodes: RawSnapshotNode[],
replacements: Map<number, RawSnapshotNode>,
): void {
forEachOtherNodeWithLabel(nodes, (node, nodeLabel, position) => {
if (!isReactNativeOverlayDismissLabel(nodeLabel) || !node.rect) return;
const minimize = findDescendant(
nodes,
position,
(descendant) =>
Boolean(descendant.rect) &&
isReactNativeOverlayMinimizeLabel(descendant.label?.trim() ?? ''),
);
if (!minimize?.rect) return;
const dismissRect = remainingHorizontalPartition(node.rect, minimize.rect);
if (!dismissRect) return;
const representativeRect = smallestContainedDismissRect(nodes, position, dismissRect);
mergeReplacement(replacements, node, { rect: representativeRect });
forEachDescendant(nodes, position, (descendant) => {
if (isReactNativeOverlayDismissLabel(descendant.label?.trim() ?? '')) {
mergeReplacement(replacements, descendant, { rect: representativeRect });
}
});
});
}
function smallestContainedDismissRect(
nodes: RawSnapshotNode[],
position: number,
partition: NonNullable<RawSnapshotNode['rect']>,
): NonNullable<RawSnapshotNode['rect']> {
let representative = partition;
forEachDescendant(nodes, position, (descendant) => {
const label = descendant.label?.trim() ?? '';
if (!descendant.rect || !isReactNativeOverlayDismissLabel(label)) return;
if (!rectContains(partition, descendant.rect)) return;
if (rectArea(descendant.rect) < rectArea(representative)) {
representative = descendant.rect;
}
});
return representative;
}
function remainingHorizontalPartition(
wrapper: NonNullable<RawSnapshotNode['rect']>,
occupied: NonNullable<RawSnapshotNode['rect']>,
): NonNullable<RawSnapshotNode['rect']> | undefined {
const wrapperRight = wrapper.x + wrapper.width;
const occupiedRight = occupied.x + occupied.width;
const expectedRightPartition = {
x: occupied.x,
y: wrapper.y,
width: wrapperRight - occupied.x,
height: wrapper.height,
};
if (occupied.x > wrapper.x && areRectsApproximatelyEqual(occupied, expectedRightPartition)) {
return { ...wrapper, width: occupied.x - wrapper.x };
}
const expectedLeftPartition = {
x: wrapper.x,
y: wrapper.y,
width: occupiedRight - wrapper.x,
height: wrapper.height,
};
if (occupiedRight < wrapperRight && areRectsApproximatelyEqual(occupied, expectedLeftPartition)) {
return { ...wrapper, x: occupiedRight, width: wrapperRight - occupiedRight };
}
return undefined;
}
export function collectIosReactNativeOverlayWrapperSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
forEachOtherNodeWithLabel(nodes, (node, _nodeLabel, position) => {
if (!isReactNativeCollapsedWarningWrapperCandidate(node)) return;
if (
isReactNativeCollapsedWarningWrapperWithVisibleBanner(
node,
collectDescendantNodes(nodes, position),
)
) {
context.suppressNode(node, collectDescendantNodes(nodes, position));
}
});
}
function collectDescendantNodes(nodes: RawSnapshotNode[], position: number): RawSnapshotNode[] {
const descendants: RawSnapshotNode[] = [];
forEachDescendant(nodes, position, (descendant) => {
descendants.push(descendant);
});
return descendants;
}
@@ -0,0 +1,137 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
import {
areRectsApproximatelyEqual,
findDescendant,
forEachDescendant,
isRepeatedStaticNode,
isScrollableSnapshotType,
isSemanticActionNode,
type SnapshotTreeRuleContext,
} from './tree.ts';
import { forEachOtherNodeWithLabel } from './noise-helpers.ts';
export function collectIosRepeatedStaticSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
const nodeLabel = node?.label?.trim();
if (!node || context.isSuppressed(node) || !nodeLabel) {
continue;
}
collectRepeatedStaticSuppressionForNode(nodes, position, node, nodeLabel, context);
}
}
function collectRepeatedStaticSuppressionForNode(
nodes: RawSnapshotNode[],
position: number,
node: RawSnapshotNode,
nodeLabel: string,
context: SnapshotTreeRuleContext,
): void {
const type = normalizeType(node.type ?? '');
if (type === 'statictext' || type === 'link') {
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, node, context);
return;
}
if (type !== 'other') {
return;
}
const semanticDescendant = findEquivalentSemanticDescendant(nodes, position, nodeLabel);
if (semanticDescendant) {
context.suppressNode(node, [semanticDescendant]);
return;
}
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, node, context);
}
function findEquivalentSemanticDescendant(
nodes: RawSnapshotNode[],
position: number,
nodeLabel: string,
): RawSnapshotNode | undefined {
return findDescendant(nodes, position, (descendant) => {
const type = normalizeType(descendant.type ?? '');
return (
(type === 'link' || type === 'searchfield' || isScrollableSnapshotType(descendant.type)) &&
descendant.label?.trim() === nodeLabel
);
});
}
function suppressRepeatedStaticDescendants(
nodes: RawSnapshotNode[],
position: number,
label: string,
representative: RawSnapshotNode,
context: SnapshotTreeRuleContext,
): void {
forEachDescendant(nodes, position, (descendant) => {
if (
!context.semanticRepresentativeIndexes.has(descendant.index) &&
isRepeatedStaticNode(descendant, label)
) {
context.suppressNode(descendant, [representative]);
}
});
}
export function collectIosActionWrapperSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
forEachOtherNodeWithLabel(nodes, (node, nodeLabel, position) => {
const semanticDescendant = findDescendant(nodes, position, (descendant) => {
return (
isSemanticActionNode(descendant) &&
descendant.label?.trim() === nodeLabel &&
(areRectsApproximatelyEqual(descendant.rect, node.rect) ||
isIosBackdropDismissWrapper(node, descendant))
);
});
if (semanticDescendant) {
context.suppressNode(node, [semanticDescendant]);
}
});
}
function isIosBackdropDismissWrapper(node: RawSnapshotNode, descendant: RawSnapshotNode): boolean {
if (descendant.label?.trim() !== node.label?.trim()) {
return false;
}
const descendantType = normalizeType(descendant.type ?? '');
return (
isNamedButtonBackdrop(node, descendantType) ||
descendantType === 'textfield' ||
isFullscreenActionLabelWrapper(node, descendantType, descendant)
);
}
function isNamedButtonBackdrop(node: RawSnapshotNode, descendantType: string): boolean {
const label = node.label?.trim();
return descendantType === 'button' && (label === 'Dismiss' || label === 'Back');
}
function isFullscreenActionLabelWrapper(
node: RawSnapshotNode,
descendantType: string,
descendant: RawSnapshotNode,
): boolean {
if (descendantType !== 'button') {
return false;
}
if (!node.rect || !descendant.rect) {
return false;
}
return (
node.rect.x === 0 &&
node.rect.y === 0 &&
node.rect.width >= 300 &&
node.rect.height >= 600 &&
descendant.rect.width < node.rect.width
);
}
@@ -0,0 +1,84 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
import { findDescendant, forEachDescendant, type SnapshotTreeRuleContext } from './tree.ts';
export function collectIosSearchToolbarSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
if (!node) continue;
if (isExposedSearchField(node)) {
suppressSearchToolbarDescendants(nodes, position, node, context);
continue;
}
if (!isSearchToolbar(node)) continue;
const innerSearch = findDescendant(
nodes,
position,
(candidate) =>
normalizeType(candidate.type ?? '') === 'searchfield' && candidate.label === 'Search',
);
if (!innerSearch) {
continue;
}
context.suppressNode(node, [innerSearch]);
suppressToolbarAncestors(node, innerSearch, context);
suppressSearchToolbarDescendants(nodes, position, innerSearch, context);
}
}
function isExposedSearchField(node: RawSnapshotNode): boolean {
return normalizeType(node.type ?? '') === 'searchfield' && node.label === 'Search';
}
function isSearchToolbar(node: RawSnapshotNode): boolean {
const type = normalizeType(node.type ?? '');
return node.label === 'Toolbar' && (type === 'toolbar' || type === 'searchfield');
}
function suppressSearchToolbarDescendants(
nodes: RawSnapshotNode[],
position: number,
keptSearch: RawSnapshotNode,
context: SnapshotTreeRuleContext,
): void {
forEachDescendant(nodes, position, (descendant) => {
if (descendant.index === keptSearch.index) {
return;
}
if (shouldSuppressIosSearchToolbarDescendant(descendant)) {
context.suppressNode(descendant, [keptSearch]);
}
});
}
function suppressToolbarAncestors(
node: RawSnapshotNode,
representative: RawSnapshotNode,
context: SnapshotTreeRuleContext,
): void {
let current = node;
while (typeof current.parentIndex === 'number') {
const parent = context.sourceNodesByIndex.get(current.parentIndex);
if (!parent || parent.label !== 'Toolbar') {
return;
}
context.suppressNode(parent, [representative]);
current = parent;
}
}
function shouldSuppressIosSearchToolbarDescendant(node: RawSnapshotNode): boolean {
const type = normalizeType(node.type ?? '');
if (type === 'button') {
return false;
}
if (type === 'image') {
return true;
}
return node.label === 'Search';
}
@@ -0,0 +1,40 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
import { collectChildrenByParent, type SnapshotTreeRuleContext } from './tree.ts';
export function collectIosStructuralIdentifierSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
const childrenByParent = collectChildrenByParent(nodes);
for (const node of nodes) {
if (normalizeType(node.type ?? '') !== 'other') {
continue;
}
if (node.hittable === true || node.label?.trim() || node.value?.trim()) {
continue;
}
if (!node.identifier?.trim()) {
continue;
}
context.suppressNode(node, collectSubtreeByParentLinks(node, childrenByParent));
}
}
function collectSubtreeByParentLinks(
root: RawSnapshotNode,
childrenByParent: ReadonlyMap<number, RawSnapshotNode[]>,
): RawSnapshotNode[] {
const descendants: RawSnapshotNode[] = [];
const visited = new Set<number>([root.index]);
const pending = [...(childrenByParent.get(root.index) ?? [])];
while (pending.length > 0) {
const current = pending.pop();
if (!current || visited.has(current.index)) continue;
visited.add(current.index);
descendants.push(current);
const children = childrenByParent.get(current.index);
if (children) pending.push(...children);
}
return descendants;
}
@@ -0,0 +1,54 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
import {
findLargestViewportRect,
forEachDescendant,
type SnapshotTreeRuleContext,
} from './tree.ts';
export function collectIosOffscreenKeyboardSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
const viewport = findLargestViewportRect(nodes);
const screenBottom = viewport ? viewport.y + viewport.height : null;
if (screenBottom === null) {
return;
}
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
if (!node || !isOffscreenKeyboardNode(node, screenBottom)) {
continue;
}
context.suppressNode(node, []);
suppressOffscreenKeyboardAncestors(node, context, screenBottom);
forEachDescendant(nodes, position, (descendant) => {
context.suppressNode(descendant, []);
});
}
}
function isOffscreenKeyboardNode(node: RawSnapshotNode, screenBottom: number): boolean {
if (!node.rect || normalizeType(node.type ?? '') !== 'keyboard') {
return false;
}
return node.rect.y >= screenBottom;
}
function suppressOffscreenKeyboardAncestors(
node: RawSnapshotNode,
context: SnapshotTreeRuleContext,
screenBottom: number,
): void {
let current =
typeof node.parentIndex === 'number'
? context.sourceNodesByIndex.get(node.parentIndex)
: undefined;
while (current?.rect && current.rect.y >= screenBottom) {
context.suppressNode(current, []);
current =
typeof current.parentIndex === 'number'
? context.sourceNodesByIndex.get(current.parentIndex)
: undefined;
}
}
@@ -0,0 +1,28 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { collectIosScrollIndicatorPresentation } from './scroll.ts';
import {
collectIosReactNativeOverlayActionPresentation,
collectIosReactNativeOverlayWrapperSuppression,
} from './noise-overlay.ts';
import { collectIosOffscreenKeyboardSuppression } from './noise-viewport.ts';
import { collectIosSearchToolbarSuppression } from './noise-search.ts';
import {
collectIosActionWrapperSuppression,
collectIosRepeatedStaticSuppression,
} from './noise-redundancy.ts';
import { collectIosStructuralIdentifierSuppression } from './noise-structural.ts';
import type { SnapshotTreeRuleContext } from './tree.ts';
export function collectIosPresentationNoiseSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
collectIosOffscreenKeyboardSuppression(nodes, context);
collectIosStructuralIdentifierSuppression(nodes, context);
collectIosScrollIndicatorPresentation(nodes, context);
collectIosSearchToolbarSuppression(nodes, context);
collectIosActionWrapperSuppression(nodes, context);
collectIosReactNativeOverlayActionPresentation(nodes, context.replacements);
collectIosReactNativeOverlayWrapperSuppression(nodes, context);
collectIosRepeatedStaticSuppression(nodes, context);
}
@@ -0,0 +1,203 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { normalizeType } from '@agent-device/contracts/snapshot';
import type { IosSnapshotFoldPolicy, IosSnapshotPresentationNode } from './types.ts';
const REGULAR_ELIGIBLE_TYPES = new Set([
'button',
'cell',
'checkbox',
'collectionview',
'link',
'menuitem',
'picker',
'searchfield',
'securetextfield',
'segmentedcontrol',
'slider',
'scrollview',
'stepper',
'switch',
'tabbar',
'table',
'textfield',
'textview',
'webview',
]);
type ProjectionInput = Readonly<{
nodes: readonly IosSnapshotPresentationNode[];
projection: 'regular' | 'raw';
scope: string | null;
depth: number | null;
foldPolicy: IosSnapshotFoldPolicy;
}>;
export type IosSnapshotProjectionResult = Readonly<{
nodes: RawSnapshotNode[];
sourceIndexes: readonly number[];
}>;
export function projectIosSnapshot(input: ProjectionInput): IosSnapshotProjectionResult {
const scoped = scopeIosSnapshotNodes(input);
return input.projection === 'raw'
? projectRawNodes(scoped, input.depth)
: projectRegularNodes(scoped, input.depth);
}
export function projectIosQualitySnapshot(
input: Omit<ProjectionInput, 'scope'>,
): IosSnapshotProjectionResult {
return input.projection === 'raw'
? projectRawNodes(input.nodes, input.depth)
: projectRegularNodes(input.nodes, input.depth);
}
function isEligibleForIosRegularPresentation(node: RawSnapshotNode): boolean {
if (node.parentIndex === undefined) return true;
return REGULAR_ELIGIBLE_TYPES.has(normalizeType(node.type ?? '')) || hasSemanticContent(node);
}
function scopeIosSnapshotNodes(input: ProjectionInput): IosSnapshotPresentationNode[] {
const query = input.scope?.trim().toLowerCase();
if (!query) return [...input.nodes];
for (let start = 0; start < input.nodes.length; start += 1) {
const candidate = input.nodes[start];
if (!candidate || !matchesScope(candidate.raw, query)) continue;
const range = subtreeRange(input.nodes, start);
const contributes =
input.projection === 'raw' ||
range.some((position) => isEligibleForIosRegularPresentation(input.nodes[position]!.raw));
if (!contributes) continue;
const scoped = range.map((position) => input.nodes[position]!);
const limited =
input.projection === 'raw' && input.depth !== null
? scoped.filter((node) => rawDepth(node) - rawDepth(candidate) <= input.depth!)
: scoped;
return reindexScopedNodes(limited, rawDepth(candidate));
}
return [];
}
function projectRawNodes(
nodes: readonly IosSnapshotPresentationNode[],
maximumDepth: number | null,
): IosSnapshotProjectionResult {
const selected =
maximumDepth === null ? [...nodes] : nodes.filter((node) => rawDepth(node) <= maximumDepth);
return {
nodes: selected.map((node) => ({ ...node.raw, rect: node.raw.rect })),
sourceIndexes: selected.map((node) => node.raw.index),
};
}
function projectRegularNodes(
sourceNodes: readonly IosSnapshotPresentationNode[],
maximumDepth: number | null,
): IosSnapshotProjectionResult {
const presented: RawSnapshotNode[] = [];
const sourceIndexes: number[] = [];
const nearestPresented = new Map<number, RawSnapshotNode>();
for (const node of sourceNodes) {
const parent = readNearestPresentedParent(node, nearestPresented);
const projected = createRegularProjectedNode(node, parent, maximumDepth, presented.length);
if (!projected) {
rememberNearestPresented(node, parent, nearestPresented);
continue;
}
presented.push(projected);
sourceIndexes.push(node.raw.index);
nearestPresented.set(node.raw.index, projected);
}
return { nodes: presented, sourceIndexes };
}
function readNearestPresentedParent(
node: IosSnapshotPresentationNode,
nearestPresented: ReadonlyMap<number, RawSnapshotNode>,
): RawSnapshotNode | undefined {
return node.raw.parentIndex === undefined
? undefined
: nearestPresented.get(node.raw.parentIndex);
}
function createRegularProjectedNode(
node: IosSnapshotPresentationNode,
parent: RawSnapshotNode | undefined,
maximumDepth: number | null,
index: number,
): RawSnapshotNode | undefined {
if (!isEligibleForIosRegularPresentation(node.raw)) return undefined;
const depth = parent ? (parent.depth ?? 0) + 1 : 0;
if (maximumDepth !== null && depth > maximumDepth) return undefined;
return {
...node.raw,
index,
depth,
parentIndex: parent?.index,
...(node.effectiveRect ? { rect: node.effectiveRect } : { rect: undefined }),
hittable: isProjectedNodeHittable(node),
};
}
function isProjectedNodeHittable(node: IosSnapshotPresentationNode): boolean {
return Boolean(
node.raw.hittable === true &&
node.effectiveRect &&
node.effectiveRect.width > 0 &&
node.effectiveRect.height > 0,
);
}
function rememberNearestPresented(
node: IosSnapshotPresentationNode,
parent: RawSnapshotNode | undefined,
nearestPresented: Map<number, RawSnapshotNode>,
): void {
if (parent) nearestPresented.set(node.raw.index, parent);
}
function reindexScopedNodes(
nodes: readonly IosSnapshotPresentationNode[],
depthOffset: number,
): IosSnapshotPresentationNode[] {
const indexMap = new Map(nodes.map((node, index) => [node.raw.index, index]));
return nodes.map((node, index) => ({
...node,
raw: {
...node.raw,
index,
depth: Math.max(0, rawDepth(node) - depthOffset),
parentIndex:
node.raw.parentIndex === undefined ? undefined : indexMap.get(node.raw.parentIndex),
},
}));
}
function matchesScope(node: RawSnapshotNode, query: string): boolean {
return [node.label, node.identifier, node.value].some(
(value) => typeof value === 'string' && value.toLowerCase().includes(query),
);
}
function subtreeRange(nodes: readonly IosSnapshotPresentationNode[], start: number): number[] {
const rootDepth = rawDepth(nodes[start]!);
const positions: number[] = [];
for (let position = start; position < nodes.length; position += 1) {
if (position > start && rawDepth(nodes[position]!) <= rootDepth) break;
positions.push(position);
}
return positions;
}
function rawDepth(node: IosSnapshotPresentationNode): number {
return Math.max(0, node.raw.depth ?? 0);
}
function hasSemanticContent(node: RawSnapshotNode): boolean {
return [node.label, node.identifier, node.value].some(
(value) => typeof value === 'string' && value.trim().length > 0,
);
}
@@ -8,7 +8,7 @@ import {
mergeReplacement,
shouldSuppressRepeatedTextDescendant,
type SnapshotTreeRuleContext,
} from '../tree.ts';
} from './tree.ts';
export function collectIosRowPresentation(
nodes: RawSnapshotNode[],
@@ -0,0 +1,139 @@
import { buildIosSnapshotPresentationKey } from '../ios-snapshot-planning.ts';
import type { IosSnapshotInput, IosSnapshotRequest } from '@agent-device/contracts/ios-snapshot';
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { buildIosInteractiveSnapshotPresentation } from './semantic-index.ts';
import { resolveViewportEvidence, validateIosPayload } from './invariants.ts';
import type { IosSnapshotEnginePresentation, IosSnapshotFoldPolicy } from './types.ts';
import { IosSnapshotEngineError } from './types.ts';
export function presentIosRunnerSnapshot(
input: Extract<IosSnapshotInput, { stage: 'presented' }>,
request: IosSnapshotRequest,
foldPolicy: IosSnapshotFoldPolicy,
): IosSnapshotEnginePresentation {
validateRunnerRequest(input, request);
const projection = input.validation.presentationKey.projection;
const viewport =
projection === 'regular' ? resolveViewportEvidence(input.validation.viewport) : undefined;
const hittabilityAvailable = input.validation.hittability.kind === 'available';
const payloadValidation = validateRunnerPayloads(
input,
projection,
viewport,
foldPolicy,
hittabilityAvailable,
);
const compacted = compactRunnerPayload(input.presentation.payload.nodes, projection, request);
const validationStats = validateRunnerOutput(
compacted.nodes,
projection,
viewport,
foldPolicy,
hittabilityAvailable,
payloadValidation,
);
return {
nodes: compacted.nodes,
...(input.presentation.qualityPayload
? { qualityNodes: [...input.presentation.qualityPayload.nodes] }
: {}),
presentedIndexesBySourceIndex: compacted.presentedIndexesBySourceIndex,
stats: {
presentedNodeCount: compacted.nodes.length,
sourceNodeCount: input.presentation.payload.nodes.length,
parentClipLookups: validationStats.parentClipLookups,
},
};
}
function validateRunnerRequest(
input: Extract<IosSnapshotInput, { stage: 'presented' }>,
request: IosSnapshotRequest,
): void {
const expectedKey = buildIosSnapshotPresentationKey(request);
if (!presentationKeysEqual(expectedKey, input.validation.presentationKey)) {
throw new IosSnapshotEngineError(
'projection-mismatch',
'presented iOS snapshot does not match the requested presentation key',
{ projection: input.validation.presentationKey.projection },
);
}
if (input.presentation.intent !== request.acquisitionIntent) {
throw new IosSnapshotEngineError(
'projection-mismatch',
'presented iOS snapshot does not match the requested acquisition intent',
{ field: 'acquisitionIntent' },
);
}
}
function validateRunnerPayloads(
input: Extract<IosSnapshotInput, { stage: 'presented' }>,
projection: 'regular' | 'raw',
viewport: Rect | undefined,
foldPolicy: IosSnapshotFoldPolicy,
hittabilityAvailable: boolean,
): { parentClipLookups: number } {
const payloadValidation = validateIosPayload(
input.presentation.payload.nodes,
projection,
viewport,
foldPolicy,
hittabilityAvailable,
);
if (input.presentation.qualityPayload) {
validateIosPayload(
input.presentation.qualityPayload.nodes,
projection,
viewport,
foldPolicy,
hittabilityAvailable,
);
}
return payloadValidation;
}
function compactRunnerPayload(
nodes: readonly RawSnapshotNode[],
projection: 'regular' | 'raw',
request: IosSnapshotRequest,
): ReturnType<typeof buildIosInteractiveSnapshotPresentation> {
if (projection === 'regular' && request.interactiveOnly) {
return buildIosInteractiveSnapshotPresentation([...nodes]);
}
return {
nodes: [...nodes],
presentedIndexesBySourceIndex: identityMapping(nodes),
};
}
function validateRunnerOutput(
nodes: readonly RawSnapshotNode[],
projection: 'regular' | 'raw',
viewport: Rect | undefined,
foldPolicy: IosSnapshotFoldPolicy,
hittabilityAvailable: boolean,
payloadValidation: { parentClipLookups: number },
): { parentClipLookups: number } {
if (projection !== 'regular' || !viewport) return payloadValidation;
return validateIosPayload(nodes, projection, viewport, foldPolicy, hittabilityAvailable);
}
function presentationKeysEqual(
left: ReturnType<typeof buildIosSnapshotPresentationKey>,
right: ReturnType<typeof buildIosSnapshotPresentationKey>,
): boolean {
return (
left.projection === right.projection &&
left.interactiveOnly === right.interactiveOnly &&
left.depth === right.depth &&
left.scope === right.scope &&
left.customActions === right.customActions
);
}
function identityMapping(
nodes: readonly RawSnapshotNode[],
): ReadonlyMap<number, readonly number[]> {
return new Map(nodes.map((node) => [node.index, [node.index]]));
}
@@ -8,7 +8,7 @@ import {
isScrollableSnapshotType,
updateReplacement,
type SnapshotTreeRuleContext,
} from '../tree.ts';
} from './tree.ts';
export function collectIosScrollIndicatorPresentation(
nodes: RawSnapshotNode[],
@@ -4,10 +4,7 @@ import { collectIosPresentationNoiseSuppression } from './noise.ts';
import { collectIosRowPresentation } from './rows.ts';
import { collectIosTransitionPresentation } from './transitions.ts';
import { collectIosWebSemanticPresentation } from './web.ts';
import {
reindexSnapshotNodesWithSuppressedParents,
type SnapshotTreeRuleContext,
} from '../tree.ts';
import { reindexSnapshotNodesWithSuppressedParents, type SnapshotTreeRuleContext } from './tree.ts';
const IOS_PRESENTATION_RULES: Array<
(nodes: RawSnapshotNode[], context: SnapshotTreeRuleContext) => void
@@ -1,11 +1,7 @@
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
import { rectContains } from '@agent-device/kernel/rect';
import { extractNodeText, normalizeType } from '@agent-device/contracts/snapshot';
import {
collectChildrenByParent,
mergeReplacement,
type SnapshotTreeRuleContext,
} from '../tree.ts';
import { collectChildrenByParent, mergeReplacement, type SnapshotTreeRuleContext } from './tree.ts';
import { collectIosReplacedActionShelves } from './action-shelf.ts';
const TITLE_PIECE_GAP_TOLERANCE = 12;
@@ -0,0 +1,65 @@
import type { Rect, RawSnapshotNode } from '@agent-device/kernel/snapshot';
export type IosSnapshotFoldPolicy = 'cursor-projected' | 'plain-viewport';
export type IosSnapshotEngineOptions = Readonly<{
foldPolicy?: IosSnapshotFoldPolicy;
}>;
export type IosSnapshotFoldOptions = Readonly<{
hittabilityAvailable?: boolean;
}>;
export type IosSnapshotPresentationNode = Readonly<{
raw: RawSnapshotNode;
effectiveRect?: Rect;
}>;
export type IosSnapshotPresentationStats = Readonly<{
presentedNodeCount: number;
sourceNodeCount: number;
parentClipLookups: number;
}>;
export type IosSnapshotEnginePresentation = Readonly<{
nodes: RawSnapshotNode[];
qualityNodes?: RawSnapshotNode[];
presentedIndexesBySourceIndex: ReadonlyMap<number, readonly number[]>;
stats: IosSnapshotPresentationStats;
}>;
export type IosSnapshotEngineFailureReason =
| 'projection-mismatch'
| 'missing-viewport'
| 'invalid-viewport'
| 'malformed-graph'
| 'regular-node-outside-cumulative-clip'
| 'regular-degenerate-actionable-node'
| 'invalid-presented-payload'
| 'invalid-quality-payload';
export type IosSnapshotEngineFailureDetails = Readonly<{
index?: number;
parentIndex?: number;
frame?: Rect;
clip?: Rect;
projection?: string;
field?: string;
}>;
export class IosSnapshotEngineError extends Error {
readonly code = 'IOS_SNAPSHOT_ENGINE_FAILED';
readonly reason: IosSnapshotEngineFailureReason;
readonly details: IosSnapshotEngineFailureDetails;
constructor(
reason: IosSnapshotEngineFailureReason,
message: string,
details: IosSnapshotEngineFailureDetails = {},
) {
super(message);
this.name = 'IosSnapshotEngineError';
this.reason = reason;
this.details = details;
}
}
@@ -5,7 +5,7 @@ import {
findNearestAncestor,
mergeReplacement,
type SnapshotTreeRuleContext,
} from '../tree.ts';
} from './tree.ts';
/**
* WebKit exposes HTML text through an `Other -> StaticText` wrapper pair on iOS.
+1 -1
View File
@@ -32,7 +32,7 @@ test('a TypeScript-only Apple change selects the iOS and macOS lanes without a S
),
);
}
assert.deepEqual(lanes('src/snapshot/snapshot-presentation/ios/action-shelf.ts'), [...IOS]);
assert.deepEqual(lanes('packages/capture-kit/src/ios-snapshot-engine/action-shelf.ts'), [...IOS]);
assert.deepEqual(lanes('test/integration/replays/macos/01-desktop.ad'), ['replay-macos']);
});
+2 -1
View File
@@ -14,7 +14,7 @@
// can: `CANONICAL_PLATFORM_FAMILIES` names the families, layering R13 pins each family's runtime
// to `packages/platform-<family>/`, and the remaining family-owned
// trees are named by a family or Apple-leaf directory segment (`android/`, `linux/`,
// `test/integration/replays/<leaf>/`, `src/snapshot/snapshot-presentation/ios/`) or, under
// `test/integration/replays/<leaf>/`, `packages/capture-kit/src/ios-snapshot-engine/`) or, under
// `test/integration/`, by the lane prefix of the smoke file. A path tagged with exactly one
// family owns that family's lanes; a path tagged with none — or with two — is shared runtime
// surface and owns every lane. Unit tests (`*.test.ts`, `__tests__/`) under `src/` and
@@ -55,6 +55,7 @@ const LEAF_LANES: Readonly<Record<Leaf, readonly CheckId[]>> = {
// `xcuitest`, whose only lanes today are the Apple family's) owns every Apple lane.
const APPLE_LEAF_TAGS: Readonly<Record<string, 'ios' | 'macos' | 'apple'>> = {
ios: 'ios',
'ios-snapshot-engine': 'ios',
macos: 'macos',
apple: 'apple',
tvos: 'apple',
@@ -458,6 +458,7 @@ test('the real tree parses, declares, and passes R11', () => {
);
assert.deepEqual([...captureKitPackage.exportTargets.keys()].sort(), [
'@agent-device/capture-kit',
'@agent-device/capture-kit/ios-snapshot-engine',
'@agent-device/capture-kit/ios-snapshot-planning',
'@agent-device/capture-kit/mobile-snapshot-semantics',
'@agent-device/capture-kit/png',
+2
View File
@@ -122,6 +122,8 @@ export const FACADE_BUDGETS: Readonly<Record<string, number>> = Object.freeze({
'packages/capture-kit/src/index.ts': 32,
// #2190 keeps iOS snapshot planning behind its dedicated subpath instead of the broad root.
'packages/capture-kit/src/ios-snapshot-planning.ts': 1,
// #2191 keeps the iOS snapshot engine behind its dedicated subpath instead of the broad root.
'packages/capture-kit/src/ios-snapshot-engine/index.ts': 36,
'packages/capture-kit/src/png-resize.ts': 18,
'packages/capture-kit/src/png-rgb-difference.ts': 1,
'packages/capture-kit/src/png-size.ts': 3,
+1 -1
View File
@@ -21,7 +21,7 @@ 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 '../snapshot/snapshot-presentation/ios/index.ts';
import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';
/**
* The ONE daemon presentation of a captured tree (ADR 0004 / #1797 "compaction layer"): normalize,
@@ -2,7 +2,7 @@ import { expect, test } from 'vitest';
import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts';
import { createInteractionDevice } from '../../commands/interaction/runtime/__tests__/test-utils/index.ts';
import { buildSnapshotState } from '../../core/snapshot-state.ts';
import { presentIosInteractiveSnapshot } from '../../snapshot/snapshot-presentation/ios/index.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', () => {
@@ -24,7 +24,7 @@ import {
} from '../../touch-reference-frame.ts';
import { isPositiveFiniteRect, rectContains } from '@agent-device/kernel/rect';
import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot';
import { buildIosInteractiveSnapshotPresentation } from '../../../snapshot/snapshot-presentation/ios/index.ts';
import { buildIosInteractiveSnapshotPresentation } from '@agent-device/capture-kit/ios-snapshot-engine';
export const MAESTRO_OBSERVATION_POLL_MS = MAESTRO_RUNTIME_ADAPTER_POLICY.observationPollMs;
export type DaemonMaestroRuntimeDependencies = {
+1 -1
View File
@@ -22,7 +22,7 @@
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { resolveRectCenter } from '@agent-device/kernel/rect-center';
import { findNearestScrollableContainer } from '../snapshot/snapshot-presentation/tree.ts';
import { findNearestScrollableContainer } from '@agent-device/capture-kit/ios-snapshot-engine';
import {
buildAncestryChain,
buildIndexMap,
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { presentIosInteractiveSnapshot } from './index.ts';
import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';
import {
closedComposerWithRetainedActionShelfNodes,
closedComposerWithRetainedRegularTreeActionNodes,
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest';
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { buildIosInteractiveSnapshotPresentation } from './index.ts';
import { buildIosInteractiveSnapshotPresentation } from '@agent-device/capture-kit/ios-snapshot-engine';
test('publishes an exact representative for every semantic source index', () => {
const nodes: RawSnapshotNode[] = [
@@ -2,8 +2,8 @@ import { describe, expect, test } from 'vitest';
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { collectIosStructuralIdentifierSuppression } from './noise.ts';
import type { SnapshotTreeRuleContext } from '../tree.ts';
import { collectIosStructuralIdentifierSuppression } from '@agent-device/capture-kit/ios-snapshot-engine';
import type { SnapshotTreeRuleContext } from '@agent-device/capture-kit/ios-snapshot-engine';
type ReadCounter = { reads: number };
@@ -1,434 +0,0 @@
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { rectArea, rectContains } from '@agent-device/kernel/rect';
import {
isReactNativeCollapsedWarningWrapperCandidate,
isReactNativeCollapsedWarningWrapperWithVisibleBanner,
isReactNativeOverlayDismissLabel,
isReactNativeOverlayMinimizeLabel,
} from '@agent-device/contracts/react-native-overlay';
import { normalizeType } from '@agent-device/contracts/snapshot';
import { collectIosScrollIndicatorPresentation } from './scroll.ts';
import {
areRectsApproximatelyEqual,
collectChildrenByParent,
findDescendant,
findLargestViewportRect,
forEachDescendant,
isRepeatedStaticNode,
isScrollableSnapshotType,
isSemanticActionNode,
mergeReplacement,
type SnapshotTreeRuleContext,
} from '../tree.ts';
export function collectIosPresentationNoiseSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
collectIosOffscreenKeyboardSuppression(nodes, context);
collectIosStructuralIdentifierSuppression(nodes, context);
collectIosScrollIndicatorPresentation(nodes, context);
collectIosSearchToolbarSuppression(nodes, context);
collectIosActionWrapperSuppression(nodes, context);
collectIosReactNativeOverlayActionPresentation(nodes, context.replacements);
collectIosReactNativeOverlayWrapperSuppression(nodes, context);
collectIosRepeatedStaticSuppression(nodes, context);
}
function collectIosReactNativeOverlayActionPresentation(
nodes: RawSnapshotNode[],
replacements: Map<number, RawSnapshotNode>,
): void {
forEachOtherNodeWithLabel(nodes, (node, nodeLabel, position) => {
if (!isReactNativeOverlayDismissLabel(nodeLabel) || !node.rect) return;
const minimize = findDescendant(
nodes,
position,
(descendant) =>
Boolean(descendant.rect) &&
isReactNativeOverlayMinimizeLabel(descendant.label?.trim() ?? ''),
);
if (!minimize?.rect) return;
const dismissRect = remainingHorizontalPartition(node.rect, minimize.rect);
if (!dismissRect) return;
const representativeRect = smallestContainedDismissRect(nodes, position, dismissRect);
mergeReplacement(replacements, node, { rect: representativeRect });
forEachDescendant(nodes, position, (descendant) => {
if (isReactNativeOverlayDismissLabel(descendant.label?.trim() ?? '')) {
mergeReplacement(replacements, descendant, { rect: representativeRect });
}
});
});
}
function smallestContainedDismissRect(
nodes: RawSnapshotNode[],
position: number,
partition: NonNullable<RawSnapshotNode['rect']>,
): NonNullable<RawSnapshotNode['rect']> {
let representative = partition;
forEachDescendant(nodes, position, (descendant) => {
const label = descendant.label?.trim() ?? '';
if (!descendant.rect || !isReactNativeOverlayDismissLabel(label)) return;
if (!rectContains(partition, descendant.rect)) return;
if (rectArea(descendant.rect) < rectArea(representative)) {
representative = descendant.rect;
}
});
return representative;
}
function remainingHorizontalPartition(
wrapper: NonNullable<RawSnapshotNode['rect']>,
occupied: NonNullable<RawSnapshotNode['rect']>,
): NonNullable<RawSnapshotNode['rect']> | undefined {
const wrapperRight = wrapper.x + wrapper.width;
const occupiedRight = occupied.x + occupied.width;
const expectedRightPartition = {
x: occupied.x,
y: wrapper.y,
width: wrapperRight - occupied.x,
height: wrapper.height,
};
if (occupied.x > wrapper.x && areRectsApproximatelyEqual(occupied, expectedRightPartition)) {
return { ...wrapper, width: occupied.x - wrapper.x };
}
const expectedLeftPartition = {
x: wrapper.x,
y: wrapper.y,
width: occupiedRight - wrapper.x,
height: wrapper.height,
};
if (occupiedRight < wrapperRight && areRectsApproximatelyEqual(occupied, expectedLeftPartition)) {
return { ...wrapper, x: occupiedRight, width: wrapperRight - occupiedRight };
}
return undefined;
}
function collectIosReactNativeOverlayWrapperSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
forEachOtherNodeWithLabel(nodes, (node, _nodeLabel, position) => {
if (!isReactNativeCollapsedWarningWrapperCandidate(node)) return;
if (
isReactNativeCollapsedWarningWrapperWithVisibleBanner(
node,
collectDescendantNodes(nodes, position),
)
) {
context.suppressNode(node, collectDescendantNodes(nodes, position));
}
});
}
function collectDescendantNodes(nodes: RawSnapshotNode[], position: number): RawSnapshotNode[] {
const descendants: RawSnapshotNode[] = [];
forEachDescendant(nodes, position, (descendant) => {
descendants.push(descendant);
});
return descendants;
}
function collectIosRepeatedStaticSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
const nodeLabel = node?.label?.trim();
if (!node || context.isSuppressed(node) || !nodeLabel) {
continue;
}
collectRepeatedStaticSuppressionForNode(nodes, position, node, nodeLabel, context);
}
}
function collectRepeatedStaticSuppressionForNode(
nodes: RawSnapshotNode[],
position: number,
node: RawSnapshotNode,
nodeLabel: string,
context: SnapshotTreeRuleContext,
): void {
const type = normalizeType(node.type ?? '');
if (type === 'statictext' || type === 'link') {
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, node, context);
return;
}
if (type !== 'other') {
return;
}
const semanticDescendant = findEquivalentSemanticDescendant(nodes, position, nodeLabel);
if (semanticDescendant) {
context.suppressNode(node, [semanticDescendant]);
return;
}
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, node, context);
}
function findEquivalentSemanticDescendant(
nodes: RawSnapshotNode[],
position: number,
nodeLabel: string,
): RawSnapshotNode | undefined {
return findDescendant(nodes, position, (descendant) => {
const type = normalizeType(descendant.type ?? '');
return (
(type === 'link' || type === 'searchfield' || isScrollableSnapshotType(descendant.type)) &&
descendant.label?.trim() === nodeLabel
);
});
}
function suppressRepeatedStaticDescendants(
nodes: RawSnapshotNode[],
position: number,
label: string,
representative: RawSnapshotNode,
context: SnapshotTreeRuleContext,
): void {
forEachDescendant(nodes, position, (descendant) => {
if (
!context.semanticRepresentativeIndexes.has(descendant.index) &&
isRepeatedStaticNode(descendant, label)
) {
context.suppressNode(descendant, [representative]);
}
});
}
function collectIosActionWrapperSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
forEachOtherNodeWithLabel(nodes, (node, nodeLabel, position) => {
const semanticDescendant = findDescendant(nodes, position, (descendant) => {
return (
isSemanticActionNode(descendant) &&
descendant.label?.trim() === nodeLabel &&
(areRectsApproximatelyEqual(descendant.rect, node.rect) ||
isIosBackdropDismissWrapper(node, descendant))
);
});
if (semanticDescendant) {
context.suppressNode(node, [semanticDescendant]);
}
});
}
function isIosBackdropDismissWrapper(node: RawSnapshotNode, descendant: RawSnapshotNode): boolean {
if (descendant.label?.trim() !== node.label?.trim()) {
return false;
}
const descendantType = normalizeType(descendant.type ?? '');
return (
isNamedButtonBackdrop(node, descendantType) ||
descendantType === 'textfield' ||
isFullscreenActionLabelWrapper(node, descendantType, descendant)
);
}
function isNamedButtonBackdrop(node: RawSnapshotNode, descendantType: string): boolean {
const label = node.label?.trim();
return descendantType === 'button' && (label === 'Dismiss' || label === 'Back');
}
function isFullscreenActionLabelWrapper(
node: RawSnapshotNode,
descendantType: string,
descendant: RawSnapshotNode,
): boolean {
if (descendantType !== 'button') {
return false;
}
if (!node.rect || !descendant.rect) {
return false;
}
return (
node.rect.x === 0 &&
node.rect.y === 0 &&
node.rect.width >= 300 &&
node.rect.height >= 600 &&
descendant.rect.width < node.rect.width
);
}
function collectIosOffscreenKeyboardSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
const viewport = findLargestViewportRect(nodes);
const screenBottom = viewport ? viewport.y + viewport.height : null;
if (screenBottom === null) {
return;
}
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
if (!node || !isOffscreenKeyboardNode(node, screenBottom)) {
continue;
}
context.suppressNode(node, []);
suppressOffscreenKeyboardAncestors(node, context, screenBottom);
forEachDescendant(nodes, position, (descendant) => {
context.suppressNode(descendant, []);
});
}
}
function isOffscreenKeyboardNode(node: RawSnapshotNode, screenBottom: number): boolean {
if (!node.rect || normalizeType(node.type ?? '') !== 'keyboard') {
return false;
}
return node.rect.y >= screenBottom;
}
function suppressOffscreenKeyboardAncestors(
node: RawSnapshotNode,
context: SnapshotTreeRuleContext,
screenBottom: number,
): void {
let current =
typeof node.parentIndex === 'number'
? context.sourceNodesByIndex.get(node.parentIndex)
: undefined;
while (current?.rect && current.rect.y >= screenBottom) {
context.suppressNode(current, []);
current =
typeof current.parentIndex === 'number'
? context.sourceNodesByIndex.get(current.parentIndex)
: undefined;
}
}
export function collectIosStructuralIdentifierSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
const childrenByParent = collectChildrenByParent(nodes);
for (const node of nodes) {
if (normalizeType(node.type ?? '') !== 'other') {
continue;
}
if (node.hittable === true || node.label?.trim() || node.value?.trim()) {
continue;
}
if (!node.identifier?.trim()) {
continue;
}
context.suppressNode(node, collectSubtreeByParentLinks(node, childrenByParent));
}
}
function collectSubtreeByParentLinks(
root: RawSnapshotNode,
childrenByParent: ReadonlyMap<number, RawSnapshotNode[]>,
): RawSnapshotNode[] {
const descendants: RawSnapshotNode[] = [];
const visited = new Set<number>([root.index]);
const pending = [...(childrenByParent.get(root.index) ?? [])];
while (pending.length > 0) {
const current = pending.pop();
if (!current || visited.has(current.index)) continue;
visited.add(current.index);
descendants.push(current);
const children = childrenByParent.get(current.index);
if (children) pending.push(...children);
}
return descendants;
}
function collectIosSearchToolbarSuppression(
nodes: RawSnapshotNode[],
context: SnapshotTreeRuleContext,
): void {
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
if (!node) continue;
if (isExposedSearchField(node)) {
suppressSearchToolbarDescendants(nodes, position, node, context);
continue;
}
if (!isSearchToolbar(node)) continue;
const innerSearch = findDescendant(
nodes,
position,
(candidate) =>
normalizeType(candidate.type ?? '') === 'searchfield' && candidate.label === 'Search',
);
if (!innerSearch) {
continue;
}
context.suppressNode(node, [innerSearch]);
suppressToolbarAncestors(node, innerSearch, context);
suppressSearchToolbarDescendants(nodes, position, innerSearch, context);
}
}
function isExposedSearchField(node: RawSnapshotNode): boolean {
return normalizeType(node.type ?? '') === 'searchfield' && node.label === 'Search';
}
function isSearchToolbar(node: RawSnapshotNode): boolean {
const type = normalizeType(node.type ?? '');
return node.label === 'Toolbar' && (type === 'toolbar' || type === 'searchfield');
}
function suppressSearchToolbarDescendants(
nodes: RawSnapshotNode[],
position: number,
keptSearch: RawSnapshotNode,
context: SnapshotTreeRuleContext,
): void {
forEachDescendant(nodes, position, (descendant) => {
if (descendant.index === keptSearch.index) {
return;
}
if (shouldSuppressIosSearchToolbarDescendant(descendant)) {
context.suppressNode(descendant, [keptSearch]);
}
});
}
function suppressToolbarAncestors(
node: RawSnapshotNode,
representative: RawSnapshotNode,
context: SnapshotTreeRuleContext,
): void {
let current = node;
while (typeof current.parentIndex === 'number') {
const parent = context.sourceNodesByIndex.get(current.parentIndex);
if (!parent || parent.label !== 'Toolbar') {
return;
}
context.suppressNode(parent, [representative]);
current = parent;
}
}
function shouldSuppressIosSearchToolbarDescendant(node: RawSnapshotNode): boolean {
const type = normalizeType(node.type ?? '');
if (type === 'button') {
return false;
}
if (type === 'image') {
return true;
}
return node.label === 'Search';
}
function forEachOtherNodeWithLabel(
nodes: RawSnapshotNode[],
visitor: (node: RawSnapshotNode, label: string, position: number) => void,
): void {
for (let position = 0; position < nodes.length; position += 1) {
const node = nodes[position];
const label = node?.label?.trim();
if (node && label && normalizeType(node.type ?? '') === 'other') {
visitor(node, label, position);
}
}
}
@@ -1,7 +1,7 @@
import { expect, test } from 'vitest';
import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { buildSnapshotVisibility } from '../../snapshot-visibility.ts';
import { presentIosInteractiveSnapshot } from './index.ts';
import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';
function buildSnapshotState(data: { nodes?: RawSnapshotNode[]; backend?: 'xctest' }) {
return {
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest';
import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { presentIosInteractiveSnapshot } from './index.ts';
import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';
function buildSnapshotState(data: { nodes?: RawSnapshotNode[]; backend?: 'xctest' }) {
return {
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest';
import { elementClassicRoomListNodes, legitimatelyLabeledCellNodes } from './rows.fixtures.ts';
import { presentIosInteractiveSnapshot } from './index.ts';
import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';
test('iOS row presentation associates generic room cells with their descendant titles', () => {
const nodes = presentIosInteractiveSnapshot(elementClassicRoomListNodes);
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest';
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { presentIosInteractiveSnapshot } from './index.ts';
import { presentIosInteractiveSnapshot } from '@agent-device/capture-kit/ios-snapshot-engine';
test('projects iOS WebKit heading and text wrappers to semantic roles', () => {
const nodes: RawSnapshotNode[] = [
@@ -1,6 +1,6 @@
import { expect, test } from 'vitest';
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import { mergeReplacement, updateReplacement } from './tree.ts';
import { mergeReplacement, updateReplacement } from '@agent-device/capture-kit/ios-snapshot-engine';
test('replacement updates derive patches from the composed node', () => {
const node: RawSnapshotNode = { index: 1, type: 'Table', hiddenContentBelow: true };