test(snapshot): add cross-runtime presentation conformance (#1973)

* test(snapshot): add cross-runtime presentation conformance

* fix(ios): preserve acquired snapshot actionability

* chore(ios): retain existing XCTest selection name

* test(snapshot): cover nested cumulative clipping
This commit is contained in:
Michał Pierzchała
2026-08-23 17:29:38 +02:00
committed by GitHub
parent 6eb74d08ce
commit e0cd06e567
5 changed files with 476 additions and 9 deletions
@@ -219,11 +219,12 @@ enum SnapshotVisibilityFold {
enabled: node.enabled,
focused: node.focused,
selected: node.selected,
hittable: node.parentIndex != nil && SnapshotGeometry.isGeometricallyActionable(
enabled: node.enabled,
frame: decision.effectiveFrame,
viewport: viewport
),
hittable: node.parentIndex != nil && node.hittable
&& SnapshotGeometry.isGeometricallyActionable(
enabled: node.enabled,
frame: decision.effectiveFrame,
viewport: viewport
),
depth: outDepth,
parentIndex: keptIndex,
hiddenContentAbove: node.hiddenContentAbove,
@@ -4,8 +4,8 @@ import XCTest
extension RunnerTests {
func testRegularPresentationPublishesGeometricActionabilityWithoutOcclusionOrTypeGate() throws {
// Non-vacuity: restoring the old raw `hittable` copy would make the covered button and the
// labeled image false, while dropping the enabled check would make the disabled button true.
// Non-vacuity: dropping the acquired `hittable` check would upgrade the covered button and
// labeled image, while dropping the enabled check would make the disabled button true.
let nodes = [
RawAXNode(
index: 0,
@@ -102,8 +102,9 @@ extension RunnerTests {
)
let presented = try XCTUnwrap(capture.payload.nodes)
XCTAssertEqual(presented.first { $0.label == "Covered button" }?.hittable, true)
XCTAssertEqual(presented.first { $0.label == "Labeled image" }?.hittable, true)
XCTAssertEqual(presented.first { $0.label == "Covered button" }?.hittable, false)
XCTAssertEqual(presented.first { $0.label == "Labeled image" }?.hittable, false)
XCTAssertEqual(presented.first { $0.label == "Overlay" }?.hittable, true)
XCTAssertEqual(presented.first { $0.label == "Disabled button" }?.hittable, false)
}
@@ -0,0 +1,138 @@
import XCTest
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
private struct SnapshotPresentationConformanceFixture: Decodable {
struct Rect: Decodable {
let x: Double
let y: Double
let width: Double
let height: Double
var snapshotRect: SnapshotRect {
SnapshotRect(x: x, y: y, width: width, height: height)
}
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
struct Node: Decodable {
let index: Int
let type: String
let label: String
let rect: Rect
let depth: Int
let parentIndex: Int?
let hittable: Bool
}
struct ExpectedNode: Decodable {
let label: String
let rect: Rect
let depth: Int
let parentIndex: Int?
let hittable: Bool
}
struct Case: Decodable {
let name: String
let projection: String
let scope: String?
let nodes: [Node]
let expected: [ExpectedNode]
}
let version: Int
let viewport: Rect
let cases: [Case]
}
extension RunnerTests {
func testSnapshotPresentationMatchesSharedRawToPresentedFixture() throws {
let fixtureURL = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // UnitTests
.deletingLastPathComponent() // AgentDeviceRunnerUITests
.deletingLastPathComponent() // AgentDeviceRunner
.deletingLastPathComponent() // runner
.deletingLastPathComponent() // apple
.deletingLastPathComponent() // repo root
.appendingPathComponent("contracts")
.appendingPathComponent("fixtures")
.appendingPathComponent("snapshot-presentation-conformance.json")
let fixture = try JSONDecoder().decode(
SnapshotPresentationConformanceFixture.self,
from: Data(contentsOf: fixtureURL)
)
XCTAssertEqual(fixture.version, 1)
XCTAssertFalse(fixture.cases.isEmpty, "conformance fixture must not be empty")
for testCase in fixture.cases {
let acquisition = SnapshotAcquisition(
hint: CaptureHint(
projection: testCase.projection == "raw" ? .raw : .regular,
depth: nil,
regularPresentedDepth: nil,
interactiveOnly: false,
customActions: false
),
nodes: testCase.nodes.map { node in
RawAXNode(
index: node.index,
type: node.type,
label: node.label,
identifier: nil,
value: nil,
rect: node.rect.snapshotRect,
enabled: true,
focused: nil,
selected: nil,
hittable: node.hittable,
depth: node.depth,
parentIndex: node.parentIndex,
hiddenContentAbove: nil,
hiddenContentBelow: nil
)
},
truncated: false,
effectiveDepth: nil,
viewport: fixture.viewport.cgRect
)
let options = PresentationOptions(
interactiveOnly: false,
depth: nil,
scope: testCase.scope,
raw: testCase.projection == "raw"
)
let presented: [PresentedNode]
if testCase.projection == "raw" {
presented = try XCTUnwrap(
SnapshotPresentation.presentRaw(acquisition, options: options).payload.nodes,
testCase.name
)
} else {
presented = try XCTUnwrap(
try SnapshotPresentation.presentRegular(
acquisition,
options: options,
policy: .cursorProjected
).payload.nodes,
testCase.name
)
}
XCTAssertEqual(presented.count, testCase.expected.count, testCase.name)
for (actual, expected) in zip(presented, testCase.expected) {
XCTAssertEqual(actual.label, expected.label, testCase.name)
XCTAssertEqual(actual.rect.x, expected.rect.x, testCase.name)
XCTAssertEqual(actual.rect.y, expected.rect.y, testCase.name)
XCTAssertEqual(actual.rect.width, expected.rect.width, testCase.name)
XCTAssertEqual(actual.rect.height, expected.rect.height, testCase.name)
XCTAssertEqual(actual.depth, expected.depth, testCase.name)
XCTAssertEqual(actual.parentIndex, expected.parentIndex, testCase.name)
XCTAssertEqual(actual.hittable, expected.hittable, testCase.name)
}
}
}
}
#endif
@@ -0,0 +1,227 @@
{
"version": 1,
"viewport": { "x": 0, "y": 0, "width": 320, "height": 240 },
"cases": [
{
"name": "cumulative scroll clip carries into the presented rect",
"projection": "regular",
"scope": null,
"nodes": [
{
"index": 0,
"type": "Application",
"label": "App",
"rect": { "x": 0, "y": 0, "width": 320, "height": 240 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"index": 1,
"type": "ScrollView",
"label": "Outer",
"rect": { "x": 16, "y": 20, "width": 180, "height": 180 },
"depth": 1,
"parentIndex": 0,
"hittable": false
},
{
"index": 2,
"type": "ScrollView",
"label": "Inner",
"rect": { "x": 80, "y": 60, "width": 100, "height": 80 },
"depth": 2,
"parentIndex": 1,
"hittable": false
},
{
"index": 3,
"type": "Button",
"label": "Partially visible",
"rect": { "x": 150, "y": 110, "width": 100, "height": 60 },
"depth": 3,
"parentIndex": 2,
"hittable": true
}
],
"expected": [
{
"label": "App",
"rect": { "x": 0, "y": 0, "width": 320, "height": 240 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"label": "Outer",
"rect": { "x": 16, "y": 20, "width": 180, "height": 180 },
"depth": 1,
"parentIndex": 0,
"hittable": false
},
{
"label": "Inner",
"rect": { "x": 80, "y": 60, "width": 100, "height": 80 },
"depth": 2,
"parentIndex": 1,
"hittable": false
},
{
"label": "Partially visible",
"rect": { "x": 150, "y": 110, "width": 30, "height": 30 },
"depth": 3,
"parentIndex": 2,
"hittable": true
}
]
},
{
"name": "raw projection preserves the helper-reported frame",
"projection": "raw",
"scope": null,
"nodes": [
{
"index": 0,
"type": "Application",
"label": "App",
"rect": { "x": 0, "y": 0, "width": 320, "height": 240 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"index": 1,
"type": "ScrollView",
"label": "Outer",
"rect": { "x": 16, "y": 20, "width": 180, "height": 180 },
"depth": 1,
"parentIndex": 0,
"hittable": false
},
{
"index": 2,
"type": "Button",
"label": "Partially visible",
"rect": { "x": 150, "y": 80, "width": 100, "height": 40 },
"depth": 2,
"parentIndex": 1,
"hittable": true
}
],
"expected": [
{
"label": "App",
"rect": { "x": 0, "y": 0, "width": 320, "height": 240 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"label": "Outer",
"rect": { "x": 16, "y": 20, "width": 180, "height": 180 },
"depth": 1,
"parentIndex": 0,
"hittable": false
},
{
"label": "Partially visible",
"rect": { "x": 150, "y": 80, "width": 100, "height": 40 },
"depth": 2,
"parentIndex": 1,
"hittable": true
}
]
},
{
"name": "viewport edge clips while preserving positive actionability",
"projection": "regular",
"scope": null,
"nodes": [
{
"index": 0,
"type": "Application",
"label": "App",
"rect": { "x": 0, "y": 0, "width": 320, "height": 240 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"index": 1,
"type": "Button",
"label": "Edge",
"rect": { "x": 300, "y": 100, "width": 40, "height": 40 },
"depth": 1,
"parentIndex": 0,
"hittable": true
}
],
"expected": [
{
"label": "App",
"rect": { "x": 0, "y": 0, "width": 320, "height": 240 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"label": "Edge",
"rect": { "x": 300, "y": 100, "width": 20, "height": 40 },
"depth": 1,
"parentIndex": 0,
"hittable": true
}
]
},
{
"name": "scope re-roots the presented subtree after clipping",
"projection": "regular",
"scope": "settings",
"nodes": [
{
"index": 0,
"type": "Application",
"label": "App",
"rect": { "x": 0, "y": 0, "width": 320, "height": 240 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"index": 1,
"type": "ScrollView",
"label": "Settings",
"rect": { "x": 20, "y": 20, "width": 280, "height": 180 },
"depth": 1,
"parentIndex": 0,
"hittable": false
},
{
"index": 2,
"type": "Button",
"label": "Save",
"rect": { "x": 30, "y": 40, "width": 100, "height": 44 },
"depth": 2,
"parentIndex": 1,
"hittable": true
}
],
"expected": [
{
"label": "Settings",
"rect": { "x": 20, "y": 20, "width": 280, "height": 180 },
"depth": 0,
"parentIndex": null,
"hittable": false
},
{
"label": "Save",
"rect": { "x": 30, "y": 40, "width": 100, "height": 44 },
"depth": 1,
"parentIndex": 0,
"hittable": true
}
]
}
]
}
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'vitest';
import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';
import type { Rect } from '@agent-device/kernel/snapshot';
type ConformanceNode = {
index: number;
type: string;
label: string;
rect: Rect;
depth: number;
parentIndex: number | null;
hittable: boolean;
};
type ConformanceExpectedNode = {
label: string;
rect: Rect;
depth: number;
parentIndex: number | null;
hittable: boolean;
};
type ConformanceFixture = {
version: number;
viewport: Rect;
cases: Array<{
name: string;
projection: 'regular' | 'raw';
scope: string | null;
nodes: ConformanceNode[];
expected: ConformanceExpectedNode[];
}>;
};
const TABLE_PATH = path.resolve(
import.meta.dirname,
'../../../../contracts/fixtures/snapshot-presentation-conformance.json',
);
function conformanceXml(fixture: ConformanceFixture, nodes: ConformanceNode[]): string {
const viewport = fixture.viewport;
const windowBounds = `[${viewport.x},${viewport.y}][${viewport.x + viewport.width},${viewport.y + viewport.height}]`;
const lines = ['<hierarchy>'];
const openDepths: number[] = [];
for (const node of nodes) {
while (openDepths.length > node.depth) {
openDepths.pop();
lines.push('</node>');
}
const scrollable = ['ScrollView', 'CollectionView', 'Table'].includes(node.type);
const attrs = [
`class="android.widget.${node.type === 'Application' ? 'FrameLayout' : node.type}"`,
`bounds="[${node.rect.x},${node.rect.y}][${node.rect.x + node.rect.width},${node.rect.y + node.rect.height}]"`,
`window-bounds="${windowBounds}"`,
'visible-to-user="true"',
'enabled="true"',
`text="${node.label}"`,
...(node.hittable ? ['clickable="true"'] : []),
...(scrollable ? ['scrollable="true"'] : []),
];
lines.push(`<node ${attrs.join(' ')}>`);
openDepths.push(node.depth);
}
while (openDepths.length > 0) {
openDepths.pop();
lines.push('</node>');
}
lines.push('</hierarchy>');
return lines.join('\n');
}
test('Android XML parsing and presentation match the shared acquisition-to-presentation fixture', () => {
const fixture = JSON.parse(fs.readFileSync(TABLE_PATH, 'utf8')) as ConformanceFixture;
assert.equal(fixture.version, 1);
assert.ok(fixture.cases.length > 0);
for (const testCase of fixture.cases) {
const built = buildUiHierarchySnapshot(
parseUiHierarchyTree(conformanceXml(fixture, testCase.nodes)),
undefined,
{
...(testCase.projection === 'raw' ? { raw: true } : {}),
...(testCase.scope === null ? {} : { scope: testCase.scope }),
androidPresentation: { deadlineAtMs: Number.POSITIVE_INFINITY },
},
);
const actual = built.nodes.map((node) => ({
label: node.label ?? '',
rect: node.rect,
depth: node.depth,
parentIndex: node.parentIndex ?? null,
hittable: node.hittable === true,
}));
assert.deepEqual(actual, testCase.expected, testCase.name);
}
});