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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ private struct ConformanceOutput: Encodable {
let name: String
let outcome: String
let nodes: [PresentedNode]
let qualityNodes: [PresentedNode]?
let error: ConformanceError?
}

Expand Down Expand Up @@ -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),
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ function makeCase(seeds: ReadonlyArray<NodeSeed>): Omit<DifferentialCase, 'name'
for (const [index, seed] of seeds.entries()) nodes.push(makeNode(seed, index, nodes));

return {
route: seedRoute(seeds[0]!),
projection: projectionFor(seeds[0]!),
interactiveOnly: false,
interactiveOnly: seeds[0]!.enabled,
depth: depthFor(seeds[0]!),
scope: scopeFor(seeds[0]!),
foldPolicy: foldPolicyFor(seeds[0]!),
Expand All @@ -73,6 +74,10 @@ function makeCase(seeds: ReadonlyArray<NodeSeed>): Omit<DifferentialCase, 'name'
};
}

function seedRoute(seed: NodeSeed): 'acquired' | 'runner-presented' {
return seed.y % 2 === 0 ? 'runner-presented' : 'acquired';
}

function makeNode(
seed: NodeSeed,
index: number,
Expand Down
198 changes: 192 additions & 6 deletions packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { IosSnapshotAcquisition } from '@agent-device/contracts/ios-snapshot';
import { createIosSnapshotRequest, deriveIosCaptureHint } from '../ios-snapshot-planning.ts';
import { IosSnapshotEngineError, presentIosSnapshot } from './index.ts';
import {
buildIosSnapshotPresentationKey,
createIosSnapshotRequest,
deriveIosCaptureHint,
} from '../ios-snapshot-planning.ts';
import { IosSnapshotEngineError, presentIosSnapshot, publishIosSnapshot } from './index.ts';
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';

const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..', '..', '..');
Expand All @@ -16,19 +20,26 @@ let swiftHarnessExecutable: string | undefined;

type DifferentialCase = Readonly<{
name: string;
route: 'acquired' | 'runner-presented';
projection: 'regular' | 'raw';
interactiveOnly: false;
interactiveOnly: boolean;
depth: number | null;
scope: string | null;
foldPolicy: 'cursor-projected' | 'plain-viewport';
viewport: Rect;
nodes: readonly RawSnapshotNode[];
requiredLabels?: readonly string[];
absentLabels?: readonly string[];
clippedLabel?: Readonly<{ label: string; rect: Rect }>;
}>;

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 }>;
}>;

Expand Down Expand Up @@ -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 };
}
}
Expand All @@ -93,13 +109,16 @@ function runSwiftCases(cases: readonly DifferentialCase[]): DifferentialOutcome[
name: string;
outcome: 'success' | 'failure';
nodes?: RawSnapshotNode[];
qualityNodes?: RawSnapshotNode[];
error?: { code: string; reason: string };
}>;
};
return parsed.cases.map((entry) => ({
name: entry.name,
outcome: entry.outcome,
nodes: canonicalNodes(entry.nodes ?? []),
rawNodes: entry.nodes ?? [],
...(entry.qualityNodes ? { qualityNodes: entry.qualityNodes } : {}),
...(entry.error ? { error: entry.error } : {}),
}));
}
Expand Down Expand Up @@ -135,7 +154,13 @@ function prepareDifferentialAcquisition(testCase: DifferentialCase): Differentia

/* c8 ignore start */
function withoutName(outcome: DifferentialOutcome): Omit<DifferentialOutcome, 'name'> {
const { name: _name, ...normalized } = outcome;
const {
name: _name,
rawNodes: _rawNodes,
qualityNodes: _qualityNodes,
canonicalQualityNodes: _canonicalQualityNodes,
...normalized
} = outcome;
return normalized;
}
/* c8 ignore stop */
Expand All @@ -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 {
Expand All @@ -173,6 +204,161 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco
}
}

export function runnerPresentationAgrees(
testCase: DifferentialCase,
swift: DifferentialOutcome,
acquired: DifferentialOutcome,
): boolean {
if (swift.outcome !== acquired.outcome) return false;
if (swift.outcome === 'failure')

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The failure arm of runnerPresentationAgrees returns after comparing Swift's error to the acquired TypeScript error, before runRunnerComposition ever runs. For a runner-presented corpus that fails identically on both languages, the stage: 'presented' adapter — the pipeline this PR is meant to exercise — is never invoked, so an acquired/presented failure-code mismatch (e.g., the presented adapter throwing projection-mismatch while the acquired adapter reports the same typed error) passes silently. Run the presented composition in this arm too and assert its typed error agrees, instead of returning on the error comparison alone.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts, line 213:

<comment>The failure arm of `runnerPresentationAgrees` returns after comparing Swift's error to the acquired TypeScript error, before `runRunnerComposition` ever runs. For a runner-presented corpus that fails identically on both languages, the `stage: 'presented'` adapter — the pipeline this PR is meant to exercise — is never invoked, so an acquired/presented failure-code mismatch (e.g., the presented adapter throwing `projection-mismatch` while the acquired adapter reports the same typed error) passes silently. Run the presented composition in this arm too and assert its typed error agrees, instead of returning on the error comparison alone.</comment>

<file context>
@@ -173,6 +204,161 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco
+  acquired: DifferentialOutcome,
+): boolean {
+  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);
</file context>
Fix with cubic

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<typeof presentIosSnapshot>;
published: readonly CanonicalNode[];
}> {
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,

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: runRunnerComposition presents the payload once via presentIosSnapshot and then calls publishIosSnapshot, which re-runs presentIosSnapshot internally on the same input and request (engine.ts line 31). Every runner-presented case therefore compacts and validates the full payload twice — per case, per fuzz seed. This doubles work that this test path controls directly; consider deriving the published payload from the single presentation, or documenting the double-present as intentional.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts, line 247:

<comment>`runRunnerComposition` presents the payload once via `presentIosSnapshot` and then calls `publishIosSnapshot`, which re-runs `presentIosSnapshot` internally on the same input and request (engine.ts line 31). Every runner-presented case therefore compacts and validates the full payload twice — per case, per fuzz seed. This doubles work that this test path controls directly; consider deriving the published payload from the single presentation, or documenting the double-present as intentional.</comment>

<file context>
@@ -173,6 +204,161 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco
+  };
+  const presented = presentIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy });
+  const published = canonicalNodes(
+    publishIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy }).payload.nodes,
+  );
+  return { presented, published };
</file context>
Fix with cubic

);
return { presented, published };
}

function runnerOutputAgrees(
testCase: DifferentialCase,
swift: DifferentialOutcome,
acquired: DifferentialOutcome,
presented: ReturnType<typeof presentIosSnapshot>,
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<typeof presentIosSnapshot>,
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 ?? []))
);
}
Comment on lines +300 to +304

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: qualityMembershipAgrees short-circuits whenever testCase.scope === null, so unscoped runner-presented cases never assert that the presented pipeline omits quality nodes. If Swift's presenter or the host stage: 'presented' adapter ever started emitting quality evidence for an unscoped corpus, this differential would not flag it (the qualityPayload would be validated for shape but never checked for emptiness). Compare against the empty expected membership instead of skipping the check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts, line 300:

<comment>`qualityMembershipAgrees` short-circuits whenever `testCase.scope === null`, so unscoped runner-presented cases never assert that the presented pipeline omits quality nodes. If Swift's presenter or the host `stage: 'presented'` adapter ever started emitting quality evidence for an unscoped corpus, this differential would not flag it (the qualityPayload would be validated for shape but never checked for emptiness). Compare against the empty expected membership instead of skipping the check.</comment>

<file context>
@@ -173,6 +204,161 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco
+  expected: readonly CanonicalNode[] | undefined,
+): boolean {
+  return (
+    testCase.scope === null ||
+    JSON.stringify(semanticMembership(canonicalNodes(actual ?? []))) ===
+      JSON.stringify(semanticMembership(expected ?? []))
</file context>
Suggested change
testCase.scope === null ||
JSON.stringify(semanticMembership(canonicalNodes(actual ?? []))) ===
JSON.stringify(semanticMembership(expected ?? []))
);
}
return (
JSON.stringify(semanticMembership(canonicalNodes(actual ?? []))) ===
JSON.stringify(semanticMembership(expected ?? []))
);
Fix with cubic


function representativesAreValid(
sources: readonly RawSnapshotNode[],
presentation: ReturnType<typeof presentIosSnapshot>,
): boolean {
const outputIndexes = new Set(presentation.nodes.map((node) => node.index));
if (sources.some((node) => !presentation.presentedIndexesBySourceIndex.has(node.index)))
return false;
return [...presentation.presentedIndexesBySourceIndex.values()].every((indexes) =>
indexes.every((index) => outputIndexes.has(index)),
);
}

function requiredLabelsHaveRepresentatives(
requiredLabels: readonly string[],
sources: readonly RawSnapshotNode[],
presentation: ReturnType<typeof presentIosSnapshot>,
published: readonly CanonicalNode[],
): boolean {
for (const label of requiredLabels) {
const matchingSources = sources.filter((node) => node.label === label);
if (
matchingSources.length === 0 ||
!matchingSources.some(
(source) => (presentation.presentedIndexesBySourceIndex.get(source.index) ?? []).length > 0,
)
)
return false;
if (!published.some((node) => node.label === label)) return false;
}
return true;
}

function clippedLabelAgrees(
expected: DifferentialCase['clippedLabel'],
published: readonly CanonicalNode[],
): boolean {
return (
!expected ||
published.some(
(node) =>
node.label === expected.label &&
JSON.stringify(node.rect) === JSON.stringify(expected.rect),
)
);
}

function semanticMembership(nodes: readonly CanonicalNode[]): string[] {
return [
...new Set(
nodes
.filter((node) => node.label !== null)
.map((node) => JSON.stringify([node.type, node.label])),

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This comparator drops identifier and value from semantic membership, so the runner differential can miss lost selector identity or entirely lose unlabeled semantic controls. Preserve the same semantic fields used by the projection and existing property checks when building the comparison identity.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/capture-kit/src/ios-snapshot-engine/conformance-harness.ts, line 357:

<comment>This comparator drops `identifier` and `value` from semantic membership, so the runner differential can miss lost selector identity or entirely lose unlabeled semantic controls. Preserve the same semantic fields used by the projection and existing property checks when building the comparison identity.</comment>

<file context>
@@ -173,6 +204,161 @@ export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutco
+    ...new Set(
+      nodes
+        .filter((node) => node.label !== null)
+        .map((node) => JSON.stringify([node.type, node.label])),
+    ),
+  ].sort();
</file context>
Fix with cubic

),
].sort();
}

export function canonicalNodes(nodes: readonly RawSnapshotNode[]): CanonicalNode[] {
return nodes.map((node) => ({
index: node.index,
Expand Down
Loading
Loading