From b9165c7e346daaf5236743d217d8e5517f87deb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 12:04:38 +0200 Subject: [PATCH 1/3] test(ios-snapshot): cover runner-presented interactive pipeline --- .../main.swift | 13 +- .../conformance-generator.ts | 7 +- .../conformance-harness.ts | 126 +++++++- .../ios-snapshot-engine/conformance.test.ts | 2 + .../src/ios-snapshot-engine/replay.ts | 13 +- .../runner-presentation-fixtures.ts | 267 +++++++++++++++++ .../runner-presentation.test.ts | 277 +----------------- scripts/ios-snapshot-differential.test.ts | 121 +++++++- 8 files changed, 533 insertions(+), 293 deletions(-) create mode 100644 packages/capture-kit/src/ios-snapshot-engine/runner-presentation-fixtures.ts diff --git a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift index 005386ba10..6b5ea3a3ba 100644 --- a/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift +++ b/apple/snapshot-presentation/Sources/SnapshotPresentationConformance/main.swift @@ -44,6 +44,7 @@ private struct ConformanceOutput: Encodable { let name: String let outcome: String let nodes: [PresentedNode] + let qualityNodes: [PresentedNode]? let error: ConformanceError? } @@ -93,25 +94,26 @@ private func present(_ input: ConformanceInput) -> ConformanceOutput { do { let inputAcquisition = acquisition(for: input) let inputOptions = options(for: input) - let nodes: [PresentedNode] + let result: SnapshotPresentationResult if input.projection == CaptureHint.Projection.raw.rawValue { - nodes = SnapshotPresentation.presentRaw(inputAcquisition, options: inputOptions).nodes + result = SnapshotPresentation.presentRaw(inputAcquisition, options: inputOptions) } else { let policy: SnapshotVisibilityFold.Policy = input.foldPolicy == "plain-viewport" ? .plainViewport : .cursorProjected - nodes = try SnapshotPresentation.presentRegular( + result = try SnapshotPresentation.presentRegular( inputAcquisition, options: inputOptions, policy: policy - ).nodes + ) } - return ConformanceOutput(name: input.name, outcome: "success", nodes: nodes, error: nil) + return ConformanceOutput(name: input.name, outcome: "success", nodes: result.nodes, qualityNodes: result.qualityNodes, error: nil) } catch let failure as SnapshotPresentationFailure { return ConformanceOutput( name: input.name, outcome: "failure", nodes: [], + qualityNodes: nil, error: ConformanceError( code: failure.code, reason: reason(for: failure), @@ -123,6 +125,7 @@ private func present(_ input: ConformanceInput) -> ConformanceOutput { name: input.name, outcome: "failure", nodes: [], + qualityNodes: nil, error: ConformanceError( code: "IOS_SNAPSHOT_PRESENTATION_FAILED", reason: "unexpected", diff --git a/packages/capture-kit/src/ios-snapshot-engine/conformance-generator.ts b/packages/capture-kit/src/ios-snapshot-engine/conformance-generator.ts index b2cb8b9d62..2c14900393 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/conformance-generator.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/conformance-generator.ts @@ -63,8 +63,9 @@ function makeCase(seeds: ReadonlyArray): Omit): Omit; }>; type DifferentialOutcome = Readonly<{ name?: string; outcome: 'success' | 'failure'; nodes: readonly CanonicalNode[]; + rawNodes?: readonly RawSnapshotNode[]; + qualityNodes?: readonly RawSnapshotNode[]; + canonicalQualityNodes?: readonly CanonicalNode[]; error?: Readonly<{ code: string; reason: string }>; }>; @@ -71,7 +82,12 @@ export function compareDifferentialCases( const swift = swiftCases.find((entry) => entry.name === testCase.name); const typescript = runTypeScriptCase(testCase); const normalizedSwift = swift ? withoutName(swift) : undefined; - if (!normalizedSwift || JSON.stringify(normalizedSwift) !== JSON.stringify(typescript)) { + if ( + !normalizedSwift || + (testCase.route === 'acquired' + ? JSON.stringify(normalizedSwift) !== JSON.stringify(typescript) + : !runnerPresentationAgrees(testCase, swift!, typescript)) + ) { return { case: testCase, swift: normalizedSwift, typescript }; } } @@ -93,6 +109,7 @@ function runSwiftCases(cases: readonly DifferentialCase[]): DifferentialOutcome[ name: string; outcome: 'success' | 'failure'; nodes?: RawSnapshotNode[]; + qualityNodes?: RawSnapshotNode[]; error?: { code: string; reason: string }; }>; }; @@ -100,6 +117,8 @@ function runSwiftCases(cases: readonly DifferentialCase[]): DifferentialOutcome[ name: entry.name, outcome: entry.outcome, nodes: canonicalNodes(entry.nodes ?? []), + rawNodes: entry.nodes ?? [], + ...(entry.qualityNodes ? { qualityNodes: entry.qualityNodes } : {}), ...(entry.error ? { error: entry.error } : {}), })); } @@ -135,7 +154,13 @@ function prepareDifferentialAcquisition(testCase: DifferentialCase): Differentia /* c8 ignore start */ function withoutName(outcome: DifferentialOutcome): Omit { - const { name: _name, ...normalized } = outcome; + const { + name: _name, + rawNodes: _rawNodes, + qualityNodes: _qualityNodes, + canonicalQualityNodes: _canonicalQualityNodes, + ...normalized + } = outcome; return normalized; } /* c8 ignore stop */ @@ -162,7 +187,13 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco const result = presentIosSnapshot({ stage: 'acquired', acquisition }, request, { foldPolicy: testCase.foldPolicy, }); - return { outcome: 'success', nodes: canonicalNodes(result.nodes) }; + return { + outcome: 'success', + nodes: canonicalNodes(result.nodes), + ...(testCase.route === 'runner-presented' && result.qualityNodes + ? { canonicalQualityNodes: canonicalNodes(result.qualityNodes) } + : {}), + }; } catch (error) { if (!(error instanceof IosSnapshotEngineError)) throw error; return { @@ -173,6 +204,89 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco } } +function runnerPresentationAgrees( + testCase: DifferentialCase, + swift: DifferentialOutcome, + acquired: DifferentialOutcome, +): boolean { + if (swift.outcome !== acquired.outcome) return false; + if (swift.outcome === 'failure') + return JSON.stringify(swift.error) === JSON.stringify(acquired.error); + const request = createIosSnapshotRequest(testCase); + const input = { + stage: 'presented' as const, + presentation: { + producer: 'apple-runner' as const, + intent: 'full' as const, + payload: { nodes: swift.rawNodes ?? [], truncated: false }, + ...(swift.qualityNodes + ? { qualityPayload: { nodes: swift.qualityNodes, truncated: false, scope: null } } + : {}), + }, + validation: { + presentationKey: buildIosSnapshotPresentationKey(request), + viewport: { kind: 'reported' as const, rect: testCase.viewport }, + hittability: { kind: 'available' as const }, + lineage: { targetId: 'differential-target', generation: 'differential-generation' }, + residue: [], + }, + }; + const presented = presentIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy }); + const published = canonicalNodes( + publishIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy }).payload.nodes, + ); + if ( + testCase.scope !== null && + JSON.stringify(semanticMembership(canonicalNodes(presented.qualityNodes ?? []))) !== + JSON.stringify(semanticMembership(acquired.canonicalQualityNodes ?? [])) + ) + return false; + const outputIndexes = new Set(presented.nodes.map((node) => node.index)); + if ( + (swift.rawNodes ?? []).some((node) => !presented.presentedIndexesBySourceIndex.has(node.index)) + ) + return false; + for (const indexes of presented.presentedIndexesBySourceIndex.values()) { + if (indexes.some((index) => !outputIndexes.has(index))) return false; + } + for (const label of testCase.requiredLabels ?? []) { + const sources = swift.rawNodes?.filter((node) => node.label === label) ?? []; + if ( + sources.length === 0 || + !sources.some( + (source) => (presented.presentedIndexesBySourceIndex.get(source.index) ?? []).length > 0, + ) + ) + return false; + if (!published.some((node) => node.label === label)) return false; + } + if ((testCase.absentLabels ?? []).some((label) => published.some((node) => node.label === label))) + return false; + if ( + testCase.clippedLabel && + !published.some( + (node) => + node.label === testCase.clippedLabel!.label && + JSON.stringify(node.rect) === JSON.stringify(testCase.clippedLabel!.rect), + ) + ) + return false; + return ( + JSON.stringify(semanticMembership(published)) === + JSON.stringify(semanticMembership(acquired.nodes)) + ); +} + +function semanticMembership(nodes: readonly CanonicalNode[]): string[] { + return [ + ...new Set( + nodes + .filter((node) => node.label !== null) + .map((node) => JSON.stringify([node.type, node.label])), + ), + ].sort(); +} + export function canonicalNodes(nodes: readonly RawSnapshotNode[]): CanonicalNode[] { return nodes.map((node) => ({ index: node.index, 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 76d4c27bbe..9c45c46c86 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts @@ -119,6 +119,7 @@ test('the differential TypeScript runner preserves typed failures', () => { assert.ok(source); const result = runTypeScriptCase({ name: source.name, + route: 'acquired', projection: source.projection, interactiveOnly: false, depth: source.depth, @@ -136,6 +137,7 @@ test('differential failure artifacts preserve replay metadata', () => { const source = fixture.cases[0]!; const testCase = { name: source.name, + route: 'acquired' as const, projection: source.projection, interactiveOnly: false as const, depth: source.depth, diff --git a/packages/capture-kit/src/ios-snapshot-engine/replay.ts b/packages/capture-kit/src/ios-snapshot-engine/replay.ts index e75d183c25..58d58e0af0 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/replay.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/replay.ts @@ -2,18 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { compareDifferentialCases } from './conformance-harness.ts'; -import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot'; - -type DifferentialCase = Readonly<{ - name: string; - projection: 'regular' | 'raw'; - interactiveOnly: false; - depth: number | null; - scope: string | null; - foldPolicy: 'cursor-projected' | 'plain-viewport'; - viewport: Rect; - nodes: readonly RawSnapshotNode[]; -}>; +type DifferentialCase = Parameters[0][number]; const casePath = process.argv[2]; if (!casePath) { diff --git a/packages/capture-kit/src/ios-snapshot-engine/runner-presentation-fixtures.ts b/packages/capture-kit/src/ios-snapshot-engine/runner-presentation-fixtures.ts new file mode 100644 index 0000000000..815df9ef57 --- /dev/null +++ b/packages/capture-kit/src/ios-snapshot-engine/runner-presentation-fixtures.ts @@ -0,0 +1,267 @@ +import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot'; + +export const viewport: Rect = { x: 0, y: 0, width: 402, height: 874 }; + +export function runnerNodes(): RawSnapshotNode[] { + return [ + runnerNode(0, 'Application', 'Settings', viewport), + runnerNode(1, 'Other', undefined, viewport, 0, 1), + runnerNode(2, 'CollectionView', 'Settings', viewport, 1, 2), + runnerNode( + 3, + 'Cell', + 'Screen Time', + { x: 16, y: 796.3333333333334, width: 370, height: 52 }, + 2, + 3, + ), + runnerNode( + 4, + 'Other', + 'Screen Time', + { x: 16, y: 796.3333333333334, width: 370, height: 52 }, + 3, + 4, + ), + runnerNode( + 5, + 'Button', + 'Screen Time', + { x: 16, y: 796.3333333333334, width: 370, height: 52 }, + 4, + 5, + ), + runnerNode( + 6, + 'StaticText', + 'Screen Time', + { x: 30, y: 808.3333, width: 137.3333, height: 28 }, + 5, + 6, + ), + runnerNode(7, 'Image', undefined, { x: 30, y: 808.3333333333334, width: 28, height: 28 }, 5, 6), + runnerNode(8, 'Cell', 'Offscreen', { x: 16, y: 820, width: 370, height: 52 }, 2, 3), + runnerNode(9, 'Button', 'Offscreen', { x: 16, y: 820, width: 370, height: 52 }, 8, 4), + { + ...runnerNode( + 10, + 'Other', + 'Vertical scroll bar, 2 pages', + { + x: 369, + y: 116, + width: 30, + height: 696, + }, + 2, + 3, + ), + value: '0%', + }, + ]; +} + +/** + * A post thread as XCTest reports it: the list's own indicator is its child, while the root post's + * selectable text is a `TextView` (a UIScrollView underneath) carrying an indicator of its own. + */ +export function threadNodes(): RawSnapshotNode[] { + const rowRect = { x: 16, y: 180, width: 370, height: 22 }; + return [ + runnerNode(0, 'Application', 'Blue Sky', viewport), + runnerNode( + 1, + 'ScrollView', + 'Vertical scroll bar, 5 pages', + { x: 0, y: 110, width: 402, height: 764 }, + 0, + 1, + ), + runnerNode(2, 'Other', 'Bob', { x: 0, y: 110, width: 402, height: 764 }, 1, 2), + runnerNode(3, 'Other', 'Thread root', rowRect, 2, 3), + runnerNode(4, 'TextView', 'Thread root', rowRect, 3, 4), + { + ...runnerNode( + 5, + 'Other', + 'Vertical scroll bar, 1 page', + { x: 353, y: 180, width: 30, height: 22 }, + 4, + 5, + ), + value: '0%', + }, + runnerNode(6, 'Button', 'Reply (58 replies)', { x: 9, y: 232, width: 54, height: 32 }, 2, 3), + runnerNode(7, 'Link', 'Reply 37', { x: 16, y: 274, width: 370, height: 165 }, 2, 3), + runnerNode(8, 'Button', 'Like (0 likes)', { x: 209, y: 412, width: 28, height: 28 }, 7, 4), + { + ...runnerNode( + 9, + 'Other', + 'Vertical scroll bar, 5 pages', + { x: 369, y: 110, width: 30, height: 702 }, + 1, + 2, + ), + value: '0%', + }, + ]; +} + +/** + * A list whose row is a scroll-shaped host that publishes as a non-scroll type. The host carries an + * indicator of its own (a one-page band over the row), while the list's own indicator reports the real + * multi-page track. Correct ownership keeps the list's band and every row below the host. + */ +export function hostRowNodes(hostType: string): RawSnapshotNode[] { + const rowRect = { x: 16, y: 180, width: 370, height: 22 }; + return [ + runnerNode(0, 'Application', 'Reader', viewport), + runnerNode( + 1, + 'ScrollView', + 'Vertical scroll bar, 3 pages', + { x: 0, y: 110, width: 402, height: 764 }, + 0, + 1, + ), + runnerNode(2, 'Other', 'Article', { x: 0, y: 110, width: 402, height: 764 }, 1, 2), + runnerNode(3, hostType, 'Page', rowRect, 2, 3), + { + ...runnerNode( + 4, + 'Other', + 'Vertical scroll bar, 1 page', + { x: 353, y: 180, width: 30, height: 22 }, + 3, + 4, + ), + value: '0%', + }, + runnerNode(5, 'Button', 'Reply (58 replies)', { x: 9, y: 232, width: 54, height: 32 }, 2, 3), + runnerNode(6, 'Link', 'Reply 37', { x: 16, y: 274, width: 370, height: 165 }, 2, 3), + runnerNode(7, 'Button', 'Like (0 likes)', { x: 209, y: 412, width: 28, height: 28 }, 6, 4), + { + ...runnerNode( + 8, + 'Other', + 'Vertical scroll bar, 3 pages', + { x: 369, y: 110, width: 30, height: 702 }, + 1, + 2, + ), + value: '0%', + }, + ]; +} + +/** + * A list that holds a nested scroll host (a `ScrollView`) which itself carries a scroll-bar label and + * a percent value. That node describes itself, so it owns nothing; the list's real multi-page band + * comes from its own indicator, keeping `Row low`. + */ +export function nestedScrollIndicatorNodes(): RawSnapshotNode[] { + return [ + runnerNode(0, 'Application', 'Reader', viewport), + runnerNode(1, 'Table', 'Feed', { x: 0, y: 40, width: 402, height: 800 }, 0, 1), + { + ...runnerNode( + 2, + 'ScrollView', + 'Vertical scroll bar, 2 pages', + { x: 0, y: 300, width: 402, height: 40 }, + 1, + 2, + ), + value: '50%', + }, + runnerNode(3, 'Button', 'Row high', { x: 16, y: 60, width: 370, height: 40 }, 1, 2), + runnerNode(4, 'Button', 'Row low', { x: 16, y: 700, width: 370, height: 40 }, 1, 2), + { + ...runnerNode( + 5, + 'Other', + 'Vertical scroll bar, 3 pages', + { x: 369, y: 116, width: 30, height: 696 }, + 1, + 2, + ), + value: '0%', + }, + ]; +} + +/** + * A list (`ScrollView`, {0,110,402,764}) holding a smaller `Card` sub-region ({0,300,402,400}) that + * nests an `Inner` wrapper of the same sub-frame and the indicator. The walk climbs `Inner` → `Card` + * (same frame) but then hits the frame change against `ScrollView`, so the indicator resolves no owner + * and `ScrollView` keeps its full extent — the frame change, not a label, is what stops ownership. + */ +export function frameChangeIndicatorNodes(): RawSnapshotNode[] { + return [ + runnerNode(0, 'Application', 'Reader', viewport), + runnerNode(1, 'ScrollView', 'Feed', { x: 0, y: 110, width: 402, height: 764 }, 0, 1), + runnerNode(2, 'Other', 'Card', { x: 0, y: 300, width: 402, height: 400 }, 1, 2), + runnerNode(3, 'Other', 'Inner', { x: 0, y: 300, width: 402, height: 400 }, 2, 3), + { + ...runnerNode( + 4, + 'Other', + 'Vertical scroll bar, 3 pages', + { x: 369, y: 300, width: 30, height: 400 }, + 3, + 4, + ), + value: '0%', + }, + runnerNode(5, 'Button', 'Row above', { x: 16, y: 120, width: 370, height: 40 }, 1, 2), + runnerNode(6, 'Button', 'Row below', { x: 16, y: 800, width: 370, height: 40 }, 1, 2), + ]; +} + +/** + * Reduced from a real Safari `snapshot -i --raw` capture (indices mirror the live tree): the page + * scroller `ScrollView` holds an `Other` → `WebView` → `WebView` chain, and the page's indicator is + * published under the inner `WebView`. A link sits below where the walk-derived band would land. + */ +export function safariWebViewNodes(): RawSnapshotNode[] { + return [ + runnerNode(0, 'Application', 'Safari', viewport), + runnerNode(1, 'ScrollView', 'iOS - Wikipedia', { x: 0, y: 0, width: 402, height: 874 }, 0, 1), + runnerNode(2, 'Other', undefined, { x: 0, y: 0, width: 402, height: 874 }, 1, 2), + runnerNode(3, 'WebView', undefined, { x: 0, y: 0, width: 402, height: 874 }, 2, 3), + runnerNode(4, 'WebView', undefined, { x: 0, y: 0, width: 402, height: 874 }, 3, 4), + { + ...runnerNode( + 5, + 'Other', + 'Vertical scroll bar, 6 pages', + { x: 369, y: 62, width: 30, height: 750 }, + 4, + 5, + ), + value: '0%', + }, + runnerNode(6, 'Link', 'History', { x: 16, y: 820, width: 370, height: 28 }, 1, 2), + ]; +} + +function runnerNode( + index: number, + type: string, + label: string | undefined, + rect: Rect, + parentIndex?: number, + depth = parentIndex === undefined ? 0 : 1, +): RawSnapshotNode { + return { + index, + type, + ...(label ? { label } : {}), + rect, + enabled: true, + hittable: true, + depth, + ...(parentIndex === undefined ? {} : { parentIndex }), + }; +} diff --git a/packages/capture-kit/src/ios-snapshot-engine/runner-presentation.test.ts b/packages/capture-kit/src/ios-snapshot-engine/runner-presentation.test.ts index f655b4c935..c71ec0df32 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/runner-presentation.test.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/runner-presentation.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; +import type { RawSnapshotNode } from '@agent-device/kernel/snapshot'; import type { IosSnapshotInput, IosSnapshotRequest, @@ -9,10 +10,16 @@ import { buildIosSnapshotPresentationKey, createIosSnapshotRequest, } from '@agent-device/capture-kit/ios-snapshot-planning'; -import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot'; import { presentIosSnapshot } from './index.ts'; - -const viewport: Rect = { x: 0, y: 0, width: 402, height: 874 }; +import { + frameChangeIndicatorNodes, + hostRowNodes, + nestedScrollIndicatorNodes, + runnerNodes, + safariWebViewNodes, + threadNodes, + viewport, +} from './runner-presentation-fixtures.ts'; test('runner presentation clips rows to a scroll viewport derived from its indicator', () => { const request = createIosSnapshotRequest({ interactiveOnly: true }); @@ -167,267 +174,3 @@ function validationFacts(request: IosSnapshotRequest): IosSnapshotValidationFact residue: [], }; } - -function runnerNodes(): RawSnapshotNode[] { - return [ - runnerNode(0, 'Application', 'Settings', viewport), - runnerNode(1, 'Other', undefined, viewport, 0, 1), - runnerNode(2, 'CollectionView', 'Settings', viewport, 1, 2), - runnerNode( - 3, - 'Cell', - 'Screen Time', - { x: 16, y: 796.3333333333334, width: 370, height: 52 }, - 2, - 3, - ), - runnerNode( - 4, - 'Other', - 'Screen Time', - { x: 16, y: 796.3333333333334, width: 370, height: 52 }, - 3, - 4, - ), - runnerNode( - 5, - 'Button', - 'Screen Time', - { x: 16, y: 796.3333333333334, width: 370, height: 52 }, - 4, - 5, - ), - runnerNode( - 6, - 'StaticText', - 'Screen Time', - { x: 30, y: 808.3333, width: 137.3333, height: 28 }, - 5, - 6, - ), - runnerNode(7, 'Image', undefined, { x: 30, y: 808.3333333333334, width: 28, height: 28 }, 5, 6), - runnerNode(8, 'Cell', 'Offscreen', { x: 16, y: 820, width: 370, height: 52 }, 2, 3), - runnerNode(9, 'Button', 'Offscreen', { x: 16, y: 820, width: 370, height: 52 }, 8, 4), - { - ...runnerNode( - 10, - 'Other', - 'Vertical scroll bar, 2 pages', - { - x: 369, - y: 116, - width: 30, - height: 696, - }, - 2, - 3, - ), - value: '0%', - }, - ]; -} - -/** - * A post thread as XCTest reports it: the list's own indicator is its child, while the root post's - * selectable text is a `TextView` (a UIScrollView underneath) carrying an indicator of its own. - */ -function threadNodes(): RawSnapshotNode[] { - const rowRect = { x: 16, y: 180, width: 370, height: 22 }; - return [ - runnerNode(0, 'Application', 'Blue Sky', viewport), - runnerNode( - 1, - 'ScrollView', - 'Vertical scroll bar, 5 pages', - { x: 0, y: 110, width: 402, height: 764 }, - 0, - 1, - ), - runnerNode(2, 'Other', 'Bob', { x: 0, y: 110, width: 402, height: 764 }, 1, 2), - runnerNode(3, 'Other', 'Thread root', rowRect, 2, 3), - runnerNode(4, 'TextView', 'Thread root', rowRect, 3, 4), - { - ...runnerNode( - 5, - 'Other', - 'Vertical scroll bar, 1 page', - { x: 353, y: 180, width: 30, height: 22 }, - 4, - 5, - ), - value: '0%', - }, - runnerNode(6, 'Button', 'Reply (58 replies)', { x: 9, y: 232, width: 54, height: 32 }, 2, 3), - runnerNode(7, 'Link', 'Reply 37', { x: 16, y: 274, width: 370, height: 165 }, 2, 3), - runnerNode(8, 'Button', 'Like (0 likes)', { x: 209, y: 412, width: 28, height: 28 }, 7, 4), - { - ...runnerNode( - 9, - 'Other', - 'Vertical scroll bar, 5 pages', - { x: 369, y: 110, width: 30, height: 702 }, - 1, - 2, - ), - value: '0%', - }, - ]; -} - -/** - * A list whose row is a scroll-shaped host that publishes as a non-scroll type. The host carries an - * indicator of its own (a one-page band over the row), while the list's own indicator reports the real - * multi-page track. Correct ownership keeps the list's band and every row below the host. - */ -function hostRowNodes(hostType: string): RawSnapshotNode[] { - const rowRect = { x: 16, y: 180, width: 370, height: 22 }; - return [ - runnerNode(0, 'Application', 'Reader', viewport), - runnerNode( - 1, - 'ScrollView', - 'Vertical scroll bar, 3 pages', - { x: 0, y: 110, width: 402, height: 764 }, - 0, - 1, - ), - runnerNode(2, 'Other', 'Article', { x: 0, y: 110, width: 402, height: 764 }, 1, 2), - runnerNode(3, hostType, 'Page', rowRect, 2, 3), - { - ...runnerNode( - 4, - 'Other', - 'Vertical scroll bar, 1 page', - { x: 353, y: 180, width: 30, height: 22 }, - 3, - 4, - ), - value: '0%', - }, - runnerNode(5, 'Button', 'Reply (58 replies)', { x: 9, y: 232, width: 54, height: 32 }, 2, 3), - runnerNode(6, 'Link', 'Reply 37', { x: 16, y: 274, width: 370, height: 165 }, 2, 3), - runnerNode(7, 'Button', 'Like (0 likes)', { x: 209, y: 412, width: 28, height: 28 }, 6, 4), - { - ...runnerNode( - 8, - 'Other', - 'Vertical scroll bar, 3 pages', - { x: 369, y: 110, width: 30, height: 702 }, - 1, - 2, - ), - value: '0%', - }, - ]; -} - -/** - * A list that holds a nested scroll host (a `ScrollView`) which itself carries a scroll-bar label and - * a percent value. That node describes itself, so it owns nothing; the list's real multi-page band - * comes from its own indicator, keeping `Row low`. - */ -function nestedScrollIndicatorNodes(): RawSnapshotNode[] { - return [ - runnerNode(0, 'Application', 'Reader', viewport), - runnerNode(1, 'Table', 'Feed', { x: 0, y: 40, width: 402, height: 800 }, 0, 1), - { - ...runnerNode( - 2, - 'ScrollView', - 'Vertical scroll bar, 2 pages', - { x: 0, y: 300, width: 402, height: 40 }, - 1, - 2, - ), - value: '50%', - }, - runnerNode(3, 'Button', 'Row high', { x: 16, y: 60, width: 370, height: 40 }, 1, 2), - runnerNode(4, 'Button', 'Row low', { x: 16, y: 700, width: 370, height: 40 }, 1, 2), - { - ...runnerNode( - 5, - 'Other', - 'Vertical scroll bar, 3 pages', - { x: 369, y: 116, width: 30, height: 696 }, - 1, - 2, - ), - value: '0%', - }, - ]; -} - -/** - * A list (`ScrollView`, {0,110,402,764}) holding a smaller `Card` sub-region ({0,300,402,400}) that - * nests an `Inner` wrapper of the same sub-frame and the indicator. The walk climbs `Inner` → `Card` - * (same frame) but then hits the frame change against `ScrollView`, so the indicator resolves no owner - * and `ScrollView` keeps its full extent — the frame change, not a label, is what stops ownership. - */ -function frameChangeIndicatorNodes(): RawSnapshotNode[] { - return [ - runnerNode(0, 'Application', 'Reader', viewport), - runnerNode(1, 'ScrollView', 'Feed', { x: 0, y: 110, width: 402, height: 764 }, 0, 1), - runnerNode(2, 'Other', 'Card', { x: 0, y: 300, width: 402, height: 400 }, 1, 2), - runnerNode(3, 'Other', 'Inner', { x: 0, y: 300, width: 402, height: 400 }, 2, 3), - { - ...runnerNode( - 4, - 'Other', - 'Vertical scroll bar, 3 pages', - { x: 369, y: 300, width: 30, height: 400 }, - 3, - 4, - ), - value: '0%', - }, - runnerNode(5, 'Button', 'Row above', { x: 16, y: 120, width: 370, height: 40 }, 1, 2), - runnerNode(6, 'Button', 'Row below', { x: 16, y: 800, width: 370, height: 40 }, 1, 2), - ]; -} - -/** - * Reduced from a real Safari `snapshot -i --raw` capture (indices mirror the live tree): the page - * scroller `ScrollView` holds an `Other` → `WebView` → `WebView` chain, and the page's indicator is - * published under the inner `WebView`. A link sits below where the walk-derived band would land. - */ -function safariWebViewNodes(): RawSnapshotNode[] { - return [ - runnerNode(0, 'Application', 'Safari', viewport), - runnerNode(1, 'ScrollView', 'iOS - Wikipedia', { x: 0, y: 0, width: 402, height: 874 }, 0, 1), - runnerNode(2, 'Other', undefined, { x: 0, y: 0, width: 402, height: 874 }, 1, 2), - runnerNode(3, 'WebView', undefined, { x: 0, y: 0, width: 402, height: 874 }, 2, 3), - runnerNode(4, 'WebView', undefined, { x: 0, y: 0, width: 402, height: 874 }, 3, 4), - { - ...runnerNode( - 5, - 'Other', - 'Vertical scroll bar, 6 pages', - { x: 369, y: 62, width: 30, height: 750 }, - 4, - 5, - ), - value: '0%', - }, - runnerNode(6, 'Link', 'History', { x: 16, y: 820, width: 370, height: 28 }, 1, 2), - ]; -} - -function runnerNode( - index: number, - type: string, - label: string | undefined, - rect: Rect, - parentIndex?: number, - depth = parentIndex === undefined ? 0 : 1, -): RawSnapshotNode { - return { - index, - type, - ...(label ? { label } : {}), - rect, - enabled: true, - hittable: true, - depth, - ...(parentIndex === undefined ? {} : { parentIndex }), - }; -} diff --git a/scripts/ios-snapshot-differential.test.ts b/scripts/ios-snapshot-differential.test.ts index 52cecb85ca..0653b13dbd 100644 --- a/scripts/ios-snapshot-differential.test.ts +++ b/scripts/ios-snapshot-differential.test.ts @@ -13,6 +13,13 @@ import { } from '../packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts'; import { differentialBatchArbitrary } from '../packages/capture-kit/src/ios-snapshot-engine/conformance-generator.ts'; import { readIosSnapshotEngineFixture } from '../packages/capture-kit/src/ios-snapshot-engine/conformance-fixture.ts'; +import { + hostRowNodes, + runnerNodes, + safariWebViewNodes, + threadNodes, + viewport as capturedViewport, +} from '../packages/capture-kit/src/ios-snapshot-engine/runner-presentation-fixtures.ts'; type DifferentialCase = Parameters[0][number]; @@ -20,6 +27,17 @@ const FUZZ_SEEDS = [219101, 219102, 219103, 219104]; const RUNS_PER_SEED = 8; const MAX_TOTAL_DURATION_MS = 60_000; +const RUNNER_UNSUPPORTED: Readonly> = { + 'geometryless cursor nodes keep independent descendants': + 'Swift conformance input requires a rectangle for every node', + 'unavailable hittability fails closed': + 'Swift conformance input requires a resolved hittability bit', + 'malformed parent is a typed failure': 'Swift presenter takes a validated preorder graph', + 'missing viewport is a typed failure': + 'runner route requires a reported viewport for regular payload validation', + 'invalid viewport is a typed failure': 'runner route requires a positive reported viewport', +}; + if (!swiftToolchainAvailable()) { throw new Error('iOS snapshot differential requires the macOS Swift toolchain'); } @@ -27,11 +45,12 @@ if (!swiftToolchainAvailable()) { test('authored Swift and TypeScript golden cases agree', { timeout: SWIFT_RUN_TIMEOUT_MS }, () => { const fixture = readIosSnapshotEngineFixture(); const cases = fixture.cases - .filter((testCase) => testCase.swift && !testCase.interactiveOnly) + .filter((testCase) => testCase.swift) .map((testCase) => ({ name: testCase.name, + route: 'acquired' as const, projection: testCase.projection, - interactiveOnly: false as const, + interactiveOnly: testCase.interactiveOnly, depth: testCase.depth, scope: testCase.scope, foldPolicy: testCase.foldPolicy, @@ -42,6 +61,103 @@ test('authored Swift and TypeScript golden cases agree', { timeout: SWIFT_RUN_TI assert.equal(mismatch, undefined, mismatch ? JSON.stringify(mismatch, null, 2) : ''); }); +test( + 'Swift presenter and host compaction preserve authored semantic membership', + { timeout: SWIFT_RUN_TIMEOUT_MS }, + () => { + const fixture = readIosSnapshotEngineFixture(); + assert.deepEqual( + fixture.cases + .filter( + (testCase) => + !testCase.swift && + ![ + 'interactive only compacts semantic representatives', + 'scroll indicator owned by a parent web view keeps list rows', + ].includes(testCase.name), + ) + .map((testCase) => testCase.name), + Object.keys(RUNNER_UNSUPPORTED), + 'every omitted authored case needs a declared Swift/runner asymmetry', + ); + assert.ok(Object.values(RUNNER_UNSUPPORTED).every((reason) => reason.length > 0)); + const cases = fixture.cases + .filter( + (testCase) => + testCase.swift || + [ + 'interactive only compacts semantic representatives', + 'scroll indicator owned by a parent web view keeps list rows', + ].includes(testCase.name), + ) + .map((testCase) => ({ + name: testCase.name, + route: 'runner-presented' as const, + projection: testCase.projection, + interactiveOnly: testCase.interactiveOnly, + depth: testCase.depth, + scope: testCase.scope, + foldPolicy: testCase.foldPolicy, + viewport: fixture.viewport, + nodes: testCase.nodes, + })); + const mismatch = compareDifferentialCases(cases); + assert.equal(mismatch, undefined, mismatch ? JSON.stringify(mismatch, null, 2) : ''); + }, +); + +const CAPTURED_INTERACTIVE_CASES = [ + { + name: 'Settings chrome', + nodes: runnerNodes(), + requiredLabels: ['Screen Time'], + absentLabels: ['Offscreen'], + }, + { + name: 'TextView control', + nodes: threadNodes(), + requiredLabels: ['Reply (58 replies)', 'Reply 37', 'Like (0 likes)'], + }, + { + name: 'WebView row', + nodes: hostRowNodes('WebView'), + requiredLabels: ['Reply (58 replies)', 'Reply 37', 'Like (0 likes)'], + }, + { + name: 'paged Cell row', + nodes: hostRowNodes('Cell'), + requiredLabels: ['Reply (58 replies)', 'Reply 37', 'Like (0 likes)'], + }, + { + name: 'Safari same-frame wrapper', + nodes: safariWebViewNodes(), + absentLabels: ['History'], + clippedLabel: { label: 'iOS - Wikipedia', rect: { x: 0, y: 62, width: 402, height: 750 } }, + }, +] as const; + +for (const capture of CAPTURED_INTERACTIVE_CASES) { + test( + `runner-presented interactive ${capture.name} preserves membership and clipping`, + { timeout: SWIFT_RUN_TIMEOUT_MS }, + () => { + const mismatch = compareDifferentialCases([ + { + ...capture, + route: 'runner-presented', + projection: 'regular', + interactiveOnly: true, + depth: null, + scope: null, + foldPolicy: 'cursor-projected', + viewport: capturedViewport, + }, + ]); + assert.equal(mismatch, undefined, mismatch ? JSON.stringify(mismatch, null, 2) : ''); + }, + ); +} + test( 'raw unscoped depth compares the same acquisition frontier', { timeout: SWIFT_RUN_TIMEOUT_MS }, @@ -56,6 +172,7 @@ test( const mismatch = compareDifferentialCases([ { name: 'raw-depth-frontier-with-malformed-tail', + route: 'acquired', projection: 'raw', interactiveOnly: false, depth: 1, From da86813a985549cff6fa62106a0392389b38e3bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 12:10:15 +0200 Subject: [PATCH 2/3] refactor(ios-snapshot): isolate differential route assertions --- .../conformance-harness.ts | 132 ++++++++++++++---- 1 file changed, 102 insertions(+), 30 deletions(-) diff --git a/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts b/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts index 39b0ce9283..16c3d6ce48 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts @@ -212,6 +212,17 @@ function runnerPresentationAgrees( if (swift.outcome !== acquired.outcome) return false; if (swift.outcome === 'failure') return JSON.stringify(swift.error) === JSON.stringify(acquired.error); + const { presented, published } = runRunnerComposition(testCase, swift); + return runnerOutputAgrees(testCase, swift, acquired, presented, published); +} + +function runRunnerComposition( + testCase: DifferentialCase, + swift: DifferentialOutcome, +): Readonly<{ + presented: ReturnType; + published: readonly CanonicalNode[]; +}> { const request = createIosSnapshotRequest(testCase); const input = { stage: 'presented' as const, @@ -235,45 +246,106 @@ function runnerPresentationAgrees( const published = canonicalNodes( publishIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy }).payload.nodes, ); - if ( - testCase.scope !== null && - JSON.stringify(semanticMembership(canonicalNodes(presented.qualityNodes ?? []))) !== - JSON.stringify(semanticMembership(acquired.canonicalQualityNodes ?? [])) - ) - return false; - const outputIndexes = new Set(presented.nodes.map((node) => node.index)); - if ( - (swift.rawNodes ?? []).some((node) => !presented.presentedIndexesBySourceIndex.has(node.index)) - ) + return { presented, published }; +} + +function runnerOutputAgrees( + testCase: DifferentialCase, + swift: DifferentialOutcome, + acquired: DifferentialOutcome, + presented: ReturnType, + published: readonly CanonicalNode[], +): boolean { + return ( + qualityMembershipAgrees(testCase, presented.qualityNodes, acquired.canonicalQualityNodes) && + representativesAreValid(swift.rawNodes ?? [], presented) && + capturedMembershipAgrees(testCase, swift.rawNodes ?? [], presented, published) && + semanticMembershipAgrees(published, acquired.nodes) + ); +} + +function capturedMembershipAgrees( + testCase: DifferentialCase, + sources: readonly RawSnapshotNode[], + presented: ReturnType, + published: readonly CanonicalNode[], +): boolean { + return ( + requiredLabelsHaveRepresentatives( + testCase.requiredLabels ?? [], + sources, + presented, + published, + ) && + (testCase.absentLabels ?? []).every( + (label) => !published.some((node) => node.label === label), + ) && + clippedLabelAgrees(testCase.clippedLabel, published) + ); +} + +function semanticMembershipAgrees( + left: readonly CanonicalNode[], + right: readonly CanonicalNode[], +): boolean { + return JSON.stringify(semanticMembership(left)) === JSON.stringify(semanticMembership(right)); +} + +function qualityMembershipAgrees( + testCase: DifferentialCase, + actual: readonly RawSnapshotNode[] | undefined, + expected: readonly CanonicalNode[] | undefined, +): boolean { + return ( + testCase.scope === null || + JSON.stringify(semanticMembership(canonicalNodes(actual ?? []))) === + JSON.stringify(semanticMembership(expected ?? [])) + ); +} + +function representativesAreValid( + sources: readonly RawSnapshotNode[], + presentation: ReturnType, +): boolean { + const outputIndexes = new Set(presentation.nodes.map((node) => node.index)); + if (sources.some((node) => !presentation.presentedIndexesBySourceIndex.has(node.index))) return false; - for (const indexes of presented.presentedIndexesBySourceIndex.values()) { - if (indexes.some((index) => !outputIndexes.has(index))) return false; - } - for (const label of testCase.requiredLabels ?? []) { - const sources = swift.rawNodes?.filter((node) => node.label === label) ?? []; + return [...presentation.presentedIndexesBySourceIndex.values()].every((indexes) => + indexes.every((index) => outputIndexes.has(index)), + ); +} + +function requiredLabelsHaveRepresentatives( + requiredLabels: readonly string[], + sources: readonly RawSnapshotNode[], + presentation: ReturnType, + published: readonly CanonicalNode[], +): boolean { + for (const label of requiredLabels) { + const matchingSources = sources.filter((node) => node.label === label); if ( - sources.length === 0 || - !sources.some( - (source) => (presented.presentedIndexesBySourceIndex.get(source.index) ?? []).length > 0, + matchingSources.length === 0 || + !matchingSources.some( + (source) => (presentation.presentedIndexesBySourceIndex.get(source.index) ?? []).length > 0, ) ) return false; if (!published.some((node) => node.label === label)) return false; } - if ((testCase.absentLabels ?? []).some((label) => published.some((node) => node.label === label))) - return false; - if ( - testCase.clippedLabel && - !published.some( + return true; +} + +function clippedLabelAgrees( + expected: DifferentialCase['clippedLabel'], + published: readonly CanonicalNode[], +): boolean { + return ( + !expected || + published.some( (node) => - node.label === testCase.clippedLabel!.label && - JSON.stringify(node.rect) === JSON.stringify(testCase.clippedLabel!.rect), + node.label === expected.label && + JSON.stringify(node.rect) === JSON.stringify(expected.rect), ) - ) - return false; - return ( - JSON.stringify(semanticMembership(published)) === - JSON.stringify(semanticMembership(acquired.nodes)) ); } From b934eb23dc63293fa9451be510956f5cb6963196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 28 Sep 2026 12:45:08 +0200 Subject: [PATCH 3/3] test(ios-snapshot): cover runner comparison parity --- .../conformance-harness.ts | 2 +- .../ios-snapshot-engine/conformance.test.ts | 95 ++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts b/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts index 16c3d6ce48..7c5320dc60 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts @@ -204,7 +204,7 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco } } -function runnerPresentationAgrees( +export function runnerPresentationAgrees( testCase: DifferentialCase, swift: DifferentialOutcome, acquired: DifferentialOutcome, 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 9c45c46c86..1a7e6a717b 100644 --- a/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts +++ b/packages/capture-kit/src/ios-snapshot-engine/conformance.test.ts @@ -3,7 +3,12 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import { test } from 'vitest'; import { IosSnapshotEngineError, presentIosSnapshot, publishIosSnapshot } from './index.ts'; -import { runTypeScriptCase, writeDifferentialFailureArtifact } from './conformance-harness.ts'; +import { + canonicalNodes, + runTypeScriptCase, + runnerPresentationAgrees, + writeDifferentialFailureArtifact, +} from './conformance-harness.ts'; import { acquisitionForGoldenCase, normalizeGoldenNodes, @@ -11,6 +16,13 @@ import { requestForGoldenCase, } from './conformance-fixture.ts'; +function runnerCase(name: string) { + const fixture = readIosSnapshotEngineFixture(); + const source = fixture.cases.find((testCase) => testCase.name === name); + assert.ok(source); + return { ...source, route: 'runner-presented' as const, viewport: fixture.viewport }; +} + test('the authored iOS snapshot corpus covers each contract seam', () => { const fixture = readIosSnapshotEngineFixture(); assert.equal(fixture.version, 1); @@ -132,6 +144,87 @@ test('the differential TypeScript runner preserves typed failures', () => { assert.ok(result.error?.code); }); +test('runner comparison accepts semantic delegation and rejects lost source membership', () => { + const source = runnerCase('interactive only compacts semantic representatives'); + const testCase = { + ...source, + requiredLabels: ['General'], + absentLabels: ['Missing'], + clippedLabel: { label: 'General', rect: { x: 16, y: 80, width: 288, height: 52 } }, + }; + const acquired = runTypeScriptCase(testCase); + const swift = (nodes: typeof source.nodes) => ({ + outcome: 'success' as const, + nodes: canonicalNodes(nodes), + rawNodes: nodes, + }); + + assert.equal(runnerPresentationAgrees(testCase, swift(source.nodes), acquired), true); + assert.equal( + runnerPresentationAgrees(testCase, swift(source.nodes.slice(0, 3)), acquired), + true, + 'Swift may delegate Button and StaticText to the Cell representative', + ); + assert.equal( + runnerPresentationAgrees(testCase, swift(source.nodes.slice(0, 2)), acquired), + false, + 'losing the Cell leaves General without a presented representative', + ); +}); + +test('runner comparison preserves typed failure reasons', () => { + const testCase = runnerCase('malformed parent is a typed failure'); + const acquired = runTypeScriptCase(testCase); + assert.equal(acquired.outcome, 'failure'); + assert.ok(acquired.error); + + assert.equal( + runnerPresentationAgrees( + testCase, + { outcome: 'failure', nodes: [], error: acquired.error }, + acquired, + ), + true, + ); + assert.equal( + runnerPresentationAgrees( + testCase, + { outcome: 'failure', nodes: [], error: { ...acquired.error, reason: 'missing-viewport' } }, + acquired, + ), + false, + ); + assert.equal( + runnerPresentationAgrees(testCase, { outcome: 'success', nodes: [] }, acquired), + false, + ); +}); + +test('runner comparison checks unscoped quality alongside scoped publication', () => { + const testCase = runnerCase('scope reroots wrappers and regular depth'); + const acquired = runTypeScriptCase(testCase); + assert.equal(acquired.outcome, 'success'); + const scoped = testCase.nodes.slice(2).map((node, index) => ({ + ...node, + index, + depth: index, + ...(index === 0 ? { parentIndex: undefined } : { parentIndex: 0 }), + })); + const swift = { + outcome: 'success' as const, + nodes: canonicalNodes(scoped), + rawNodes: scoped, + qualityNodes: testCase.nodes, + }; + + assert.equal(runnerPresentationAgrees(testCase, swift, acquired), true); + assert.equal( + runnerPresentationAgrees(testCase, { ...swift, qualityNodes: scoped }, acquired), + false, + 'a scoped quality payload must not lose the unscoped App and Wrapper', + ); +}); + test('differential failure artifacts preserve replay metadata', () => { const fixture = readIosSnapshotEngineFixture(); const source = fixture.cases[0]!;