diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 3d469bf868..c98c926254 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -542,7 +542,7 @@ extension RunnerTests { return sparseTruncatedSnapshotPayload( message: recoveredSnapshotMessage(failure), snapshotQuality: SnapshotQuality( - state: "sparse", + state: .sparse, backend: SnapshotBackendKind.recursiveTree.rawValue, reason: failure.message, reasonCode: "ax-rejected", diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index e7f3898652..bf56a6e2fd 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -8,11 +8,21 @@ import AgentDeviceSnapshotPresentation // stamp the outcome with a structured quality verdict so the daemon renders state instead of // re-deriving it from node shapes. Recovery ordering is data here, never a per-call-site branch. +/// The closed set of verdict states the host accepts. The wire strings are the shared table at +/// `contracts/fixtures/ios-snapshot-quality-states.json`, which `allCases` is pinned to; `reasonCode` +/// stays open because an unknown one costs only its wording, never the verdict. +enum SnapshotQualityState: String, Codable, CaseIterable { + /// First backend produced a usable tree. + case healthy + /// A later backend did. + case recovered + /// No backend produced a usable tree; the best attempt is returned as-is. + case sparse +} + /// Structured quality verdict shipped with every iOS snapshot payload. struct SnapshotQuality: Codable { - /// healthy: first backend produced a usable tree. recovered: a later backend did. - /// sparse: no backend produced a usable tree; the best attempt is returned as-is. - let state: String + let state: SnapshotQualityState /// Backend that produced the returned payload: tree | queries | private-ax. let backend: String /// Why recovery ran (first failure), why the payload is degraded, or why an internal backend @@ -391,7 +401,7 @@ extension RunnerTests { return stampedSnapshotPayload( capture, backend: kind, - state: recovered ? "recovered" : "healthy", + state: recovered ? .recovered : .healthy, reason: recovered || firstFailure?.code == "requested-backend" ? firstFailure : nil ) } @@ -416,11 +426,11 @@ extension RunnerTests { } let fallbackPayload = - best.map { stampedSnapshotPayload($0.capture, backend: $0.kind, state: "sparse", reason: firstFailure) } + best.map { stampedSnapshotPayload($0.capture, backend: $0.kind, state: .sparse, reason: firstFailure) } ?? stampedSnapshotPayload( SnapshotBackendCapture(payload: sparseTruncatedSnapshotPayload(), effectiveDepth: nil), backend: effectivePlan.last ?? plan.last ?? .recursiveTree, - state: "sparse", + state: .sparse, reason: firstFailure ) return fallbackPayload @@ -680,7 +690,7 @@ extension RunnerTests { func stampedSnapshotPayload( _ capture: SnapshotBackendCapture, backend: SnapshotBackendKind, - state: String, + state: SnapshotQualityState, reason: (reason: String, code: String)? ) -> DataPayload { let health: RunnerAccessibilityHealth = reason?.code == "ax-rejected" ? .unavailable : .healthy @@ -705,7 +715,7 @@ extension RunnerTests { // "recovered") stays untruncated, so strict absence reads can trust it. Only a real cap // (payload truncation, a depth-limited private AX capture) or a sparse terminal payload // is truncated. - truncated: payload.truncated == true || state == "sparse" || capture.effectiveDepth != nil, + truncated: payload.truncated == true || state == .sparse || capture.effectiveDepth != nil, qualityPayload: capture.qualityPayload.flatMap { quality in guard let nodes = quality.nodes else { return nil } return SnapshotQualityPayload(nodes: nodes, truncated: quality.truncated == true) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift index edec6e8333..3a220bb594 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift @@ -149,7 +149,7 @@ extension RunnerTests { XCTAssertNil(box.error) let quality = try XCTUnwrap(box.payload?.snapshotQuality) XCTAssertEqual(quality.backend, SnapshotBackendKind.privateAX.rawValue) - XCTAssertEqual(quality.state, "recovered") + XCTAssertEqual(quality.state, .recovered) XCTAssertTrue( quality.reason?.contains("tree capture exceeded") == true, "the tree XPC, not the viewport read, must be the abandoned block: \(quality.reason ?? "nil")" @@ -345,7 +345,7 @@ extension RunnerTests { SnapshotBackendKind.privateAX.rawValue, "a sweep that ended on its slice deadline is a tier timeout, not an accepted capture" ) - XCTAssertEqual(quality?.state, "recovered") + XCTAssertEqual(quality?.state, .recovered) XCTAssertGreaterThan(box.payload?.nodes?.count ?? 0, 1, "private AX answers with a real tree") XCTAssertFalse(box.abandonedAtReturn, "the sweep must answer inside its own main-thread hop") XCTAssertTrue( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift index a2c709eeb9..a6f552b1bc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift @@ -162,7 +162,7 @@ extension RunnerTests { let payload = stampedSnapshotPayload( capture, backend: .recursiveTree, - state: "healthy", + state: .healthy, reason: nil ) @@ -187,7 +187,7 @@ extension RunnerTests { customActions: coverage ), backend: .recursiveTree, - state: "healthy", + state: .healthy, reason: nil ) XCTAssertNil(silent.message) @@ -200,7 +200,7 @@ extension RunnerTests { effectiveDepth: 4 ), backend: .privateAX, - state: "recovered", + state: .recovered, reason: (reason: "tree capture timed out", code: "budget") ) XCTAssertEqual(underlying.message, "underlying") @@ -224,24 +224,24 @@ extension RunnerTests { // The CI signature behind `is absent ... capture was truncated`: a complete private AX // tree selected while the XCTest channel is penalized is whole, and must say so. let recovered = stampedSnapshotPayload( - complete, backend: .privateAX, state: "recovered", reason: deferred) - XCTAssertEqual(recovered.snapshotQuality?.state, "recovered") + complete, backend: .privateAX, state: .recovered, reason: deferred) + XCTAssertEqual(recovered.snapshotQuality?.state, .recovered) XCTAssertEqual(recovered.truncated, false) let depthLimited = stampedSnapshotPayload( SnapshotBackendCapture(payload: complete.payload, effectiveDepth: 56), - backend: .privateAX, state: "recovered", reason: deferred) + backend: .privateAX, state: .recovered, reason: deferred) XCTAssertEqual(depthLimited.truncated, true) let cappedPayload = stampedSnapshotPayload( SnapshotBackendCapture( payload: DataPayload(nodes: complete.payload.nodes ?? [], truncated: true), effectiveDepth: nil), - backend: .recursiveTree, state: "healthy", reason: nil) + backend: .recursiveTree, state: .healthy, reason: nil) XCTAssertEqual(cappedPayload.truncated, true) let sparse = stampedSnapshotPayload( - complete, backend: .querySweep, state: "sparse", + complete, backend: .querySweep, state: .sparse, reason: ("snapshot returned no semantic controls or content", "sparse-tree")) XCTAssertEqual(sparse.truncated, true) } @@ -260,7 +260,7 @@ extension RunnerTests { let payload = stampedSnapshotPayload( capture, backend: .recursiveTree, - state: "healthy", + state: .healthy, reason: nil ) @@ -291,7 +291,7 @@ extension RunnerTests { let payload = stampedSnapshotPayload( capture, backend: .recursiveTree, - state: "healthy", + state: .healthy, reason: nil ) @@ -507,7 +507,7 @@ extension RunnerTests { let quality = try XCTUnwrap(capped.snapshotQuality) XCTAssertEqual(quality.backend, SnapshotBackendKind.privateAX.rawValue) - XCTAssertNotEqual(quality.state, "sparse") + XCTAssertNotEqual(quality.state, .sparse) let nodes = try XCTUnwrap(capped.nodes) XCTAssertGreaterThan(nodes.count, 1) XCTAssertEqual(nodes.map(\.depth).max(), 1) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotQualityStateTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotQualityStateTests.swift new file mode 100644 index 0000000000..8ef84b8fc8 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotQualityStateTests.swift @@ -0,0 +1,68 @@ +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + private struct StampedWireVerdict: Decodable { + struct Quality: Decodable { + let state: String + } + let snapshotQuality: Quality + } + + private func loadSnapshotQualityStatesFixture() throws -> [String] { + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // UnitTests + .deletingLastPathComponent() // AgentDeviceRunnerUITests + .deletingLastPathComponent() // AgentDeviceRunner + .deletingLastPathComponent() // runner + .deletingLastPathComponent() // apple + .deletingLastPathComponent() // repo root + .appendingPathComponent("contracts") + .appendingPathComponent("fixtures") + .appendingPathComponent("ios-snapshot-quality-states.json") + return try JSONDecoder().decode([String].self, from: Data(contentsOf: fixtureURL)) + } + + /// The one claim of this file: the runner's closed enum and the shared TypeScript table name the + /// same states. The kernel's `SNAPSHOT_QUALITY_STATES` is pinned to it too, so the two runtimes + /// cannot drift into a verdict the host drops along with its disclosure. Compared as a set: the + /// names are the contract, and a reordering of `allCases` cannot produce a wrong verdict. + func testSnapshotQualityStatesMatchSharedWireFixture() throws { + XCTAssertEqual( + Set(try loadSnapshotQualityStatesFixture()), + Set(SnapshotQualityState.allCases.map(\.rawValue)), + "update the fixture and the kernel tuple together with the enum" + ) + } + + /// What the daemon receives for each state, taken from the production stamping path rather than a + /// hand-built verdict: the wire string is the case's own raw value, so a change of + /// representation — an `Int` backing, a nested object — goes red here on the actual payload, and + /// a renamed raw value goes red in the fixture test above. + func testStampedVerdictEncodesTheCaseRawValue() throws { + let capture = SnapshotBackendCapture( + payload: DataPayload(nodes: [], truncated: false), + effectiveDepth: nil + ) + for state in SnapshotQualityState.allCases { + let payload = stampedSnapshotPayload( + capture, + backend: .recursiveTree, + state: state, + reason: nil + ) + let wire = try JSONDecoder().decode( + StampedWireVerdict.self, + from: JSONEncoder().encode(payload) + ) + XCTAssertEqual(wire.snapshotQuality.state, state.rawValue) + } + } + + /// Closed in both directions: a wire string nobody declared never becomes a verdict. + func testVerdictStateRejectsAnUndeclaredWireString() throws { + let json = Data(#"{"state":"degraded","backend":"tree"}"#.utf8) + XCTAssertThrowsError(try JSONDecoder().decode(SnapshotQuality.self, from: json)) + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift index c679bbcbde..20b3b9631e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift @@ -23,7 +23,7 @@ extension RunnerTests { XCTAssertEqual(payload.runnerFatalReason, Self.axSnapshotUnavailableReason) // The planned terminal result carries the structured verdict like every other planned // snapshot — downstream sparse handling keys off it, not off node shapes. - XCTAssertEqual(payload.snapshotQuality?.state, "sparse") + XCTAssertEqual(payload.snapshotQuality?.state, .sparse) XCTAssertEqual(payload.snapshotQuality?.reasonCode, "ax-rejected") XCTAssertEqual(payload.snapshotQuality?.reason, Self.axSnapshotFailureMessage) XCTAssertNil(currentApp) diff --git a/contracts/fixtures/ios-snapshot-quality-states.json b/contracts/fixtures/ios-snapshot-quality-states.json new file mode 100644 index 0000000000..ab9c625897 --- /dev/null +++ b/contracts/fixtures/ios-snapshot-quality-states.json @@ -0,0 +1 @@ +["healthy", "recovered", "sparse"] diff --git a/packages/capture-kit/src/snapshot-quality-verdict.test.ts b/packages/capture-kit/src/snapshot-quality-verdict.test.ts index ea8afbc157..0c27cf5588 100644 --- a/packages/capture-kit/src/snapshot-quality-verdict.test.ts +++ b/packages/capture-kit/src/snapshot-quality-verdict.test.ts @@ -1,6 +1,8 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import { readSerializedSnapshotCaptureAnnotations } from '@agent-device/contracts/capture'; +import { SNAPSHOT_QUALITY_STATES } from '@agent-device/kernel/snapshot'; import { isSparseSnapshotQualityVerdict, preferredSnapshotBackendForVerdict, @@ -47,6 +49,22 @@ test('readSnapshotQualityVerdict rejects unknown state or backend as verdict-abs assert.equal(readSnapshotQualityVerdict({ state: 'sparse', backend: 'mystery' }), undefined); assert.equal(readSnapshotQualityVerdict({ backend: 'tree' }), undefined); assert.equal(readSnapshotQualityVerdict(null), undefined); + // An inherited key is not a declared state: membership stays on the map's own keys. + assert.equal(readSnapshotQualityVerdict({ state: 'constructor', backend: 'tree' }), undefined); +}); + +test('readSnapshotQualityVerdict reads every declared wire state', () => { + for (const state of SNAPSHOT_QUALITY_STATES) { + assert.deepEqual(readSnapshotQualityVerdict({ state, backend: 'tree' }), { + state, + backend: 'tree', + reason: undefined, + reasonCode: undefined, + customActions: undefined, + effectiveDepth: undefined, + collapsedLeafIndexes: undefined, + }); + } }); test('readSnapshotQualityVerdict keeps the verdict but drops an unknown reasonCode', () => { @@ -98,3 +116,43 @@ test('preferredSnapshotBackendForVerdict pins only private-ax captures', () => { ); assert.equal(preferredSnapshotBackendForVerdict(undefined), undefined); }); + +/** + * Two readings of one verdict exist on purpose: this module normalizes an untrusted runner payload, + * while contracts re-publishes what this repo published and normalizes nothing (the eager-closure + * gate forbids either reaching a shared module, and the duplication gate refuses a second + * normalization). They must still agree on which payloads are a verdict at all: a name one version + * cannot speak is verdict-absent on both sides of the daemon boundary. + */ +const VERDICT_PAYLOADS: unknown[] = [ + { state: 'sparse', backend: 'private-ax' }, + { state: 'healthy', backend: 'tree', reason: 'ok', reasonCode: 'requested-backend' }, + { state: 'recovered', backend: 'queries', reason: 42, effectiveDepth: '56' }, + { state: 'sparse', backend: 'tree', collapsedLeafIndexes: [3, 'four'] }, + { state: 'sparse', backend: 'tree', customActions: { read: 12 } }, + { state: 'sparse', backend: 'tree', customActions: { read: 12, candidates: 19 } }, + { state: 'sparse', backend: 'tree', timing: { acquisitionMs: 12.5 } }, + { state: 'sparse', backend: 'tree', timing: { acquisitionMs: 12.5, presentationMs: 34.75 } }, + { state: 'sparse', backend: 'tree', reasonCode: 'future-code' }, + { state: 'recovered', backend: 'android-helper', reasonCode: 'requested-backend' }, + { state: 'degraded', backend: 'tree' }, + { state: 'sparse', backend: 'uiautomator' }, + { state: 'constructor', backend: 'constructor' }, + { backend: 'tree' }, + { state: 'sparse' }, + null, + 'verdict', +]; + +test('the contracts re-read calls a verdict a verdict on every payload', () => { + for (const payload of VERDICT_PAYLOADS) { + const reRead = readSerializedSnapshotCaptureAnnotations({ + snapshotQuality: payload, + }).snapshotQuality; + assert.equal( + reRead === undefined, + readSnapshotQualityVerdict(payload) === undefined, + JSON.stringify(payload), + ); + } +}); diff --git a/packages/capture-kit/src/snapshot-quality-verdict.ts b/packages/capture-kit/src/snapshot-quality-verdict.ts index 2913abe32f..475d09009f 100644 --- a/packages/capture-kit/src/snapshot-quality-verdict.ts +++ b/packages/capture-kit/src/snapshot-quality-verdict.ts @@ -1,24 +1,35 @@ -import type { SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; +import type { SnapshotQualityState, SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; import { SNAPSHOT_QUALITY_BACKEND_CAPABILITIES } from './snapshot-quality-backend-capabilities.ts'; -const SNAPSHOT_QUALITY_STATES = new Set([ - 'healthy', - 'recovered', - 'sparse', -]); -const SNAPSHOT_QUALITY_BACKENDS = new Set( - Object.keys(SNAPSHOT_QUALITY_BACKEND_CAPABILITIES) as SnapshotQualityVerdict['backend'][], -); -const SNAPSHOT_QUALITY_REASON_CODES = new Set>([ - 'ax-rejected', - 'sparse-tree', - 'budget', - 'no-nodes', - 'capture-failed', - 'presentation-failed', - 'deferred', - 'requested-backend', -]); +/** + * The verdict names this version can speak, keyed against the kernel unions so a map cannot fall + * behind one. They cannot be one shared kernel predicate: this module's eager closure is frozen at + * its merge-base size (#2872). The strategies need no map — `SNAPSHOT_QUALITY_BACKEND_CAPABILITIES` + * is already keyed by exactly those names. + */ +const DECLARED_STATES: Record = { + healthy: true, + recovered: true, + sparse: true, +}; + +const DECLARED_REASON_CODES: Record, true> = { + 'ax-rejected': true, + 'sparse-tree': true, + budget: true, + 'no-nodes': true, + 'capture-failed': true, + 'presentation-failed': true, + deferred: true, + 'requested-backend': true, +}; + +function isDeclared( + vocabulary: Record, + value: unknown, +): value is Key { + return typeof value === 'string' && Object.hasOwn(vocabulary, value); +} export function readSnapshotQualityVerdict(value: unknown): SnapshotQualityVerdict | undefined { if (!value || typeof value !== 'object') return undefined; @@ -27,31 +38,19 @@ export function readSnapshotQualityVerdict(value: unknown): SnapshotQualityVerdi // verdict this version understands, so it falls through as verdict-absent and the legacy // node-shape detectors run instead of being silently suppressed by a malformed payload. if ( - typeof raw.state !== 'string' || - !SNAPSHOT_QUALITY_STATES.has(raw.state as SnapshotQualityVerdict['state']) - ) { - return undefined; - } - if ( - typeof raw.backend !== 'string' || - !SNAPSHOT_QUALITY_BACKENDS.has(raw.backend as SnapshotQualityVerdict['backend']) + !isDeclared(DECLARED_STATES, raw.state) || + !isDeclared(SNAPSHOT_QUALITY_BACKEND_CAPABILITIES, raw.backend) ) { return undefined; } const timing = readSnapshotQualityTiming(raw.timing); return { - state: raw.state as SnapshotQualityVerdict['state'], - backend: raw.backend as SnapshotQualityVerdict['backend'], + state: raw.state, + backend: raw.backend, reason: typeof raw.reason === 'string' ? raw.reason : undefined, // An unknown reasonCode is dropped, not rejected: a forward-version runner that adds one // still yields a usable verdict (only the budget-specific wording is keyed off it). - reasonCode: - typeof raw.reasonCode === 'string' && - SNAPSHOT_QUALITY_REASON_CODES.has( - raw.reasonCode as NonNullable, - ) - ? (raw.reasonCode as SnapshotQualityVerdict['reasonCode']) - : undefined, + reasonCode: isDeclared(DECLARED_REASON_CODES, raw.reasonCode) ? raw.reasonCode : undefined, customActions: readCustomActionCoverage(raw.customActions), effectiveDepth: typeof raw.effectiveDepth === 'number' ? raw.effectiveDepth : undefined, collapsedLeafIndexes: Array.isArray(raw.collapsedLeafIndexes) diff --git a/packages/contracts/src/snapshot-capture-annotations.test.ts b/packages/contracts/src/snapshot-capture-annotations.test.ts index 2deeb0f761..0a9dff237e 100644 --- a/packages/contracts/src/snapshot-capture-annotations.test.ts +++ b/packages/contracts/src/snapshot-capture-annotations.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; +import { SNAPSHOT_QUALITY_STATES } from '@agent-device/kernel/snapshot'; import { readResponseWarnings } from '@agent-device/kernel/success-text'; import { readSerializedSnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts'; @@ -29,3 +30,60 @@ test('absent or non-array warnings stay absent on the serialized annotations', ( undefined, ); }); + +test('every wire verdict state survives the serialized annotations', () => { + for (const state of SNAPSHOT_QUALITY_STATES) { + const verdict = { + state, + backend: 'private-ax', + reason: 'tree capture timed out', + reasonCode: 'budget', + effectiveDepth: 56, + collapsedLeafIndexes: [3], + customActions: { read: 12, candidates: 19, truncated: 1, blocked: false }, + timing: { acquisitionMs: 12.5, presentationMs: 34.75 }, + }; + assert.deepEqual( + readSerializedSnapshotCaptureAnnotations({ snapshotQuality: verdict }).snapshotQuality, + verdict, + ); + } +}); + +/** + * This reader runs on the daemon's serialized response, and it used to project any string into the + * verdict type. A state the declared vocabulary does not name now reads as verdict-absent, which is + * what lets the shape-based fallback stay in charge instead of a disclosure for nothing. + */ +test('a state outside the declared vocabulary drops the serialized verdict', () => { + for (const state of [ + 'heathy', + 'healthy ', + 'Sparse', + 'degraded', + 'constructor', + '', + 42, + null, + undefined, + ]) { + const annotations = readSerializedSnapshotCaptureAnnotations({ + snapshotQuality: { state, backend: 'tree' }, + }); + assert.equal(annotations.snapshotQuality, undefined, JSON.stringify(state)); + } +}); + +/** + * `backend` names the recovery strategy in the user-facing warning line, so it goes through the + * declared strategies too: a strategy this version cannot name is not a verdict it can present. The + * optional fields are forwarded as published (see the reader); normalizing them is capture-kit. + */ +test('an undeclared backend drops the serialized verdict', () => { + for (const backend of ['uiautomator', 'tree ', 'Tree', 'constructor', '', 42, null, undefined]) { + const annotations = readSerializedSnapshotCaptureAnnotations({ + snapshotQuality: { state: 'sparse', backend }, + }); + assert.equal(annotations.snapshotQuality, undefined, JSON.stringify(backend)); + } +}); diff --git a/packages/contracts/src/snapshot-capture-annotations.ts b/packages/contracts/src/snapshot-capture-annotations.ts index da90cae94f..7ad86290cd 100644 --- a/packages/contracts/src/snapshot-capture-annotations.ts +++ b/packages/contracts/src/snapshot-capture-annotations.ts @@ -1,6 +1,28 @@ -import type { IosTargetActivation, SnapshotQualityVerdict } from '@agent-device/kernel/snapshot'; +import type { + IosTargetActivation, + SnapshotCaptureBackend, + SnapshotQualityState, + SnapshotQualityVerdict, +} from '@agent-device/kernel/snapshot'; import type { AndroidSnapshotBackendMetadata } from './snapshot-types.ts'; +/** + * The verdict names this host has to speak, each keyed against its kernel union so a name added + * there without a key here is a compile error. They cannot be one shared kernel predicate: the + * eager-closure gate keeps `kernel/snapshot.ts` out of `facades/capture.ts` (#2872). + */ +const DECLARED_STATES: Record = { + healthy: true, + recovered: true, + sparse: true, +}; +const DECLARED_BACKENDS: Record = { + tree: true, + queries: true, + 'private-ax': true, + 'android-helper': true, +}; + export type SnapshotCaptureAnalysis = { rawNodeCount: number; maxDepth: number; @@ -33,7 +55,7 @@ export type PublicSnapshotCaptureAnnotations = Pick< export function snapshotCaptureAnnotationsFrom( source: Partial> & { quality?: unknown }, ): SnapshotCaptureAnnotations { - const quality = readSnapshotQualityVerdict(source.quality); + const quality = readPublishedSnapshotQualityVerdict(source.quality); return { ...(source.analysis ? { analysis: source.analysis } : {}), ...(source.androidSnapshot ? { androidSnapshot: source.androidSnapshot } : {}), @@ -67,7 +89,7 @@ export function readSerializedSnapshotCaptureAnnotations( const warnings = Array.isArray(data.warnings) ? data.warnings.filter((entry): entry is string => typeof entry === 'string') : undefined; - const quality = readSnapshotQualityVerdict(data.snapshotQuality); + const quality = readPublishedSnapshotQualityVerdict(data.snapshotQuality); const targetActivation = readTargetActivation(data.targetActivation); return publicSnapshotCaptureAnnotations({ ...(androidSnapshot @@ -88,12 +110,26 @@ function readTargetActivation(value: unknown): IosTargetActivation | undefined { : undefined; } -function readSnapshotQualityVerdict(value: unknown): SnapshotQualityVerdict | undefined { +/** + * Re-read of a fact this module published, in the shape `readTargetActivation` above uses: the two + * names that decide presentation are checked, the rest is forwarded as published. capture-kit's + * `readSnapshotQualityVerdict` normalizes an untrusted runner payload field by field; the two + * readings are pinned to each other in `snapshot-quality-verdict.test.ts`. + */ +function readPublishedSnapshotQualityVerdict(value: unknown): SnapshotQualityVerdict | undefined { if (!value || typeof value !== 'object') return undefined; const raw = value as Record; - return typeof raw.state === 'string' && typeof raw.backend === 'string' - ? (raw as SnapshotQualityVerdict) - : undefined; + if (!isDeclared(DECLARED_STATES, raw.state) || !isDeclared(DECLARED_BACKENDS, raw.backend)) { + return undefined; + } + return raw as SnapshotQualityVerdict; +} + +function isDeclared( + vocabulary: Record, + value: unknown, +): value is Key { + return typeof value === 'string' && Object.hasOwn(vocabulary, value); } function readObject(value: unknown): Record | undefined { diff --git a/packages/kernel/src/snapshot-quality-states.test.ts b/packages/kernel/src/snapshot-quality-states.test.ts new file mode 100644 index 0000000000..45fdbb4e2a --- /dev/null +++ b/packages/kernel/src/snapshot-quality-states.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { SNAPSHOT_QUALITY_STATES, type SnapshotQualityState } from './snapshot.ts'; + +const SNAPSHOT_QUALITY_STATES_FIXTURE_PATH = path.resolve( + import.meta.dirname, + '..', + '..', + '..', + 'contracts', + 'fixtures', + 'ios-snapshot-quality-states.json', +); + +function readSnapshotQualityStatesFixture(): string[] { + return JSON.parse(fs.readFileSync(SNAPSHOT_QUALITY_STATES_FIXTURE_PATH, 'utf8')) as string[]; +} + +/** + * The tuple's own claim, stated on `SNAPSHOT_QUALITY_STATES`: the fixture is its wire vocabulary, + * and the runner's `SnapshotQualityState.allCases` is pinned to the same file by a unit test. As a + * set: the names are the contract, and a reordering breaks no verdict anywhere. + */ +test('the declared verdict states are the shared wire vocabulary', () => { + assert.deepEqual( + new Set(readSnapshotQualityStatesFixture()), + new Set(SNAPSHOT_QUALITY_STATES), + 'update the fixture and the Swift enum together with the tuple', + ); +}); + +test('the verdict state type admits exactly the declared states', () => { + // @ts-expect-error a state nobody declared cannot enter the verdict type + const undeclared: SnapshotQualityState = 'degraded'; + void undeclared; +}); diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index eace66eba8..7b24d551b5 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -3,8 +3,9 @@ * The daemon renders it; it never re-derives degradation from node shapes. * * Defined here (the foundational snapshot type module) rather than in - * snapshot-quality/verdict.ts so SnapshotNode can reference it without a cyclic import; - * snapshot-quality/verdict.ts owns the validation logic. + * capture-kit's snapshot-quality-verdict.ts so SnapshotNode can reference it without a cyclic + * import. Ownership splits three ways: this module owns the vocabularies below, capture-kit parses + * an untrusted runner payload into them, and contracts re-hydrates a verdict this repo published. */ /** * Which capture STRATEGY produced a snapshot, within one platform's plan — @@ -23,8 +24,21 @@ export type SnapshotQualityTiming = { presentationMs: number; }; +/** + * The verdict states a capture plan may stamp. This tuple is the ONE declaration of that + * vocabulary, and `SnapshotQualityVerdict['state']` is its projection; readers hold exhaustive maps + * over the union instead of importing this module, because the eager-closure gate freezes their + * loading shape (#2872). This tuple and the Apple runner's `SnapshotQualityState.allCases` are each + * pinned as a set to `contracts/fixtures/ios-snapshot-quality-states.json`, so a state one side + * renames, adds, or deletes without the other goes red there instead of arriving as a verdict the + * host cannot name — which reads as verdict-absent and drops the disclosure with it. + */ +export const SNAPSHOT_QUALITY_STATES = ['healthy', 'recovered', 'sparse'] as const; + +export type SnapshotQualityState = (typeof SNAPSHOT_QUALITY_STATES)[number]; + export type SnapshotQualityVerdict = { - state: 'healthy' | 'recovered' | 'sparse'; + state: SnapshotQualityState; backend: SnapshotCaptureBackend; reason?: string; // 'deferred' = the penalty circuit breaker pre-selected a non-XCTest backend; nothing new