From 8b9a50fc24c93635434a5c7ab3329143403c32f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 19:07:41 +0200 Subject: [PATCH 1/9] refactor(ios): converge Limrun snapshots through engine --- .../src/ios-interactor-snapshot.test.ts | 2 + .../src/ios-snapshot-adapter.fixtures.ts | 66 ++++++++++ .../src/ios-snapshot-adapter.test.ts | 104 ++++++++++++++++ .../src/ios-snapshot-adapter.ts | 117 ++++++++++++++++++ packages/provider-limrun/src/ios.ts | 9 +- 5 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts create mode 100644 packages/provider-limrun/src/ios-snapshot-adapter.test.ts create mode 100644 packages/provider-limrun/src/ios-snapshot-adapter.ts diff --git a/packages/provider-limrun/src/ios-interactor-snapshot.test.ts b/packages/provider-limrun/src/ios-interactor-snapshot.test.ts index 84db55aff9..27a3fecbf0 100644 --- a/packages/provider-limrun/src/ios-interactor-snapshot.test.ts +++ b/packages/provider-limrun/src/ios-interactor-snapshot.test.ts @@ -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; diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts b/packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts new file mode 100644 index 0000000000..3fb31dbb06 --- /dev/null +++ b/packages/provider-limrun/src/ios-snapshot-adapter.fixtures.ts @@ -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 { + return { + instanceId: 'limrun-snapshot-test-instance', + client: { + elementTree: async () => JSON.stringify(tree), + deviceInfo: { screenWidth: screen.width, screenHeight: screen.height }, + }, + } as Pick; +} diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts new file mode 100644 index 0000000000..c97a9d2da5 --- /dev/null +++ b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts @@ -0,0 +1,104 @@ +import { expect, test, vi } from 'vitest'; +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('uses Limrun deviceInfo as the reported engine viewport without passing presentation options to elementTree', async () => { + const session = createLimrunSnapshotSession(); + const elementTree = vi.fn(session.client.elementTree); + const result = await captureLimrunIosSnapshot( + { ...session, client: { ...session.client, elementTree } }, + { scope: 'Target', depth: 1, interactiveOnly: true }, + ); + + expect(elementTree).toHaveBeenCalledWith(); + expect(result.nodes?.map((node) => node.label)).toEqual(['Target', 'Save', 'Save']); + expect(result.nodes?.find((node) => node.label === 'Save')?.hittable).toBe(false); + 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).toBe(false); +}); + +test('the Limrun capability plan narrows only the raw traversal tier and records unavailable facts', () => { + 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: 2, 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 session = createLimrunSnapshotSession(limrunSnapshotTree(), { width: 0, height: 240 }); + + await expect(captureLimrunIosSnapshot(session)).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'invalid-viewport' }, + }); + + 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); raw output is available, but regular presentation requires viewport evidence.', + ); +}); + +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, + }); +}); diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.ts b/packages/provider-limrun/src/ios-snapshot-adapter.ts new file mode 100644 index 0000000000..709e2e10e8 --- /dev/null +++ b/packages/provider-limrun/src/ios-snapshot-adapter.ts @@ -0,0 +1,117 @@ +import type { + IosAcquisitionResidue, + IosSnapshotAcquisition, + IosViewportEvidence, +} from '@agent-device/contracts/ios-snapshot'; +import type { SnapshotOptions, SnapshotResult } from '@agent-device/contracts/interactor-types'; +import { + IOS_SNAPSHOT_PRODUCER_CAPABILITIES, + createIosSnapshotRequest, + planIosSnapshot, +} from '@agent-device/capture-kit/ios-snapshot-planning'; +import { + IosSnapshotEngineError, + presentIosSnapshot, +} from '@agent-device/capture-kit/ios-snapshot-engine'; +import { AppError } from '@agent-device/kernel/errors'; +import type { LimrunIosSession } from './ios.ts'; +import { flattenIosTree, type IosTreeNode } from './snapshot.ts'; + +const LIMRUN_IOS_PRODUCER = IOS_SNAPSHOT_PRODUCER_CAPABILITIES['limrun-ios-tree']; + +export async function captureLimrunIosSnapshot( + session: Pick, + options?: SnapshotOptions, +): Promise { + const request = createIosSnapshotRequest({ + raw: options?.raw, + interactiveOnly: options?.interactiveOnly, + depth: options?.depth, + scope: options?.scope, + customActions: options?.customActions, + }); + const plan = planIosSnapshot(request, LIMRUN_IOS_PRODUCER); + const treeJson = await session.client.elementTree(); + const nodes = flattenIosTree(JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]); + const viewport = readLimrunViewport(session.client.deviceInfo); + const residue = limrunAcquisitionResidue(plan.evidence.hittability, viewport); + const hint = { ...plan.hint, acquisitionIntent: 'full' as const }; + const acquisition: IosSnapshotAcquisition = { + producer: 'limrun-ios-tree', + intent: 'full', + hint, + nodes, + truncated: false, + viewport, + lineage: { targetId: session.instanceId }, + residue, + }; + + try { + const presentation = presentIosSnapshot({ stage: 'acquired', acquisition }, request); + const warnings = limrunSnapshotWarnings(residue); + return { + nodes: presentation.nodes, + truncated: acquisition.truncated, + backend: 'xctest', + producer: 'limrun-ios-tree', + ...(warnings.length > 0 ? { warnings } : {}), + }; + } catch (error) { + throwLimrunSnapshotError(error); + } +} + +function readLimrunViewport( + deviceInfo: { screenWidth?: number; screenHeight?: number } | undefined, +): IosViewportEvidence { + const width = deviceInfo?.screenWidth; + const height = deviceInfo?.screenHeight; + if (typeof width !== 'number' || typeof height !== 'number') { + return { kind: 'missing', reason: 'not-provided' }; + } + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return { kind: 'missing', reason: 'invalid' }; + } + return { kind: 'reported', rect: { x: 0, y: 0, width, height } }; +} + +function limrunAcquisitionResidue( + hittability: 'available' | 'unavailable', + viewport: IosViewportEvidence, +): IosAcquisitionResidue[] { + return [ + ...(hittability === 'unavailable' + ? [{ kind: 'unavailable-fact' as const, fact: 'hittability' as const }] + : []), + ...(viewport.kind === 'missing' + ? [{ kind: 'missing-viewport' as const, reason: viewport.reason }] + : []), + ]; +} + +function limrunSnapshotWarnings(residue: readonly IosAcquisitionResidue[]): string[] { + const warnings: string[] = []; + if (residue.some((entry) => entry.kind === 'unavailable-fact' && entry.fact === 'hittability')) { + warnings.push( + 'Limrun iOS snapshots do not provide hittability evidence; regular snapshots will not mark nodes actionable.', + ); + } + const missingViewport = residue.find((entry) => entry.kind === 'missing-viewport'); + if (missingViewport) { + warnings.push( + `Limrun iOS snapshots did not provide a valid viewport (${missingViewport.reason}); raw output is available, but regular presentation requires viewport evidence.`, + ); + } + return warnings; +} + +function throwLimrunSnapshotError(error: unknown): never { + if (!(error instanceof IosSnapshotEngineError)) throw error; + throw new AppError( + 'COMMAND_FAILED', + error.message, + { reason: error.reason, iosSnapshotEngine: { details: error.details } }, + error, + ); +} diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index b35b4b93af..da2d427c66 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -21,7 +21,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; -import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; +import { captureLimrunIosSnapshot } from './ios-snapshot-adapter.ts'; +import { toIosSelector, writeBase64File } from './snapshot.ts'; import { normalizeOptionalString } from './strings.ts'; import { awaitLimrunDeploymentOperation, @@ -254,10 +255,8 @@ class LimrunIosInteractor implements Interactor { await writeBase64File(outPath, screenshot.base64); } - async snapshot(_options?: SnapshotOptions): Promise { - const treeJson = await this.session.client.elementTree(); - const parsed = JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]; - return { nodes: flattenIosTree(parsed), backend: 'xctest', producer: 'limrun-ios-tree' }; + async snapshot(options?: SnapshotOptions): Promise { + return await captureLimrunIosSnapshot(this.session, options); } async back(): Promise { From 27dc4da111bbbcaa39e5a8bc07ca9b2eb6a204c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 19:21:32 +0200 Subject: [PATCH 2/9] fix(limrun): defer snapshot engine loading --- packages/provider-limrun/src/ios.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index da2d427c66..932bf9840b 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -21,7 +21,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; -import { captureLimrunIosSnapshot } from './ios-snapshot-adapter.ts'; import { toIosSelector, writeBase64File } from './snapshot.ts'; import { normalizeOptionalString } from './strings.ts'; import { @@ -256,6 +255,7 @@ class LimrunIosInteractor implements Interactor { } async snapshot(options?: SnapshotOptions): Promise { + const { captureLimrunIosSnapshot } = await import('./ios-snapshot-adapter.ts'); return await captureLimrunIosSnapshot(this.session, options); } From 5b4d28d48457abb61700911df0a0ffc9079475ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 19:32:50 +0200 Subject: [PATCH 3/9] fix(limrun): harden snapshot viewport evidence --- .../src/ios-snapshot-adapter.test.ts | 37 ++++++-- .../src/ios-snapshot-adapter.ts | 95 ++++++++++++++++--- 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts index c97a9d2da5..8be4c214a9 100644 --- a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts +++ b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts @@ -11,16 +11,23 @@ import { limrunSnapshotTree, } from './ios-snapshot-adapter.fixtures.ts'; -test('uses Limrun deviceInfo as the reported engine viewport without passing presentation options to elementTree', async () => { - const session = createLimrunSnapshotSession(); +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 } }, - { scope: 'Target', depth: 1, interactiveOnly: true }, + { interactiveOnly: false }, ); expect(elementTree).toHaveBeenCalledWith(); - expect(result.nodes?.map((node) => node.label)).toEqual(['Target', 'Save', 'Save']); + 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.warnings).toContain( 'Limrun iOS snapshots do not provide hittability evidence; regular snapshots will not mark nodes actionable.', @@ -74,18 +81,34 @@ test('regular Limrun presentation never infers hittability from an enabled recta }); test('regular presentation fails with a typed viewport error while raw output discloses the missing evidence', async () => { - const session = createLimrunSnapshotSession(limrunSnapshotTree(), { width: 0, height: 240 }); + 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' }, + 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); raw output is available, but regular presentation requires viewport evidence.', + '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 () => { diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.ts b/packages/provider-limrun/src/ios-snapshot-adapter.ts index 709e2e10e8..50c3cc05ab 100644 --- a/packages/provider-limrun/src/ios-snapshot-adapter.ts +++ b/packages/provider-limrun/src/ios-snapshot-adapter.ts @@ -14,10 +14,13 @@ import { presentIosSnapshot, } from '@agent-device/capture-kit/ios-snapshot-engine'; import { AppError } from '@agent-device/kernel/errors'; +import type { Rect } from '@agent-device/kernel/snapshot'; import type { LimrunIosSession } from './ios.ts'; import { flattenIosTree, type IosTreeNode } from './snapshot.ts'; const LIMRUN_IOS_PRODUCER = IOS_SNAPSHOT_PRODUCER_CAPABILITIES['limrun-ios-tree']; +type LimrunRect = NonNullable; +type NumericRect = Readonly<{ x: number; y: number; width: number; height: number }>; export async function captureLimrunIosSnapshot( session: Pick, @@ -32,8 +35,9 @@ export async function captureLimrunIosSnapshot( }); const plan = planIosSnapshot(request, LIMRUN_IOS_PRODUCER); const treeJson = await session.client.elementTree(); - const nodes = flattenIosTree(JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]); - const viewport = readLimrunViewport(session.client.deviceInfo); + const parsed = JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]; + const nodes = flattenIosTree(parsed); + const viewport = readLimrunViewport(parsed, session.client.deviceInfo); const residue = limrunAcquisitionResidue(plan.evidence.hittability, viewport); const hint = { ...plan.hint, acquisitionIntent: 'full' as const }; const acquisition: IosSnapshotAcquisition = { @@ -58,24 +62,75 @@ export async function captureLimrunIosSnapshot( ...(warnings.length > 0 ? { warnings } : {}), }; } catch (error) { - throwLimrunSnapshotError(error); + throwLimrunSnapshotError(error, residue); } } function readLimrunViewport( + tree: IosTreeNode | IosTreeNode[], + deviceInfo: { screenWidth?: number; screenHeight?: number } | undefined, +): IosViewportEvidence { + const treeRect = readLimrunTreeViewport(tree); + if (treeRect) return { kind: 'derived', rect: treeRect }; + + return readLimrunDeviceInfoViewport(deviceInfo); +} + +function readLimrunTreeViewport(tree: IosTreeNode | IosTreeNode[]): Rect | undefined { + const roots = Array.isArray(tree) ? tree : [tree]; + return roots + .filter(isLimrunViewportRoot) + .map(readLimrunNodeRect) + .filter((rect): rect is Rect => rect !== undefined) + .sort((left, right) => rectArea(right) - rectArea(left))[0]; +} + +function isLimrunViewportRoot(node: IosTreeNode): boolean { + const type = (node.elementType ?? node.type ?? node.role ?? '').toLowerCase(); + return type === 'application' || type === 'window'; +} + +function readLimrunNodeRect(node: IosTreeNode): Rect | undefined { + const rect = node.rect ?? node.frame; + if (!isPositiveFiniteRect(rect)) return undefined; + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +function readLimrunDeviceInfoViewport( deviceInfo: { screenWidth?: number; screenHeight?: number } | undefined, ): IosViewportEvidence { const width = deviceInfo?.screenWidth; const height = deviceInfo?.screenHeight; - if (typeof width !== 'number' || typeof height !== 'number') { + if (width === undefined || height === undefined) { return { kind: 'missing', reason: 'not-provided' }; } - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + if (!isPositiveFiniteNumber(width) || !isPositiveFiniteNumber(height)) { return { kind: 'missing', reason: 'invalid' }; } return { kind: 'reported', rect: { x: 0, y: 0, width, height } }; } +function isPositiveFiniteRect(rect: LimrunRect | undefined): rect is NumericRect { + return ( + isFiniteNumber(rect?.x) && + isFiniteNumber(rect?.y) && + isPositiveFiniteNumber(rect?.width) && + isPositiveFiniteNumber(rect?.height) + ); +} + +function isPositiveFiniteNumber(value: number | undefined): value is number { + return isFiniteNumber(value) && value > 0; +} + +function isFiniteNumber(value: number | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function rectArea(rect: Rect): number { + return rect.width * rect.height; +} + function limrunAcquisitionResidue( hittability: 'available' | 'unavailable', viewport: IosViewportEvidence, @@ -97,21 +152,35 @@ function limrunSnapshotWarnings(residue: readonly IosAcquisitionResidue[]): stri 'Limrun iOS snapshots do not provide hittability evidence; regular snapshots will not mark nodes actionable.', ); } - const missingViewport = residue.find((entry) => entry.kind === 'missing-viewport'); - if (missingViewport) { - warnings.push( - `Limrun iOS snapshots did not provide a valid viewport (${missingViewport.reason}); raw output is available, but regular presentation requires viewport evidence.`, - ); - } + const viewportWarning = limrunViewportWarning(residue); + if (viewportWarning) warnings.push(viewportWarning); return warnings; } -function throwLimrunSnapshotError(error: unknown): never { +function limrunViewportWarning(residue: readonly IosAcquisitionResidue[]): string | undefined { + const missingViewport = residue.find((entry) => entry.kind === 'missing-viewport'); + return missingViewport + ? `Limrun iOS snapshots did not provide a valid viewport (${missingViewport.reason}); retry with --raw to inspect the acquired tree, while regular presentation requires viewport evidence.` + : undefined; +} + +function throwLimrunSnapshotError( + error: unknown, + residue: readonly IosAcquisitionResidue[], +): never { if (!(error instanceof IosSnapshotEngineError)) throw error; + const hint = + error.reason === 'missing-viewport' || error.reason === 'invalid-viewport' + ? limrunViewportWarning(residue) + : undefined; throw new AppError( 'COMMAND_FAILED', error.message, - { reason: error.reason, iosSnapshotEngine: { details: error.details } }, + { + reason: error.reason, + iosSnapshotEngine: { details: error.details }, + ...(hint ? { hint } : {}), + }, error, ); } From 23797db90407654c958ba1180650368562728d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 20:07:06 +0200 Subject: [PATCH 4/9] fix(limrun): preserve snapshot engine evidence --- .../ios-snapshot-engine/conformance.test.ts | 2 +- .../capture-kit/src/ios-snapshot-planning.ts | 2 +- packages/contracts/src/interactor-types.ts | 4 ++- packages/contracts/src/ios-snapshot.ts | 7 +++-- packages/kernel/src/snapshot.ts | 8 +++++ .../src/ios-snapshot-adapter.test.ts | 11 +++++-- .../src/ios-snapshot-adapter.ts | 11 +++++-- src/core/snapshot-state.ts | 8 +++-- .../__tests__/snapshot-capture.test.ts | 31 ++++++++++++++++++- src/daemon/handlers/snapshot-capture.ts | 4 ++- 10 files changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts b/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts index 8afcd9fee4..d46f38c9c0 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts @@ -86,7 +86,7 @@ test('the independent iOS snapshot goldens match the TypeScript engine', () => { | { outcome: 'success'; nodes: ReturnType; - truncated: boolean; + truncated?: boolean; residue: typeof acquisition.residue; qualityLabels?: readonly (string | null)[]; } diff --git a/packages/capture-kit/src/ios-snapshot-planning.ts b/packages/capture-kit/src/ios-snapshot-planning.ts index 37fa1513fb..933a537296 100644 --- a/packages/capture-kit/src/ios-snapshot-planning.ts +++ b/packages/capture-kit/src/ios-snapshot-planning.ts @@ -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', diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index ad4498a08e..975a824ea3 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -12,6 +12,7 @@ import type { RawSnapshotNode, Point, Rect, + SnapshotEnginePresentedMarker, SnapshotOptions as BaseSnapshotOptions, SnapshotProvenance, } from '@agent-device/kernel/snapshot'; @@ -247,7 +248,8 @@ export type KeyboardEnterResult = */ export type SnapshotResult = Omit & { nodes?: RawSnapshotNode[]; -} & SnapshotProvenance; +} & SnapshotProvenance & + SnapshotEnginePresentedMarker; export type Interactor = { open( diff --git a/packages/contracts/src/ios-snapshot.ts b/packages/contracts/src/ios-snapshot.ts index 227300931c..ed12cfa9f1 100644 --- a/packages/contracts/src/ios-snapshot.ts +++ b/packages/contracts/src/ios-snapshot.ts @@ -71,7 +71,8 @@ export type IosSnapshotFact = | 'interactive-query' | 'viewport' | 'hittability' - | 'generation'; + | 'generation' + | 'truncation'; type IosSnapshotProducerCapabilityFacts = Readonly<{ acquisitionDepth: IosSnapshotAcquisitionDepthCapability; @@ -148,7 +149,7 @@ type IosSnapshotAcquisitionForIntent = Read intent: Intent; hint: CaptureHint & Readonly<{ acquisitionIntent: Intent }>; nodes: readonly RawSnapshotNode[]; - truncated: boolean; + truncated?: boolean; viewport: IosViewportEvidence; lineage: IosSnapshotLineage; residue: readonly IosAcquisitionResidue[]; @@ -216,7 +217,7 @@ export type IosSnapshotPlan = Readonly<{ export type IosSnapshotPublishedPayload = Readonly<{ nodes: readonly SnapshotNode[]; - truncated: boolean; + truncated?: boolean; }>; export type IosSnapshotComparisonIdentity = Readonly<{ diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index 4fc96bcfdb..477b8589bb 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -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; diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts index 8be4c214a9..ad63b385e3 100644 --- a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts +++ b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import { SNAPSHOT_ENGINE_PRESENTED } from '@agent-device/kernel/snapshot'; import { IOS_SNAPSHOT_PRODUCER_CAPABILITIES, createIosSnapshotRequest, @@ -29,6 +30,10 @@ test('derives the current engine viewport from the tree before the cached device '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.', ); @@ -47,14 +52,14 @@ test.each([ const result = await captureLimrunIosSnapshot(createLimrunSnapshotSession(), options); expect(result.nodes?.map((node) => node.label)).toEqual(labels); - expect(result.truncated).toBe(false); + expect(result.truncated).toBeUndefined(); }); -test('the Limrun capability plan narrows only the raw traversal tier and records unavailable facts', () => { +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: 2, scope: null, interactiveOnly: false }); + expect(plan.narrowing).toEqual({ depth: null, scope: null, interactiveOnly: false }); expect(plan.evidence).toEqual({ scope: 'incomplete', interactiveQuery: 'incomplete', diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.ts b/packages/provider-limrun/src/ios-snapshot-adapter.ts index 50c3cc05ab..1ce0faf884 100644 --- a/packages/provider-limrun/src/ios-snapshot-adapter.ts +++ b/packages/provider-limrun/src/ios-snapshot-adapter.ts @@ -14,7 +14,7 @@ import { presentIosSnapshot, } from '@agent-device/capture-kit/ios-snapshot-engine'; import { AppError } from '@agent-device/kernel/errors'; -import type { Rect } from '@agent-device/kernel/snapshot'; +import { SNAPSHOT_ENGINE_PRESENTED, type Rect } from '@agent-device/kernel/snapshot'; import type { LimrunIosSession } from './ios.ts'; import { flattenIosTree, type IosTreeNode } from './snapshot.ts'; @@ -45,7 +45,6 @@ export async function captureLimrunIosSnapshot( intent: 'full', hint, nodes, - truncated: false, viewport, lineage: { targetId: session.instanceId }, residue, @@ -56,9 +55,9 @@ export async function captureLimrunIosSnapshot( const warnings = limrunSnapshotWarnings(residue); return { nodes: presentation.nodes, - truncated: acquisition.truncated, backend: 'xctest', producer: 'limrun-ios-tree', + [SNAPSHOT_ENGINE_PRESENTED]: true, ...(warnings.length > 0 ? { warnings } : {}), }; } catch (error) { @@ -136,6 +135,7 @@ function limrunAcquisitionResidue( viewport: IosViewportEvidence, ): IosAcquisitionResidue[] { return [ + { kind: 'unavailable-fact', fact: 'truncation' }, ...(hittability === 'unavailable' ? [{ kind: 'unavailable-fact' as const, fact: 'hittability' as const }] : []), @@ -147,6 +147,11 @@ function limrunAcquisitionResidue( function limrunSnapshotWarnings(residue: readonly IosAcquisitionResidue[]): string[] { const warnings: string[] = []; + if (residue.some((entry) => entry.kind === 'unavailable-fact' && entry.fact === 'truncation')) { + warnings.push( + 'Limrun iOS tree responses do not expose truncation metadata; tree completeness is not independently verified.', + ); + } if (residue.some((entry) => entry.kind === 'unavailable-fact' && entry.fact === 'hittability')) { warnings.push( 'Limrun iOS snapshots do not provide hittability evidence; regular snapshots will not mark nodes actionable.', diff --git a/src/core/snapshot-state.ts b/src/core/snapshot-state.ts index c5614c42ab..0d3bb20586 100644 --- a/src/core/snapshot-state.ts +++ b/src/core/snapshot-state.ts @@ -7,9 +7,11 @@ import { isAndroidInputMethodNode } from '@agent-device/contracts/android-input- import { attachRefs, buildSnapshotPresentationKey, + SNAPSHOT_ENGINE_PRESENTED, snapshotPresentationOptionsFromFlags, type RawSnapshotNode, type SnapshotBackend, + type SnapshotEnginePresentedMarker, type SnapshotStateProvenance, snapshotStateProvenance, type SnapshotState, @@ -38,7 +40,8 @@ export function buildSnapshotState( nodes?: RawSnapshotNode[]; truncated?: boolean; quality?: unknown; - } & SnapshotStateProvenance, + } & SnapshotStateProvenance & + SnapshotEnginePresentedMarker, flags: | (Pick & Partial>) @@ -121,13 +124,14 @@ function backendScopesAfterWire(backend: SnapshotBackend | undefined): boolean { } function shouldPresentIosInteractiveSnapshot( - provenance: SnapshotStateProvenance, + provenance: SnapshotStateProvenance & SnapshotEnginePresentedMarker, flags: | (Pick & Partial>) | undefined, ): boolean { return ( + provenance[SNAPSHOT_ENGINE_PRESENTED] !== true && provenance.backend === 'xctest' && iosSnapshotPresentationStage(provenance) === 'acquired' && flags?.snapshotInteractiveOnly === true && diff --git a/src/daemon/handlers/__tests__/snapshot-capture.test.ts b/src/daemon/handlers/__tests__/snapshot-capture.test.ts index 42f8be4ff0..f0ac8c758c 100644 --- a/src/daemon/handlers/__tests__/snapshot-capture.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-capture.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { captureSnapshotData } from '../snapshot-capture.ts'; +import { captureSnapshot, captureSnapshotData } from '../snapshot-capture.ts'; import { buildSnapshotVisibility } from '../../../snapshot/snapshot-visibility.ts'; +import { SNAPSHOT_ENGINE_PRESENTED } from '@agent-device/kernel/snapshot'; import { ANDROID_EMULATOR, IOS_SIMULATOR, @@ -8,7 +9,14 @@ import { } from '../../../__tests__/test-utils/device-fixtures.ts'; const captureSnapshotWithInteractor = vi.hoisted(() => vi.fn()); +const iosPresentation = vi.hoisted(() => vi.fn()); vi.mock('../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor })); +vi.mock('@agent-device/capture-kit/ios-snapshot-engine', async (importOriginal) => { + const actual = + await importOriginal(); + iosPresentation.mockImplementation(actual.presentIosInteractiveSnapshot); + return { ...actual, presentIosInteractiveSnapshot: iosPresentation }; +}); test('iOS interactive capture sends scope to runner presentation', async () => { captureSnapshotWithInteractor.mockClear(); @@ -30,6 +38,27 @@ test('iOS interactive capture sends scope to runner presentation', async () => { ); }); +test('daemon does not re-present provider results already presented by the shared engine', async () => { + iosPresentation.mockClear(); + const providerResult = { + nodes: [{ index: 0, depth: 0, type: 'Application' }], + backend: 'xctest' as const, + producer: 'limrun-ios-tree' as const, + [SNAPSHOT_ENGINE_PRESENTED]: true as const, + }; + + const result = await captureSnapshot({ + device: IOS_SIMULATOR, + session: undefined, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/snapshot-capture-test.log', + captureData: async () => providerResult, + }); + + expect(iosPresentation).not.toHaveBeenCalled(); + expect(Object.getOwnPropertySymbols(result.snapshot)).not.toContain(SNAPSHOT_ENGINE_PRESENTED); +}); + test('snapshot capture preserves scope for every other platform projection', async () => { captureSnapshotWithInteractor.mockClear(); for (const [device, flags] of [ diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index ee80df6fd3..6a29530411 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -10,6 +10,7 @@ import { findNodeByRef, normalizeRef, type RawSnapshotNode, + type SnapshotEnginePresentedMarker, type SnapshotStateProvenance, type SnapshotState, } from '@agent-device/kernel/snapshot'; @@ -55,7 +56,8 @@ type SnapshotData = { truncated?: boolean; quality?: unknown; } & Omit & - SnapshotStateProvenance; + SnapshotStateProvenance & + SnapshotEnginePresentedMarker; type SnapshotAttempt = { data: SnapshotData; From 80c4c2864d3fa80cca77e314ccb1da8666c02b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 21:51:10 +0200 Subject: [PATCH 5/9] fix(limrun): preserve unknown snapshot truncation --- packages/contracts/src/client-capture.ts | 3 +- src/__tests__/client.test.ts | 16 ++++++ src/agent-device-client.ts | 2 +- src/commands/capture/runtime/snapshot.test.ts | 2 +- src/commands/capture/runtime/snapshot.ts | 4 +- .../legacy-snapshot-capture-fixture.ts | 3 +- .../__tests__/snapshot-handler.test.ts | 57 ++++++++++++++++++- src/daemon/result-serialization.ts | 2 +- 8 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/client-capture.ts b/packages/contracts/src/client-capture.ts index 7d1ea71fdb..9aaba506ad 100644 --- a/packages/contracts/src/client-capture.ts +++ b/packages/contracts/src/client-capture.ts @@ -37,7 +37,8 @@ export type CaptureSnapshotOptions = AgentDeviceRequestOverrides & export type CaptureSnapshotResult = { nodes: SnapshotNode[]; - truncated: boolean; + /** Present only when the capture owner establishes whether the tree was truncated. */ + truncated?: boolean; appName?: string; appBundleId?: string; visibility?: SnapshotVisibility; diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 29030fb981..332748abac 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1118,6 +1118,22 @@ test('client capture.snapshot preserves visibility metadata from daemon response }); }); +test('client capture.snapshot preserves unknown truncation as an omitted field', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + nodes: [], + warnings: ['tree completeness is not independently verified'], + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.snapshot(); + + assert.equal('truncated' in result, false); + assert.equal(result.truncated, undefined); +}); + test('client capture.snapshot preserves refsGeneration from daemon responses (ADR 0014)', async () => { const setup = createTransport(async () => ({ ok: true, diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index ceb3626342..28b45cbce3 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -511,7 +511,7 @@ function normalizeSnapshotResult( const appBundleId = readOptionalString(data, 'appBundleId'); return { nodes: readSnapshotNodes(data.nodes), - truncated: data.truncated === true, + ...(typeof data.truncated === 'boolean' ? { truncated: data.truncated } : {}), appName: readOptionalString(data, 'appName'), appBundleId, ...optionalSnapshotResponseFields(data), diff --git a/src/commands/capture/runtime/snapshot.test.ts b/src/commands/capture/runtime/snapshot.test.ts index 8b209aa70a..df3cf8d6ff 100644 --- a/src/commands/capture/runtime/snapshot.test.ts +++ b/src/commands/capture/runtime/snapshot.test.ts @@ -36,7 +36,7 @@ test('runtime snapshot captures nodes and updates the session baseline', async ( const result = await device.capture.snapshot({ session: 'default' }); assert.equal(result.nodes[0]?.label, 'Home'); - assert.equal(result.truncated, false); + assert.equal(result.truncated, undefined); assert.equal(result.appName, 'Demo'); assert.equal(result.appBundleId, 'com.example.demo'); assert.equal(stored?.snapshot?.nodes[0]?.label, 'Home'); diff --git a/src/commands/capture/runtime/snapshot.ts b/src/commands/capture/runtime/snapshot.ts index b661c4552a..a90708c883 100644 --- a/src/commands/capture/runtime/snapshot.ts +++ b/src/commands/capture/runtime/snapshot.ts @@ -37,7 +37,7 @@ import { export type SnapshotCommandResult = { nodes: SnapshotNode[]; - truncated: boolean; + truncated?: boolean; appName?: string; appBundleId?: string; visibility?: SnapshotVisibility; @@ -70,7 +70,7 @@ export const snapshotCommand: RuntimeCommand< await runtime.sessions.set(nextSnapshotSession(options.session, capture)); return copySnapshotClickabilityEvidence(capture.snapshot, { nodes: capture.snapshot.nodes, - truncated: capture.snapshot.truncated ?? false, + ...(capture.snapshot.truncated !== undefined ? { truncated: capture.snapshot.truncated } : {}), visibility: buildSnapshotVisibility({ nodes: capture.snapshot.nodes, backend: capture.snapshot.backend, diff --git a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts index 48ec1d62dd..7cd2816763 100644 --- a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts +++ b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SnapshotEnginePresentedMarker } from '@agent-device/kernel/snapshot'; import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; import type { captureSnapshotWithInteractor } from '../handlers/snapshot-interactor-capture.ts'; @@ -19,7 +20,7 @@ export const legacyDispatchCapture = vi.fn< positionals?: string[], outPath?: string, context?: Record, - ) => Promise | void> + ) => Promise<(Record & SnapshotEnginePresentedMarker) | void> >(async () => ({})); /** diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index f851584c04..a6958f5146 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -17,7 +17,10 @@ import { AppError } from '@agent-device/kernel/errors'; import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { buildSnapshotSignatures } from '../../../snapshot/snapshot-freshness/index.ts'; import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; -import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; +import { + buildSnapshotPresentationKey, + SNAPSHOT_ENGINE_PRESENTED, +} from '@agent-device/kernel/snapshot'; import { snapshotCliOutput } from '../../../commands/capture/output.ts'; import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -124,9 +127,12 @@ const providerIosDevice: SessionState['device'] = { booted: true, }; -function makeProviderRuntimeOwning(device: SessionState['device']): ProviderDeviceRuntime { +function makeProviderRuntimeOwning( + device: SessionState['device'], + provider = 'browserstack', +): ProviderDeviceRuntime { return { - provider: 'browserstack', + provider, leaseLifecycle: {}, deviceInventoryProvider: async () => [device], ownsDevice: (candidate) => candidate.id === device.id, @@ -484,6 +490,51 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { expect(bindCount).toBe(1); }); +test('Limrun unknown truncation stays omitted through daemon and public output', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'limrun-ios-unknown-truncation'; + const limrunDevice: SessionState['device'] = { + platform: 'apple', + appleOs: 'ios', + id: 'limrun:ios:lease-a', + name: 'Limrun iOS', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + sessionStore.set(sessionName, makeSession(sessionName, limrunDevice)); + setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(limrunDevice, 'limrun')]); + legacyDispatchCapture.mockResolvedValue({ + nodes: [{ index: 0, depth: 0, type: 'Application', label: 'Demo' }], + backend: 'xctest', + producer: 'limrun-ios-tree', + warnings: ['tree completeness is not independently verified'], + [SNAPSHOT_ENGINE_PRESENTED]: true, + }); + + const response = await handleSnapshotCommands({ + req: { + token: 't', + session: sessionName, + command: 'snapshot', + positionals: [], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) return; + expect(response.data).not.toHaveProperty('truncated'); + + const cliOutput = await snapshotCliOutput({ + result: response.data as unknown as CaptureSnapshotResult, + }); + expect(cliOutput.jsonData).not.toHaveProperty('truncated'); +}); + test('diff on local iOS still requires a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app-diff'; diff --git a/src/daemon/result-serialization.ts b/src/daemon/result-serialization.ts index 3e71e3cd49..80f74b3d2b 100644 --- a/src/daemon/result-serialization.ts +++ b/src/daemon/result-serialization.ts @@ -67,7 +67,7 @@ export function serializeDevice(device: AgentDeviceDevice): Record { return { nodes: result.nodes, - truncated: result.truncated, + ...(result.truncated !== undefined ? { truncated: result.truncated } : {}), ...(result.appName ? { appName: result.appName } : {}), ...(result.appBundleId ? { appBundleId: result.appBundleId } : {}), ...(result.visibility ? { visibility: result.visibility } : {}), From de17fe10df68942b89748d5a81a7bc7144098991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 21:58:26 +0200 Subject: [PATCH 6/9] refactor(ios): reuse private presentation evidence seam --- packages/contracts/src/facades/capture.ts | 3 +++ packages/contracts/src/interactor-types.ts | 4 +--- .../src/snapshot-private-evidence.ts | 19 +++++++++++++++ packages/kernel/src/snapshot.ts | 8 ------- .../src/ios-snapshot-adapter.test.ts | 4 ++-- .../src/ios-snapshot-adapter.ts | 19 ++++++++------- src/core/snapshot-state.ts | 14 +++++------ .../legacy-snapshot-capture-fixture.ts | 3 +-- .../__tests__/snapshot-capture.test.ts | 17 ++++++------- .../__tests__/snapshot-handler.test.ts | 24 ++++++++++--------- src/daemon/handlers/snapshot-capture.ts | 4 +--- 11 files changed, 66 insertions(+), 53 deletions(-) diff --git a/packages/contracts/src/facades/capture.ts b/packages/contracts/src/facades/capture.ts index d11cab7c83..4883e2b0ac 100644 --- a/packages/contracts/src/facades/capture.ts +++ b/packages/contracts/src/facades/capture.ts @@ -53,14 +53,17 @@ export type { ScreenshotResultData, } from '../snapshot-types.ts'; export { + attachSnapshotPresentationEvidence, attachSnapshotClickabilityEvidence, attachSnapshotOcclusionContextEvidence, copySnapshotClickabilityEvidence, readSnapshotClickabilityEvidence, readSnapshotOcclusionContextEvidence, + readSnapshotPresentationEvidence, } from '../snapshot-private-evidence.ts'; export type { AndroidSiblingOrderEvidence, + SnapshotPresentationEvidence, SnapshotClickabilityEvidence, SnapshotOcclusionContextEvidence, } from '../snapshot-private-evidence.ts'; diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 975a824ea3..ad4498a08e 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -12,7 +12,6 @@ import type { RawSnapshotNode, Point, Rect, - SnapshotEnginePresentedMarker, SnapshotOptions as BaseSnapshotOptions, SnapshotProvenance, } from '@agent-device/kernel/snapshot'; @@ -248,8 +247,7 @@ export type KeyboardEnterResult = */ export type SnapshotResult = Omit & { nodes?: RawSnapshotNode[]; -} & SnapshotProvenance & - SnapshotEnginePresentedMarker; +} & SnapshotProvenance; export type Interactor = { open( diff --git a/packages/contracts/src/snapshot-private-evidence.ts b/packages/contracts/src/snapshot-private-evidence.ts index 530f0c13ed..43e524f022 100644 --- a/packages/contracts/src/snapshot-private-evidence.ts +++ b/packages/contracts/src/snapshot-private-evidence.ts @@ -28,9 +28,15 @@ export type SnapshotOcclusionContextEvidence = { androidSiblingOrderByNodeIndex?: ReadonlyMap; }; +/** Identifies the engine that owns interactive iOS snapshot presentation. */ +export type SnapshotPresentationEvidence = { + owner: 'ios-snapshot-engine'; +}; + type SnapshotPrivateEvidence = { clickability?: SnapshotClickabilityEvidence; occlusionContext?: SnapshotOcclusionContextEvidence; + presentation?: SnapshotPresentationEvidence; }; const privateEvidenceBySnapshotObject = new WeakMap(); @@ -59,6 +65,19 @@ export function readSnapshotClickabilityEvidence( return owner ? privateEvidenceBySnapshotObject.get(owner)?.clickability : undefined; } +export function attachSnapshotPresentationEvidence( + owner: T, + evidence: SnapshotPresentationEvidence, +): T { + return attachSnapshotPrivateEvidence(owner, { presentation: evidence }); +} + +export function readSnapshotPresentationEvidence( + owner: object | null | undefined, +): SnapshotPresentationEvidence | undefined { + return owner ? privateEvidenceBySnapshotObject.get(owner)?.presentation : undefined; +} + export function copySnapshotClickabilityEvidence( source: object | null | undefined, target: T, diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index 477b8589bb..4fc96bcfdb 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -18,14 +18,6 @@ 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; diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts index ad63b385e3..388bac2ab0 100644 --- a/packages/provider-limrun/src/ios-snapshot-adapter.test.ts +++ b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vitest'; -import { SNAPSHOT_ENGINE_PRESENTED } from '@agent-device/kernel/snapshot'; +import { readSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; import { IOS_SNAPSHOT_PRODUCER_CAPABILITIES, createIosSnapshotRequest, @@ -30,7 +30,7 @@ test('derives the current engine viewport from the tree before the cached device 'Save', ]); expect(result.nodes?.find((node) => node.label === 'Save')?.hittable).toBe(false); - expect(result[SNAPSHOT_ENGINE_PRESENTED]).toBe(true); + expect(readSnapshotPresentationEvidence(result)).toEqual({ owner: 'ios-snapshot-engine' }); expect(result.warnings).toContain( 'Limrun iOS tree responses do not expose truncation metadata; tree completeness is not independently verified.', ); diff --git a/packages/provider-limrun/src/ios-snapshot-adapter.ts b/packages/provider-limrun/src/ios-snapshot-adapter.ts index 1ce0faf884..feeca7b9fd 100644 --- a/packages/provider-limrun/src/ios-snapshot-adapter.ts +++ b/packages/provider-limrun/src/ios-snapshot-adapter.ts @@ -4,6 +4,7 @@ import type { IosViewportEvidence, } from '@agent-device/contracts/ios-snapshot'; import type { SnapshotOptions, SnapshotResult } from '@agent-device/contracts/interactor-types'; +import { attachSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; import { IOS_SNAPSHOT_PRODUCER_CAPABILITIES, createIosSnapshotRequest, @@ -14,7 +15,7 @@ import { presentIosSnapshot, } from '@agent-device/capture-kit/ios-snapshot-engine'; import { AppError } from '@agent-device/kernel/errors'; -import { SNAPSHOT_ENGINE_PRESENTED, type Rect } from '@agent-device/kernel/snapshot'; +import { type Rect } from '@agent-device/kernel/snapshot'; import type { LimrunIosSession } from './ios.ts'; import { flattenIosTree, type IosTreeNode } from './snapshot.ts'; @@ -53,13 +54,15 @@ export async function captureLimrunIosSnapshot( try { const presentation = presentIosSnapshot({ stage: 'acquired', acquisition }, request); const warnings = limrunSnapshotWarnings(residue); - return { - nodes: presentation.nodes, - backend: 'xctest', - producer: 'limrun-ios-tree', - [SNAPSHOT_ENGINE_PRESENTED]: true, - ...(warnings.length > 0 ? { warnings } : {}), - }; + return attachSnapshotPresentationEvidence( + { + nodes: presentation.nodes, + backend: 'xctest', + producer: 'limrun-ios-tree', + ...(warnings.length > 0 ? { warnings } : {}), + }, + { owner: 'ios-snapshot-engine' }, + ); } catch (error) { throwLimrunSnapshotError(error, residue); } diff --git a/src/core/snapshot-state.ts b/src/core/snapshot-state.ts index 0d3bb20586..689807e046 100644 --- a/src/core/snapshot-state.ts +++ b/src/core/snapshot-state.ts @@ -1,17 +1,16 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import { readSnapshotOcclusionContextEvidence, + readSnapshotPresentationEvidence, snapshotCaptureAnnotationsFrom, } from '@agent-device/contracts/capture'; import { isAndroidInputMethodNode } from '@agent-device/contracts/android-input-ownership'; import { attachRefs, buildSnapshotPresentationKey, - SNAPSHOT_ENGINE_PRESENTED, snapshotPresentationOptionsFromFlags, type RawSnapshotNode, type SnapshotBackend, - type SnapshotEnginePresentedMarker, type SnapshotStateProvenance, snapshotStateProvenance, type SnapshotState, @@ -40,8 +39,7 @@ export function buildSnapshotState( nodes?: RawSnapshotNode[]; truncated?: boolean; quality?: unknown; - } & SnapshotStateProvenance & - SnapshotEnginePresentedMarker, + } & SnapshotStateProvenance, flags: | (Pick & Partial>) @@ -57,7 +55,7 @@ export function buildSnapshotState( const normalizedNodes = normalizeSnapshotTree( snapshotRaw ? backendAnnotatedNodes : pruneGroupNodes(backendAnnotatedNodes), ); - const presentableNodes = shouldPresentIosInteractiveSnapshot(data, flags) + const presentableNodes = shouldPresentLegacyIosInteractiveSnapshot(data, flags) ? presentIosInteractiveSnapshot(normalizedNodes) : normalizedNodes; const scopedNodes = @@ -123,17 +121,17 @@ function backendScopesAfterWire(backend: SnapshotBackend | undefined): boolean { return backend !== 'macos-helper' && backend !== 'android' && backend !== 'xctest'; } -function shouldPresentIosInteractiveSnapshot( - provenance: SnapshotStateProvenance & SnapshotEnginePresentedMarker, +function shouldPresentLegacyIosInteractiveSnapshot( + provenance: object & SnapshotStateProvenance, flags: | (Pick & Partial>) | undefined, ): boolean { return ( - provenance[SNAPSHOT_ENGINE_PRESENTED] !== true && provenance.backend === 'xctest' && iosSnapshotPresentationStage(provenance) === 'acquired' && + readSnapshotPresentationEvidence(provenance)?.owner !== 'ios-snapshot-engine' && flags?.snapshotInteractiveOnly === true && flags.snapshotRaw !== true ); diff --git a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts index 7cd2816763..48ec1d62dd 100644 --- a/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts +++ b/src/daemon/__tests__/legacy-snapshot-capture-fixture.ts @@ -1,6 +1,5 @@ import { vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { SnapshotEnginePresentedMarker } from '@agent-device/kernel/snapshot'; import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; import type { captureSnapshotWithInteractor } from '../handlers/snapshot-interactor-capture.ts'; @@ -20,7 +19,7 @@ export const legacyDispatchCapture = vi.fn< positionals?: string[], outPath?: string, context?: Record, - ) => Promise<(Record & SnapshotEnginePresentedMarker) | void> + ) => Promise | void> >(async () => ({})); /** diff --git a/src/daemon/handlers/__tests__/snapshot-capture.test.ts b/src/daemon/handlers/__tests__/snapshot-capture.test.ts index f0ac8c758c..f73f378816 100644 --- a/src/daemon/handlers/__tests__/snapshot-capture.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-capture.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { captureSnapshot, captureSnapshotData } from '../snapshot-capture.ts'; import { buildSnapshotVisibility } from '../../../snapshot/snapshot-visibility.ts'; -import { SNAPSHOT_ENGINE_PRESENTED } from '@agent-device/kernel/snapshot'; +import { attachSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; import { ANDROID_EMULATOR, IOS_SIMULATOR, @@ -40,12 +40,14 @@ test('iOS interactive capture sends scope to runner presentation', async () => { test('daemon does not re-present provider results already presented by the shared engine', async () => { iosPresentation.mockClear(); - const providerResult = { - nodes: [{ index: 0, depth: 0, type: 'Application' }], - backend: 'xctest' as const, - producer: 'limrun-ios-tree' as const, - [SNAPSHOT_ENGINE_PRESENTED]: true as const, - }; + const providerResult = attachSnapshotPresentationEvidence( + { + nodes: [{ index: 0, depth: 0, type: 'Application' }], + backend: 'xctest' as const, + producer: 'limrun-ios-tree' as const, + }, + { owner: 'ios-snapshot-engine' }, + ); const result = await captureSnapshot({ device: IOS_SIMULATOR, @@ -56,7 +58,6 @@ test('daemon does not re-present provider results already presented by the share }); expect(iosPresentation).not.toHaveBeenCalled(); - expect(Object.getOwnPropertySymbols(result.snapshot)).not.toContain(SNAPSHOT_ENGINE_PRESENTED); }); test('snapshot capture preserves scope for every other platform projection', async () => { diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index a6958f5146..5eadc2a119 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -17,10 +17,8 @@ import { AppError } from '@agent-device/kernel/errors'; import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { buildSnapshotSignatures } from '../../../snapshot/snapshot-freshness/index.ts'; import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; -import { - buildSnapshotPresentationKey, - SNAPSHOT_ENGINE_PRESENTED, -} from '@agent-device/kernel/snapshot'; +import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; +import { attachSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; import { snapshotCliOutput } from '../../../commands/capture/output.ts'; import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; @@ -504,13 +502,17 @@ test('Limrun unknown truncation stays omitted through daemon and public output', }; sessionStore.set(sessionName, makeSession(sessionName, limrunDevice)); setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(limrunDevice, 'limrun')]); - legacyDispatchCapture.mockResolvedValue({ - nodes: [{ index: 0, depth: 0, type: 'Application', label: 'Demo' }], - backend: 'xctest', - producer: 'limrun-ios-tree', - warnings: ['tree completeness is not independently verified'], - [SNAPSHOT_ENGINE_PRESENTED]: true, - }); + legacyDispatchCapture.mockResolvedValue( + attachSnapshotPresentationEvidence( + { + nodes: [{ index: 0, depth: 0, type: 'Application', label: 'Demo' }], + backend: 'xctest', + producer: 'limrun-ios-tree', + warnings: ['tree completeness is not independently verified'], + }, + { owner: 'ios-snapshot-engine' }, + ), + ); const response = await handleSnapshotCommands({ req: { diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 6a29530411..ee80df6fd3 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -10,7 +10,6 @@ import { findNodeByRef, normalizeRef, type RawSnapshotNode, - type SnapshotEnginePresentedMarker, type SnapshotStateProvenance, type SnapshotState, } from '@agent-device/kernel/snapshot'; @@ -56,8 +55,7 @@ type SnapshotData = { truncated?: boolean; quality?: unknown; } & Omit & - SnapshotStateProvenance & - SnapshotEnginePresentedMarker; + SnapshotStateProvenance; type SnapshotAttempt = { data: SnapshotData; From 771365491881f6f440960aefcfadd41e8aa25223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 21:58:53 +0200 Subject: [PATCH 7/9] test(ios): remove stale presentation assertion binding --- src/daemon/handlers/__tests__/snapshot-capture.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon/handlers/__tests__/snapshot-capture.test.ts b/src/daemon/handlers/__tests__/snapshot-capture.test.ts index f73f378816..1f11c123e9 100644 --- a/src/daemon/handlers/__tests__/snapshot-capture.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-capture.test.ts @@ -49,7 +49,7 @@ test('daemon does not re-present provider results already presented by the share { owner: 'ios-snapshot-engine' }, ); - const result = await captureSnapshot({ + await captureSnapshot({ device: IOS_SIMULATOR, session: undefined, flags: { snapshotInteractiveOnly: true }, From 8906038331ba04baf9d3232a815cdd33e8b709f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 1 Sep 2026 23:12:19 +0200 Subject: [PATCH 8/9] test(ios): extract snapshot truncation regressions --- .../client-snapshot-truncation.test.ts | 20 ++++ src/__tests__/client-transport-fixture.ts | 36 +++++++ src/__tests__/client.test.ts | 58 +---------- .../__tests__/snapshot-handler-fixture.ts | 32 +++++++ .../__tests__/snapshot-handler.test.ts | 81 +--------------- .../__tests__/snapshot-truncation.test.ts | 95 +++++++++++++++++++ 6 files changed, 190 insertions(+), 132 deletions(-) create mode 100644 src/__tests__/client-snapshot-truncation.test.ts create mode 100644 src/__tests__/client-transport-fixture.ts create mode 100644 src/daemon/handlers/__tests__/snapshot-handler-fixture.ts create mode 100644 src/daemon/handlers/__tests__/snapshot-truncation.test.ts diff --git a/src/__tests__/client-snapshot-truncation.test.ts b/src/__tests__/client-snapshot-truncation.test.ts new file mode 100644 index 0000000000..7a7b598610 --- /dev/null +++ b/src/__tests__/client-snapshot-truncation.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { createTransport } from './client-transport-fixture.ts'; + +test('client capture.snapshot preserves unknown truncation as an omitted field', async () => { + const setup = createTransport(async () => ({ + ok: true, + data: { + nodes: [], + warnings: ['tree completeness is not independently verified'], + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const result = await client.capture.snapshot(); + + assert.equal('truncated' in result, false); + assert.equal(result.truncated, undefined); +}); diff --git a/src/__tests__/client-transport-fixture.ts b/src/__tests__/client-transport-fixture.ts new file mode 100644 index 0000000000..c84292b76a --- /dev/null +++ b/src/__tests__/client-transport-fixture.ts @@ -0,0 +1,36 @@ +import type { AgentDeviceClientConfig } from '../agent-device-client.ts'; +import type { DaemonRequest, DaemonResponse } from '@agent-device/kernel/contracts'; +import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; + +const TEST_STATE_DIR = mkdtempForTestSync('agent-device-client-test-'); + +export function createTransport( + handler: (req: Omit) => Promise | DaemonResponse, +): { + calls: Array>; + config: AgentDeviceClientConfig; + transport: (req: Omit) => Promise; +} { + const calls: Array> = []; + const config: AgentDeviceClientConfig = { + session: 'qa', + stateDir: TEST_STATE_DIR, + cwd: '/tmp/agent-device', + debug: true, + daemonBaseUrl: 'http://daemon.example.test', + daemonAuthToken: 'secret', + daemonTransport: 'http', + tenant: 'acme', + sessionIsolation: 'tenant', + runId: 'run-123', + leaseId: 'lease-123', + }; + return { + calls, + config, + transport: async (req) => { + calls.push(req); + return await handler(req); + }, + }; +} diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 332748abac..f1353ca722 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -10,7 +10,6 @@ import type { import { createAgentDeviceClient, type AgentDeviceClient, - type AgentDeviceClientConfig, type DiffSnapshotCommandResult, type DoctorCommandResult, type PrepareCommandResult, @@ -24,18 +23,12 @@ import { } from '../agent-device-client.ts'; import { runCommand } from '../commands/command-surface.ts'; import type { CommandResult } from '../core/command-descriptor/command-result.ts'; -import type { - DaemonRequest, - DaemonResponse, - DaemonResponseData, -} from '@agent-device/kernel/contracts'; +import type { DaemonResponse, DaemonResponseData } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; import fs from 'node:fs'; import nodePath from 'node:path'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; - -// Isolated so open/close metro-session-hint file writes never touch the real state dir. -const TEST_STATE_DIR = mkdtempForTestSync('agent-device-client-test-'); +import { createTransport } from './client-transport-fixture.ts'; // #1802: replay/test requests carry the script text the CLIENT read, so these cases need real // files. `cwd` is what the writer resolves the caller's relative path against. @@ -96,37 +89,6 @@ const closedProjectionResponses: Record = { }, }; -function createTransport( - handler: (req: Omit) => Promise | DaemonResponse, -): { - calls: Array>; - config: AgentDeviceClientConfig; - transport: (req: Omit) => Promise; -} { - const calls: Array> = []; - const config: AgentDeviceClientConfig = { - session: 'qa', - stateDir: TEST_STATE_DIR, - cwd: '/tmp/agent-device', - debug: true, - daemonBaseUrl: 'http://daemon.example.test', - daemonAuthToken: 'secret', - daemonTransport: 'http', - tenant: 'acme', - sessionIsolation: 'tenant', - runId: 'run-123', - leaseId: 'lease-123', - }; - return { - calls, - config, - transport: async (req) => { - calls.push(req); - return await handler(req); - }, - }; -} - test('client exposes narrowed result types for closed daemon projections', async () => { const setup = createTransport(async (req) => closedProjectionResponse(req.command)); const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); @@ -1118,22 +1080,6 @@ test('client capture.snapshot preserves visibility metadata from daemon response }); }); -test('client capture.snapshot preserves unknown truncation as an omitted field', async () => { - const setup = createTransport(async () => ({ - ok: true, - data: { - nodes: [], - warnings: ['tree completeness is not independently verified'], - }, - })); - const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); - - const result = await client.capture.snapshot(); - - assert.equal('truncated' in result, false); - assert.equal(result.truncated, undefined); -}); - test('client capture.snapshot preserves refsGeneration from daemon responses (ADR 0014)', async () => { const setup = createTransport(async () => ({ ok: true, diff --git a/src/daemon/handlers/__tests__/snapshot-handler-fixture.ts b/src/daemon/handlers/__tests__/snapshot-handler-fixture.ts new file mode 100644 index 0000000000..53dfc8061d --- /dev/null +++ b/src/daemon/handlers/__tests__/snapshot-handler-fixture.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; +import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; +import { SessionStore } from '../../session-store.ts'; +import type { SessionState } from '../../types.ts'; + +export function makeSessionStore(): SessionStore { + const root = mkdtempForTestSync('agent-device-snapshot-handler-'); + return new SessionStore(path.join(root, 'sessions')); +} + +export function makeSession( + name: string, + device: SessionState['device'], + extra?: Partial, +): SessionState { + return { name, device, createdAt: Date.now(), actions: [], ...extra }; +} + +export function makeProviderRuntimeOwning( + device: SessionState['device'], + provider = 'browserstack', +): ProviderDeviceRuntime { + return { + provider, + leaseLifecycle: {}, + deviceInventoryProvider: async () => [device], + ownsDevice: (candidate) => candidate.id === device.id, + getInteractor: () => undefined, + shutdown: async () => undefined, + }; +} diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 5eadc2a119..3ad13c1473 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -6,22 +6,18 @@ import { resetGetRuntimeFixture, } from './interaction-get-runtime-fixture.ts'; import fs from 'node:fs'; -import path from 'node:path'; import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; import { captureSnapshot } from '../snapshot-capture.ts'; import { SessionStore } from '../../session-store.ts'; import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; -import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { DaemonResponse, SessionState } from '../../types.ts'; import { AppError } from '@agent-device/kernel/errors'; import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; import { buildSnapshotSignatures } from '../../../snapshot/snapshot-freshness/index.ts'; import { buildInteractionSurfaceSignature } from '../../interaction-outcome-policy.ts'; import { buildSnapshotPresentationKey } from '@agent-device/kernel/snapshot'; -import { attachSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; import { snapshotCliOutput } from '../../../commands/capture/output.ts'; import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; -import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { fixtureScreenshotCaptures, fixtureSettingsMutations, @@ -29,6 +25,11 @@ import { snapshotRuntimeFixture, } from '../../__tests__/snapshot-runtime-fixture.ts'; import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; +import { + makeProviderRuntimeOwning, + makeSession, + makeSessionStore, +} from './snapshot-handler-fixture.ts'; vi.mock('../snapshot-interactor-capture.ts', async () => { const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); @@ -75,15 +76,6 @@ function handleSnapshotCommands( }); } -function makeSessionStore(): SessionStore { - const root = mkdtempForTestSync('agent-device-snapshot-handler-'); - return new SessionStore(path.join(root, 'sessions')); -} - -type SessionExtra = Partial; -function makeSession(name: string, d: SessionState['device'], extra?: SessionExtra): SessionState { - return { name, device: d, createdAt: Date.now(), actions: [], ...extra }; -} // An Apple wait runs inside an opened app: that bundle id is XCUITest's attach target, and // without one the plan asks for the without-active-app row local Apple refuses. const appAttach = (d: SessionState['device']): Partial => @@ -125,20 +117,6 @@ const providerIosDevice: SessionState['device'] = { booted: true, }; -function makeProviderRuntimeOwning( - device: SessionState['device'], - provider = 'browserstack', -): ProviderDeviceRuntime { - return { - provider, - leaseLifecycle: {}, - deviceInventoryProvider: async () => [device], - ownsDevice: (candidate) => candidate.id === device.id, - getInteractor: () => undefined, - shutdown: async () => undefined, - }; -} - afterEach(() => { setActiveProviderDeviceRuntimes([]); }); @@ -488,55 +466,6 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { expect(bindCount).toBe(1); }); -test('Limrun unknown truncation stays omitted through daemon and public output', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'limrun-ios-unknown-truncation'; - const limrunDevice: SessionState['device'] = { - platform: 'apple', - appleOs: 'ios', - id: 'limrun:ios:lease-a', - name: 'Limrun iOS', - kind: 'simulator', - target: 'mobile', - booted: true, - }; - sessionStore.set(sessionName, makeSession(sessionName, limrunDevice)); - setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(limrunDevice, 'limrun')]); - legacyDispatchCapture.mockResolvedValue( - attachSnapshotPresentationEvidence( - { - nodes: [{ index: 0, depth: 0, type: 'Application', label: 'Demo' }], - backend: 'xctest', - producer: 'limrun-ios-tree', - warnings: ['tree completeness is not independently verified'], - }, - { owner: 'ios-snapshot-engine' }, - ), - ); - - const response = await handleSnapshotCommands({ - req: { - token: 't', - session: sessionName, - command: 'snapshot', - positionals: [], - flags: {}, - }, - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response?.ok).toBe(true); - if (!response?.ok) return; - expect(response.data).not.toHaveProperty('truncated'); - - const cliOutput = await snapshotCliOutput({ - result: response.data as unknown as CaptureSnapshotResult, - }); - expect(cliOutput.jsonData).not.toHaveProperty('truncated'); -}); - test('diff on local iOS still requires a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app-diff'; diff --git a/src/daemon/handlers/__tests__/snapshot-truncation.test.ts b/src/daemon/handlers/__tests__/snapshot-truncation.test.ts new file mode 100644 index 0000000000..7898add29d --- /dev/null +++ b/src/daemon/handlers/__tests__/snapshot-truncation.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { legacyDispatchCapture } from '../../__tests__/legacy-snapshot-capture-fixture.ts'; +import { handleSnapshotCommands as handleProductionSnapshotCommands } from '../snapshot.ts'; +import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; +import { platformResourceCleanup } from '../../../platform-runtime-resource-cleanup.ts'; +import { attachSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; +import type { CaptureSnapshotResult } from '@agent-device/contracts/client'; +import { snapshotCliOutput } from '../../../commands/capture/output.ts'; +import { + makeProviderRuntimeOwning, + makeSession, + makeSessionStore, +} from './snapshot-handler-fixture.ts'; +import { + resetSnapshotRuntimeFixture, + snapshotRuntimeFixture, +} from '../../__tests__/snapshot-runtime-fixture.ts'; + +vi.mock('../snapshot-interactor-capture.ts', async () => { + const fixture = await import('../../__tests__/legacy-snapshot-capture-fixture.ts'); + return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; +}); + +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, runAppleRunnerCommand: vi.fn(async () => ({})) }; +}); + +vi.mock('../../ios-app-session-hint.ts', () => ({ + buildIosOpenCommandHint: vi.fn(async () => undefined), +})); + +afterEach(() => { + setActiveProviderDeviceRuntimes([]); +}); + +beforeEach(() => { + resetSnapshotRuntimeFixture(); + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); +}); + +test('Limrun unknown truncation stays omitted through daemon and public output', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'limrun-ios-unknown-truncation'; + const limrunDevice = { + platform: 'apple', + appleOs: 'ios', + id: 'limrun:ios:lease-a', + name: 'Limrun iOS', + kind: 'simulator', + target: 'mobile', + booted: true, + } as const; + sessionStore.set(sessionName, makeSession(sessionName, limrunDevice)); + setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(limrunDevice, 'limrun')]); + legacyDispatchCapture.mockResolvedValue( + attachSnapshotPresentationEvidence( + { + nodes: [{ index: 0, depth: 0, type: 'Application', label: 'Demo' }], + backend: 'xctest', + producer: 'limrun-ios-tree', + warnings: ['tree completeness is not independently verified'], + }, + { owner: 'ios-snapshot-engine' }, + ), + ); + + const runtime = snapshotRuntimeFixture(); + const response = await handleProductionSnapshotCommands({ + req: { + token: 't', + session: sessionName, + command: 'snapshot', + positionals: [], + flags: {}, + }, + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + inspectFacts: runtime.inspectFacts, + bindDevice: runtime.bindDevice, + platformResourceCleanup, + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) return; + expect(response.data).not.toHaveProperty('truncated'); + + const cliOutput = await snapshotCliOutput({ + result: response.data as unknown as CaptureSnapshotResult, + }); + expect(cliOutput.jsonData).not.toHaveProperty('truncated'); +}); From 01e0e5c4bc97360d592fc3fbcc571b3d19674111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 2 Sep 2026 00:06:12 +0200 Subject: [PATCH 9/9] test: ratchet snapshot suite size pins --- scripts/__tests__/test-file-size-ratchet.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index a1f63a0e81..d02b0458b1 100644 --- a/scripts/__tests__/test-file-size-ratchet.test.ts +++ b/scripts/__tests__/test-file-size-ratchet.test.ts @@ -34,14 +34,14 @@ const TRIPWIRE_LINES = 1_000; // Exact current lengths. Lower a pin when its file shrinks; never raise one — extract instead. const PINNED_TEST_FILE_LINES: Readonly> = Object.freeze({ 'src/__tests__/remote-connection.test.ts': 2973, - 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2138, + 'src/daemon/handlers/__tests__/snapshot-handler.test.ts': 2120, 'src/commands/interaction/runtime/settle.test.ts': 2359, 'src/daemon/replay/internal/__tests__/session-replay-runtime-maestro.test.ts': 1963, 'packages/platform-apple/src/runner/__tests__/runner-session.test.ts': 1957, 'src/daemon/client/__tests__/daemon-client.test.ts': 1873, 'packages/platform-android/src/__tests__/snapshot.test.ts': 1435, 'packages/platform-apple/src/runner/__tests__/runner-client.test.ts': 1441, - 'src/__tests__/client.test.ts': 1592, + 'src/__tests__/client.test.ts': 1554, 'test/integration/provider-scenarios/android-lifecycle.test.ts': 1556, 'src/daemon/client/__tests__/daemon-client-lifecycle.test.ts': 1409, 'packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts': 1280,