mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
6eb74d08ce
commit
e0cd06e567
+6
-5
@@ -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,
|
||||
|
||||
+5
-4
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+138
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user