Skip to content

Commit db242d3

Browse files
committed
fix(ios): centralize WebDriver snapshot evidence
1 parent e4af7a1 commit db242d3

18 files changed

Lines changed: 214 additions & 101 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { toIosSnapshotEngineErrorDetails } from './errors.ts';
4+
import { IosSnapshotEngineError } from './types.ts';
5+
6+
test('engine error details expose only the public reason and field', () => {
7+
const error = new IosSnapshotEngineError('invalid-viewport', 'invalid viewport', {
8+
field: 'viewport',
9+
index: 4,
10+
frame: { x: 0, y: 0, width: 10, height: 10 },
11+
});
12+
13+
assert.deepEqual(toIosSnapshotEngineErrorDetails(error), {
14+
reason: 'invalid-viewport',
15+
field: 'viewport',
16+
});
17+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { IosSnapshotEngineError, IosSnapshotEngineFailureReason } from './types.ts';
2+
3+
export type IosSnapshotEnginePublicErrorDetails = Readonly<{
4+
reason: IosSnapshotEngineFailureReason;
5+
field?: string;
6+
}>;
7+
8+
export function toIosSnapshotEngineErrorDetails(
9+
error: IosSnapshotEngineError,
10+
): IosSnapshotEnginePublicErrorDetails {
11+
return {
12+
reason: error.reason,
13+
...(error.details.field ? { field: error.details.field } : {}),
14+
};
15+
}

packages/capture-kit/src/ios-snapshot-engine/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ export {
1212
export { collectIosStructuralIdentifierSuppression } from './noise-structural.ts';
1313
export { findNearestScrollableContainer, mergeReplacement, updateReplacement } from './tree.ts';
1414
export { IosSnapshotEngineError } from './types.ts';
15+
export { toIosSnapshotEngineErrorDetails } from './errors.ts';
16+
export { resolveIosViewportEvidenceFromRoots } from './viewport.ts';
1517
export type { SnapshotTreeRuleContext } from './tree.ts';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { resolveIosViewportEvidenceFromRoots } from './viewport.ts';
4+
5+
test('viewport evidence prefers reported application and window roots', () => {
6+
assert.deepEqual(
7+
resolveIosViewportEvidenceFromRoots([
8+
{ type: 'XCUIElementTypeApplication', rectStatus: 'invalid' },
9+
{
10+
type: 'XCUIElementTypeWindow',
11+
rect: { x: 0, y: 0, width: 390, height: 844 },
12+
rectStatus: 'reported',
13+
},
14+
]),
15+
{ kind: 'reported', rect: { x: 0, y: 0, width: 390, height: 844 } },
16+
);
17+
});
18+
19+
test('viewport evidence can fall back to the largest top-level root', () => {
20+
assert.deepEqual(
21+
resolveIosViewportEvidenceFromRoots(
22+
[
23+
{ type: 'Other', rect: { x: 0, y: 0, width: 100, height: 100 } },
24+
{ type: 'Other', rect: { x: 0, y: 0, width: 200, height: 300 } },
25+
],
26+
{ fallbackToLargestRoot: true },
27+
),
28+
{ kind: 'reported', rect: { x: 0, y: 0, width: 200, height: 300 } },
29+
);
30+
});
31+
32+
test('viewport evidence preserves explicit missing geometry reasons', () => {
33+
assert.deepEqual(
34+
resolveIosViewportEvidenceFromRoots([{ type: 'Application', rectStatus: 'invalid' }]),
35+
{ kind: 'missing', reason: 'invalid' },
36+
);
37+
assert.deepEqual(
38+
resolveIosViewportEvidenceFromRoots([{ type: 'Application', rectStatus: 'not-provided' }]),
39+
{ kind: 'missing', reason: 'not-provided' },
40+
);
41+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { IosViewportEvidence } from '@agent-device/contracts/ios-snapshot';
2+
import { normalizeType } from '@agent-device/contracts/snapshot';
3+
import type { Rect } from '@agent-device/kernel/snapshot';
4+
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
5+
6+
export type IosSnapshotViewportRoot = Readonly<{
7+
type?: string;
8+
rect?: Rect;
9+
rectStatus?: 'reported' | 'invalid' | 'not-provided';
10+
}>;
11+
12+
export function resolveIosViewportEvidenceFromRoots(
13+
roots: readonly IosSnapshotViewportRoot[],
14+
options: Readonly<{ fallbackToLargestRoot?: boolean }> = {},
15+
): IosViewportEvidence | undefined {
16+
const viewportRoots = roots.filter(isViewportRoot);
17+
const candidates =
18+
viewportRoots.length > 0 || options.fallbackToLargestRoot !== true ? viewportRoots : roots;
19+
const root = [...candidates].sort(compareViewportRoots)[0];
20+
if (!root) return undefined;
21+
if (isPositiveFiniteRect(root.rect)) return { kind: 'reported', rect: root.rect };
22+
return {
23+
kind: 'missing',
24+
reason:
25+
root.rectStatus === 'invalid' || (root.rectStatus === undefined && root.rect !== undefined)
26+
? 'invalid'
27+
: 'not-provided',
28+
};
29+
}
30+
31+
function isViewportRoot(root: IosSnapshotViewportRoot): boolean {
32+
const type = normalizeType(root.type ?? '');
33+
return type === 'application' || type === 'window';
34+
}
35+
36+
function compareViewportRoots(
37+
left: IosSnapshotViewportRoot,
38+
right: IosSnapshotViewportRoot,
39+
): number {
40+
const status = rootGeometryRank(right.rectStatus) - rootGeometryRank(left.rectStatus);
41+
return status || rectArea(right.rect) - rectArea(left.rect);
42+
}
43+
44+
function rootGeometryRank(status: IosSnapshotViewportRoot['rectStatus']): number {
45+
return status === 'reported' ? 2 : status === 'invalid' ? 1 : 0;
46+
}
47+
48+
function rectArea(rect: Rect | undefined): number {
49+
return rect && isPositiveFiniteRect(rect) ? rect.width * rect.height : 0;
50+
}

packages/capture-kit/src/ios-snapshot-planning.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ test('presented producers cannot claim acquisition narrowing', () => {
147147
});
148148
});
149149

150+
test('Appium source advertises viewport evidence when its root reports geometry', () => {
151+
assert.equal(IOS_SNAPSHOT_PRODUCER_CAPABILITIES['appium-source'].viewportEvidence, 'available');
152+
});
153+
150154
test('comparison identity rejects every identity axis and residue mismatch', () => {
151155
const base = comparisonIdentity();
152156
const mismatches: IosSnapshotComparisonIdentity[] = [

packages/capture-kit/src/ios-snapshot-planning.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
4646
},
4747
scopeCompleteness: 'incomplete',
4848
interactiveQueryCompleteness: 'incomplete',
49-
viewportEvidence: 'unavailable',
49+
viewportEvidence: 'available',
5050
hittabilityEvidence: 'unavailable',
5151
},
5252
'limrun-ios-tree': {

packages/contracts/src/snapshot-private-evidence.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type SnapshotOcclusionContextEvidence = {
2828
androidSiblingOrderByNodeIndex?: ReadonlyMap<number, AndroidSiblingOrderEvidence>;
2929
};
3030

31+
/** Identifies the engine that owns interactive iOS snapshot presentation. */
3132
export type SnapshotPresentationEvidence = {
3233
owner: 'ios-snapshot-engine';
3334
};

packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -275,13 +275,12 @@ test('snapshot reports typed runner presentation failures', async () => {
275275
{ runCommand: async () => ({ nodes: [{ index: 0, type: 'Application' }] }) },
276276
);
277277

278-
await assert.rejects(
279-
interactor.snapshot(),
280-
(error: unknown) =>
281-
error instanceof AppError &&
282-
error.code === 'COMMAND_FAILED' &&
283-
error.details?.reason === 'missing-viewport',
284-
);
278+
await assert.rejects(interactor.snapshot(), (error: unknown) => {
279+
assert.ok(error instanceof AppError);
280+
assert.equal(error.code, 'COMMAND_FAILED');
281+
assert.deepEqual(error.details, { reason: 'missing-viewport', field: 'viewport' });
282+
return true;
283+
});
285284
});
286285

287286
test('sparse runner payloads with no viewport fail before publishing actionable nodes', async () => {

packages/platform-apple/src/runner/snapshot-presentation.ts

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import type {
44
IosViewportEvidence,
55
} from '@agent-device/contracts/ios-snapshot';
66
import type { SnapshotOptions } from '@agent-device/contracts/interactor-types';
7-
import { normalizeType } from '@agent-device/contracts/snapshot';
87
import {
98
IosSnapshotEngineError,
109
presentIosRunnerSnapshot,
10+
resolveIosViewportEvidenceFromRoots,
11+
toIosSnapshotEngineErrorDetails,
1112
} from '@agent-device/capture-kit/ios-snapshot-engine';
1213
import { readSnapshotQualityVerdict } from '@agent-device/capture-kit/snapshot-quality-verdict';
1314
import {
1415
createIosSnapshotRequest,
1516
buildIosSnapshotPresentationKey,
1617
} from '@agent-device/capture-kit/ios-snapshot-planning';
17-
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
1818
import { AppError } from '@agent-device/kernel/errors';
1919
import type { RawSnapshotNode, SnapshotQualityVerdict } from '@agent-device/kernel/snapshot';
2020

@@ -119,36 +119,18 @@ function runnerViewportEvidence(
119119
qualityNodes: readonly RawSnapshotNode[] | undefined,
120120
): IosViewportEvidence {
121121
return (
122-
readReportedViewport(qualityNodes) ??
123-
readReportedViewport(nodes) ?? { kind: 'missing', reason: 'not-provided' }
122+
resolveIosViewportEvidenceFromRoots(rootNodes(qualityNodes), {
123+
fallbackToLargestRoot: true,
124+
}) ??
125+
resolveIosViewportEvidenceFromRoots(rootNodes(nodes), { fallbackToLargestRoot: true }) ?? {
126+
kind: 'missing',
127+
reason: 'not-provided',
128+
}
124129
);
125130
}
126131

127-
function readReportedViewport(
128-
nodes: readonly RawSnapshotNode[] | undefined,
129-
): IosViewportEvidence | undefined {
130-
const roots = nodes?.filter((node) => node.parentIndex === undefined) ?? [];
131-
const root =
132-
[...roots]
133-
.filter((node) => isViewportRoot(node))
134-
.sort(compareRectArea)
135-
.at(0) ?? [...roots].sort(compareRectArea).at(0);
136-
if (!root) return undefined;
137-
if (isPositiveFiniteRect(root.rect)) return { kind: 'reported', rect: root.rect };
138-
return { kind: 'missing', reason: root.rect ? 'invalid' : 'not-provided' };
139-
}
140-
141-
function isViewportRoot(node: RawSnapshotNode): boolean {
142-
const type = normalizeType(node.type ?? '');
143-
return type === 'application' || type === 'window';
144-
}
145-
146-
function compareRectArea(left: RawSnapshotNode, right: RawSnapshotNode): number {
147-
return rectArea(right.rect) - rectArea(left.rect);
148-
}
149-
150-
function rectArea(rect: RawSnapshotNode['rect']): number {
151-
return rect ? rect.width * rect.height : 0;
132+
function rootNodes(nodes: readonly RawSnapshotNode[] | undefined): readonly RawSnapshotNode[] {
133+
return nodes?.filter((node) => node.parentIndex === undefined) ?? [];
152134
}
153135

154136
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -160,7 +142,7 @@ function throwSnapshotEngineError(error: unknown): never {
160142
throw new AppError(
161143
'COMMAND_FAILED',
162144
error.message,
163-
{ reason: error.reason, iosSnapshotEngine: { details: error.details } },
145+
toIosSnapshotEngineErrorDetails(error),
164146
error,
165147
);
166148
}

0 commit comments

Comments
 (0)