test(ios): add snapshot engine conformance gates (#2213)

* test(ios): add snapshot engine conformance gates

* test(ios): align differential acquisition inputs

* fix(ios): gate Swift differential on macOS

* test(ios): keep differential coverage host-aware

* test(ios): own snapshot differential on macOS
This commit is contained in:
Michał Pierzchała
2026-09-01 15:59:31 +02:00
committed by GitHub
parent 02116ccdd8
commit a8ee397168
20 changed files with 2664 additions and 49 deletions
+1
View File
@@ -20,6 +20,7 @@
"src/utils/png-worker.ts",
"scripts/patch-xcuitest-runner-icon.ts",
"scripts/runner-request-count/run.ts",
"packages/capture-kit/src/ios-snapshot-engine/replay.ts",
// #1596 regression fixture: runs as a real `node --experimental-strip-types`
// subprocess (test/integration/daemon-replace-exit-flush.test.ts), so
// dependency analysis cannot follow the runCmdSync string path to it.
+4
View File
@@ -70,6 +70,10 @@ jobs:
uses: ./.github/actions/run-gate
with: { gate: macos-coverage }
- name: Run iOS snapshot Swift/TypeScript differential
uses: ./.github/actions/run-gate
with: { gate: ios-snapshot-differential }
- name: Restore and build macOS XCTest runner
uses: ./.github/actions/setup-apple-runner-build
with:
@@ -1,8 +1,8 @@
import AgentDeviceSnapshotPresentation
import Foundation
import CoreGraphics
import Foundation
private struct Input: Decodable {
private struct ConformanceInput: Decodable {
struct Node: Decodable {
let index: Int
let type: String
@@ -20,60 +20,132 @@ private struct Input: Decodable {
let hiddenContentBelow: Bool?
}
let name: String
let projection: String
let interactiveOnly: Bool
let depth: Int?
let scope: String?
let foldPolicy: String
let viewport: SnapshotRect
let nodes: [Node]
}
private struct Output: Encodable {
private struct BatchInput: Decodable {
let cases: [ConformanceInput]
}
private struct ConformanceError: Encodable {
let code: String
let reason: String
let message: String
}
private struct ConformanceOutput: Encodable {
let name: String
let outcome: String
let nodes: [PresentedNode]
let error: ConformanceError?
}
private struct BatchOutput: Encodable {
let cases: [ConformanceOutput]
}
private func acquisition(for input: ConformanceInput) -> SnapshotAcquisition {
let inputOptions = options(for: input)
return SnapshotAcquisition(
hint: SnapshotPresentation.captureHint(for: inputOptions),
nodes: input.nodes.map { node in
RawAXNode(
index: node.index,
type: node.type,
label: node.label,
identifier: node.identifier,
value: node.value,
rect: node.rect,
enabled: node.enabled,
focused: node.focused,
selected: node.selected,
hittable: node.hittable,
depth: node.depth,
parentIndex: node.parentIndex,
hiddenContentAbove: node.hiddenContentAbove,
hiddenContentBelow: node.hiddenContentBelow
)
},
truncated: false,
effectiveDepth: nil,
viewport: input.viewport.cgRect
)
}
private func options(for input: ConformanceInput) -> PresentationOptions {
PresentationOptions(
interactiveOnly: input.interactiveOnly,
depth: input.depth,
scope: input.scope,
raw: input.projection == CaptureHint.Projection.raw.rawValue
)
}
private func present(_ input: ConformanceInput) -> ConformanceOutput {
do {
let inputAcquisition = acquisition(for: input)
let inputOptions = options(for: input)
let nodes: [PresentedNode]
if input.projection == CaptureHint.Projection.raw.rawValue {
nodes = SnapshotPresentation.presentRaw(inputAcquisition, options: inputOptions).nodes
} else {
let policy: SnapshotVisibilityFold.Policy = input.foldPolicy == "plain-viewport"
? .plainViewport
: .cursorProjected
nodes = try SnapshotPresentation.presentRegular(
inputAcquisition,
options: inputOptions,
policy: policy
).nodes
}
return ConformanceOutput(name: input.name, outcome: "success", nodes: nodes, error: nil)
} catch let failure as SnapshotPresentationFailure {
return ConformanceOutput(
name: input.name,
outcome: "failure",
nodes: [],
error: ConformanceError(
code: failure.code,
reason: reason(for: failure),
message: failure.message
)
)
} catch {
return ConformanceOutput(
name: input.name,
outcome: "failure",
nodes: [],
error: ConformanceError(
code: "IOS_SNAPSHOT_PRESENTATION_FAILED",
reason: "unexpected",
message: String(describing: error)
)
)
}
}
private func reason(for failure: SnapshotPresentationFailure) -> String {
switch failure {
case .regularNodeOutsideCumulativeClip:
return "regular-node-outside-cumulative-clip"
case .regularDegenerateNodeIsActionable:
return "regular-degenerate-actionable-node"
}
}
private let input = try JSONDecoder().decode(
Input.self,
BatchInput.self,
from: FileHandle.standardInput.readDataToEndOfFile()
)
private let options = PresentationOptions(
interactiveOnly: input.interactiveOnly,
depth: input.depth,
scope: input.scope,
raw: input.projection == CaptureHint.Projection.raw.rawValue
private let output = try JSONEncoder().encode(
BatchOutput(cases: input.cases.map(present))
)
private let acquisition = SnapshotAcquisition(
hint: SnapshotPresentation.captureHint(for: options),
nodes: input.nodes.map { node in
RawAXNode(
index: node.index,
type: node.type,
label: node.label,
identifier: node.identifier,
value: node.value,
rect: node.rect,
enabled: node.enabled,
focused: node.focused,
selected: node.selected,
hittable: node.hittable,
depth: node.depth,
parentIndex: node.parentIndex,
hiddenContentAbove: node.hiddenContentAbove,
hiddenContentBelow: node.hiddenContentBelow
)
},
truncated: false,
effectiveDepth: nil,
viewport: input.viewport.cgRect
)
private let result = try SnapshotPresentation.present(acquisition, options: options)
?? SnapshotPresentationResult(
nodes: [],
truncated: false,
effectiveDepth: nil,
customActions: nil,
qualityNodes: nil
)
private let output = try JSONEncoder().encode(Output(nodes: result.nodes))
FileHandle.standardOutput.write(output)
FileHandle.standardOutput.write(Data([0x0a]))
File diff suppressed because it is too large Load Diff
+1
View File
@@ -117,6 +117,7 @@
"maestro:conformance": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/maestro-conformance/format-generated-json.test.mjs packages/maestro/test/conformance/verify.test.ts packages/maestro/test/conformance/differential/engine-process.test.ts packages/maestro/test/conformance/differential/report-output.test.ts packages/maestro/test/conformance/differential/run.test.ts packages/maestro/test/conformance/differential/invariants.test.ts",
"maestro:conformance:regenerate": "node --experimental-strip-types scripts/maestro-conformance/regenerate.mjs",
"maestro:conformance:differential": "node --experimental-strip-types packages/maestro/test/conformance/differential/run.ts",
"test:ios-snapshot-differential": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/ios-snapshot-differential.test.ts",
"size": "node scripts/size-report.mjs",
"perf": "node --experimental-strip-types scripts/perf/run.ts",
"mutation:run": "node --experimental-strip-types scripts/mutation/run.ts",
+1
View File
@@ -73,6 +73,7 @@
},
"devDependencies": {
"@types/pngjs": "^6.0.5",
"fast-check": "^4.9.0",
"pngjs": "^7.0.0"
}
}
@@ -0,0 +1,112 @@
import fs from 'node:fs';
import path from 'node:path';
import type {
IosAcquisitionResidue,
IosSnapshotAcquisition,
IosSnapshotRequest,
IosViewportEvidence,
} from '@agent-device/contracts/ios-snapshot';
import { createIosSnapshotRequest, deriveIosCaptureHint } from '../ios-snapshot-planning.ts';
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
type GoldenProjectionNode = Readonly<{
index: number;
type: string | null;
label: string | null;
rect: Rect | null;
depth: number | null;
parentIndex: number | null;
hittable: boolean;
hiddenContentAbove: boolean;
hiddenContentBelow: boolean;
}>;
type GoldenExpected = Readonly<{
outcome: 'success' | 'failure';
nodes: readonly GoldenProjectionNode[];
truncated?: boolean;
residue?: readonly IosAcquisitionResidue[];
error?: Readonly<{ code: string; reason: string }>;
}>;
type GoldenCase = Readonly<{
name: string;
swift: boolean;
projection: 'regular' | 'raw';
interactiveOnly: boolean;
depth: number | null;
scope: string | null;
foldPolicy: 'cursor-projected' | 'plain-viewport';
truncated: boolean;
residue: readonly IosAcquisitionResidue[];
qualityLabels?: readonly (string | null)[];
nodes: readonly RawSnapshotNode[];
viewportEvidence?: IosViewportEvidence;
expected: GoldenExpected;
}>;
type GoldenFixture = Readonly<{
version: number;
viewport: Rect;
cases: readonly GoldenCase[];
}>;
const IOS_SNAPSHOT_ENGINE_FIXTURE_PATH = path.resolve(
import.meta.dirname,
'..',
'..',
'..',
'..',
'contracts',
'fixtures',
'ios-snapshot-engine-conformance.json',
);
export function readIosSnapshotEngineFixture(): GoldenFixture {
return JSON.parse(fs.readFileSync(IOS_SNAPSHOT_ENGINE_FIXTURE_PATH, 'utf8')) as GoldenFixture;
}
export function requestForGoldenCase(testCase: GoldenCase): IosSnapshotRequest {
return createIosSnapshotRequest({
projection: testCase.projection,
interactiveOnly: testCase.interactiveOnly,
depth: testCase.depth,
scope: testCase.scope,
acquisitionIntent: 'full',
});
}
export function acquisitionForGoldenCase(
fixture: GoldenFixture,
testCase: GoldenCase,
): IosSnapshotAcquisition {
const request = requestForGoldenCase(testCase);
return {
producer: 'simulator-ax-bridge',
intent: 'full',
hint: { ...deriveIosCaptureHint(request), acquisitionIntent: 'full' },
nodes: testCase.nodes,
truncated: testCase.truncated,
viewport: testCase.viewportEvidence ?? { kind: 'reported', rect: fixture.viewport },
lineage: { targetId: 'golden-target', generation: 'golden-generation' },
residue: testCase.residue,
};
}
function normalizeGoldenNode(node: RawSnapshotNode): GoldenProjectionNode {
return {
index: node.index,
type: node.type ?? null,
label: node.label ?? null,
rect: node.rect ?? null,
depth: node.depth ?? null,
parentIndex: node.parentIndex ?? null,
hittable: node.hittable === true,
hiddenContentAbove: node.hiddenContentAbove === true,
hiddenContentBelow: node.hiddenContentBelow === true,
};
}
export function normalizeGoldenNodes(nodes: readonly RawSnapshotNode[]): GoldenProjectionNode[] {
return nodes.map(normalizeGoldenNode);
}
@@ -0,0 +1,144 @@
import fc from 'fast-check';
import type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
import type { compareDifferentialCases } from './conformance-harness.ts';
type DifferentialCase = Parameters<typeof compareDifferentialCases>[0][number];
const VIEWPORT: Rect = { x: 0, y: 0, width: 320, height: 240 };
const TYPES = [
'Other',
'Window',
'ScrollView',
'CollectionView',
'Table',
'Cell',
'Button',
'StaticText',
'TextField',
] as const;
type NodeSeed = {
type: (typeof TYPES)[number];
label: string | undefined;
x: number;
y: number;
width: number;
height: number;
parent: number;
enabled: boolean;
hittable: boolean;
hiddenContentAbove: boolean;
hiddenContentBelow: boolean;
};
const nodeSeedArbitrary = fc.record({
type: fc.constantFrom(...TYPES),
label: fc.constantFrom<string | undefined>(undefined, 'Target', 'Save', 'Decoration', 'Panel'),
x: fc.integer({ min: -80, max: 360 }),
y: fc.integer({ min: -80, max: 320 }),
width: fc.integer({ min: 0, max: 260 }),
height: fc.integer({ min: 0, max: 220 }),
parent: fc.integer({ min: -1, max: 10 }),
enabled: fc.boolean(),
hittable: fc.boolean(),
hiddenContentAbove: fc.boolean(),
hiddenContentBelow: fc.boolean(),
});
const caseShapeArbitrary = fc
.array(nodeSeedArbitrary, { minLength: 1, maxLength: 10 })
.map((seeds) => makeCase(seeds));
export const differentialBatchArbitrary = fc
.array(caseShapeArbitrary, { minLength: 1, maxLength: 10 })
.map((cases) =>
cases.map((testCase, index) => ({
...testCase,
name: 'fuzz-case-' + String(index),
})),
);
function makeCase(seeds: ReadonlyArray<NodeSeed>): Omit<DifferentialCase, 'name'> {
const nodes: RawSnapshotNode[] = [];
for (const [index, seed] of seeds.entries()) nodes.push(makeNode(seed, index, nodes));
return {
projection: projectionFor(seeds[0]!),
interactiveOnly: false,
depth: depthFor(seeds[0]!),
scope: scopeFor(seeds[0]!),
foldPolicy: foldPolicyFor(seeds[0]!),
viewport: VIEWPORT,
nodes,
};
}
function makeNode(
seed: NodeSeed,
index: number,
nodes: readonly RawSnapshotNode[],
): RawSnapshotNode {
const parentIndex = parentIndexFor(seed, index);
return {
index,
type: typeFor(seed, index),
label: labelFor(seed, index),
rect: rectFor(seed, index),
enabled: enabledFor(seed, index),
hittable: hittableFor(seed, index),
depth: nodeDepth(parentIndex, nodes),
...(parentIndex === undefined ? {} : { parentIndex }),
...hiddenContentFor(seed),
};
}
function parentIndexFor(seed: NodeSeed, index: number): number | undefined {
return index === 0 || seed.parent < 0 ? undefined : Math.min(seed.parent, index - 1);
}
function nodeDepth(parentIndex: number | undefined, nodes: readonly RawSnapshotNode[]): number {
return parentIndex === undefined ? 0 : (nodes[parentIndex]?.depth ?? 0) + 1;
}
function typeFor(seed: NodeSeed, index: number): string {
return index === 0 ? 'Application' : seed.type;
}
function labelFor(seed: NodeSeed, index: number): string | undefined {
return index === 0 ? 'App' : seed.label;
}
function rectFor(seed: NodeSeed, index: number): Rect {
return index === 0 ? VIEWPORT : { x: seed.x, y: seed.y, width: seed.width, height: seed.height };
}
function enabledFor(seed: NodeSeed, index: number): boolean {
return index === 0 || seed.enabled;
}
function hittableFor(seed: NodeSeed, index: number): boolean {
return index !== 0 && seed.hittable;
}
function hiddenContentFor(seed: NodeSeed): Partial<RawSnapshotNode> {
return {
...(seed.hiddenContentAbove ? { hiddenContentAbove: true } : {}),
...(seed.hiddenContentBelow ? { hiddenContentBelow: true } : {}),
};
}
function projectionFor(seed: NodeSeed): 'regular' | 'raw' {
return seed.type === 'Other' ? 'raw' : 'regular';
}
function depthFor(seed: NodeSeed): number | null {
return seed.width % 5 === 0 ? null : seed.width % 4;
}
function scopeFor(seed: NodeSeed): string | null {
return seed.height % 5 === 0 ? 'target' : null;
}
function foldPolicyFor(seed: NodeSeed): 'cursor-projected' | 'plain-viewport' {
return seed.x % 2 === 0 ? 'cursor-projected' : 'plain-viewport';
}
@@ -0,0 +1,212 @@
import { execFileSync } from 'node:child_process';
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 type { RawSnapshotNode, Rect } from '@agent-device/kernel/snapshot';
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..', '..', '..');
const SWIFT_PACKAGE_PATH = path.join(REPO_ROOT, 'apple', 'snapshot-presentation');
const SWIFT_PRODUCT = 'snapshot-presentation-conformance';
export const SWIFT_RUN_TIMEOUT_MS = 60_000;
let swiftHarnessExecutable: string | undefined;
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 DifferentialOutcome = Readonly<{
name?: string;
outcome: 'success' | 'failure';
nodes: readonly CanonicalNode[];
error?: Readonly<{ code: string; reason: string }>;
}>;
type CanonicalNode = Readonly<{
index: number;
type: string | null;
label: string | null;
rect: Rect | null;
depth: number | null;
parentIndex: number | null;
hittable: boolean;
hiddenContentAbove: boolean;
hiddenContentBelow: boolean;
}>;
type DifferentialMismatch = Readonly<{
case: DifferentialCase;
swift: unknown;
typescript: unknown;
}>;
export function swiftToolchainAvailable(): boolean {
if (process.platform !== 'darwin') return false;
/* c8 ignore start */
try {
execFileSync('swift', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
/* c8 ignore stop */
}
/* c8 ignore start */
export function compareDifferentialCases(
cases: readonly DifferentialCase[],
): DifferentialMismatch | undefined {
const swiftCases = runSwiftCases(cases);
for (const testCase of cases) {
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)) {
return { case: testCase, swift: normalizedSwift, typescript };
}
}
return undefined;
}
/* c8 ignore stop */
/* c8 ignore start */
function runSwiftCases(cases: readonly DifferentialCase[]): DifferentialOutcome[] {
const stdout = execFileSync(swiftConformanceExecutable(), [], {
cwd: REPO_ROOT,
encoding: 'utf8',
input: JSON.stringify({ cases: cases.map(prepareDifferentialAcquisition) }),
timeout: SWIFT_RUN_TIMEOUT_MS,
maxBuffer: 8 * 1024 * 1024,
});
const parsed = JSON.parse(stdout) as {
cases: Array<{
name: string;
outcome: 'success' | 'failure';
nodes?: RawSnapshotNode[];
error?: { code: string; reason: string };
}>;
};
return parsed.cases.map((entry) => ({
name: entry.name,
outcome: entry.outcome,
nodes: canonicalNodes(entry.nodes ?? []),
...(entry.error ? { error: entry.error } : {}),
}));
}
/* c8 ignore stop */
/* c8 ignore start */
function swiftConformanceExecutable(): string {
if (swiftHarnessExecutable) return swiftHarnessExecutable;
execFileSync('swift', ['build', '--package-path', SWIFT_PACKAGE_PATH], {
cwd: REPO_ROOT,
stdio: 'ignore',
timeout: SWIFT_RUN_TIMEOUT_MS,
});
const binPath = execFileSync(
'swift',
['build', '--show-bin-path', '--package-path', SWIFT_PACKAGE_PATH],
{ cwd: REPO_ROOT, encoding: 'utf8', timeout: SWIFT_RUN_TIMEOUT_MS },
).trim();
swiftHarnessExecutable = path.join(binPath, SWIFT_PRODUCT);
return swiftHarnessExecutable;
}
/* c8 ignore stop */
function prepareDifferentialAcquisition(testCase: DifferentialCase): DifferentialCase {
if (testCase.projection !== 'raw' || testCase.scope !== null || testCase.depth === null) {
return testCase;
}
return {
...testCase,
nodes: testCase.nodes.filter((node) => (node.depth ?? 0) <= testCase.depth!),
};
}
/* c8 ignore start */
function withoutName(outcome: DifferentialOutcome): Omit<DifferentialOutcome, 'name'> {
const { name: _name, ...normalized } = outcome;
return normalized;
}
/* c8 ignore stop */
export function runTypeScriptCase(testCase: DifferentialCase): DifferentialOutcome {
const acquisitionInput = prepareDifferentialAcquisition(testCase);
const request = createIosSnapshotRequest({
projection: testCase.projection,
interactiveOnly: testCase.interactiveOnly,
depth: testCase.depth,
scope: testCase.scope,
});
const acquisition: IosSnapshotAcquisition = {
producer: 'simulator-ax-bridge',
intent: 'full',
hint: { ...deriveIosCaptureHint(request), acquisitionIntent: 'full' },
nodes: acquisitionInput.nodes,
truncated: false,
viewport: { kind: 'reported', rect: testCase.viewport },
lineage: { targetId: 'differential-target', generation: 'differential-generation' },
residue: [],
};
try {
const result = presentIosSnapshot({ stage: 'acquired', acquisition }, request, {
foldPolicy: testCase.foldPolicy,
});
return { outcome: 'success', nodes: canonicalNodes(result.nodes) };
} catch (error) {
if (!(error instanceof IosSnapshotEngineError)) throw error;
return {
outcome: 'failure',
nodes: [],
error: { code: error.code, reason: error.reason },
};
}
}
export function canonicalNodes(nodes: readonly RawSnapshotNode[]): CanonicalNode[] {
return nodes.map((node) => ({
index: node.index,
type: node.type ?? null,
label: node.label ?? null,
rect: node.rect ? canonicalRect(node.rect) : null,
depth: node.depth ?? null,
parentIndex: node.parentIndex ?? null,
hittable: node.hittable === true,
hiddenContentAbove: node.hiddenContentAbove === true,
hiddenContentBelow: node.hiddenContentBelow === true,
}));
}
function canonicalRect(rect: Rect): Rect {
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
export function writeDifferentialFailureArtifact(input: {
testCase: DifferentialCase;
seed: number;
counterexamplePath: string;
}): { directory: string; casePath: string; replayCommand: string } {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ios-snapshot-fuzz-'));
const casePath = path.join(directory, 'case.json');
fs.writeFileSync(casePath, JSON.stringify({ cases: [input.testCase] }, null, 2) + '\n');
const replayCommand = [
'node --experimental-strip-types',
'packages/capture-kit/src/ios-snapshot-engine/replay.ts',
JSON.stringify(casePath),
].join(' ');
fs.writeFileSync(
path.join(directory, 'replay-command.txt'),
replayCommand + '\nseed=' + String(input.seed) + '\npath=' + input.counterexamplePath + '\n',
);
return { directory, casePath, replayCommand };
}
@@ -0,0 +1,173 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import path from 'node:path';
import { test } from 'vitest';
import type { CaptureHint, IosSnapshotRequestInput } from '@agent-device/contracts/ios-snapshot';
import {
createIosSnapshotRequest,
deriveIosCaptureHint,
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { IosSnapshotEngineError, presentIosSnapshot } from './index.ts';
import { runTypeScriptCase, writeDifferentialFailureArtifact } from './conformance-harness.ts';
import {
acquisitionForGoldenCase,
normalizeGoldenNodes,
readIosSnapshotEngineFixture,
requestForGoldenCase,
} from './conformance-fixture.ts';
const CAPTURE_HINT_FIXTURE_PATH = path.resolve(
import.meta.dirname,
'..',
'..',
'..',
'..',
'contracts',
'fixtures',
'ios-snapshot-capture-hint.json',
);
type CaptureHintFixture = Readonly<{
name: string;
request: IosSnapshotRequestInput;
expected: CaptureHint;
}>;
test('the independent capture-hint corpus agrees with the engine request boundary', () => {
const fixtures = JSON.parse(
fs.readFileSync(CAPTURE_HINT_FIXTURE_PATH, 'utf8'),
) as CaptureHintFixture[];
assert.ok(fixtures.length > 0);
assert.equal(new Set(fixtures.map((fixture) => fixture.name)).size, fixtures.length);
for (const fixture of fixtures) {
const request = createIosSnapshotRequest(fixture.request);
assert.deepEqual(deriveIosCaptureHint(request), fixture.expected, fixture.name);
}
});
test('the authored iOS snapshot corpus covers each contract seam', () => {
const fixture = readIosSnapshotEngineFixture();
assert.equal(fixture.version, 1);
assert.ok(fixture.cases.length >= 12);
assert.equal(new Set(fixture.cases.map((testCase) => testCase.name)).size, fixture.cases.length);
const required = [
'nested ancestor clips and actionability',
'viewport edge remains positively actionable',
'geometryless cursor nodes keep independent descendants',
'plain viewport keeps child visibility independent',
'raw projection preserves reported geometry',
'scope reroots wrappers and regular depth',
'scope depth zero retains only the matched root',
'raw scope depth counts source depth',
'hidden scroll content becomes directional hints',
'interactive only compacts semantic representatives',
'unavailable hittability fails closed',
'malformed parent is a typed failure',
'missing viewport is a typed failure',
'invalid viewport is a typed failure',
'residue and truncation survive publication',
];
for (const name of required) {
assert.ok(
fixture.cases.some((testCase) => testCase.name === name),
name,
);
}
});
test('the independent iOS snapshot goldens match the TypeScript engine', () => {
const fixture = readIosSnapshotEngineFixture();
for (const testCase of fixture.cases) {
const request = requestForGoldenCase(testCase);
const acquisition = acquisitionForGoldenCase(fixture, testCase);
const expected = testCase.expected;
let actual:
| {
outcome: 'success';
nodes: ReturnType<typeof normalizeGoldenNodes>;
truncated: boolean;
residue: typeof acquisition.residue;
qualityLabels?: readonly (string | null)[];
}
| {
outcome: 'failure';
nodes: [];
error: { code: string; reason: string };
};
try {
const result = presentIosSnapshot({ stage: 'acquired', acquisition }, request, {
foldPolicy: testCase.foldPolicy,
});
actual = {
outcome: 'success',
nodes: normalizeGoldenNodes(result.nodes),
truncated: acquisition.truncated,
residue: acquisition.residue,
...(testCase.qualityLabels
? { qualityLabels: result.qualityNodes?.map((node) => node.label ?? null) }
: {}),
};
} catch (error) {
assert.ok(error instanceof IosSnapshotEngineError, testCase.name);
actual = {
outcome: 'failure',
nodes: [],
error: { code: error.code, reason: error.reason },
};
}
assert.deepEqual(
actual,
testCase.qualityLabels ? { ...expected, qualityLabels: testCase.qualityLabels } : expected,
testCase.name,
);
}
});
test('the differential TypeScript runner preserves typed failures', () => {
const fixture = readIosSnapshotEngineFixture();
const source = fixture.cases.find(
(testCase) => testCase.name === 'malformed parent is a typed failure',
);
assert.ok(source);
const result = runTypeScriptCase({
name: source.name,
projection: source.projection,
interactiveOnly: false,
depth: source.depth,
scope: source.scope,
foldPolicy: source.foldPolicy,
viewport: fixture.viewport,
nodes: source.nodes,
});
assert.equal(result.outcome, 'failure');
assert.ok(result.error?.code);
});
test('differential failure artifacts preserve replay metadata', () => {
const fixture = readIosSnapshotEngineFixture();
const source = fixture.cases[0]!;
const testCase = {
name: source.name,
projection: source.projection,
interactiveOnly: false as const,
depth: source.depth,
scope: source.scope,
foldPolicy: source.foldPolicy,
viewport: fixture.viewport,
nodes: source.nodes,
};
const artifact = writeDifferentialFailureArtifact({
testCase,
seed: 219101,
counterexamplePath: '0:0',
});
const stored = JSON.parse(fs.readFileSync(artifact.casePath, 'utf8')) as {
cases: readonly unknown[];
};
const metadata = fs.readFileSync(path.join(artifact.directory, 'replay-command.txt'), 'utf8');
assert.equal(stored.cases.length, 1);
assert.match(metadata, /seed=219101/);
assert.match(metadata, /path=0:0/);
});
@@ -0,0 +1,261 @@
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { test } from 'vitest';
import type {
IosSnapshotAcquisition,
IosSnapshotInput,
IosSnapshotRequest,
IosSnapshotValidationFacts,
} from '@agent-device/contracts/ios-snapshot';
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
import {
buildIosSnapshotPresentationKey,
createIosSnapshotRequest,
deriveIosCaptureHint,
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { canonicalNodes, runTypeScriptCase } from './conformance-harness.ts';
import { differentialBatchArbitrary } from './conformance-generator.ts';
import { acquisitionForGoldenCase, readIosSnapshotEngineFixture } from './conformance-fixture.ts';
import { IosSnapshotEngineError, presentIosSnapshot, publishIosSnapshot } from './index.ts';
test('property: regular effective geometry stays within the viewport and actionability fails closed', () => {
fc.assert(fc.property(differentialBatchArbitrary, assertRegularCases), {
seed: 219105,
numRuns: 100,
});
});
test('property: an unscoped, unlimited raw presentation preserves every reported frame', () => {
fc.assert(fc.property(differentialBatchArbitrary, assertRawCases), {
seed: 219106,
numRuns: 100,
});
});
test('property: interactive is a subset of regular, and regular is a subset of raw', () => {
fc.assert(fc.property(differentialBatchArbitrary, assertProjectionSubsets), {
seed: 219107,
numRuns: 100,
});
});
test('property: unavailable hittability fails closed without changing comparison identity', () => {
const fixture = readIosSnapshotEngineFixture();
const testCase = fixture.cases.find(
(entry) => entry.name === 'unavailable hittability fails closed',
);
assert.ok(testCase);
const request = createIosSnapshotRequest({ projection: 'regular' });
const acquisition = acquisitionForRequest(acquisitionForGoldenCase(fixture, testCase), request);
const input: IosSnapshotInput = { stage: 'acquired', acquisition };
const presentation = presentIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy });
assert.ok(presentation.nodes.every((node) => node.hittable !== true));
const publication = publishIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy });
assert.deepEqual(publication.comparisonIdentity.lineage, acquisition.lineage);
assert.deepEqual(publication.comparisonIdentity.residue, acquisition.residue);
assert.deepEqual(publication.residue, acquisition.residue);
});
test('property: runner validation distinguishes an invalid quality payload', () => {
const fixture = readIosSnapshotEngineFixture();
const testCase = fixture.cases.find(
(entry) => entry.name === 'nested ancestor clips and actionability',
);
assert.ok(testCase);
const request = createIosSnapshotRequest({ projection: 'regular' });
const acquisition = acquisitionForRequest(acquisitionForGoldenCase(fixture, testCase), request);
const acquired = publishIosSnapshot({ stage: 'acquired', acquisition }, request, {
foldPolicy: testCase.foldPolicy,
});
const validation: IosSnapshotValidationFacts = {
presentationKey: buildIosSnapshotPresentationKey(request),
viewport: acquisition.viewport,
hittability: { kind: 'available' },
lineage: acquisition.lineage,
residue: acquisition.residue,
};
const input: IosSnapshotInput = {
stage: 'presented',
presentation: {
producer: 'apple-runner',
intent: 'full',
payload: { nodes: acquired.payload.nodes, truncated: false },
qualityPayload: {
nodes: [
{
...acquired.payload.nodes[0]!,
rect: { x: 0, y: 0, width: 321, height: 240 },
},
],
truncated: false,
scope: null,
},
},
validation,
};
assert.throws(
() => presentIosSnapshot(input, request, { foldPolicy: testCase.foldPolicy }),
(error: unknown) =>
error instanceof IosSnapshotEngineError && error.reason === 'invalid-quality-payload',
);
});
type DifferentialCase = Parameters<typeof runTypeScriptCase>[0];
type DifferentialNode = ReturnType<typeof runTypeScriptCase>['nodes'][number];
function assertProjectionSubsets(cases: readonly DifferentialCase[]): void {
for (const testCase of cases) {
const regularRequest = createIosSnapshotRequest({ projection: 'regular' });
const interactiveRequest = createIosSnapshotRequest({
projection: 'regular',
interactiveOnly: true,
});
const rawRequest = createIosSnapshotRequest({ projection: 'raw' });
const acquisition = acquisitionForDifferentialCase(testCase, regularRequest);
const regular = presentIosSnapshot({ stage: 'acquired', acquisition }, regularRequest, {
foldPolicy: testCase.foldPolicy,
});
const interactive = presentIosSnapshot(
{
stage: 'acquired',
acquisition: acquisitionForRequest(acquisition, interactiveRequest),
},
interactiveRequest,
{ foldPolicy: testCase.foldPolicy },
);
const raw = presentIosSnapshot(
{ stage: 'acquired', acquisition: acquisitionForRequest(acquisition, rawRequest) },
rawRequest,
{ foldPolicy: testCase.foldPolicy },
);
assertMultisetSubset(nodeIdentities(interactive.nodes), nodeIdentities(regular.nodes));
assertMultisetSubset(nodeIdentities(regular.nodes), nodeIdentities(raw.nodes));
}
}
function assertMultisetSubset(subset: readonly string[], superset: readonly string[]): void {
const remaining = new Map<string, number>();
for (const identity of superset) remaining.set(identity, (remaining.get(identity) ?? 0) + 1);
for (const identity of subset) {
const count = remaining.get(identity) ?? 0;
assert.ok(count > 0, identity);
remaining.set(identity, count - 1);
}
}
function nodeIdentities(nodes: readonly RawSnapshotNode[]): string[] {
return nodes.map((node) =>
JSON.stringify([node.label ?? null, node.identifier ?? null, node.value ?? null]),
);
}
function acquisitionForDifferentialCase(
testCase: DifferentialCase,
request: IosSnapshotRequest,
): IosSnapshotAcquisition {
const fullRequest = requireFullRequest(request);
return {
producer: 'simulator-ax-bridge',
intent: fullRequest.acquisitionIntent,
hint: {
...deriveIosCaptureHint(fullRequest),
acquisitionIntent: fullRequest.acquisitionIntent,
},
nodes: testCase.nodes,
truncated: false,
viewport: { kind: 'reported', rect: testCase.viewport },
lineage: { targetId: 'property-target', generation: 'property-generation' },
residue: [],
};
}
function acquisitionForRequest(
acquisition: IosSnapshotAcquisition,
request: IosSnapshotRequest,
): IosSnapshotAcquisition {
const fullRequest = requireFullRequest(request);
return {
producer: acquisition.producer,
intent: fullRequest.acquisitionIntent,
hint: {
...deriveIosCaptureHint(fullRequest),
acquisitionIntent: fullRequest.acquisitionIntent,
},
nodes: acquisition.nodes,
truncated: acquisition.truncated,
viewport: acquisition.viewport,
lineage: acquisition.lineage,
residue: acquisition.residue,
};
}
type FullSnapshotRequest = IosSnapshotRequest & { acquisitionIntent: 'full' };
function requireFullRequest(request: IosSnapshotRequest): FullSnapshotRequest {
if (request.acquisitionIntent !== 'full') {
throw new Error('property fixtures require full acquisition');
}
return request as FullSnapshotRequest;
}
function assertRegularCases(cases: readonly DifferentialCase[]): void {
for (const testCase of cases) assertRegularCase(testCase);
}
function assertRegularCase(testCase: DifferentialCase): void {
const result = runTypeScriptCase(testCase);
assert.equal(result.outcome, 'success');
if (testCase.projection === 'raw') return;
for (const node of result.nodes) assertRegularNode(node, testCase.viewport);
}
function assertRegularNode(node: DifferentialNode, viewport: DifferentialCase['viewport']): void {
assertRect(node, viewport);
assertActionability(node, viewport);
assertParentOrder(node);
}
function assertRect(node: DifferentialNode, viewport: DifferentialCase['viewport']): void {
if (!node.rect) return;
assert.ok(node.rect.width >= 0 && node.rect.height >= 0);
if (node.rect.width > 0 && node.rect.height > 0) assertRectWithinViewport(node.rect, viewport);
}
function assertRectWithinViewport(
rect: NonNullable<DifferentialNode['rect']>,
viewport: DifferentialCase['viewport'],
): void {
assert.ok(rect.x >= viewport.x - 0.0001);
assert.ok(rect.y >= viewport.y - 0.0001);
assert.ok(rect.x + rect.width <= viewport.x + viewport.width + 0.0001);
assert.ok(rect.y + rect.height <= viewport.y + viewport.height + 0.0001);
}
function assertActionability(node: DifferentialNode, viewport: DifferentialCase['viewport']): void {
if (!node.hittable) return;
assert.ok(node.rect && node.rect.width > 0 && node.rect.height > 0);
const centerX = node.rect.x + node.rect.width / 2;
const centerY = node.rect.y + node.rect.height / 2;
assert.ok(centerX >= viewport.x && centerX <= viewport.x + viewport.width);
assert.ok(centerY >= viewport.y && centerY <= viewport.y + viewport.height);
}
function assertParentOrder(node: DifferentialNode): void {
if (node.parentIndex !== null) assert.ok(node.parentIndex < node.index);
}
function assertRawCases(cases: readonly DifferentialCase[]): void {
for (const testCase of cases) {
const rawCase = {
...testCase,
name: testCase.name + '-raw',
projection: 'raw' as const,
depth: null,
scope: null,
};
const result = runTypeScriptCase(rawCase);
assert.equal(result.outcome, 'success');
assert.deepEqual(result.nodes, canonicalNodes(testCase.nodes));
}
}
@@ -0,0 +1,37 @@
/* c8 ignore file */
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[];
}>;
const casePath = process.argv[2];
if (!casePath) {
throw new Error('usage: replay.ts <case.json>');
}
const input = JSON.parse(fs.readFileSync(path.resolve(casePath), 'utf8')) as {
cases?: DifferentialCase[];
};
const cases = input.cases ?? [];
if (cases.length !== 1) {
throw new Error('case.json must contain exactly one differential case');
}
const mismatch = compareDifferentialCases(cases);
if (mismatch) {
console.error(JSON.stringify(mismatch, null, 2));
process.exitCode = 1;
} else {
console.log('replayed ' + cases[0]!.name + ': Swift and TypeScript agree');
}
@@ -82,13 +82,21 @@ function validateRunnerPayloads(
hittabilityAvailable,
);
if (input.presentation.qualityPayload) {
validateIosPayload(
input.presentation.qualityPayload.nodes,
projection,
viewport,
foldPolicy,
hittabilityAvailable,
);
try {
validateIosPayload(
input.presentation.qualityPayload.nodes,
projection,
viewport,
foldPolicy,
hittabilityAvailable,
);
} catch (error) {
if (error instanceof IosSnapshotEngineError) {
throw new IosSnapshotEngineError('invalid-quality-payload', error.message, error.details);
}
/* c8 ignore next */
throw error;
}
}
return payloadValidation;
}
+3
View File
@@ -211,6 +211,9 @@ importers:
'@types/pngjs':
specifier: ^6.0.5
version: 6.0.5
fast-check:
specifier: ^4.9.0
version: 4.9.0
pngjs:
specifier: ^7.0.0
version: 7.0.0
+6
View File
@@ -46,6 +46,12 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [
gate('package', 'Published package (publint, attw, clean-install resolution)', 'check:package'),
gate('integration-node', 'Node integration smoke', 'test:integration:node'),
gate('macos-coverage', 'macOS command coverage manifest', 'test:integration:macos-coverage'),
gate(
'ios-snapshot-differential',
'iOS snapshot Swift/TypeScript differential',
'test:ios-snapshot-differential',
false,
),
{
id: 'vitest-related',
label: 'Tests related by Vitest module graph',
+11
View File
@@ -46,6 +46,7 @@ export type CheckId =
| 'provider-integration'
| 'integration-node'
| 'macos-coverage'
| 'ios-snapshot-differential'
| 'integration-progress'
| 'swift-runner-ios'
| 'swift-runner-macos'
@@ -105,6 +106,7 @@ export const ALL_CHECKS: readonly CheckId[] = [
// run before the related-project workload heats the host.
'integration-node',
'macos-coverage',
'ios-snapshot-differential',
'vitest-related',
'unit',
'unit-ci',
@@ -435,6 +437,15 @@ const BUILD_OWNERSHIP: ReadonlyArray<{
detail: string;
owns: (file: string) => boolean;
}> = [
{
check: 'ios-snapshot-differential',
rule: 'own:ios-snapshot-differential',
detail: 'the required macOS lane runs the Swift/TypeScript snapshot differential',
owns: (file) =>
file.startsWith('packages/capture-kit/src/ios-snapshot-engine/') ||
file.startsWith('apple/snapshot-presentation/') ||
file === 'contracts/fixtures/ios-snapshot-engine-conformance.json',
},
// Both platform builds compile the same runner sources, and each is a separate
// gate in a separate lane, so a Swift change owns both.
{
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fc from 'fast-check';
import {
compareDifferentialCases,
swiftToolchainAvailable,
SWIFT_RUN_TIMEOUT_MS,
writeDifferentialFailureArtifact,
} 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';
type DifferentialCase = Parameters<typeof compareDifferentialCases>[0][number];
const FUZZ_SEEDS = [219101, 219102, 219103, 219104];
const RUNS_PER_SEED = 8;
const MAX_TOTAL_DURATION_MS = 60_000;
if (!swiftToolchainAvailable()) {
throw new Error('iOS snapshot differential requires the macOS Swift toolchain');
}
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)
.map((testCase) => ({
name: testCase.name,
projection: testCase.projection,
interactiveOnly: false as const,
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) : '');
});
test(
'raw unscoped depth compares the same acquisition frontier',
{ timeout: SWIFT_RUN_TIMEOUT_MS },
() => {
const fixture = readIosSnapshotEngineFixture();
const depthCase = fixture.cases.find(
(testCase) => testCase.name === 'raw unscoped depth uses the acquisition frontier',
);
assert.ok(depthCase);
const deepNode = depthCase.nodes.at(-1);
assert.ok(deepNode);
const mismatch = compareDifferentialCases([
{
name: 'raw-depth-frontier-with-malformed-tail',
projection: 'raw',
interactiveOnly: false,
depth: 1,
scope: null,
foldPolicy: 'cursor-projected',
viewport: fixture.viewport,
nodes: [...depthCase.nodes, { ...deepNode, index: 1, parentIndex: 1, depth: 2 }],
},
]);
assert.equal(mismatch, undefined, mismatch ? JSON.stringify(mismatch, null, 2) : '');
},
);
test(
'deterministic Swift/TypeScript differential fuzz stays under 60000ms',
{ timeout: SWIFT_RUN_TIMEOUT_MS * FUZZ_SEEDS.length },
() => {
const startedAt = performance.now();
for (const seed of FUZZ_SEEDS) {
assertDifferentialSeed(seed);
assertWithinKillCriterion(startedAt, seed);
}
},
);
function assertDifferentialSeed(seed: number): void {
const result = fc.check(
fc.property(
differentialBatchArbitrary,
(cases) => compareDifferentialCases(cases) === undefined,
),
{
seed,
numRuns: RUNS_PER_SEED,
endOnFailure: true,
interruptAfterTimeLimit: SWIFT_RUN_TIMEOUT_MS,
},
);
if (!result.failed) return;
const counterexample = Array.isArray(result.counterexample?.[0])
? (result.counterexample[0] as DifferentialCase[])
: [];
const mismatch = compareDifferentialCases(counterexample);
const testCase = mismatch?.case ?? counterexample[0];
if (!testCase) {
throw new Error('differential fuzz failed without a reproducible case: ' + String(result));
}
const artifact = writeDifferentialFailureArtifact({
testCase,
seed,
counterexamplePath: result.counterexamplePath ?? 'unknown',
});
throw new Error(
'Swift/TypeScript differential mismatch for ' +
testCase.name +
'; minimal case: ' +
artifact.casePath +
'; replay: ' +
artifact.replayCommand,
);
}
function assertWithinKillCriterion(startedAt: number, seed: number): void {
if (performance.now() - startedAt <= MAX_TOTAL_DURATION_MS) return;
throw new Error(
'differential fuzz exceeded its ' +
String(MAX_TOTAL_DURATION_MS) +
'ms kill criterion after seed ' +
String(seed),
);
}
+6
View File
@@ -110,6 +110,7 @@ import { sourceExecutionCompatibilityViolations } from './source-execution-polic
import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts';
import { replayOwnershipViolations } from './replay-ownership.ts';
import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts';
import { iosSnapshotEngineOwnershipViolations } from './ios-snapshot-engine-policy.ts';
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
encoding: 'utf8',
@@ -557,6 +558,7 @@ export const LAYERING_RULE_IDS = [
'platform-package-policy',
'retired-platforms-zone',
'replay-ownership',
'ios-snapshot-engine-ownership',
] as const;
export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
@@ -598,6 +600,10 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
),
'retired-platforms-zone': () => checkRetiredPlatformsZone(listTrackedPlatformZoneFiles(repoRoot)),
'replay-ownership': (context) => replayOwnershipViolations(context.sourceFiles),
'ios-snapshot-engine-ownership': (context) =>
iosSnapshotEngineOwnershipViolations(
[...context.sources].map(([path, source]) => ({ path, source })),
),
};
export function main(): number {
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import {
IOS_SNAPSHOT_ENGINE_FILE,
IOS_SNAPSHOT_RUNNER_FILE,
iosSnapshotEngineOwnershipViolations,
} from './ios-snapshot-engine-policy.ts';
const repoRoot = path.resolve(import.meta.dirname, '../..');
function sources(overrides: ReadonlyMap<string, string> = new Map()) {
return [IOS_SNAPSHOT_ENGINE_FILE, IOS_SNAPSHOT_RUNNER_FILE].map((file) => ({
path: file,
source: overrides.get(file) ?? fs.readFileSync(path.join(repoRoot, file), 'utf8'),
}));
}
test('the iOS snapshot engine owns each presentation boundary exactly once', () => {
assert.deepEqual(iosSnapshotEngineOwnershipViolations(sources()), []);
});
test('the structural gate rejects a planted duplicate acquired presentation', () => {
const engine = fs.readFileSync(path.join(repoRoot, IOS_SNAPSHOT_ENGINE_FILE), 'utf8');
const planted = engine.replace(
'return presentAcquiredSnapshot(input.acquisition, request, foldPolicy);',
'return presentAcquiredSnapshot(input.acquisition, request, foldPolicy);\n presentAcquiredSnapshot(input.acquisition, request, foldPolicy);',
);
assert.notEqual(planted, engine);
const violations = iosSnapshotEngineOwnershipViolations(
sources(new Map([[IOS_SNAPSHOT_ENGINE_FILE, planted]])),
);
assert.ok(
violations.some((violation) => violation.message.includes('presentAcquiredSnapshot exactly 1')),
JSON.stringify(violations),
);
});
@@ -0,0 +1,229 @@
import { parseSync } from 'oxc-parser';
import type { LayeringViolation } from './model.ts';
import { memberPath, visitAst } from './layering-ast.ts';
export const IOS_SNAPSHOT_ENGINE_OWNERSHIP_RULE = 'R72 ios-snapshot-engine-ownership';
export const IOS_SNAPSHOT_ENGINE_FILE = 'packages/capture-kit/src/ios-snapshot-engine/engine.ts';
export const IOS_SNAPSHOT_RUNNER_FILE =
'packages/capture-kit/src/ios-snapshot-engine/runner-presentation.ts';
type SourceFile = Readonly<{ path: string; source: string }>;
type AstNode = Record<string, unknown>;
type CallSite = Readonly<{ line: number; arguments: readonly AstNode[] }>;
export function iosSnapshotEngineOwnershipViolations(
sources: readonly SourceFile[],
): LayeringViolation[] {
const byPath = new Map(sources.map((file) => [file.path, file.source]));
const engineSource = byPath.get(IOS_SNAPSHOT_ENGINE_FILE);
const runnerSource = byPath.get(IOS_SNAPSHOT_RUNNER_FILE);
const violations: LayeringViolation[] = [];
if (!engineSource) violations.push(missingFile(IOS_SNAPSHOT_ENGINE_FILE));
if (!runnerSource) violations.push(missingFile(IOS_SNAPSHOT_RUNNER_FILE));
if (!engineSource || !runnerSource) return violations;
const engine = parseSource(IOS_SNAPSHOT_ENGINE_FILE, engineSource);
const runner = parseSource(IOS_SNAPSHOT_RUNNER_FILE, runnerSource);
const present = functionBody(engine, 'presentIosSnapshot');
const publish = functionBody(engine, 'publishIosSnapshot');
const acquired = functionBody(engine, 'presentAcquiredSnapshot');
const presented = functionBody(runner, 'presentIosRunnerSnapshot');
const runnerPayloads = functionBody(runner, 'validateRunnerPayloads');
const runnerCompaction = functionBody(runner, 'compactRunnerPayload');
requireCallCount(
violations,
IOS_SNAPSHOT_ENGINE_FILE,
'presentIosSnapshot',
present,
'presentAcquiredSnapshot',
1,
);
requireCallCount(
violations,
IOS_SNAPSHOT_ENGINE_FILE,
'presentIosSnapshot',
present,
'presentIosRunnerSnapshot',
1,
);
requireCallCount(
violations,
IOS_SNAPSHOT_ENGINE_FILE,
'publishIosSnapshot',
publish,
'presentIosSnapshot',
1,
);
requireCallCount(
violations,
IOS_SNAPSHOT_ENGINE_FILE,
'presentAcquiredSnapshot',
acquired,
'buildIosInteractiveSnapshotPresentation',
1,
);
requireCallCount(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'presentIosRunnerSnapshot',
presented,
'validateRunnerPayloads',
1,
);
requireCallCount(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'presentIosRunnerSnapshot',
presented,
'compactRunnerPayload',
1,
);
requireCallCount(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'presentIosRunnerSnapshot',
presented,
'foldIosSnapshot',
0,
);
requireCallCount(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'presentIosRunnerSnapshot',
presented,
'validateIosPayload',
0,
);
requireCallCount(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'presentIosRunnerSnapshot',
presented,
'buildIosInteractiveSnapshotPresentation',
0,
);
requireCallCount(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'validateRunnerPayloads',
runnerPayloads,
'validateIosPayload',
2,
);
requireArgumentPath(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'validateRunnerPayloads',
runnerPayloads,
'validateIosPayload',
['input', 'presentation', 'payload', 'nodes'],
);
requireArgumentPath(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'validateRunnerPayloads',
runnerPayloads,
'validateIosPayload',
['input', 'presentation', 'qualityPayload', 'nodes'],
);
requireCallCount(
violations,
IOS_SNAPSHOT_RUNNER_FILE,
'compactRunnerPayload',
runnerCompaction,
'buildIosInteractiveSnapshotPresentation',
1,
);
return violations;
}
function missingFile(file: string): LayeringViolation {
return {
rule: IOS_SNAPSHOT_ENGINE_OWNERSHIP_RULE,
file,
line: 1,
message: `${file} is missing, so the iOS snapshot engine ownership paths cannot be checked`,
};
}
function parseSource(file: string, source: string): AstNode {
return parseSync(file, source).program as unknown as AstNode;
}
function functionBody(program: AstNode, name: string): AstNode | undefined {
let body: AstNode | undefined;
visitAst(program, (node) => {
if (body || node.type !== 'FunctionDeclaration') return;
const id = node.id as AstNode | undefined;
if (id?.type !== 'Identifier' || id.name !== name) return;
body = node.body as AstNode | undefined;
});
return body;
}
function callSites(body: AstNode | undefined, name: string): CallSite[] {
if (!body) return [];
const sites: CallSite[] = [];
visitAst(body, (node) => {
if (node.type !== 'CallExpression' || identifierName(node.callee) !== name) return;
sites.push({
line: 1,
arguments: (node.arguments as AstNode[] | undefined) ?? [],
});
});
return sites;
}
function identifierName(node: unknown): string | undefined {
if (!node || typeof node !== 'object') return undefined;
const record = node as AstNode;
return record.type === 'Identifier' && typeof record.name === 'string' ? record.name : undefined;
}
function requireCallCount(
violations: LayeringViolation[],
file: string,
functionName: string,
body: AstNode | undefined,
callName: string,
expected: number,
): void {
const sites = callSites(body, callName);
if (sites.length === expected) return;
violations.push({
rule: IOS_SNAPSHOT_ENGINE_OWNERSHIP_RULE,
file,
line: sites[0]?.line ?? 1,
message:
`${functionName} must call ${callName} exactly ${String(expected)} time(s); found ` +
String(sites.length),
});
}
function requireArgumentPath(
violations: LayeringViolation[],
file: string,
functionName: string,
body: AstNode | undefined,
callName: string,
expectedPath: readonly string[],
): void {
const sites = callSites(body, callName);
if (sites.some((site) => site.arguments.some((argument) => samePath(argument, expectedPath)))) {
return;
}
violations.push({
rule: IOS_SNAPSHOT_ENGINE_OWNERSHIP_RULE,
file,
line: sites[0]?.line ?? 1,
message: `${functionName} must validate ${expectedPath.join('.')}`,
});
}
function samePath(node: AstNode, expected: readonly string[]): boolean {
const actual = memberPath(node);
return (
actual?.length === expected.length && actual.every((part, index) => part === expected[index])
);
}