Skip to content
Open
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 @@ -438,6 +438,11 @@ struct DataPayload: Codable {
var snapshotQuality: SnapshotQuality?
/// Set when the capture describes an in-place system surface, not the app itself (#2438).
var systemSurface: SystemSurfaceProvenancePayload?
/// Whether this capture read a modal presentation over the app's own content (#3565). The runner's
/// accessibility tree reports no UIKit class names, so a runner capture states `presented` from
/// positive evidence and `unknown` otherwise; `absent` belongs to a producer that can read window
/// structure, and a tier that classified nothing publishes no key at all.
var presentedSurface: PresentedSurface?
/// The keyboard band this capture measured, when the tier that answered reads keyboards at all
/// (#2660). Absent means the query-sweep or private-AX tier answered, and the daemon's tap guard
/// keeps deriving the band from the tree.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,20 @@ extension RunnerTests {
nodes.append(contentNode)
}

return DataPayload(nodes: nodes, truncated: false)
// The probe resolved a blocking modal over the app, so this payload states its own subject rather
// than leaving a reader to infer it from a tree that holds only the modal (#3565).
return Self.blockingSystemModalPayload(nodes: nodes)
#endif
}

/// The payload a resolved blocking system modal publishes: the modal's own tree, and the fact that
/// the modal is what it describes. The probe is the one capture that states `presented` without
/// reading a window structure — its tree holds only the modal, so nothing downstream could read the
/// presentation out of it (#3565). Split from the probe body because that body needs a device.
static func blockingSystemModalPayload(nodes: [PresentedNode]) -> DataPayload {
DataPayload(nodes: nodes, truncated: false, presentedSurface: .presented)
}

func firstBlockingSystemModal(
in springboard: XCUIApplication,
deadline: Date = .distantFuture
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import XCTest
import AgentDeviceSnapshotPresentation

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
// #3565: a capture has to say whether it read a modal presentation over the app. The blocking
// system-modal probe is the one capture that can state `presented` without reading any window
// structure: the tree it publishes holds only the modal, so nothing downstream could read a
// presentation out of it. Only the payload factory is pinned here, because the probe body that calls it
// enumerates XCUIElements and needs a device.
extension RunnerTests {
func testResolvedBlockingSystemModalPayloadStatesAPresentedSurface() {
XCTAssertEqual(Self.blockingSystemModalPayload(nodes: []).presentedSurface, .presented)
}

// Silence is the answer a consumer must not be able to confuse with absence: a capture the runner
// never classified (an older runner, a tier that published no reading) encodes no key at all.
func testUnclassifiedSnapshotPayloadEncodesNoPresentedSurfaceKey() throws {
let encoded = try JSONEncoder().encode(DataPayload(nodes: [], truncated: false))
let object = try XCTUnwrap(
JSONSerialization.jsonObject(with: encoded) as? [String: Any],
"the snapshot payload encodes as an object"
)
XCTAssertNil(object["presentedSurface"])
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

/// Whether this capture read a modal presentation over the app's own content (#3565). The producer owns
/// the answer, and `absent` is a reading rather than a default, because a consumer arms a dismissal
/// confirmation on it: a capture whose evidence cannot answer says `unknown` instead of staying silent,
/// since silence is also what a producer that never classified publishes.
///
/// Mirrors the TypeScript `PresentedSurface` in `@agent-device/kernel/snapshot`. The rule that arrives at
/// one of these three values is `SnapshotPresentedSurface` beside this type.
public enum PresentedSurface: String, Codable, Equatable {
/// A presentation covers the content this capture describes.
case presented
/// Every window this capture read showed only its own content container.
case absent
/// The capture supports neither answer.
case unknown
}
4 changes: 4 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@
"types": "./src/snapshot/scroll-edge-state.ts",
"default": "./src/snapshot/scroll-edge-state.ts"
},
"./snapshot-baseline": {
"types": "./src/snapshot-baseline.ts",
"default": "./src/snapshot-baseline.ts"
},
"./snapshot-chrome": {
"types": "./src/snapshot-chrome.ts",
"default": "./src/snapshot-chrome.ts"
Expand Down
38 changes: 38 additions & 0 deletions packages/capture-kit/src/snapshot-baseline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from 'vitest';
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import { surfaceScopedNodes } from './snapshot-baseline.ts';

const nodes = [{ index: 0, type: 'Application', ref: 'e1' }];

/**
* #3565 and #2438: this is the one mint both settle routes take their baseline from, so every fact a
* capture established about what it read has to leave here with the nodes. A baseline that lost the
* presented-surface fact would read to #1831's dismissal confirmation as "no producer spoke" instead of
* the reading the capture actually took.
*/
test('the mint carries the presented-surface fact beside the nodes', () => {
for (const presentedSurface of ['presented', 'absent', 'unknown'] as const) {
expect(surfaceScopedNodes(snapshot({ presentedSurface }))).toEqual({
nodes,
presentedSurface,
});
}
});

test('the mint carries no presented surface a capture never read', () => {
expect(surfaceScopedNodes(snapshot({}))).toEqual({ nodes });
});

test('the mint carries the system surface the nodes describe', () => {
expect(
surfaceScopedNodes(snapshot({ iosSystemSurfaceBundleId: 'com.apple.SafariViewService' })),
).toEqual({ nodes, surfaceBundleId: 'com.apple.SafariViewService' });
});

test('a session with no stored snapshot still mints an empty baseline', () => {
expect(surfaceScopedNodes(undefined)).toEqual({ nodes: [] });
});

function snapshot(fields: Partial<SnapshotState>): SnapshotState {
return { nodes, createdAt: 0, presentationKey: '', ...fields } as SnapshotState;
}
25 changes: 25 additions & 0 deletions packages/capture-kit/src/snapshot-baseline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { SurfaceScopedNodes } from '@agent-device/contracts/interaction';

/**
* Mints the one baseline value a post-action comparison carries: the captured nodes together with the
* surface they describe and the presentation the capture read (#2438, #3565).
*
* Both settle routes take their baseline from here — the element-targeted interaction route and the
* generic daemon route (`scroll`/`back --settle`) — so a fact a capture established cannot be carried
* by one route and dropped by the other. The pair sits beside {@link buildSnapshotState} because that
* is the layer both routes can reach: the interaction route lives in `commands/`, the generic route in
* `daemon/`, and the layering forbids the second from importing the first.
*
* One value, never two channels: every boundary that carries a baseline carries the type this returns,
* so nodes cannot arrive without the surface they describe.
*/
export function surfaceScopedNodes(snapshot: SnapshotState | undefined): SurfaceScopedNodes {
return {
nodes: snapshot?.nodes ?? [],
...(snapshot?.iosSystemSurfaceBundleId
? { surfaceBundleId: snapshot.iosSystemSurfaceBundleId }
: {}),
...(snapshot?.presentedSurface ? { presentedSurface: snapshot.presentedSurface } : {}),
};
}
7 changes: 7 additions & 0 deletions packages/capture-kit/src/snapshot-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
snapshotPresentationOptionsFromFlags,
snapshotRefMinting,
type IosTargetActivation,
type PresentedSurface,
type RawSnapshotNode,
type SnapshotBackend,
type SnapshotCaptureProvenance,
Expand Down Expand Up @@ -55,6 +56,11 @@ export function buildSnapshotState(
keyboard?: SnapshotKeyboardBandFact;
/** The box the producer measured the rects in (#3182), carried to the response that reads this state. */
viewport?: SnapshotViewportSize;
/**
* iOS: the producer's reading of a modal presentation over the app (#3565), decided on the
* acquired tree so every projection of one screen carries the same value.
*/
presentedSurface?: PresentedSurface;
} & SnapshotCaptureProvenance,
flags:
| (Pick<CommandFlags, 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw'> &
Expand Down Expand Up @@ -92,6 +98,7 @@ export function buildSnapshotState(
? { comparisonKey: iosSnapshotComparisonIdentityKey(data.comparisonIdentity) }
: {}),
...(data.systemSurface ? { iosSystemSurfaceBundleId: data.systemSurface.bundleId } : {}),
...(data.presentedSurface ? { presentedSurface: data.presentedSurface } : {}),
...(data.targetActivation ? { targetActivation: data.targetActivation } : {}),
...(data.keyboard ? { keyboard: data.keyboard } : {}),
...(data.viewport ? { viewport: data.viewport } : {}),
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
import type {
Point,
Rect,
PresentedSurface,
SnapshotKeyboardBandFact,
SnapshotNode,
SnapshotOptions,
Expand Down Expand Up @@ -58,6 +59,12 @@ export type BackendSnapshotResult = {
* the band from `nodes`.
*/
keyboard?: SnapshotKeyboardBandFact;
/**
* iOS: whether this capture read a modal presentation over the app (#3565), classified by the
* producer on its acquired tree. A backend that publishes nothing classified nothing; a consumer
* treats that as `'unknown'`, never as absence.
*/
presentedSurface?: PresentedSurface;
/**
* The box these rects are measured in, as the producer measured it (#3182). Absent means the
* producer measured no box — never a zero — since that absence is the only answer a producer with
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/client-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SessionSurface } from './session-surface.ts';
import type { PublicSnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts';
import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
import type {
PresentedSurface,
SnapshotCommandOptionFields,
SnapshotKeyboardBandFact,
SnapshotNode,
Expand Down Expand Up @@ -78,6 +79,13 @@ export type CaptureSnapshotResult = {
* screen size only; content-safe gesture bounds stay with #1821.
*/
viewport?: SnapshotViewportSize;
/**
* iOS: whether the capture read a modal presentation over the app (#3565), decided on the acquired
* tree before interactive filtering, compaction, depth, or scope. `'absent'` is a positive reading;
* a capture whose producer could not decide — or did not speak — reports `'unknown'`, and absence
* of the field means a non-iOS producer or an older runner published nothing.
*/
presentedSurface?: PresentedSurface;
/**
* Screenshot captured automatically when the semantic snapshot was sparse.
* Remote clients receive a materialized local path through the daemon artifact channel.
Expand Down
19 changes: 18 additions & 1 deletion packages/contracts/src/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Point, SnapshotKeyboardBandFact, SnapshotNode } from '@agent-device/kernel/snapshot';
import type {
PresentedSurface,
Point,
SnapshotKeyboardBandFact,
SnapshotNode,
} from '@agent-device/kernel/snapshot';
import type { ResponseCost } from '@agent-device/kernel/contracts';
import type { ClickButton } from './click-button.ts';
import type { FillUnconfirmedVerification } from './fill-evidence.ts';
Expand Down Expand Up @@ -127,6 +132,11 @@ export type PreresolvedInteractionTarget = {
* its baseline came from.
*/
iosSystemSurfaceBundleId?: string;
/**
* Whether the producer that captured `nodes` read a modal presentation over the app (#3565),
* carried so the adopting leaf's settle baseline keeps the fact its own capture established.
*/
presentedSurface?: PresentedSurface;
/**
* The keyboard band the capture of `nodes` measured, carried so the adopting consumer's keyboard
* guard reads the same band the tree was captured with instead of re-deriving one (#2660).
Expand All @@ -148,6 +158,13 @@ export type SurfaceScopedNodes = {
nodes: SnapshotNode[];
/** Bundle id of the in-place iOS system surface; absent for ordinary app content. */
surfaceBundleId?: string;
/**
* Whether the producer that captured these nodes read a modal presentation over the app (#3565).
* The settle baseline is carried in this type, so the fact has to travel with the nodes: a consumer
* that arms #1831's dismissal confirmation reads it here instead of guessing from the tree.
* Absent means the capture's producer published no fact.
*/
presentedSurface?: PresentedSurface;
};

export type ResolvedInteractionTarget =
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
IosSnapshotComparisonIdentity,
} from './ios-snapshot.ts';
import type {
PresentedSurface,
RawSnapshotNode,
Point,
Rect,
Expand Down Expand Up @@ -265,6 +266,12 @@ export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> &
* over the session app rather than the app itself (#2438).
*/
systemSurface?: IosSystemSurfaceProvenance;
/**
* Whether this capture read a modal presentation over the app (#3565), computed by the producer on
* the acquired tree before any projection. Absent means the producer published no fact at all,
* which a consumer must treat as `'unknown'`.
*/
presentedSurface?: PresentedSurface;
/**
* The keyboard band the producer measured while capturing, when it can measure one (#2660). A
* producer that publishes nothing measured nothing, so the tap-path guard keeps deriving the band
Expand Down
99 changes: 99 additions & 0 deletions packages/kernel/src/presented-surface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { PRESENTED_SURFACE_STATES } from './snapshot.ts';
import { readPresentedSurfaceFact } from './record.ts';

// The presented-surface fact (#3565) arrives from two producers — the Apple runner's capture payload
// and a serialized snapshot response — and both seams read it here. What the reader must never do is
// turn a value it cannot name into silence: a consumer that arms the #1831 dismissal confirmation on
// `undefined` is back to guessing from a tree shape, which is the thing this fact retires.
//
// It takes the payload rather than the value it holds, so the lookup itself is owned here: every
// caller reading a wire fact is doing a property lookup on data the producer controls.

// One-argument form publishes the key with that value, `undefined` included, so a test can name the
// difference between a producer that never wrote the key and one that wrote it with nothing in it.
const carrying = (presentedSurface: unknown): Record<string, unknown> => ({
nodeCount: 2,
presentedSurface,
});

test('a capture that published no fact stays silent, and only that payload is silent', () => {
assert.equal(readPresentedSurfaceFact({ nodeCount: 2 }), undefined);
});

// A key that is present with no value is not the same event as no key: the producer declared the fact
// and failed to state it, which is what `unknown` exists to say. Collapsing the two would let a
// producer that dropped the value read exactly like a backend that has never had the fact at all.
test('a published key holding no value reads unknown, not the silence of no key', () => {
assert.equal(readPresentedSurfaceFact(carrying(undefined)), 'unknown');
});

// A missing key and an inherited one are not the same event, and only one of them is silence: a plain
// lookup walks to `Object.prototype`, where any module sharing the process could park a value, and
// `absent` is precisely the state that tells #1831 no dismissal is owed.
test('a payload with no own fact key is silent rather than inherited', () => {
Object.defineProperty(Object.prototype, 'presentedSurface', {
value: 'absent',
configurable: true,
writable: true,
});
try {
assert.equal(readPresentedSurfaceFact({ nodeCount: 2 }), undefined);
assert.equal(
readPresentedSurfaceFact(JSON.parse('{"presentedSurface":"presented"}')),
'presented',
);
} finally {
delete (Object.prototype as unknown as Record<string, unknown>).presentedSurface;
}
});

test('each declared state reads back as itself', () => {
for (const state of PRESENTED_SURFACE_STATES) {
assert.equal(readPresentedSurfaceFact(carrying(state)), state);
}
});

// The vocabulary is a closed set, so a producer that renames or invents a state must not reach a
// consumer as a state it cannot name. `absent` is the dangerous direction: reading a typo of it as
// `absent` would disarm a confirmation that is waiting for a dismissal.
test('a state this vocabulary never declared reads as unknown, never as absent', () => {
for (const payload of ['none', 'Presented', 'no-sheet', '', 'null', 0, 1, true, [], {}, null]) {
assert.equal(
readPresentedSurfaceFact(carrying(payload)),
'unknown',
`payload ${JSON.stringify(payload)}`,
);
}
});

// The reader trusts only its own table's own properties, so a published value that happens to name an
// inherited `Object.prototype` member is not a state.
test('a published value naming an inherited object member is not a state', () => {
for (const payload of ['constructor', 'toString', '__proto__']) {
assert.equal(readPresentedSurfaceFact(carrying(payload)), 'unknown', payload);
}
});

// The reader decides from a table it owns, and the exported vocabulary is frozen rather than
// `as const` alone: a consumer that mutated the array could otherwise move the boundary between a
// trusted state and `unknown` for every other module in the process.
test('the exported vocabulary cannot be widened or narrowed at runtime', () => {
assert.ok(Object.isFrozen(PRESENTED_SURFACE_STATES));

for (const mutate of [
() => (PRESENTED_SURFACE_STATES as unknown as string[]).push('not-presented'),
() => ((PRESENTED_SURFACE_STATES as unknown as string[])[0] = 'absent'),
() => (PRESENTED_SURFACE_STATES as unknown as string[]).pop(),
]) {
try {
mutate();
} catch {
// A strict-mode assignment or a frozen push throws; either way the array is unchanged.
}
}

assert.deepEqual([...PRESENTED_SURFACE_STATES], ['presented', 'absent', 'unknown']);
assert.equal(readPresentedSurfaceFact(carrying('not-presented')), 'unknown');
});
Loading
Loading