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 8afcd9fee..d46f38c9c 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 37fa1513f..933a53729 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/client-capture.ts b/packages/contracts/src/client-capture.ts index 7d1ea71fd..9aaba506a 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/packages/contracts/src/facades/capture.ts b/packages/contracts/src/facades/capture.ts index d11cab7c8..4883e2b0a 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/ios-snapshot.ts b/packages/contracts/src/ios-snapshot.ts index 227300931..ed12cfa9f 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/contracts/src/snapshot-private-evidence.ts b/packages/contracts/src/snapshot-private-evidence.ts index 530f0c13e..43e524f02 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/provider-limrun/src/ios-interactor-snapshot.test.ts b/packages/provider-limrun/src/ios-interactor-snapshot.test.ts index 84db55aff..27a3fecbf 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 000000000..3fb31dbb0 --- /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 000000000..388bac2ab --- /dev/null +++ b/packages/provider-limrun/src/ios-snapshot-adapter.test.ts @@ -0,0 +1,132 @@ +import { expect, test, vi } from 'vitest'; +import { readSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; +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(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.', + ); + 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, + }); +}); 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 000000000..feeca7b9f --- /dev/null +++ b/packages/provider-limrun/src/ios-snapshot-adapter.ts @@ -0,0 +1,194 @@ +import type { + IosAcquisitionResidue, + IosSnapshotAcquisition, + 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, + 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 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, + 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 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 = { + producer: 'limrun-ios-tree', + intent: 'full', + hint, + nodes, + viewport, + lineage: { targetId: session.instanceId }, + residue, + }; + + try { + const presentation = presentIosSnapshot({ stage: 'acquired', acquisition }, request); + const warnings = limrunSnapshotWarnings(residue); + return attachSnapshotPresentationEvidence( + { + nodes: presentation.nodes, + backend: 'xctest', + producer: 'limrun-ios-tree', + ...(warnings.length > 0 ? { warnings } : {}), + }, + { owner: 'ios-snapshot-engine' }, + ); + } catch (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 (width === undefined || height === undefined) { + return { kind: 'missing', reason: 'not-provided' }; + } + 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, +): IosAcquisitionResidue[] { + return [ + { kind: 'unavailable-fact', fact: 'truncation' }, + ...(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 === '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.', + ); + } + const viewportWarning = limrunViewportWarning(residue); + if (viewportWarning) warnings.push(viewportWarning); + return warnings; +} + +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 }, + ...(hint ? { hint } : {}), + }, + error, + ); +} diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index b35b4b93a..932bf9840 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -21,7 +21,7 @@ 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 { toIosSelector, writeBase64File } from './snapshot.ts'; import { normalizeOptionalString } from './strings.ts'; import { awaitLimrunDeploymentOperation, @@ -254,10 +254,9 @@ 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 { + const { captureLimrunIosSnapshot } = await import('./ios-snapshot-adapter.ts'); + return await captureLimrunIosSnapshot(this.session, options); } async back(): Promise { diff --git a/scripts/__tests__/test-file-size-ratchet.test.ts b/scripts/__tests__/test-file-size-ratchet.test.ts index a1f63a0e8..d02b0458b 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, diff --git a/src/__tests__/client-snapshot-truncation.test.ts b/src/__tests__/client-snapshot-truncation.test.ts new file mode 100644 index 000000000..7a7b59861 --- /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 000000000..c84292b76 --- /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 29030fb98..f1353ca72 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 }); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index ceb362634..28b45cbce 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 8b209aa70..df3cf8d6f 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 b661c4552..a90708c88 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/core/snapshot-state.ts b/src/core/snapshot-state.ts index c5614c42a..689807e04 100644 --- a/src/core/snapshot-state.ts +++ b/src/core/snapshot-state.ts @@ -1,6 +1,7 @@ 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'; @@ -54,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 = @@ -120,8 +121,8 @@ function backendScopesAfterWire(backend: SnapshotBackend | undefined): boolean { return backend !== 'macos-helper' && backend !== 'android' && backend !== 'xctest'; } -function shouldPresentIosInteractiveSnapshot( - provenance: SnapshotStateProvenance, +function shouldPresentLegacyIosInteractiveSnapshot( + provenance: object & SnapshotStateProvenance, flags: | (Pick & Partial>) @@ -130,6 +131,7 @@ function shouldPresentIosInteractiveSnapshot( return ( provenance.backend === 'xctest' && iosSnapshotPresentationStage(provenance) === 'acquired' && + readSnapshotPresentationEvidence(provenance)?.owner !== 'ios-snapshot-engine' && flags?.snapshotInteractiveOnly === true && flags.snapshotRaw !== true ); diff --git a/src/daemon/handlers/__tests__/snapshot-capture.test.ts b/src/daemon/handlers/__tests__/snapshot-capture.test.ts index 42f8be4ff..1f11c123e 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 { attachSnapshotPresentationEvidence } from '@agent-device/contracts/capture'; 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,28 @@ 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 = attachSnapshotPresentationEvidence( + { + nodes: [{ index: 0, depth: 0, type: 'Application' }], + backend: 'xctest' as const, + producer: 'limrun-ios-tree' as const, + }, + { owner: 'ios-snapshot-engine' }, + ); + + await captureSnapshot({ + device: IOS_SIMULATOR, + session: undefined, + flags: { snapshotInteractiveOnly: true }, + logPath: '/tmp/snapshot-capture-test.log', + captureData: async () => providerResult, + }); + + expect(iosPresentation).not.toHaveBeenCalled(); +}); + test('snapshot capture preserves scope for every other platform projection', async () => { captureSnapshotWithInteractor.mockClear(); for (const [device, flags] of [ 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 000000000..53dfc8061 --- /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 f851584c0..3ad13c147 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -6,12 +6,10 @@ 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'; @@ -20,7 +18,6 @@ import { buildInteractionSurfaceSignature } from '../../interaction-outcome-poli import { buildSnapshotPresentationKey } 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'; import { fixtureScreenshotCaptures, fixtureSettingsMutations, @@ -28,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'); @@ -74,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 => @@ -124,17 +117,6 @@ const providerIosDevice: SessionState['device'] = { booted: true, }; -function makeProviderRuntimeOwning(device: SessionState['device']): ProviderDeviceRuntime { - return { - provider: 'browserstack', - leaseLifecycle: {}, - deviceInventoryProvider: async () => [device], - ownsDevice: (candidate) => candidate.id === device.id, - getInteractor: () => undefined, - shutdown: async () => undefined, - }; -} - afterEach(() => { setActiveProviderDeviceRuntimes([]); }); 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 000000000..7898add29 --- /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'); +}); diff --git a/src/daemon/result-serialization.ts b/src/daemon/result-serialization.ts index 3e71e3cd4..80f74b3d2 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 } : {}),