Skip to content

Commit 6c8c050

Browse files
authored
refactor(ios): converge Limrun snapshots through engine (#2222)
* refactor(ios): converge Limrun snapshots through engine * fix(limrun): defer snapshot engine loading * fix(limrun): harden snapshot viewport evidence * fix(limrun): preserve snapshot engine evidence * fix(limrun): preserve unknown snapshot truncation * refactor(ios): reuse private presentation evidence seam * test(ios): remove stale presentation assertion binding * test(ios): extract snapshot truncation regressions * test: ratchet snapshot suite size pins * test(snapshot): cover provider presentation ownership * test(snapshot): type Limrun composition fixture * test(snapshot): exercise public Limrun runtime composition
1 parent db08548 commit 6c8c050

17 files changed

Lines changed: 715 additions & 98 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,14 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
5656
producer: 'limrun-ios-tree',
5757
stage: 'acquired',
5858
acquisitionDepth: {
59-
rawTraversal: { kind: 'complete' },
59+
rawTraversal: { kind: 'incomplete' },
6060
regularPresented: { kind: 'incomplete' },
6161
},
6262
scopeCompleteness: 'incomplete',
6363
interactiveQueryCompleteness: 'incomplete',
6464
viewportEvidence: 'available',
6565
hittabilityEvidence: 'unavailable',
66-
presentationOwner: 'snapshot-state',
66+
presentationOwner: 'ios-snapshot-engine',
6767
},
6868
} as const satisfies Record<IosSnapshotProducer, IosSnapshotProducerCapabilities>;
6969

packages/contracts/src/client-capture.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type CaptureSnapshotOptions = AgentDeviceRequestOverrides &
3737

3838
export type CaptureSnapshotResult = {
3939
nodes: SnapshotNode[];
40+
/** Present only when the capture owner establishes whether the tree was truncated. */
4041
truncated?: boolean;
4142
appName?: string;
4243
appBundleId?: string;

packages/contracts/src/ios-snapshot.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ export type IosSnapshotFact =
7272
| 'interactive-query'
7373
| 'viewport'
7474
| 'hittability'
75-
| 'generation';
75+
| 'generation'
76+
| 'truncation';
7677

7778
type IosSnapshotProducerCapabilityFacts = Readonly<{
7879
acquisitionDepth: IosSnapshotAcquisitionDepthCapability;

packages/provider-limrun/src/ios-interactor-snapshot.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ test('limrun iOS snapshot stamps the xctest channel with its own producer', asyn
88
elementTree: async () =>
99
JSON.stringify({
1010
elementType: 'Application',
11+
frame: { x: 0, y: 0, width: 320, height: 240 },
1112
children: [{ elementType: 'Button', label: 'Continue', enabled: true }],
1213
}),
14+
deviceInfo: { screenWidth: 320, screenHeight: 240 },
1315
},
1416
} as unknown as LimrunIosSession;
1517

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { LimrunIosSession } from './ios.ts';
2+
import type { IosTreeNode } from './snapshot.ts';
3+
4+
export const LIMRUN_SNAPSHOT_SCREEN: Readonly<{ width: number; height: number }> = Object.freeze({
5+
width: 320,
6+
height: 240,
7+
});
8+
9+
export function limrunSnapshotTree(): IosTreeNode {
10+
return {
11+
elementType: 'Application',
12+
label: 'App',
13+
frame: {
14+
x: 0,
15+
y: 0,
16+
width: LIMRUN_SNAPSHOT_SCREEN.width,
17+
height: LIMRUN_SNAPSHOT_SCREEN.height,
18+
},
19+
children: [
20+
{
21+
elementType: 'Table',
22+
label: 'Settings',
23+
frame: {
24+
x: 0,
25+
y: 0,
26+
width: LIMRUN_SNAPSHOT_SCREEN.width,
27+
height: LIMRUN_SNAPSHOT_SCREEN.height,
28+
},
29+
children: [
30+
{
31+
elementType: 'Cell',
32+
label: 'Target',
33+
frame: { x: 16, y: 40, width: 288, height: 52 },
34+
children: [
35+
{
36+
elementType: 'Button',
37+
label: 'Save',
38+
frame: { x: 32, y: 48, width: 100, height: 36 },
39+
enabled: true,
40+
hittable: true,
41+
},
42+
{
43+
elementType: 'StaticText',
44+
label: 'Save',
45+
frame: { x: 32, y: 48, width: 100, height: 36 },
46+
},
47+
],
48+
},
49+
],
50+
},
51+
],
52+
};
53+
}
54+
55+
export function createLimrunSnapshotSession(
56+
tree: IosTreeNode | IosTreeNode[] = limrunSnapshotTree(),
57+
screen = LIMRUN_SNAPSHOT_SCREEN,
58+
): Pick<LimrunIosSession, 'client' | 'instanceId'> {
59+
return {
60+
instanceId: 'limrun-snapshot-test-instance',
61+
client: {
62+
elementTree: async () => JSON.stringify(tree),
63+
deviceInfo: { screenWidth: screen.width, screenHeight: screen.height },
64+
},
65+
} as Pick<LimrunIosSession, 'client' | 'instanceId'>;
66+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { expect, test, vi } from 'vitest';
2+
import {
3+
IOS_SNAPSHOT_PRODUCER_CAPABILITIES,
4+
createIosSnapshotRequest,
5+
planIosSnapshot,
6+
} from '@agent-device/capture-kit/ios-snapshot-planning';
7+
import { captureLimrunIosSnapshot } from './ios-snapshot-adapter.ts';
8+
import {
9+
LIMRUN_SNAPSHOT_SCREEN,
10+
createLimrunSnapshotSession,
11+
limrunSnapshotTree,
12+
} from './ios-snapshot-adapter.fixtures.ts';
13+
14+
test('derives the current engine viewport from the tree before the cached deviceInfo', async () => {
15+
const session = createLimrunSnapshotSession(limrunSnapshotTree(), { width: 240, height: 320 });
16+
const elementTree = vi.fn(session.client.elementTree);
17+
const result = await captureLimrunIosSnapshot(
18+
{ ...session, client: { ...session.client, elementTree } },
19+
{ interactiveOnly: false },
20+
);
21+
22+
expect(elementTree).toHaveBeenCalledWith();
23+
expect(result.nodes?.[0]?.rect).toEqual({ x: 0, y: 0, width: 320, height: 240 });
24+
expect(result.nodes?.map((node) => node.label)).toEqual([
25+
'App',
26+
'Settings',
27+
'Target',
28+
'Save',
29+
'Save',
30+
]);
31+
expect(result.nodes?.find((node) => node.label === 'Save')).not.toHaveProperty('hittable');
32+
expect(result.warnings).toContain(
33+
'Limrun iOS tree responses do not expose truncation metadata; tree completeness is not independently verified.',
34+
);
35+
expect(result.warnings).toContain(
36+
'Limrun iOS snapshots do not provide hittability evidence; regular snapshots will not mark nodes actionable.',
37+
);
38+
});
39+
40+
test.each([
41+
{
42+
name: 'raw full',
43+
options: { raw: true, interactiveOnly: true },
44+
labels: ['App', 'Settings', 'Target', 'Save', 'Save'],
45+
},
46+
{ name: 'raw traversal depth', options: { raw: true, depth: 1 }, labels: ['App', 'Settings'] },
47+
{ name: 'regular presented depth', options: { depth: 1 }, labels: ['App', 'Settings'] },
48+
{ name: 'regular scope', options: { scope: 'Target' }, labels: ['Target', 'Save', 'Save'] },
49+
])('routes $name through the shared engine projection', async ({ options, labels }) => {
50+
const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(), options);
51+
52+
expect(result.nodes?.map((node) => node.label)).toEqual(labels);
53+
expect(result.truncated).toBeUndefined();
54+
});
55+
56+
test('the Limrun capability plan leaves unsupported acquisition tiers to the shared engine', () => {
57+
const request = createIosSnapshotRequest({ raw: true, interactiveOnly: true, depth: 2 });
58+
const plan = planIosSnapshot(request, IOS_SNAPSHOT_PRODUCER_CAPABILITIES['limrun-ios-tree']);
59+
60+
expect(plan.narrowing).toEqual({ depth: null, scope: null, interactiveOnly: false });
61+
expect(plan.evidence).toEqual({
62+
scope: 'incomplete',
63+
interactiveQuery: 'incomplete',
64+
viewport: 'available',
65+
hittability: 'unavailable',
66+
});
67+
});
68+
69+
test('regular Limrun presentation never infers hittability from an enabled rectangle', async () => {
70+
const tree = limrunSnapshotTree();
71+
tree.children![0]!.children!.push({
72+
elementType: 'Button',
73+
label: 'Enabled but unverified',
74+
frame: { x: 120, y: 120, width: 80, height: 40 },
75+
enabled: true,
76+
hittable: true,
77+
});
78+
79+
const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(tree));
80+
const target = result.nodes?.find((node) => node.label === 'Enabled but unverified');
81+
82+
expect(target).toMatchObject({ enabled: true, rect: { x: 120, y: 120, width: 80, height: 40 } });
83+
expect(target).not.toHaveProperty('hittable');
84+
});
85+
86+
test('regular presentation fails with a typed viewport error while raw output discloses the missing evidence', async () => {
87+
const tree = limrunSnapshotTree();
88+
tree.frame = undefined;
89+
const session = createLimrunSnapshotSession(tree, { width: 0, height: 240 });
90+
91+
await expect(captureLimrunIosSnapshot(session)).rejects.toMatchObject({
92+
code: 'COMMAND_FAILED',
93+
details: {
94+
reason: 'invalid-viewport',
95+
hint: 'Limrun iOS snapshots did not provide a valid viewport (invalid); retry with --raw to inspect the acquired tree, while regular presentation requires viewport evidence.',
96+
},
97+
});
98+
99+
const raw = await captureLimrunIosSnapshot(session, { raw: true });
100+
expect(raw.nodes).toHaveLength(5);
101+
expect(raw.warnings).toContain(
102+
'Limrun iOS snapshots did not provide a valid viewport (invalid); retry with --raw to inspect the acquired tree, while regular presentation requires viewport evidence.',
103+
);
104+
});
105+
106+
test('falls back to valid Limrun deviceInfo when the tree has no viewport root frame', async () => {
107+
const tree = limrunSnapshotTree();
108+
tree.frame = undefined;
109+
110+
const result = await captureLimrunIosSnapshot(
111+
createLimrunSnapshotSession(tree, { width: 240, height: 320 }),
112+
);
113+
114+
expect(result.nodes).toHaveLength(5);
115+
});
116+
117+
test('the Limrun acquired result keeps provider provenance and SDK screen dimensions', async () => {
118+
const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession());
119+
120+
expect(result).toMatchObject({
121+
backend: 'xctest',
122+
producer: 'limrun-ios-tree',
123+
});
124+
expect(result.nodes?.[0]?.rect).toEqual({
125+
x: 0,
126+
y: 0,
127+
width: LIMRUN_SNAPSHOT_SCREEN.width,
128+
height: LIMRUN_SNAPSHOT_SCREEN.height,
129+
});
130+
});

0 commit comments

Comments
 (0)