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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ extension RunnerTests {
let app: XCUIApplication
/// Set when `app` is a system surface served in place over the still-bound session app (#2438).
var systemSurface: SystemSurfaceHost? = nil
var observation: SnapshotObservationPayload? = nil
}

enum ActiveCommandPreparation {
Expand Down Expand Up @@ -494,6 +495,26 @@ extension RunnerTests {
command: Command,
routeToSpringboard: Bool = false
) -> ActiveCommandPreparation {
if command.command == .snapshot, command.observeOnly == true {
#if os(iOS)
guard command.appBundleId?.trimmedNonEmpty != nil else {
Comment thread
thymikee marked this conversation as resolved.
return .response(observeOnlyUnavailableResponse())
}
let observedApp = resolveAppWithoutActivation(command: command)
let reportedState = observedApp.state
guard reportedState == .runningForeground else {
return .response(observeOnlyUnavailableResponse())
}
return .context(ActiveCommandContext(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
app: observedApp,
observation: SnapshotObservationPayload(appState: Self.applicationStateName(reportedState))
))
#else
return .response(Response(ok: false, error: ErrorPayload(
code: "UNSUPPORTED_OPERATION", message: "observe-only snapshot is supported on iOS and iPadOS only"
)))
#endif
}
if routeToSpringboard {
return .context(ActiveCommandContext(app: springboard))
}
Expand Down Expand Up @@ -644,6 +665,14 @@ extension RunnerTests {
return XCUIApplication(bundleIdentifier: host.bundleId).state
}

func observeOnlyUnavailableResponse() -> Response {
Response(ok: false, error: ErrorPayload(
code: "OBSERVATION_UNAVAILABLE",
message: "The session app cannot be observed without activation.",
hint: "XCUIApplication must report runningForeground before and after capture. This state report does not prove screen ownership (#2696)."
))
}

func currentXCTestFailureCount() -> Int {
return testRun?.failureCount ?? 0
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ struct Command: Codable {
let interactiveOnly: Bool?
let preferredBackend: String?
let customActions: Bool?
let observeOnly: Bool?
let depth: Int?
let scope: String?
let raw: Bool?
Expand Down Expand Up @@ -488,6 +489,7 @@ struct DataPayload: Codable {
var failedStepIndex: Int?
var sequenceResults: [SequenceStepResult]?
var targetActivation: TargetActivationFactPayload?
var observation: SnapshotObservationPayload?
/// Present on a screenshot the runner captured from a display it resolved, alongside the
/// `message` path or `imageBase64` payload that carries the image itself (#2728).
var screenshotMetadata: ScreenshotMetadataPayload?
Expand Down Expand Up @@ -529,6 +531,13 @@ struct SystemSurfaceProvenancePayload: Codable {
let kind: String
}

struct SnapshotObservationPayload: Codable {
var mode = "observe-only"
var activationPerformed = false
let appState: String
var appStateSource = "xcuiapplication-state"
}

struct SnapshotQualityPayload: Codable {
let nodes: [PresentedNode]
let truncated: Bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct SnapshotCaptureTarget {
/// What snapshot command preparation hands the off-main capture.
enum SnapshotCommandPreparation {
case response(Response)
case capture(SnapshotCaptureTarget, systemSurface: SystemSurfaceHost?)
case capture(SnapshotCaptureTarget, systemSurface: SystemSurfaceHost?, observation: SnapshotObservationPayload?)
}

/// The target a bounded XCTest probe arms its abandonment penalty with.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,27 @@ extension RunnerTests {
return .response(response)
case .context(let context):
return .capture(
self.takeSnapshotCaptureTarget(app: context.app),
systemSurface: context.systemSurface
context.observation == nil
? self.takeSnapshotCaptureTarget(app: context.app)
: SnapshotCaptureTarget(
app: context.app,
bundleId: command.appBundleId?.trimmedNonEmpty,
processIdentifier: Self.processIdentifier(of: context.app)
),
systemSurface: context.systemSurface,
observation: context.observation
)
}
}
switch preparation {
case .response(let response):
return response
case .capture(let target, let systemSurface):
case .capture(let target, let systemSurface, let observation):
return try executeSnapshotPrepared(
command: command,
target: target,
systemSurface: systemSurface
systemSurface: systemSurface,
observation: observation
)
}
}
Expand Down Expand Up @@ -86,7 +94,8 @@ extension RunnerTests {
private func executeSnapshotPrepared(
command: Command,
target: SnapshotCaptureTarget,
systemSurface: SystemSurfaceHost?
systemSurface: SystemSurfaceHost?,
observation: SnapshotObservationPayload?
) throws -> Response {
let options = Self.presentationOptions(from: command)
do {
Expand All @@ -102,6 +111,21 @@ extension RunnerTests {
kind: systemSurface.kind.rawValue
)
}
if let observation {
guard let capturedProcessIdentifier = target.processIdentifier else {
return observeOnlyUnavailableResponse()
}
let unchanged = try runMainThreadWork(
"observe_only_state",
timeout: Self.mainThreadExecutionTimeout,
timeoutError: Self.mainThreadExecutionTimeoutError
) {
target.app.state == .runningForeground
&& Self.processIdentifier(of: target.app) == capturedProcessIdentifier
}
guard unchanged else { return observeOnlyUnavailableResponse() }
payload.observation = observation
}
setNeedsPostSnapshotInteractionDelay()
return Response(ok: true, data: payload)
} catch let failure as SnapshotCaptureFailure {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,73 @@ extension RunnerTests {
// `os(iOS)` regions in this file are pure runner decisions and also run on the macOS host
// lane (ci.yml) — see the classification convention in RunnerTests.swift.
#if os(iOS)
@MainActor
func testObserveOnlySnapshotRefusesBackgroundWithoutActivationOrDisclosure() throws {
app.launch()
XCUIDevice.shared.press(.home)
XCTAssertTrue(app.wait(for: .runningBackground, timeout: 10))
pendingTargetActivation = nil
defer { app.terminate() }
let command = try runnerCommandFixture(
"{\"command\":\"snapshot\",\"appBundleId\":\"com.callstack.agentdevice.runner\",\"observeOnly\":true}"
)
guard case .response(let response) = prepareActiveCommandContext(command: command) else {
return XCTFail("observe-only must refuse a background target")
}
XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "OBSERVATION_UNAVAILABLE")
XCTAssertEqual(app.state, .runningBackground)
XCTAssertNil(pendingTargetActivation)
XCTAssertNil(response.data?.targetActivation)
}

@MainActor
func testObserveOnlySnapshotReportsStateWithoutBindingOrActivatingTarget() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
invalidateCachedTarget(reason: "unit_test_setup")
pendingTargetActivation = nil
defer { app.terminate() }
let command = try runnerCommandFixture(
"{\"command\":\"snapshot\",\"appBundleId\":\"com.callstack.agentdevice.runner\",\"observeOnly\":true}"
)
guard case .context(let context) = prepareActiveCommandContext(command: command) else {
return XCTFail("observe-only must accept the reported foreground state")
}
XCTAssertEqual(context.observation?.mode, "observe-only")
XCTAssertEqual(context.observation?.activationPerformed, false)
XCTAssertEqual(context.observation?.appState, "runningForeground")
XCTAssertEqual(context.observation?.appStateSource, "xcuiapplication-state")
XCTAssertNil(context.systemSurface)
XCTAssertNil(mainOwned.bundleId)
XCTAssertNil(pendingTargetActivation)
}

@MainActor
func testRegularSnapshotStillRepairsBackgroundAndDisclosesPriorState() throws {
app.launch()
mainOwned.app = app
mainOwned.bundleId = "com.callstack.agentdevice.runner"
XCUIDevice.shared.press(.home)
XCTAssertTrue(app.wait(for: .runningBackground, timeout: 10))
pendingTargetActivation = nil
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
pendingTargetActivation = nil
app.terminate()
}
let command = try runnerCommandFixture(
"{\"command\":\"snapshot\",\"appBundleId\":\"com.callstack.agentdevice.runner\"}"
)
guard case .context(let context) = prepareActiveCommandContext(command: command) else {
return XCTFail("regular snapshot must keep the foreground repair")
}
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
XCTAssertNil(context.observation)
XCTAssertEqual(pendingTargetActivation?.priorState, Int(XCUIApplication.State.runningBackground.rawValue))
XCTAssertEqual(pendingTargetActivation?.reason, "stale_target")
}

@MainActor
func testMissingBundleCommandInvalidatesCompleteCachedTargetState() throws {
app.launch()
Expand Down
1 change: 1 addition & 0 deletions contracts/fixtures/runner-requests.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
{"name": "ios-simulator.interactor-read-text.point", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "readText", "x": 10, "y": 20, "appBundleId": "com.example.app", "commandId": "<commandId>"}},
{"name": "ios-simulator.interactor-set-orientation.rotate", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "rotate", "orientation": "landscape-left", "appBundleId": "com.example.app", "commandId": "<commandId>"}},
{"name": "ios-simulator.interactor-snapshot.every-option", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "preferredBackend": "tree", "customActions": true, "depth": 3, "scope": "Go", "raw": true, "commandId": "<commandId>"}},
{"name": "ios-simulator.interactor-snapshot.observe-only", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "observeOnly": true, "commandId": "<commandId>"}},
{"name": "ios-simulator.interactor-write-clipboard.text", "producer": "packages/platform-apple/src/__tests__/runner-requests.test.ts", "request": {"command": "pasteboardWrite", "text": "one-time code 246810", "commandId": "<commandId>"}},
{"name": "ios-simulator.recording-clock-anchor.snapshot", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "depth": 1}},
{"name": "ios-simulator.recording-start.default", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStart", "outPath": "capture.mp4", "appBundleId": "com.example.app"}},
Expand Down
12 changes: 12 additions & 0 deletions packages/command-registry/src/flag-definitions-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,18 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: true,
recorded: true,
},
{
key: 'snapshotObserveOnly',
names: ['--observe-only'],
type: 'boolean',
usageLabel: '--observe-only',
usageDescription:
'Snapshot: observe the iOS session app without activation or foreground repair',
inputDescription:
'iOS only. Refuse instead of activating or repairing the session app. Reports XCUIApplication state, which does not prove screen ownership (#2696).',
projectConfig: false,
recorded: false,
},
{
key: 'snapshotForceFull',
names: ['--force-full'],
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export type CliFlags = CloudProviderProfileFields &
snapshotScope?: string;
snapshotRaw?: boolean;
snapshotCustomActions?: boolean;
snapshotObserveOnly?: boolean;
snapshotForceFull?: boolean;
artifact?: string;
dsym?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type {
export {
publicSnapshotCaptureAnnotations,
readSerializedSnapshotCaptureAnnotations,
readSnapshotObservation,
snapshotCaptureAnnotationsFrom,
} from '../snapshot-capture-annotations.ts';
export type {
Expand Down
24 changes: 23 additions & 1 deletion packages/contracts/src/snapshot-capture-annotations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
IosTargetActivation,
IosSnapshotObservation,
SnapshotCaptureBackend,
SnapshotQualityState,
SnapshotQualityVerdict,
Expand Down Expand Up @@ -43,11 +44,12 @@ export type SnapshotCaptureAnnotations = {
warnings?: string[];
/** The Apple runner re-activated the session app while serving this capture (#2682). */
targetActivation?: IosTargetActivation;
observation?: IosSnapshotObservation;
};

export type PublicSnapshotCaptureAnnotations = Pick<
SnapshotCaptureAnnotations,
'androidSnapshot' | 'warnings' | 'targetActivation'
'androidSnapshot' | 'warnings' | 'targetActivation' | 'observation'
> & {
snapshotQuality?: SnapshotQualityVerdict;
};
Expand All @@ -63,6 +65,7 @@ export function snapshotCaptureAnnotationsFrom(
...(quality ? { quality } : {}),
...(source.warnings ? { warnings: source.warnings } : {}),
...(source.targetActivation ? { targetActivation: source.targetActivation } : {}),
...(source.observation ? { observation: source.observation } : {}),
};
}

Expand All @@ -76,6 +79,7 @@ export function publicSnapshotCaptureAnnotations(
? { warnings: annotations.warnings }
: {}),
...(annotations.targetActivation ? { targetActivation: annotations.targetActivation } : {}),
...(annotations.observation ? { observation: annotations.observation } : {}),
};
}

Expand All @@ -91,16 +95,34 @@ export function readSerializedSnapshotCaptureAnnotations(
: undefined;
const quality = readPublishedSnapshotQualityVerdict(data.snapshotQuality);
const targetActivation = readTargetActivation(data.targetActivation);
const observation = readSnapshotObservation(data.observation);
return publicSnapshotCaptureAnnotations({
...(androidSnapshot
? { androidSnapshot: androidSnapshot as AndroidSnapshotBackendMetadata }
: {}),
...(quality ? { quality } : {}),
...(warnings ? { warnings } : {}),
...(targetActivation ? { targetActivation } : {}),
...(observation ? { observation } : {}),
});
}

export function readSnapshotObservation(value: unknown): IosSnapshotObservation | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
return raw.mode === 'observe-only' &&
raw.activationPerformed === false &&
raw.appState === 'runningForeground' &&
raw.appStateSource === 'xcuiapplication-state'
? {
mode: 'observe-only',
activationPerformed: false,
appState: raw.appState,
appStateSource: raw.appStateSource,
}
: undefined;
}

/** Re-read of a fact this module projected; the declared keys are the only ones it publishes. */
function readTargetActivation(value: unknown): IosTargetActivation | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
Expand Down
14 changes: 14 additions & 0 deletions packages/contracts/src/snapshot-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ function bindSnapshotInteractor(
params: SnapshotInteractorBindingParams,
): SnapshotRuntimeOperations {
const captureSnapshot = async (input: CaptureSnapshotInput) => {
await assertSnapshotObservationOwner(params, input);
const signal = captureSnapshotSignal(params.signal, input);
const runner: RunnerContext = {
...input.execution,
Expand Down Expand Up @@ -151,6 +152,19 @@ function bindSnapshotInteractor(
});
}

async function assertSnapshotObservationOwner(
params: SnapshotInteractorBindingParams,
input: CaptureSnapshotInput,
): Promise<void> {
if (input.options?.observeOnly !== true) return;
const { isHandheldAppleDevice } = await import('@agent-device/kernel/device');
if (params.ownership === 'local' && isHandheldAppleDevice(params.device)) return;
throw new AppError(
'UNSUPPORTED_OPERATION',
'observe-only snapshot requires a local iOS or iPadOS runner.',
);
}

export function bindLocalSnapshotInteractor(
params: Readonly<{
device: DeviceInfo;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/snapshot-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
SnapshotState,
SnapshotOptions,
SnapshotQualityVerdict,
IosSnapshotObservation,
ScreenshotOverlayRef,
SnapshotViewportSize,
} from '@agent-device/kernel/snapshot';
Expand Down Expand Up @@ -44,6 +45,7 @@ export type BackendSnapshotResult = {
};
quality?: SnapshotQualityVerdict;
warnings?: string[];
observation?: IosSnapshotObservation;
};
export type BackendSnapshotOptions = SnapshotOptions & {
includeRects?: boolean;
Expand Down
Loading
Loading