Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ test('the independent iOS snapshot goldens match the TypeScript engine', () => {
| {
outcome: 'success';
nodes: ReturnType<typeof normalizeGoldenNodes>;
truncated: boolean;
truncated?: boolean;
residue: typeof acquisition.residue;
qualityLabels?: readonly (string | null)[];
}
Expand Down
2 changes: 1 addition & 1 deletion packages/capture-kit/src/ios-snapshot-planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const IOS_SNAPSHOT_PRODUCER_CAPABILITY_VALUES = {
producer: 'limrun-ios-tree',
stage: 'acquired',
acquisitionDepth: {
rawTraversal: { kind: 'complete' },
rawTraversal: { kind: 'incomplete' },
regularPresented: { kind: 'incomplete' },
},
scopeCompleteness: 'incomplete',
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
RawSnapshotNode,
Point,
Rect,
SnapshotEnginePresentedMarker,
SnapshotOptions as BaseSnapshotOptions,
SnapshotProvenance,
} from '@agent-device/kernel/snapshot';
Expand Down Expand Up @@ -247,7 +248,8 @@ export type KeyboardEnterResult =
*/
export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> & {
nodes?: RawSnapshotNode[];
} & SnapshotProvenance;
} & SnapshotProvenance &
SnapshotEnginePresentedMarker;

export type Interactor = {
open(
Expand Down
7 changes: 4 additions & 3 deletions packages/contracts/src/ios-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export type IosSnapshotFact =
| 'interactive-query'
| 'viewport'
| 'hittability'
| 'generation';
| 'generation'
| 'truncation';

type IosSnapshotProducerCapabilityFacts = Readonly<{
acquisitionDepth: IosSnapshotAcquisitionDepthCapability;
Expand Down Expand Up @@ -148,7 +149,7 @@ type IosSnapshotAcquisitionForIntent<Intent extends IosAcquisitionIntent> = Read
intent: Intent;
hint: CaptureHint & Readonly<{ acquisitionIntent: Intent }>;
nodes: readonly RawSnapshotNode[];
truncated: boolean;
truncated?: boolean;
viewport: IosViewportEvidence;
lineage: IosSnapshotLineage;
residue: readonly IosAcquisitionResidue[];
Expand Down Expand Up @@ -216,7 +217,7 @@ export type IosSnapshotPlan = Readonly<{

export type IosSnapshotPublishedPayload = Readonly<{
nodes: readonly SnapshotNode[];
truncated: boolean;
truncated?: boolean;
}>;

export type IosSnapshotComparisonIdentity = Readonly<{
Expand Down
8 changes: 8 additions & 0 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export type SnapshotCaptureBackend = 'tree' | 'queries' | 'private-ax' | 'androi
/** Internal backends that evidence probes may select explicitly. */
export type SnapshotPreferredBackend = 'tree' | 'private-ax';

export const SNAPSHOT_ENGINE_PRESENTED: unique symbol = Symbol(
'agent-device.snapshot-engine-presented',
);

export type SnapshotEnginePresentedMarker = Readonly<{
[SNAPSHOT_ENGINE_PRESENTED]?: true;
}>;

export type SnapshotQualityTiming = {
acquisitionMs: number;
presentationMs: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-limrun/src/ios-interactor-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ test('limrun iOS snapshot stamps the xctest channel with its own producer', asyn
elementTree: async () =>
JSON.stringify({
elementType: 'Application',
frame: { x: 0, y: 0, width: 320, height: 240 },
children: [{ elementType: 'Button', label: 'Continue', enabled: true }],
}),
deviceInfo: { screenWidth: 320, screenHeight: 240 },
},
} as unknown as LimrunIosSession;

Expand Down
66 changes: 66 additions & 0 deletions packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { LimrunIosSession } from './ios.ts';
import type { IosTreeNode } from './snapshot.ts';

export const LIMRUN_SNAPSHOT_SCREEN: Readonly<{ width: number; height: number }> = Object.freeze({
width: 320,
height: 240,
});

export function limrunSnapshotTree(): IosTreeNode {
return {
elementType: 'Application',
label: 'App',
frame: {
x: 0,
y: 0,
width: LIMRUN_SNAPSHOT_SCREEN.width,
height: LIMRUN_SNAPSHOT_SCREEN.height,
},
children: [
{
elementType: 'Table',
label: 'Settings',
frame: {
x: 0,
y: 0,
width: LIMRUN_SNAPSHOT_SCREEN.width,
height: LIMRUN_SNAPSHOT_SCREEN.height,
},
children: [
{
elementType: 'Cell',
label: 'Target',
frame: { x: 16, y: 40, width: 288, height: 52 },
children: [
{
elementType: 'Button',
label: 'Save',
frame: { x: 32, y: 48, width: 100, height: 36 },
enabled: true,
hittable: true,
},
{
elementType: 'StaticText',
label: 'Save',
frame: { x: 32, y: 48, width: 100, height: 36 },
},
],
},
],
},
],
};
}

export function createLimrunSnapshotSession(
tree: IosTreeNode | IosTreeNode[] = limrunSnapshotTree(),
screen = LIMRUN_SNAPSHOT_SCREEN,
): Pick<LimrunIosSession, 'client' | 'instanceId'> {
return {
instanceId: 'limrun-snapshot-test-instance',
client: {
elementTree: async () => JSON.stringify(tree),
deviceInfo: { screenWidth: screen.width, screenHeight: screen.height },
},
} as Pick<LimrunIosSession, 'client' | 'instanceId'>;
}
132 changes: 132 additions & 0 deletions packages/provider-limrun/src/ios-snapshot-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { expect, test, vi } from 'vitest';
import { SNAPSHOT_ENGINE_PRESENTED } from '@agent-device/kernel/snapshot';
import {
IOS_SNAPSHOT_PRODUCER_CAPABILITIES,
createIosSnapshotRequest,
planIosSnapshot,
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { captureLimrunIosSnapshot } from './ios-snapshot-adapter.ts';
import {
LIMRUN_SNAPSHOT_SCREEN,
createLimrunSnapshotSession,
limrunSnapshotTree,
} from './ios-snapshot-adapter.fixtures.ts';

test('derives the current engine viewport from the tree before the cached deviceInfo', async () => {
const session = createLimrunSnapshotSession(limrunSnapshotTree(), { width: 240, height: 320 });
const elementTree = vi.fn(session.client.elementTree);
const result = await captureLimrunIosSnapshot(
{ ...session, client: { ...session.client, elementTree } },
{ interactiveOnly: false },
);

expect(elementTree).toHaveBeenCalledWith();
expect(result.nodes?.[0]?.rect).toEqual({ x: 0, y: 0, width: 320, height: 240 });
expect(result.nodes?.map((node) => node.label)).toEqual([
'App',
'Settings',
'Target',
'Save',
'Save',
]);
expect(result.nodes?.find((node) => node.label === 'Save')?.hittable).toBe(false);
expect(result[SNAPSHOT_ENGINE_PRESENTED]).toBe(true);
expect(result.warnings).toContain(
'Limrun iOS tree responses do not expose truncation metadata; tree completeness is not independently verified.',
);
expect(result.warnings).toContain(
'Limrun iOS snapshots do not provide hittability evidence; regular snapshots will not mark nodes actionable.',
);
});

test.each([
{
name: 'raw full',
options: { raw: true, interactiveOnly: true },
labels: ['App', 'Settings', 'Target', 'Save', 'Save'],
},
{ name: 'raw traversal depth', options: { raw: true, depth: 1 }, labels: ['App', 'Settings'] },
{ name: 'regular presented depth', options: { depth: 1 }, labels: ['App', 'Settings'] },
{ name: 'regular scope', options: { scope: 'Target' }, labels: ['Target', 'Save', 'Save'] },
])('routes $name through the shared engine projection', async ({ options, labels }) => {
const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(), options);

expect(result.nodes?.map((node) => node.label)).toEqual(labels);
expect(result.truncated).toBeUndefined();
});

test('the Limrun capability plan leaves unsupported acquisition tiers to the shared engine', () => {
const request = createIosSnapshotRequest({ raw: true, interactiveOnly: true, depth: 2 });
const plan = planIosSnapshot(request, IOS_SNAPSHOT_PRODUCER_CAPABILITIES['limrun-ios-tree']);

expect(plan.narrowing).toEqual({ depth: null, scope: null, interactiveOnly: false });
expect(plan.evidence).toEqual({
scope: 'incomplete',
interactiveQuery: 'incomplete',
viewport: 'available',
hittability: 'unavailable',
});
});

test('regular Limrun presentation never infers hittability from an enabled rectangle', async () => {
const tree = limrunSnapshotTree();
tree.children![0]!.children!.push({
elementType: 'Button',
label: 'Enabled but unverified',
frame: { x: 120, y: 120, width: 80, height: 40 },
enabled: true,
hittable: true,
});

const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(tree));
const target = result.nodes?.find((node) => node.label === 'Enabled but unverified');

expect(target).toMatchObject({ enabled: true, rect: { x: 120, y: 120, width: 80, height: 40 } });
expect(target?.hittable).toBe(false);
});

test('regular presentation fails with a typed viewport error while raw output discloses the missing evidence', async () => {
const tree = limrunSnapshotTree();
tree.frame = undefined;
const session = createLimrunSnapshotSession(tree, { width: 0, height: 240 });

await expect(captureLimrunIosSnapshot(session)).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: {
reason: 'invalid-viewport',
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.',
},
});

const raw = await captureLimrunIosSnapshot(session, { raw: true });
expect(raw.nodes).toHaveLength(5);
expect(raw.warnings).toContain(
'Limrun iOS snapshots did not provide a valid viewport (invalid); retry with --raw to inspect the acquired tree, while regular presentation requires viewport evidence.',
);
});

test('falls back to valid Limrun deviceInfo when the tree has no viewport root frame', async () => {
const tree = limrunSnapshotTree();
tree.frame = undefined;

const result = await captureLimrunIosSnapshot(
createLimrunSnapshotSession(tree, { width: 240, height: 320 }),
);

expect(result.nodes).toHaveLength(5);
});

test('the Limrun acquired result keeps provider provenance and SDK screen dimensions', async () => {
const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession());

expect(result).toMatchObject({
backend: 'xctest',
producer: 'limrun-ios-tree',
});
expect(result.nodes?.[0]?.rect).toEqual({
x: 0,
y: 0,
width: LIMRUN_SNAPSHOT_SCREEN.width,
height: LIMRUN_SNAPSHOT_SCREEN.height,
});
});
Loading
Loading