diff --git a/.changeset/limrun-snapshot-producer.md b/.changeset/limrun-snapshot-producer.md new file mode 100644 index 0000000000..604d92cf6a --- /dev/null +++ b/.changeset/limrun-snapshot-producer.md @@ -0,0 +1,5 @@ +--- +'agent-device': patch +--- + +Limrun iOS snapshots no longer use the `limrun-ios-tree` producer. Bridge captures report `simulator-ax-bridge`; captures that fall back to XCTest report `apple-runner`. If you inspect saved snapshot provenance, continue to handle the older `limrun-ios-tree` value for historical captures. diff --git a/.github/actions/setup-apple-runner-build/action.yml b/.github/actions/setup-apple-runner-build/action.yml index 09514d9450..78b85b7be7 100644 --- a/.github/actions/setup-apple-runner-build/action.yml +++ b/.github/actions/setup-apple-runner-build/action.yml @@ -42,7 +42,7 @@ runs: id: source-hash run: | set -euo pipefail - echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'apple/snapshot-bridge/ForegroundApplicationGeometry.h') }}" >> "$GITHUB_OUTPUT" + echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'apple/snapshot-bridge/**') }}" >> "$GITHUB_OUTPUT" shell: bash - name: Resolve Apple runner cache schema diff --git a/.github/workflows/release-ios-runner.yml b/.github/workflows/release-ios-runner.yml index e07913888e..67515a6479 100644 --- a/.github/workflows/release-ios-runner.yml +++ b/.github/workflows/release-ios-runner.yml @@ -52,6 +52,16 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" shell: bash + # Limrun simulators run iOS 26 and cannot reach this machine's Xcode. Xcode 26 embeds XCTest in + # the runner app; Xcode 27 leaves it out, and the packaging step refuses such a runner. + - name: Select Xcode 26 + run: | + set -euo pipefail + XCODE_APP="$(ls -d /Applications/Xcode_26*.app | sort -V | tail -n 1)" + echo "DEVELOPER_DIR=${XCODE_APP}/Contents/Developer" >> "$GITHUB_ENV" + echo "Using ${XCODE_APP}" + shell: bash + - name: Build iOS simulator runner env: AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h index dc74dcf5ad..090e1b31e8 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h @@ -1,10 +1,14 @@ #import "RunnerObjCExceptionCatcher.h" #import "RunnerAXSnapshotBridge.h" #import "RunnerSimulatorEdgeTouch.h" +#import "RunnerSnapshotBridge.h" #import "RunnerSynthesizedGesture.h" #import "RunnerSynthesizedTextEntry.h" #import "RunnerXCTestEventBridge.h" // The packaged runner omits UnitTests/. OTHER_CFLAGS defines do not reach this header. +#if __has_include("UnitTests/RunnerSnapshotBridgeReaderRequest.h") +#import "UnitTests/RunnerSnapshotBridgeReaderRequest.h" +#endif #if __has_include("UnitTests/RunnerAXModalScopeCapture.h") #import "UnitTests/RunnerAXModalScopeCapture.h" #endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.h new file mode 100644 index 0000000000..4fecd52671 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.h @@ -0,0 +1,19 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Serves the Simulator AX bridge reader (`apple/snapshot-bridge`) from the runner process, for hosts +/// that cannot start the guest bridge process, such as a remote Simulator. Each call returns one bridge +/// envelope: the reader's own response keys, plus `processStartTime` beside `pid` so the host can name +/// the app generation it read. A host that has no envelope to answer with gets a failure envelope, never +/// an exception. +/// +/// `verb` is `target` or `describe`. `target` names the foreground process of `bundleId`. `describe` reads +/// that process's tree; with an expected pid and start time it refuses another process of the same +/// bundle as `stale_target`, and it refuses a capture whose process changed while it was read. +NSDictionary *RunnerSnapshotBridgeHandle(NSString *verb, NSString *bundleId, NSDictionary *request); + +/// The largest response the reader's frame carries: the bound a request that names none falls back to. +uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void); + +NS_ASSUME_NONNULL_END diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.m new file mode 100644 index 0000000000..0bd9ccf50e --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.m @@ -0,0 +1,288 @@ +#import "RunnerSnapshotBridge.h" + +#import +#import "SnapshotBridgeRuntime.h" + +#if TARGET_OS_IOS && TARGET_OS_SIMULATOR + +// The reader's own translation units, compiled into the runner through HEADER_SEARCH_PATHS so the runner +// and the guest bridge process cannot drift apart. The guest server (SnapshotBridge.m) stays out: the +// runner's transport replaces its socket. The guest build owns the reader's warning flags, so the runner +// target's stricter style checks stop at these includes. +// The runner abandons a read that outlives its budget (RunnerSnapshotBridgeRead) instead of ending the +// process, which also runs every XCTest command. +#define SNAPSHOT_BRIDGE_WATCHDOG_ENDS_PROCESS 0 +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wnullability-completeness" +#pragma clang diagnostic ignored "-Wimplicit-retain-self" +#include "SnapshotBridgeCapture.m" +#include "SnapshotBridgeRuntime.m" +#include "SnapshotBridgeSurfaceHosts.m" +#include "SnapshotBridgeRequest.m" +#pragma clang diagnostic pop + +#import +#import +#import + +@protocol RunnerSnapshotBridgePrimaryApp ++ (id)primaryApp; +- (pid_t)pid; +@end + +static NSDictionary *RunnerSnapshotBridgeFailure(NSDictionary *request, + NSString *kind, + NSString *code, + NSString *message) +{ + NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @""; + NSMutableDictionary *failure = [failureResponse(requestId, kind, code, message) mutableCopy]; + // A refusal answers the read the host planned, so it names that target; the process the runner + // found instead travels as `observedPid`, never as the envelope's own pid. + if ([request[@"expectedPid"] isKindOfClass:NSNumber.class]) failure[@"pid"] = request[@"expectedPid"]; + if ([request[@"generation"] isKindOfClass:NSString.class]) failure[@"generation"] = request[@"generation"]; + return failure; +} + +/// The process AXRuntime names as the primary foreground application, the same owner the reader checks +/// before and after a capture. +static pid_t RunnerSnapshotBridgePrimaryPid(void) +{ + Class elementClass = objc_lookUpClass("AXElement"); + if (![elementClass respondsToSelector:@selector(primaryApp)]) return 0; + id application = [(Class)elementClass primaryApp]; + if (![application respondsToSelector:@selector(pid)]) return 0; + return [(id)application pid]; +} + +/// The process start time, as seconds and microseconds since the epoch. With the pid it names one +/// process for its whole life, so a relaunch is a new generation even when the kernel reuses the pid. +static NSString *_Nullable RunnerSnapshotBridgeStartTime(pid_t pid) +{ + struct kinfo_proc info; + size_t size = sizeof(info); + int name[] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid}; + if (sysctl(name, 4, &info, &size, NULL, 0) != 0 || size == 0 || info.kp_proc.p_pid != pid) return nil; + struct timeval started = info.kp_proc.p_starttime; + return [NSString stringWithFormat:@"%ld.%06d", (long)started.tv_sec, (int)started.tv_usec]; +} + +/// The executable path of `pid`, the first string `KERN_PROCARGS2` reports after the argument count. +static NSString *_Nullable RunnerSnapshotBridgeExecutable(pid_t pid) +{ + int name[] = {CTL_KERN, KERN_PROCARGS2, pid}; + size_t size = 0; + if (sysctl(name, 3, NULL, &size, NULL, 0) != 0 || size <= sizeof(int)) return nil; + NSMutableData *buffer = [NSMutableData dataWithLength:size]; + if (sysctl(name, 3, buffer.mutableBytes, &size, NULL, 0) != 0 || size <= sizeof(int)) return nil; + const char *path = (const char *)buffer.bytes + sizeof(int); + size_t remaining = size - sizeof(int); + size_t length = strnlen(path, remaining); + if (length == 0 || length == remaining) return nil; + return [[NSString alloc] initWithBytes:path length:length encoding:NSUTF8StringEncoding]; +} + +/// The bundle identifier of the app bundle that holds the process's executable. +static NSString *_Nullable RunnerSnapshotBridgeBundleId(pid_t pid) +{ + NSString *executable = RunnerSnapshotBridgeExecutable(pid); + if (!executable) return nil; + NSRange app = [executable rangeOfString:@".app/" options:NSBackwardsSearch]; + if (app.location == NSNotFound) return nil; + NSString *bundlePath = [executable substringToIndex:NSMaxRange(app) - 1]; + return [NSBundle bundleWithPath:bundlePath].bundleIdentifier; +} + +typedef struct { + pid_t pid; + NSString *_Nullable startTime; +} RunnerSnapshotBridgeProcess; + +/// The foreground process of `bundleId`, or a failure envelope saying why there is none. +static NSDictionary *_Nullable RunnerSnapshotBridgeResolve(NSString *bundleId, + NSDictionary *request, + RunnerSnapshotBridgeProcess *process) +{ + NSString *setupError = nil; + if (!sharedRuntime(&setupError)) { + return RunnerSnapshotBridgeFailure(request, @"unsupported", @"runtime-unavailable", setupError); + } + pid_t pid = RunnerSnapshotBridgePrimaryPid(); + if (pid <= 0) { + return RunnerSnapshotBridgeFailure(request, @"application_unavailable", @"foreground-owner-unverified", + @"AXRuntime reported no primary foreground application"); + } + if (![RunnerSnapshotBridgeBundleId(pid) isEqualToString:bundleId]) { + return RunnerSnapshotBridgeFailure(request, @"application_unavailable", @"foreground-owner-unverified", + @"the primary foreground application is not the requested app"); + } + NSString *startTime = RunnerSnapshotBridgeStartTime(pid); + if (!startTime) { + return RunnerSnapshotBridgeFailure(request, @"stale_target", @"target-process-unavailable", + @"the foreground process exited before its identity was read"); + } + process->pid = pid; + process->startTime = startTime; + return nil; +} + +static NSDictionary *RunnerSnapshotBridgeTarget(NSString *bundleId, NSDictionary *request) +{ + RunnerSnapshotBridgeProcess process = {0, nil}; + NSDictionary *failure = RunnerSnapshotBridgeResolve(bundleId, request, &process); + if (failure) return failure; + NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @""; + return @{ + kProtocolVersionKey : @(kProtocolVersion), + kSourceVersionKey : kSourceVersion, + kRequestIdKey : requestId, + @"ok" : @YES, + @"pid" : @(process.pid), + @"processStartTime" : process.startTime, + }; +} + +static NSDictionary *RunnerSnapshotBridgeDescribe(NSString *bundleId, NSDictionary *request) +{ + RunnerSnapshotBridgeProcess process = {0, nil}; + NSDictionary *failure = RunnerSnapshotBridgeResolve(bundleId, request, &process); + if (failure) return failure; + id expectedPid = request[@"expectedPid"]; + id expectedStartTime = request[@"expectedProcessStartTime"]; + if (([expectedPid isKindOfClass:NSNumber.class] && [expectedPid intValue] != process.pid) || + ([expectedStartTime isKindOfClass:NSString.class] && ![expectedStartTime isEqualToString:process.startTime])) { + NSMutableDictionary *stale = [RunnerSnapshotBridgeFailure(request, @"stale_target", @"target-process-changed", + @"the foreground process of the app is a new process") mutableCopy]; + stale[@"observedPid"] = @(process.pid); + stale[@"observedProcessStartTime"] = process.startTime; + return stale; + } + NSMutableDictionary *describe = [request mutableCopy]; + [describe removeObjectsForKeys:@[ @"expectedPid", @"expectedProcessStartTime" ]]; + describe[@"pid"] = @(process.pid); + NSMutableDictionary *response = [SnapshotBridgeHandleRequest(describe) mutableCopy]; + if (![RunnerSnapshotBridgeStartTime(process.pid) isEqualToString:process.startTime]) { + return RunnerSnapshotBridgeFailure(request, @"stale_target", @"target-process-changed", + @"the app's process changed while its tree was read"); + } + response[@"processStartTime"] = process.startTime; + return response; +} + +/// The budget of a request that names none. The daemon names one on every request. +static const NSUInteger RunnerSnapshotBridgeDefaultBudgetMs = 3000; + +static NSUInteger RunnerSnapshotBridgeBudgetMs(NSDictionary *request) +{ + NSNumber *budget = [request[@"maxDurationMs"] isKindOfClass:NSNumber.class] ? request[@"maxDurationMs"] : nil; + if (budget.unsignedIntegerValue == 0) return RunnerSnapshotBridgeDefaultBudgetMs; + return MIN(budget.unsignedIntegerValue, kMaximumDurationMs); +} + +/// 1 while a read is admitted and has not returned, including a read abandoned past its budget: the +/// accessibility call it waits on cannot be cancelled, so later reads are refused until it returns +/// rather than queued behind it. +static atomic_int RunnerSnapshotBridgeReadsInFlight = 0; + +static dispatch_queue_t RunnerSnapshotBridgeReaderQueue(void) +{ + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.callstack.agentdevice.runner.snapshot-bridge-reader", + DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + +@interface RunnerSnapshotBridgeAnswer : NSObject +@property(atomic, strong, nullable) NSDictionary *value; +@end + +@implementation RunnerSnapshotBridgeAnswer +@end + +/// Runs one read on the reader queue and waits for it up to `budgetMs`. A read that outlives the budget +/// is abandoned, not stopped: its answer is dropped when it returns. +static NSDictionary *RunnerSnapshotBridgeRead(NSDictionary *request, + NSUInteger budgetMs, + NSDictionary * (^read)(void)) +{ + // Admission is one atomic step, because requests arrive on a concurrent queue. + int idle = 0; + if (!atomic_compare_exchange_strong(&RunnerSnapshotBridgeReadsInFlight, &idle, 1)) { + return RunnerSnapshotBridgeFailure(request, @"application_not_responding", @"bridge-read-in-flight", + @"another accessibility read has not returned yet"); + } + RunnerSnapshotBridgeAnswer *answer = [RunnerSnapshotBridgeAnswer new]; + dispatch_semaphore_t done = dispatch_semaphore_create(0); + dispatch_async(RunnerSnapshotBridgeReaderQueue(), ^{ + @try { + answer.value = read(); + } @catch (NSException *exception) { + answer.value = RunnerSnapshotBridgeFailure(request, @"reader_unavailable", @"reader-exception", + exception.reason ?: @"the snapshot reader raised an exception"); + } + atomic_store(&RunnerSnapshotBridgeReadsInFlight, 0); + dispatch_semaphore_signal(done); + }); + if (dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)budgetMs * NSEC_PER_MSEC)) != 0) { + return RunnerSnapshotBridgeFailure(request, @"application_not_responding", @"bridge-read-abandoned", + @"the accessibility read outlived its budget and was abandoned"); + } + return answer.value; +} + +uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void) +{ + return kMaximumFrameBytes; +} + +NSDictionary *RunnerSnapshotBridgeHandle(NSString *verb, NSString *bundleId, NSDictionary *request) +{ + if ([verb isEqualToString:@"describe"] && request[@"surfaceHostExecutables"] != nil && + request[@"simulatorUdid"] == nil) { + return RunnerSnapshotBridgeFailure(request, @"unsupported", @"system-surface-host-unverified", + @"the runner cannot identify its Simulator for the surface host check"); + } + if ([verb isEqualToString:@"target"]) { + return RunnerSnapshotBridgeRead(request, RunnerSnapshotBridgeBudgetMs(request), ^{ + return RunnerSnapshotBridgeTarget(bundleId, request); + }); + } + if ([verb isEqualToString:@"describe"]) { + return RunnerSnapshotBridgeRead(request, RunnerSnapshotBridgeBudgetMs(request), ^{ + return RunnerSnapshotBridgeDescribe(bundleId, request); + }); + } + return RunnerSnapshotBridgeFailure(request, @"bad_request", @"verb-not-supported", + @"the runner snapshot bridge accepts target and describe requests only"); +} + +#else + +/// Off the iOS Simulator the runner has no reader, so its only envelope is the refusal below. +static const uint32_t kRunnerSnapshotBridgeRefusalFrameBytes = 4096; + +uint32_t RunnerSnapshotBridgeMaximumFrameBytes(void) +{ + return kRunnerSnapshotBridgeRefusalFrameBytes; +} + +NSDictionary *RunnerSnapshotBridgeHandle(NSString *verb, NSString *bundleId, NSDictionary *request) +{ + (void)verb; + (void)bundleId; + id requestId = request[@"requestId"]; + return @{ + @"protocolVersion" : @(SNAPSHOT_BRIDGE_PROTOCOL_VERSION), + @"sourceVersion" : SNAPSHOT_BRIDGE_SOURCE_VERSION, + @"requestId" : [requestId isKindOfClass:NSString.class] ? requestId : @"", + @"ok" : @NO, + @"error_kind" : @"unsupported", + @"error_code" : @"runner-bridge-unavailable", + @"error" : @"the runner serves the snapshot bridge on the iOS Simulator only", + }; +} + +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index 8abef176e3..160f7c4103 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -466,6 +466,8 @@ extension RunnerTests { return executeUptime() case .appState: return executeAppState(command: command) + case .snapshotBridge: + return executeSnapshotBridge(command: command) case .pasteboardWrite: return executePasteboardWrite(command: command) case .activate: diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 28b53a0fb7..c5d3f55785 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -45,7 +45,7 @@ extension RunnerTests { } switch command.command { case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime, - .appState, .pasteboardWrite, .snapshot: + .appState, .pasteboardWrite, .snapshot, .snapshotBridge: return Response( ok: false, error: ErrorPayload( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 755879ac0a..a24d539d9e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -36,6 +36,8 @@ enum CommandType: String, Codable, CaseIterable { case status case uptime case appState + /// Reads the Simulator AX bridge tree on the transport, off the XCTest main thread. + case snapshotBridge case pasteboardWrite case activate case terminate @@ -279,6 +281,7 @@ struct Command: Codable { let litPanel: RunnerLitPanelFact? let sequenceOrientation: Int? let steps: [SequenceStep]? + let snapshotBridge: SnapshotBridgeCommandPayload? } extension Command { @@ -307,6 +310,11 @@ extension Command { case .status, .appState: return .runnerCaptureRead(retainsJournalResponseJson: true) + // The bridge read answers from AXRuntime, never through the session app, and the transport serves + // it before the journal, so no response is ever stored to replay. + case .snapshotBridge: + return .runnerCaptureRead(retainsJournalResponseJson: false) + case .screenshot: return .runnerCaptureRead(retainsJournalResponseJson: false) @@ -333,6 +341,23 @@ extension Command { } } +/// One request to the Simulator AX bridge reader that the runner serves (`RunnerSnapshotBridge.h`). The +/// bounds are the guest bridge's own `describe` fields; `expectedPid` and `expectedProcessStartTime` name +/// the app generation the host planned the read against. +struct SnapshotBridgeCommandPayload: Codable { + let verb: String + let requestId: String + let generation: String? + let expectedPid: Int? + let expectedProcessStartTime: String? + let maxDepth: Int? + let maxNodes: Int? + let maxDurationMs: Int? + let maxResponseBytes: Int? + let nativeLevelsHint: Int? + let surfaceHostExecutables: [String]? +} + enum ScrollReleaseBehavior: String, Codable { case controlled case inertial @@ -534,6 +559,9 @@ struct DataPayload: Codable { /// crawl still grinds, so the healthy response must carry the live occupancy rather than let the /// daemon read `ok` as proof the runner drained (#2552). var runnerMainThreadBusy: Bool? + /// The Simulator AX bridge envelope, serialized as the guest bridge would write it, so the host + /// validates one envelope format whichever process read the tree. + var snapshotBridgeEnvelope: String? var completedSteps: Int? var sequenceOrientation: Int? var failedStepIndex: Int? diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotBridge.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotBridge.swift new file mode 100644 index 0000000000..5ccbe99232 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotBridge.swift @@ -0,0 +1,118 @@ +import Foundation + +extension RunnerTests { + /// Where a bridge request waits for its read. The queue is concurrent, so a request reaches the + /// reader's admission at once: the reader runs one read at a time and refuses an overlapping one + /// immediately, and a read's budget starts at its admission rather than after a hidden wait. + static let snapshotBridgeQueue = DispatchQueue( + label: "agent-device.runner.snapshot-bridge", + attributes: .concurrent + ) + + /// The largest envelope the transport carries when a request names no smaller bound. It is the + /// reader's own frame bound, so a tree too large for one transport is too large for the other. + static let snapshotBridgeMaximumResponseBytes = Int(RunnerSnapshotBridgeMaximumFrameBytes()) + + /// The guest bridge counts its 4-byte length prefix against `maxResponseBytes`, and so does the host's + /// frame decoder, so an envelope here is held to the same bound. + static let snapshotBridgeFrameHeaderBytes = MemoryLayout.size + + /// Serves one `snapshotBridge` command. The answer is always a bridge envelope, including the reader's + /// own refusals; only a request the runner cannot read at all is a runner error. + func executeSnapshotBridge(command: Command) -> Response { + guard + let payload = command.snapshotBridge, + let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleId.isEmpty + else { + return Response( + ok: false, + error: ErrorPayload( + code: "INVALID_ARGS", + message: "snapshotBridge requires appBundleId and a snapshotBridge payload" + ) + ) + } + let envelope = RunnerSnapshotBridgeHandle( + payload.verb, + bundleId, + Self.snapshotBridgeReaderRequest(payload) + ) + let limit = payload.maxResponseBytes ?? Self.snapshotBridgeMaximumResponseBytes + return Response( + ok: true, + data: DataPayload(snapshotBridgeEnvelope: Self.serializedSnapshotBridgeEnvelope( + envelope, + requestId: payload.requestId, + maxBytes: limit + )) + ) + } + + /// The reader's request dictionary. The tree and automation flags are the guest bridge's fixed + /// `describe` contract, so the host does not repeat them on the runner transport. + static func snapshotBridgeReaderRequest( + _ payload: SnapshotBridgeCommandPayload, + simulatorUdid: String? = ProcessInfo.processInfo.environment["SIMULATOR_UDID"] + ) -> [String: Any] { + var request: [String: Any] = [ + "verb": "describe", + "requestId": payload.requestId, + "snapshotTree": true, + "automationMode": true, + ] + // The reader tells an absent field from a present one, so a field the host did not send stays out. + if let generation = payload.generation { request["generation"] = generation } + if let expectedPid = payload.expectedPid { request["expectedPid"] = expectedPid } + if let expectedProcessStartTime = payload.expectedProcessStartTime { + request["expectedProcessStartTime"] = expectedProcessStartTime + } + if let maxDepth = payload.maxDepth { request["maxDepth"] = maxDepth } + if let maxNodes = payload.maxNodes { request["maxNodes"] = maxNodes } + if let maxDurationMs = payload.maxDurationMs { request["maxDurationMs"] = maxDurationMs } + if let maxResponseBytes = payload.maxResponseBytes { + request["maxResponseBytes"] = maxResponseBytes + } + if let nativeLevelsHint = payload.nativeLevelsHint { + request["nativeLevelsHint"] = nativeLevelsHint + } + if let executables = payload.surfaceHostExecutables { + // The runner runs inside the Simulator it reads, so it names that Simulator itself. Without one + // the key stays out, and the runner refuses the surface proof for this capture. + request["surfaceHostExecutables"] = executables + if let simulatorUdid, !simulatorUdid.isEmpty { request["simulatorUdid"] = simulatorUdid } + } + return request + } + + /// The envelope as JSON, or the guest bridge's own refusal: `response-too-large` when it exceeds the + /// request's bound, `response-not-json-safe` when it cannot be serialized. The refusal keeps the + /// identity fields, so the host still reports which process the tree belonged to. + static func serializedSnapshotBridgeEnvelope( + _ envelope: [AnyHashable: Any], + requestId: String, + maxBytes: Int + ) -> String { + let data = JSONSerialization.isValidJSONObject(envelope) + ? try? JSONSerialization.data(withJSONObject: envelope) : nil + if let data, data.count + snapshotBridgeFrameHeaderBytes <= maxBytes, + let text = String(data: data, encoding: .utf8) { + return text + } + var refusal: [String: Any] = data == nil + ? [ + "requestId": requestId, "ok": false, "error_kind": "malformed_tree", + "error_code": "response-not-json-safe", "error": "response was not JSON serializable", + ] + : [ + "requestId": requestId, "ok": false, "error_kind": "response_limit_exceeded", + "error_code": "response-too-large", + "error": "snapshot response exceeds the per-request response bound", + ] + for key in ["protocolVersion", "sourceVersion", "pid", "generation", "processStartTime"] { + if let value = envelope[key] { refusal[key] = value } + } + let encoded = (try? JSONSerialization.data(withJSONObject: refusal)) ?? Data("{}".utf8) + return String(data: encoded, encoding: .utf8) ?? "{}" + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift index 9990e181b4..f181a109c1 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift @@ -202,9 +202,20 @@ extension RunnerTests { do { let command = try JSONDecoder().decode(Command.self, from: body) - if let response = inlineResponse(for: command) { + switch offQueueRoute(for: command) { + case .inline(let response): completion((jsonResponse(status: 200, response: response), false)) return + case .snapshotBridge: + Self.snapshotBridgeQueue.async { + let response = self.executeSnapshotBridge(command: command) + self.transportQueue.async { + completion((self.jsonResponse(status: 200, response: response), false)) + } + } + return + case nil: + break } // The journal decides here, at the one route an inbound request takes, whether this send may run // the command at all, and when the answer is to join an execution it registers this send for that @@ -288,13 +299,24 @@ extension RunnerTests { // MARK: - Command Routing - /// Status and uptime read runner state without entering the journal or the command queue. - func inlineResponse(for command: Command) -> Response? { + /// How the transport answers a command without the journal or the serial command queue. A reply on + /// either route is no evidence about queued work, which is why the daemon charges neither. + enum OffQueueRoute { + /// Answered on the transport queue: status and uptime read the runner's own state. + case inline(Response) + /// Answered on the snapshot bridge queue: the AX tree read never waits behind XCTest main-thread + /// work, and it changes nothing, so a resend reads the tree again instead of joining a journal entry. + case snapshotBridge + } + + func offQueueRoute(for command: Command) -> OffQueueRoute? { switch command.command { case .status: - return executeStatus(command: command) + return .inline(executeStatus(command: command)) case .uptime: - return executeUptime() + return .inline(executeUptime()) + case .snapshotBridge: + return .snapshotBridge default: return nil } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerSnapshotBridgeReaderRequest.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerSnapshotBridgeReaderRequest.h new file mode 100644 index 0000000000..3ee76f2677 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerSnapshotBridgeReaderRequest.h @@ -0,0 +1,3 @@ +// Exposes the shared reader's request validation to the runner unit bundle, which sends it the request +// the runner builds. The packaged runner omits UnitTests/. +#import "SnapshotBridgeRequest.h" diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index c8eabb962d..8bba410ff5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -58,13 +58,13 @@ extension RunnerTests { let name = String(describing: request) let command = try decodeProductionRunnerRequest(request, name) let readOnly = try XCTUnwrap(row["readOnly"] as? Bool, name) - XCTAssertNil(inlineResponse(for: command), name) + XCTAssertNil(offQueueRoute(for: command), name) XCTAssertEqual(command.traits.isReadOnly, readOnly, name) covered.insert(command.command) } let queued = try Set(CommandType.allCases.filter { type in let command = try runnerCommandFixture(#"{"command":"\#(type.rawValue)"}"#) - return inlineResponse(for: command) == nil + return offQueueRoute(for: command) == nil }) XCTAssertEqual(covered, queued, "every queued CommandType needs parity coverage") } @@ -241,19 +241,20 @@ extension RunnerTests { /// Commands that did not exist at the merge-base, so no classification of its is compared with /// theirs. `appState` arrived with #2929, `pasteboardWrite` with the runner-written simulator - /// clipboard. + /// clipboard, `snapshotBridge` with the runner-served Simulator AX bridge. private static let commandsNewerThanTheMergeBase: Set = [ - .appState, .pasteboardWrite, .volumeUp, .volumeDown, + .appState, .pasteboardWrite, .volumeUp, .volumeDown, .snapshotBridge, ] /// The commands production never runs through the prepared path's body: `executeOnMain` answers - /// these before `executeOnMainPrepared` runs, and `executeDispatched` answers `snapshot` earlier - /// still, on both this and the merge-base chain. The merge-base witness predicate was therefore + /// these before `executeOnMainPrepared` runs, `executeDispatched` answers `snapshot` earlier + /// still, on both this and the merge-base chain, and the transport answers `snapshotBridge` off + /// its queue before any of them. The merge-base witness predicate was therefore /// never evaluated for them and neither is the derived one. If a command starts reaching the /// prepared path, removing it here is a claim the equivalence assertion below has to keep proving. private static let commandsAnsweredBeforeThePreparedPath: Set = [ .status, .uptime, .appState, .pasteboardWrite, .activate, .terminate, .targetReset, .shutdown, - .recordStart, .recordStop, .snapshot, + .recordStart, .recordStop, .snapshot, .snapshotBridge, ] private func assertRememberedTextEntryWitnessInvalidation( @@ -339,6 +340,7 @@ extension RunnerTests { (.status, expectation(interaction: false, retry: true, launch: .noApp, converts: false, retains: true, isReadOnly: true)), (.uptime, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true, isReadOnly: false)), (.appState, expectation(interaction: false, retry: true, launch: .noApp, converts: false, retains: true, isReadOnly: true)), + (.snapshotBridge, expectation(interaction: false, retry: true, launch: .noApp, converts: false, retains: false, isReadOnly: true)), (.pasteboardWrite, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true, isReadOnly: false)), (.activate, expectation(interaction: false, retry: false, launch: .mayLaunch, converts: true, retains: true, isReadOnly: false)), (.terminate, expectation(interaction: false, retry: false, launch: .noApp, converts: false, retains: true, isReadOnly: false)), diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift new file mode 100644 index 0000000000..fe1e50257b --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotBridgeTests.swift @@ -0,0 +1,156 @@ +import Foundation +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + private func snapshotBridgeEnvelope(_ response: Response) throws -> [String: Any] { + let text = try XCTUnwrap(response.data?.snapshotBridgeEnvelope, "no bridge envelope") + return try XCTUnwrap(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + } + + func testSnapshotBridgeAppReadRequiresTheAppBundleId() throws { + let command = try runnerCommandFixture( + #"{"command":"snapshotBridge","snapshotBridge":{"verb":"describe","requestId":"r"}}"# + ) + let response = executeSnapshotBridge(command: command) + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "INVALID_ARGS") + } + + private func firstReadPayload() throws -> SnapshotBridgeCommandPayload { + try JSONDecoder().decode( + SnapshotBridgeCommandPayload.self, + from: Data( + #"{"verb":"describe","requestId":"r","generation":"1:2.000001","expectedPid":9,"expectedProcessStartTime":"2.000001","maxDepth":8,"maxNodes":500,"maxDurationMs":2500,"maxResponseBytes":4096}"# + .utf8) + ) + } + + func testFirstReadOfAGenerationSendsNoKeyForAnAbsentHint() throws { + let request = Self.snapshotBridgeReaderRequest(try firstReadPayload()) as NSDictionary + XCTAssertNil(request["nativeLevelsHint"]) + XCTAssertFalse(request.allKeys.contains { ($0 as? String) == "nativeLevelsHint" }) + XCTAssertFalse(request.allValues.contains { $0 is NSNull }) + XCTAssertEqual(request["generation"] as? String, "1:2.000001") + XCTAssertEqual(request["maxResponseBytes"] as? Int, 4096) + } + + func testBridgedRequestKeepsAHintTheHostSent() throws { + let payload = try JSONDecoder().decode( + SnapshotBridgeCommandPayload.self, + from: Data( + #"{"verb":"describe","requestId":"r","generation":"g","nativeLevelsHint":3}"#.utf8) + ) + let request = Self.snapshotBridgeReaderRequest(payload) as NSDictionary + XCTAssertEqual(request["nativeLevelsHint"] as? Int, 3) + XCTAssertNil(request["maxDepth"]) + } + + // The runner compiles the shared reader only for the iOS Simulator (RunnerSnapshotBridge.m). + #if os(iOS) && targetEnvironment(simulator) + func testMissingSimulatorIdentityRefusesSurfaceProofForThisCapture() throws { + let payload = try JSONDecoder().decode( + SnapshotBridgeCommandPayload.self, + from: Data( + #"{"verb":"describe","requestId":"r","generation":"g","expectedPid":9,"surfaceHostExecutables":["host"]}"# + .utf8) + ) + for udid: String? in [nil, ""] { + let request = Self.snapshotBridgeReaderRequest(payload, simulatorUdid: udid) + XCTAssertNil(request["simulatorUdid"]) + let response = RunnerSnapshotBridgeHandle("describe", "com.example.app", request) + XCTAssertEqual(response["error_code"] as? String, "system-surface-host-unverified") + XCTAssertEqual(response["pid"] as? Int, 9) + XCTAssertEqual(response["generation"] as? String, "g") + } + let named = Self.snapshotBridgeReaderRequest(payload, simulatorUdid: "SIM") + XCTAssertEqual(named["simulatorUdid"] as? String, "SIM") + } + func testReaderDoesNotRefuseTheBoundsOfAFirstRead() throws { + var request = Self.snapshotBridgeReaderRequest(try firstReadPayload()) + request["pid"] = 1 + request["maxDurationMs"] = 500 + let refusal = SnapshotBridgeHandleRequest(request) + XCTAssertNotEqual(refusal["error_code"] as? String, "bounds-invalid") + XCTAssertNotEqual(refusal["error_kind"] as? String, "bad_request") + } + + func testReaderRefusesAJsonBooleanWhereANumberIsRequired() throws { + var request = Self.snapshotBridgeReaderRequest(try firstReadPayload()) + request["pid"] = true + XCTAssertEqual(SnapshotBridgeHandleRequest(request)["error_code"] as? String, "pid-required") + request["pid"] = 1 + request["maxDepth"] = false + XCTAssertEqual(SnapshotBridgeHandleRequest(request)["error_code"] as? String, "bounds-invalid") + } + #endif + + #if !(os(iOS) && targetEnvironment(simulator)) + func testUnavailableBridgeRefusalCarriesProtocolVersions() throws { + let envelope = RunnerSnapshotBridgeHandle("describe", "app", ["requestId": "r"]) + XCTAssertEqual(envelope["protocolVersion"] as? Int, 1) + XCTAssertEqual(envelope["sourceVersion"] as? String, "agent-device-simulator-ax-v1.10.0") + XCTAssertEqual(envelope["error_code"] as? String, "runner-bridge-unavailable") + } + #endif + + func testEnvelopeAtTheBoundIncludingItsFrameHeaderIsServed() throws { + let envelope: [AnyHashable: Any] = ["requestId": "r", "ok": true, "pid": 7] + let size = try JSONSerialization.data(withJSONObject: envelope).count + let header = Self.snapshotBridgeFrameHeaderBytes + let served = Self.serializedSnapshotBridgeEnvelope( + envelope, requestId: "r", maxBytes: size + header) + XCTAssertEqual( + try JSONSerialization.jsonObject(with: Data(served.utf8)) as? NSDictionary, + envelope as NSDictionary) + let refused = Self.serializedSnapshotBridgeEnvelope( + envelope, requestId: "r", maxBytes: size + header - 1) + let refusal = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(refused.utf8)) as? [String: Any]) + XCTAssertEqual(refusal["error_code"] as? String, "response-too-large") + } + + func testTheRunnersOwnRefusalFitsTheDefaultBound() throws { + let command = try runnerCommandFixture( + #"{"command":"snapshotBridge","appBundleId":"com.example.app","snapshotBridge":{"verb":"invalid","requestId":"request-1"}}"# + ) + let envelope = try snapshotBridgeEnvelope(executeSnapshotBridge(command: command)) + XCTAssertNotEqual(envelope["error_code"] as? String, "response-too-large") + } + + func testUnserializableSnapshotBridgeEnvelopeIsRefusedAsNotJsonSafe() throws { + let text = Self.serializedSnapshotBridgeEnvelope( + ["requestId": "r", "ok": true, "pid": 7, "tree": Date()], + requestId: "r", + maxBytes: 1024 + ) + let refusal = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any] + ) + XCTAssertEqual(refusal["error_kind"] as? String, "malformed_tree") + XCTAssertEqual(refusal["error_code"] as? String, "response-not-json-safe") + XCTAssertEqual(refusal["pid"] as? Int, 7) + } + + func testOversizedSnapshotBridgeEnvelopeBecomesTheGuestRefusalWithItsIdentity() throws { + let text = Self.serializedSnapshotBridgeEnvelope( + [ + "requestId": "r", "ok": true, "pid": 7, "generation": "g", "processStartTime": "1.000001", + "tree": String(repeating: "x", count: 2048), + ], + requestId: "r", + maxBytes: 1024 + ) + let refusal = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any] + ) + XCTAssertEqual(refusal["ok"] as? Bool, false) + XCTAssertEqual(refusal["error_kind"] as? String, "response_limit_exceeded") + XCTAssertEqual(refusal["error_code"] as? String, "response-too-large") + XCTAssertEqual(refusal["requestId"] as? String, "r") + XCTAssertEqual(refusal["pid"] as? Int, 7) + XCTAssertEqual(refusal["generation"] as? String, "g") + XCTAssertNil(refusal["tree"]) + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift index 2d1aaa86b9..11456ceaec 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift @@ -899,15 +899,20 @@ extension RunnerTests { } #endif - /// Routes `command` the way the transport routes it: an inline read, then the journal's verdict on its + /// Routes `command` the way the transport routes it: an off-queue read, then the journal's verdict on its /// `commandId`. A finished id comes back as the answer the runner recorded for it; the execute path /// rethrows what the dispatch threw, which the wire path turns into its own response. Sends that need /// the wire path itself, including coalescing and admission end to end, go through /// `startTransportListener` instead. func execute(command: Command) throws -> Response { dispatchPrecondition(condition: .onQueue(.main)) - if let response = inlineResponse(for: command) { + switch offQueueRoute(for: command) { + case .inline(let response): return response + case .snapshotBridge: + return executeSnapshotBridge(command: command) + case nil: + break } switch commandJournal.accept(command: command) { case .terminal(let outcome): diff --git a/apple/snapshot-bridge/README.md b/apple/snapshot-bridge/README.md index c20c33df61..71a91fea23 100644 --- a/apple/snapshot-bridge/README.md +++ b/apple/snapshot-bridge/README.md @@ -1,9 +1,9 @@ # Simulator AX bridge This directory contains the small private accessibility reader used by the -Apple platform acquisition facet. The framed server and request validation -live in `SnapshotBridge.m`; private runtime binding lives in -`SnapshotBridgeRuntime.m`. It is compiled for the iOS Simulator on first use +Apple platform acquisition facet. The framed server lives in +`SnapshotBridge.m`; request validation lives in `SnapshotBridgeRequest.m`; +private runtime binding lives in `SnapshotBridgeRuntime.m`. It is compiled for the iOS Simulator on first use and is never downloaded, pre-signed, or built by npm installation. It serves iPhone and iPad Simulators, which both run the iOS Simulator runtime (#3844). diff --git a/apple/snapshot-bridge/SnapshotBridge.m b/apple/snapshot-bridge/SnapshotBridge.m index 6fc2f0e322..00ff82f047 100644 --- a/apple/snapshot-bridge/SnapshotBridge.m +++ b/apple/snapshot-bridge/SnapshotBridge.m @@ -1,10 +1,10 @@ /* - * The framed server and request validation for the private Simulator AX reader. - * The runtime binding is isolated in SnapshotBridgeRuntime.m. + * The framed server for the private Simulator AX reader. Request validation is in + * SnapshotBridgeRequest.m and the runtime binding in SnapshotBridgeRuntime.m. */ +#import "SnapshotBridgeRequest.h" #import "SnapshotBridgeRuntime.h" -#import "SnapshotBridgeSurfaceHosts.h" #import @@ -27,167 +27,6 @@ static void bridgeLog(NSString *message) fflush(stderr); } -NSDictionary *failureResponse(NSString *requestId, - NSString *kind, - NSString *code, - NSString *message) -{ - return @{ - kProtocolVersionKey : @(kProtocolVersion), - kSourceVersionKey : kSourceVersion, - kRequestIdKey : requestId ?: @"", - @"ok" : @NO, - @"error_kind" : kind ?: @"reader_unavailable", - @"error_code" : code ?: @"unknown", - @"error" : message ?: @"snapshot bridge request failed", - }; -} - -static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, NSUInteger *output) -{ - if (![value isKindOfClass:NSNumber.class]) return NO; - NSNumber *number = value; - if (number.doubleValue != floor(number.doubleValue)) return NO; - if (number.unsignedIntegerValue < minimum || number.unsignedIntegerValue > maximum) return NO; - if (output) *output = number.unsignedIntegerValue; - return YES; -} - -/* - * A registered iOS system surface host (a web sign-in or Apple Pay sheet) presents out of the app's - * process, and while it is up the app stays the primary accessibility owner, so this reader would - * serve the occluded app tree as if healthy (#2438). When the host asks, the reader refuses the - * capture while a host process runs for this Simulator. It reads the process table in place: a - * spawned `pgrep` and `ps` cost each capture about 45ms (#2751). - */ -static NSString *const kSurfaceHostExecutablesKey = @"surfaceHostExecutables"; -static NSString *const kSimulatorUdidKey = @"simulatorUdid"; -static NSString *const kSurfaceHostExecutableKey = @"surfaceHostExecutable"; -static const NSUInteger kMaximumSurfaceHosts = 8; -static const NSUInteger kMaximumSurfaceHostExecutableLength = 256; -static const NSUInteger kMaximumSimulatorUdidLength = 64; - -/* The refusal for a capture a running surface host may cover, or nil when none runs. */ -static NSDictionary *_Nullable captureRefusalForSurfaceHosts(NSArray *executables, - NSString *simulatorUdid, - NSString *requestId) -{ - BOOL unverified = NO; - NSString *running = runningSurfaceHost(executables, simulatorUdid, &unverified); - if (running) { - NSMutableDictionary *refusal = [failureResponse(requestId, @"unsupported", @"system-surface-host-present", - @"a system surface host is running for this Simulator") mutableCopy]; - refusal[kSurfaceHostExecutableKey] = running; - return refusal; - } - if (unverified) { - return failureResponse(requestId, @"unsupported", @"system-surface-host-unverified", - @"a system surface host process could not be placed on this Simulator"); - } - return nil; -} - -/* The requested surface host check: nil when the request asks for none, NSNull when malformed. */ -static id surfaceHostExecutables(NSDictionary *request) -{ - id value = request[kSurfaceHostExecutablesKey]; - if (value == nil) return nil; - if (![value isKindOfClass:NSArray.class] || [value count] == 0 || [value count] > kMaximumSurfaceHosts) { - return NSNull.null; - } - for (id executable in value) { - if (![executable isKindOfClass:NSString.class] || [executable length] == 0 || - [executable length] > kMaximumSurfaceHostExecutableLength) { - return NSNull.null; - } - } - return value; -} - -static NSDictionary *withTargetIdentity(NSDictionary *response, NSNumber *pid, NSString *generation) -{ - NSMutableDictionary *annotated = [response mutableCopy]; - annotated[@"pid"] = pid; - annotated[@"generation"] = generation; - return annotated; -} - -static NSDictionary *handleRequest(NSDictionary *request) -{ - NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @""; - id verb = request[@"verb"]; - if (![verb isKindOfClass:NSString.class] || ![verb isEqualToString:@"describe"]) { - return failureResponse(requestId, @"bad_request", @"verb-not-supported", @"snapshot bridge accepts describe requests only"); - } - NSNumber *pidValue = request[@"pid"]; - if (!validBoundInteger(pidValue, 1, INT_MAX, NULL)) { - return failureResponse(requestId, @"bad_request", @"pid-required", @"describe requires a positive target pid"); - } - NSString *generation = [request[@"generation"] isKindOfClass:NSString.class] ? request[@"generation"] : @""; - if (generation.length == 0) { - return failureResponse(requestId, @"bad_request", @"generation-required", @"describe requires an opaque target generation"); - } - id snapshotTree = request[@"snapshotTree"]; - if (![snapshotTree isKindOfClass:NSNumber.class] || ![snapshotTree boolValue]) { - return failureResponse(requestId, @"bad_request", @"snapshot-tree-required", @"snapshotTree must be enabled"); - } - id automationMode = request[@"automationMode"]; - if (![automationMode isKindOfClass:NSNumber.class] || ![automationMode boolValue]) { - return failureResponse(requestId, @"bad_request", @"automation-mode-required", @"automationMode must be enabled"); - } - NSUInteger maxDepth = 0; - NSUInteger maxNodes = 0; - NSUInteger maxDurationMs = 0; - NSUInteger maxResponseBytes = 0; - if (!validBoundInteger(request[@"maxDepth"], 0, kMaximumDepth, &maxDepth) || - !validBoundInteger(request[@"maxNodes"], 1, kMaximumNodes, &maxNodes) || - !validBoundInteger(request[@"maxDurationMs"], 1, kMaximumDurationMs, &maxDurationMs) || - !validBoundInteger(request[@"maxResponseBytes"], 1024, kMaximumFrameBytes, &maxResponseBytes)) { - return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"snapshot bridge request bounds are outside the bridge limits"); - } - NSUInteger nativeLevelsHint = 0; - if (request[@"nativeLevelsHint"] != nil && - !validBoundInteger(request[@"nativeLevelsHint"], 1, kMaximumDepth + 1, &nativeLevelsHint)) { - return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"nativeLevelsHint is outside the bridge depth limits"); - } - id surfaceHosts = surfaceHostExecutables(request); - NSString *simulatorUdid = [request[kSimulatorUdidKey] isKindOfClass:NSString.class] ? request[kSimulatorUdidKey] : @""; - if (surfaceHosts == NSNull.null || - (surfaceHosts && (simulatorUdid.length == 0 || simulatorUdid.length > kMaximumSimulatorUdidLength))) { - return failureResponse(requestId, @"bad_request", @"surface-hosts-invalid", @"surfaceHostExecutables needs bounded executable names and a simulatorUdid"); - } - if (surfaceHosts) { - NSDictionary *refusal = captureRefusalForSurfaceHosts(surfaceHosts, simulatorUdid, requestId); - if (refusal) return withTargetIdentity(refusal, pidValue, generation); - } - - NSString *setupError = nil; - BridgeRuntime *runtime = sharedRuntime(&setupError); - if (!runtime) { - NSMutableDictionary *unavailable = [failureResponse(requestId, @"unsupported", @"runtime-unavailable", setupError) mutableCopy]; - unavailable[@"pid"] = pidValue; - unavailable[@"generation"] = generation; - return unavailable; - } - NSDictionary *error = nil; - NSDictionary *response = [runtime snapshotForProcess:pidValue.intValue - maxDepth:maxDepth - maxNodes:maxNodes - nativeLevelsHint:nativeLevelsHint - requestId:requestId - generation:generation - maxDurationMs:maxDurationMs - error:&error]; - if (response && surfaceHosts) { - // A sheet presented during the capture covers what it read. - NSDictionary *refusal = captureRefusalForSurfaceHosts(surfaceHosts, simulatorUdid, requestId); - if (refusal) return withTargetIdentity(refusal, pidValue, generation); - } - if (response) return response; - if (error) return withTargetIdentity(error, pidValue, generation); - return failureResponse(requestId, @"reader_unavailable", @"empty-response", @"AX bridge returned no response"); -} - static BOOL readFully(int fd, void *buffer, size_t length) { size_t offset = 0; @@ -298,7 +137,7 @@ static int serve(NSString *socketPath, int idleTimeoutSeconds, BOOL exitOnDiscon if (!readFully(connection, body.mutableBytes, length)) break; id parsed = [NSJSONSerialization JSONObjectWithData:body options:0 error:NULL]; NSDictionary *response = [parsed isKindOfClass:NSDictionary.class] - ? handleRequest(parsed) + ? SnapshotBridgeHandleRequest(parsed) : failureResponse(@"", @"bad_request", @"json-object-required", @"request frame must be a JSON object"); NSData *encoded = serializedResponse(response, responseLimitForRequest(parsed)); if (encoded.length > kMaximumFrameBytes) break; diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.h b/apple/snapshot-bridge/SnapshotBridgeCapture.h index 555c941ad3..f2b1f2b44f 100644 --- a/apple/snapshot-bridge/SnapshotBridgeCapture.h +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.h @@ -7,6 +7,27 @@ extern const NSUInteger kMaximumRequestLevels; /// Requests one acquisition may issue before it fails as continuation-budget-exhausted. extern const NSUInteger kMaximumRequestsPerCapture; +/// The domain of the failures the tree reader raises itself. A failure of a native read keeps the +/// native read's own domain. +extern NSErrorDomain const SnapshotCaptureErrorDomain; + +/// Why the reader stopped. The runtime maps each one to its wire refusal code. +typedef NS_ERROR_ENUM(SnapshotCaptureErrorDomain, SnapshotCaptureError) { + /// The acquisition used every request it may issue. + SnapshotCaptureErrorBudgetExhausted = 1, + /// A native read returned a node that is not a snapshot dictionary. + SnapshotCaptureErrorMalformedContinuation = 2, + /// A node withheld children but carried no element to continue from. + SnapshotCaptureErrorContinuationElementUnavailable = 3, + /// A continuation re-read returned fewer children than the first read reported: the tree changed + /// between the two reads, as a scrolling list does when it recycles its cells. + SnapshotCaptureErrorTreeChanged = 4, + /// The foreground process changed while the reader read the tree. + SnapshotCaptureErrorForegroundOwnerChanged = 5, + /// A node below the first level reported no child count, so its children cannot be bounded. + SnapshotCaptureErrorBoundaryChildCountUnavailable = 6, +}; + typedef id _Nullable (^SnapshotElementReader)(id element, NSUInteger depth, NSUInteger nodes, NSError **error); /// Native request accounting for one acquisition: requests issued, requests the accessibility diff --git a/apple/snapshot-bridge/SnapshotBridgeCapture.m b/apple/snapshot-bridge/SnapshotBridgeCapture.m index f8f6c01c6e..b38f096467 100644 --- a/apple/snapshot-bridge/SnapshotBridgeCapture.m +++ b/apple/snapshot-bridge/SnapshotBridgeCapture.m @@ -1,5 +1,7 @@ #import "SnapshotBridgeCapture.h" +NSErrorDomain const SnapshotCaptureErrorDomain = @"agent-device.snapshot"; + static NSString *const attributesKey = @"UIAccessibilitySnapshotKeyAttributes"; static NSString *const childrenKey = @"UIAccessibilitySnapshotKeyChildren"; static NSString *const childCountKey = @"UIAccessibilitySnapshotKeyChildrenCount"; @@ -32,7 +34,7 @@ - (nullable NSDictionary *)read:(id)element depth:(NSUInteger)depth error:(NSErr NSUInteger attemptDepth = MIN(depth, self.acceptedDepth); for (NSUInteger retries = 0;; retries++) { if (self.requests >= kMaximumRequestsPerCapture) { - if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:1 + if (error) *error = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorBudgetExhausted userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation request budget exhausted"}]; return nil; } @@ -58,7 +60,7 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de if (![tree isKindOfClass:NSDictionary.class] || ![tree[attributesKey] isKindOfClass:NSDictionary.class] || ![tree[childrenKey] isKindOfClass:NSArray.class]) { - if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:2 + if (error) *error = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorMalformedContinuation userInfo:@{NSLocalizedDescriptionKey: @"malformed snapshot continuation"}]; return nil; } @@ -73,7 +75,7 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de childCount.doubleValue == (double)childCount.unsignedIntegerValue; if (nativeLevels <= 1 && !knownChildCount && depth <= 1) self.truncated = YES; if (depth > 1 && nativeLevels <= 1 && !knownChildCount) { - if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:6 + if (error) *error = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorBoundaryChildCountUnavailable userInfo:@{NSLocalizedDescriptionKey: @"snapshot boundary child count unavailable"}]; return nil; } @@ -88,7 +90,7 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de if (withheld && children.count < self.remainingNodes) { id element = tree[elementKey]; if (!element) { - if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:3 + if (error) *error = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorContinuationElementUnavailable userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation element unavailable"}]; return nil; } @@ -96,8 +98,13 @@ - (nullable NSDictionary *)materialize:(NSDictionary *)tree depth:(NSUInteger)de if (!continuation) return nil; nativeLevels = MIN(depth, self.acceptedDepth); children = continuation[childrenKey]; - if (![children isKindOfClass:NSArray.class] || children.count < MIN(childCount.unsignedIntegerValue, self.remainingNodes)) { - if (error) *error = [NSError errorWithDomain:@"agent-device.snapshot" code:4 + if (![children isKindOfClass:NSArray.class]) { + if (error) *error = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorMalformedContinuation + userInfo:@{NSLocalizedDescriptionKey: @"malformed snapshot continuation"}]; + return nil; + } + if (children.count < MIN(childCount.unsignedIntegerValue, self.remainingNodes)) { + if (error) *error = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorTreeChanged userInfo:@{NSLocalizedDescriptionKey: @"snapshot continuation children unavailable"}]; return nil; } diff --git a/apple/snapshot-bridge/SnapshotBridgeRequest.h b/apple/snapshot-bridge/SnapshotBridgeRequest.h new file mode 100644 index 0000000000..a8078754bb --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridgeRequest.h @@ -0,0 +1,9 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Validates one `describe` request and reads the tree it names. The guest server and the XCTest +/// runner both answer through this function, so a request means the same thing on either transport. +NSDictionary *SnapshotBridgeHandleRequest(NSDictionary *request); + +NS_ASSUME_NONNULL_END diff --git a/apple/snapshot-bridge/SnapshotBridgeRequest.m b/apple/snapshot-bridge/SnapshotBridgeRequest.m new file mode 100644 index 0000000000..ddd4fc11bb --- /dev/null +++ b/apple/snapshot-bridge/SnapshotBridgeRequest.m @@ -0,0 +1,174 @@ +/* + * Request validation for the private Simulator AX reader, shared by the guest server + * (SnapshotBridge.m) and the XCTest runner, which serves the same reader without a guest process. + */ + +#import "SnapshotBridgeRequest.h" +#import "SnapshotBridgeRuntime.h" +#import "SnapshotBridgeSurfaceHosts.h" + +#import +#import + +NSDictionary *failureResponse(NSString *requestId, + NSString *kind, + NSString *code, + NSString *message) +{ + return @{ + kProtocolVersionKey : @(kProtocolVersion), + kSourceVersionKey : kSourceVersion, + kRequestIdKey : requestId ?: @"", + @"ok" : @NO, + @"error_kind" : kind ?: @"reader_unavailable", + @"error_code" : code ?: @"unknown", + @"error" : message ?: @"snapshot bridge request failed", + }; +} + +static BOOL validBoundInteger(id value, NSUInteger minimum, NSUInteger maximum, NSUInteger *output) +{ + if (![value isKindOfClass:NSNumber.class]) return NO; + NSNumber *number = value; + // A JSON boolean is an NSNumber too, and must not pass as 0 or 1. + if (CFGetTypeID((__bridge CFTypeRef)number) == CFBooleanGetTypeID()) return NO; + if (number.doubleValue != floor(number.doubleValue)) return NO; + if (number.unsignedIntegerValue < minimum || number.unsignedIntegerValue > maximum) return NO; + if (output) *output = number.unsignedIntegerValue; + return YES; +} + +/* + * A registered iOS system surface host (a web sign-in or Apple Pay sheet) presents out of the app's + * process, and while it is up the app stays the primary accessibility owner, so this reader would + * serve the occluded app tree as if healthy (#2438). When the host asks, the reader refuses the + * capture while a host process runs for this Simulator. It reads the process table in place: a + * spawned `pgrep` and `ps` cost each capture about 45ms (#2751). + */ +static NSString *const kSurfaceHostExecutablesKey = @"surfaceHostExecutables"; +static NSString *const kSimulatorUdidKey = @"simulatorUdid"; +static NSString *const kSurfaceHostExecutableKey = @"surfaceHostExecutable"; +static const NSUInteger kMaximumSurfaceHosts = 8; +static const NSUInteger kMaximumSurfaceHostExecutableLength = 256; +static const NSUInteger kMaximumSimulatorUdidLength = 64; + +/* The refusal for a capture a running surface host may cover, or nil when none runs. */ +static NSDictionary *_Nullable captureRefusalForSurfaceHosts(NSArray *executables, + NSString *simulatorUdid, + NSString *requestId) +{ + BOOL unverified = NO; + NSString *running = runningSurfaceHost(executables, simulatorUdid, &unverified); + if (running) { + NSMutableDictionary *refusal = [failureResponse(requestId, @"unsupported", @"system-surface-host-present", + @"a system surface host is running for this Simulator") mutableCopy]; + refusal[kSurfaceHostExecutableKey] = running; + return refusal; + } + if (unverified) { + return failureResponse(requestId, @"unsupported", @"system-surface-host-unverified", + @"a system surface host process could not be placed on this Simulator"); + } + return nil; +} + +/* The requested surface host check: nil when the request asks for none, NSNull when malformed. */ +static id surfaceHostExecutables(NSDictionary *request) +{ + id value = request[kSurfaceHostExecutablesKey]; + if (value == nil) return nil; + if (![value isKindOfClass:NSArray.class] || [value count] == 0 || [value count] > kMaximumSurfaceHosts) { + return NSNull.null; + } + for (id executable in value) { + if (![executable isKindOfClass:NSString.class] || [executable length] == 0 || + [executable length] > kMaximumSurfaceHostExecutableLength) { + return NSNull.null; + } + } + return value; +} + +static NSDictionary *withTargetIdentity(NSDictionary *response, NSNumber *pid, NSString *generation) +{ + NSMutableDictionary *annotated = [response mutableCopy]; + annotated[@"pid"] = pid; + annotated[@"generation"] = generation; + return annotated; +} + +NSDictionary *SnapshotBridgeHandleRequest(NSDictionary *request) +{ + NSString *requestId = [request[kRequestIdKey] isKindOfClass:NSString.class] ? request[kRequestIdKey] : @""; + id verb = request[@"verb"]; + if (![verb isKindOfClass:NSString.class] || ![verb isEqualToString:@"describe"]) { + return failureResponse(requestId, @"bad_request", @"verb-not-supported", @"snapshot bridge accepts describe requests only"); + } + NSNumber *pidValue = request[@"pid"]; + if (!validBoundInteger(pidValue, 1, INT_MAX, NULL)) { + return failureResponse(requestId, @"bad_request", @"pid-required", @"describe requires a positive target pid"); + } + NSString *generation = [request[@"generation"] isKindOfClass:NSString.class] ? request[@"generation"] : @""; + if (generation.length == 0) { + return failureResponse(requestId, @"bad_request", @"generation-required", @"describe requires an opaque target generation"); + } + id snapshotTree = request[@"snapshotTree"]; + if (![snapshotTree isKindOfClass:NSNumber.class] || ![snapshotTree boolValue]) { + return failureResponse(requestId, @"bad_request", @"snapshot-tree-required", @"snapshotTree must be enabled"); + } + id automationMode = request[@"automationMode"]; + if (![automationMode isKindOfClass:NSNumber.class] || ![automationMode boolValue]) { + return failureResponse(requestId, @"bad_request", @"automation-mode-required", @"automationMode must be enabled"); + } + NSUInteger maxDepth = 0; + NSUInteger maxNodes = 0; + NSUInteger maxDurationMs = 0; + NSUInteger maxResponseBytes = 0; + if (!validBoundInteger(request[@"maxDepth"], 0, kMaximumDepth, &maxDepth) || + !validBoundInteger(request[@"maxNodes"], 1, kMaximumNodes, &maxNodes) || + !validBoundInteger(request[@"maxDurationMs"], 1, kMaximumDurationMs, &maxDurationMs) || + !validBoundInteger(request[@"maxResponseBytes"], 1024, kMaximumFrameBytes, &maxResponseBytes)) { + return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"snapshot bridge request bounds are outside the bridge limits"); + } + NSUInteger nativeLevelsHint = 0; + if (request[@"nativeLevelsHint"] != nil && + !validBoundInteger(request[@"nativeLevelsHint"], 1, kMaximumDepth + 1, &nativeLevelsHint)) { + return failureResponse(requestId, @"bad_request", @"bounds-invalid", @"nativeLevelsHint is outside the bridge depth limits"); + } + id surfaceHosts = surfaceHostExecutables(request); + NSString *simulatorUdid = [request[kSimulatorUdidKey] isKindOfClass:NSString.class] ? request[kSimulatorUdidKey] : @""; + if (surfaceHosts == NSNull.null || + (surfaceHosts && (simulatorUdid.length == 0 || simulatorUdid.length > kMaximumSimulatorUdidLength))) { + return failureResponse(requestId, @"bad_request", @"surface-hosts-invalid", @"surfaceHostExecutables needs bounded executable names and a simulatorUdid"); + } + if (surfaceHosts) { + NSDictionary *refusal = captureRefusalForSurfaceHosts(surfaceHosts, simulatorUdid, requestId); + if (refusal) return withTargetIdentity(refusal, pidValue, generation); + } + + NSString *setupError = nil; + BridgeRuntime *runtime = sharedRuntime(&setupError); + if (!runtime) { + NSMutableDictionary *unavailable = [failureResponse(requestId, @"unsupported", @"runtime-unavailable", setupError) mutableCopy]; + unavailable[@"pid"] = pidValue; + unavailable[@"generation"] = generation; + return unavailable; + } + NSDictionary *error = nil; + NSDictionary *response = [runtime snapshotForProcess:pidValue.intValue + maxDepth:maxDepth + maxNodes:maxNodes + nativeLevelsHint:nativeLevelsHint + requestId:requestId + generation:generation + maxDurationMs:maxDurationMs + error:&error]; + if (response && surfaceHosts) { + // A sheet presented during the capture covers what it read. + NSDictionary *refusal = captureRefusalForSurfaceHosts(surfaceHosts, simulatorUdid, requestId); + if (refusal) return withTargetIdentity(refusal, pidValue, generation); + } + if (response) return response; + if (error) return withTargetIdentity(error, pidValue, generation); + return failureResponse(requestId, @"reader_unavailable", @"empty-response", @"AX bridge returned no response"); +} diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.h b/apple/snapshot-bridge/SnapshotBridgeRuntime.h index d5085513c9..222857af54 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.h +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.h @@ -1,5 +1,8 @@ #import +#define SNAPSHOT_BRIDGE_PROTOCOL_VERSION 1 +#define SNAPSHOT_BRIDGE_SOURCE_VERSION @"agent-device-simulator-ax-v1.10.0" + NS_ASSUME_NONNULL_BEGIN extern NSString *const kProtocolVersionKey; diff --git a/apple/snapshot-bridge/SnapshotBridgeRuntime.m b/apple/snapshot-bridge/SnapshotBridgeRuntime.m index b3872c680d..7e8059b54d 100644 --- a/apple/snapshot-bridge/SnapshotBridgeRuntime.m +++ b/apple/snapshot-bridge/SnapshotBridgeRuntime.m @@ -18,8 +18,8 @@ NSString *const kProtocolVersionKey = @"protocolVersion"; NSString *const kSourceVersionKey = @"sourceVersion"; NSString *const kRequestIdKey = @"requestId"; -NSString *const kSourceVersion = @"agent-device-simulator-ax-v1.10.0"; -const NSUInteger kProtocolVersion = 1; +NSString *const kSourceVersion = SNAPSHOT_BRIDGE_SOURCE_VERSION; +const NSUInteger kProtocolVersion = SNAPSHOT_BRIDGE_PROTOCOL_VERSION; const uint32_t kMaximumFrameBytes = 16 * 1024 * 1024; const NSUInteger kMaximumDepth = 128; const NSUInteger kMaximumNodes = 10000; @@ -38,6 +38,25 @@ static NSString *const kAttributeIsUserInteractionEnabled = @"XC_kAXXCAttributeIsUserInteractionEnabled"; static NSString *const kPresentationDimmingViewClass = @"UIDimmingView"; static NSString *const kSnapshotAttributes = @"UIAccessibilitySnapshotKeyAttributes"; + +/// The wire refusal for a reader error. A tree that changed between two reads and a foreground that +/// changed during a continuation describe the screen at that moment, so their codes are capture-scoped; +/// the other reader errors describe what this reader can read. +static NSDictionary *captureFailureResponse(NSString *requestId, SnapshotCaptureError code, NSString *message) { + switch (code) { + case SnapshotCaptureErrorBudgetExhausted: + return failureResponse(requestId, @"reader_unavailable", @"continuation-budget-exhausted", message); + case SnapshotCaptureErrorTreeChanged: + return failureResponse(requestId, @"malformed_tree", @"snapshot-tree-changed", message); + case SnapshotCaptureErrorForegroundOwnerChanged: + return failureResponse(requestId, @"unsupported", @"foreground-owner-changed", message); + case SnapshotCaptureErrorMalformedContinuation: + case SnapshotCaptureErrorContinuationElementUnavailable: + case SnapshotCaptureErrorBoundaryChildCountUnavailable: + return failureResponse(requestId, @"malformed_tree", @"snapshot-tree-malformed", message); + } + return failureResponse(requestId, @"malformed_tree", @"snapshot-tree-malformed", message); +} static NSString *const kSnapshotChildren = @"UIAccessibilitySnapshotKeyChildren"; static NSString *const kSnapshotElement = @"UIAccessibilitySnapshotKeyElement"; static NSString *const kXctAutomationSupportPath = @@ -86,6 +105,14 @@ + (nullable XCAccessibilityElement *)elementWithProcessIdentifier:(pid_t)pid; return @{ @"X" : x, @"Y" : y, @"Width" : width, @"Height" : height }; } +// Nothing can stop an XPC call that the accessibility server never answers. The guest bridge process +// ends itself when a read outlives its budget, and its host starts a new one. A process that serves the +// reader for other work, such as the XCTest runner, must not end itself: it defines this macro as 0 +// before it includes this file, and abandons the read instead. +#ifndef SNAPSHOT_BRIDGE_WATCHDOG_ENDS_PROCESS +#define SNAPSHOT_BRIDGE_WATCHDOG_ENDS_PROCESS 1 +#endif + @interface SnapshotWatchdogState : NSObject @property(atomic) BOOL completed; @end @@ -106,7 +133,9 @@ static dispatch_source_t startRequestWatchdog(NSUInteger durationMs, SnapshotWat DISPATCH_TIME_FOREVER, 0); dispatch_source_set_event_handler(watchdog, ^{ +#if SNAPSHOT_BRIDGE_WATCHDOG_ENDS_PROCESS if (!state.completed) kill(getpid(), SIGKILL); +#endif }); dispatch_resume(watchdog); return watchdog; @@ -412,7 +441,7 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid snapshot = captureSnapshotTree((__bridge id)raw, maxDepth, maxNodes, nativeLevelsHint, ^id(id element, NSUInteger depth, NSUInteger nodes, NSError **captureError) { if (![self isPrimaryForegroundProcess:pid]) { - if (captureError) *captureError = [NSError errorWithDomain:@"agent-device.snapshot" code:5 + if (captureError) *captureError = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorForegroundOwnerChanged userInfo:@{NSLocalizedDescriptionKey: @"foreground owner changed during continuation"}]; return nil; } @@ -432,10 +461,8 @@ - (nullable NSDictionary *)snapshotForProcess:(pid_t)pid finishRequestWatchdog(watchdog, watchdogState); return nil; } - if (!snapshot && [runtimeError.domain isEqualToString:@"agent-device.snapshot"]) { - BOOL exhausted = runtimeError.code == 1; - if (error) *error = failureResponse(requestId, exhausted ? @"reader_unavailable" : @"malformed_tree", - exhausted ? @"continuation-budget-exhausted" : @"snapshot-tree-malformed", runtimeError.localizedDescription); + if (!snapshot && [runtimeError.domain isEqualToString:SnapshotCaptureErrorDomain]) { + if (error) *error = captureFailureResponse(requestId, (SnapshotCaptureError)runtimeError.code, runtimeError.localizedDescription); finishRequestWatchdog(watchdog, watchdogState); return nil; } diff --git a/contracts/fixtures/ios-ax-recovery-conformance.json b/contracts/fixtures/ios-ax-recovery-conformance.json index c696d7d4df..1430bc66dc 100644 --- a/contracts/fixtures/ios-ax-recovery-conformance.json +++ b/contracts/fixtures/ios-ax-recovery-conformance.json @@ -359,6 +359,35 @@ } } }, + { + "name": "children-shrink-during-continuation", + "request": { + "traversalDepth": 64, + "explicitDepth": false, + "nodeBudget": 1500 + }, + "native": { + "tree": { + "chain": 45 + }, + "rejectLevelsAbove": 48, + "frontierEvidence": "counted", + "shrinkOnContinuation": true + }, + "expected": { + "host-bridge": { + "outcome": "failed", + "failure": "tree-changed", + "requests": 3, + "rejected": 1, + "continuations": 1 + }, + "runner": { + "outcome": "not-applicable", + "reason": "the runner's private AX bridge has no child counts, so it cannot see a shrink; the reader that the runner compiles from apple/snapshot-bridge is the host-bridge producer" + } + } + }, { "name": "host-request-budget", "request": { diff --git a/contracts/fixtures/ios-snapshot-bridge-refusals.json b/contracts/fixtures/ios-snapshot-bridge-refusals.json new file mode 100644 index 0000000000..75bddb823a --- /dev/null +++ b/contracts/fixtures/ios-snapshot-bridge-refusals.json @@ -0,0 +1,116 @@ +{ + "description": "Every refusal code the snapshot bridge emits, with the scope the route gives it. A capture-scoped refusal speaks for one capture and keeps the app generation on the bridge; every other code retires the generation. Emitters: the Objective-C reader (apple/snapshot-bridge), the runner-served bridge (RunnerSnapshotBridge.m, RunnerTests+SnapshotBridge.swift), and the TypeScript snapshot source (packages/platform-apple/src/snapshot-source, runner-snapshot-bridge.ts). The TypeScript policy packages/platform-apple/src/snapshot-route-failure-policy.ts mirrors this table. test/repo-guards/snapshot-bridge-refusals.test.ts fails when an emitted code is missing here, when a code here is emitted nowhere, or when the policy disagrees. Classify a new code here by an explicit decision; an unlisted code fails the guard.", + "captureScoped": [ + { + "code": "window-coordinate-space-unresolved", + "consequence": "coordinate-space-unresolved" + }, + { + "code": "app-window-not-full-screen", + "consequence": "window-not-full-screen" + }, + { + "code": "foreground-owner-unverified", + "consequence": "foreground-ownership" + }, + { + "code": "foreground-owner-changed", + "consequence": "foreground-ownership" + }, + { + "code": "bridge-read-in-flight", + "consequence": "bridge-read-busy" + }, + { + "code": "bridge-preparation-pending", + "consequence": "bridge-preparation" + }, + { + "code": "snapshot-tree-changed", + "consequence": "tree-changed" + }, + { + "code": "system-surface-host-present", + "consequence": "system-surface-host" + }, + { + "code": "system-surface-host-unverified", + "consequence": "system-surface-host" + } + ], + "generationScoped": [ + "abort-signal", + "application-element-missing", + "application-server-unavailable", + "application-timeout", + "attribute-vocabulary-mismatch", + "automation-mode-required", + "automation-mode-unavailable", + "automation-type-invalid", + "bounds-invalid", + "bridge-closed-before-connect", + "bridge-connect-timeout", + "bridge-connection-closed", + "bridge-data-invalid", + "bridge-exit-deadline", + "bridge-exited", + "bridge-frame-incomplete", + "bridge-generation-mismatch", + "bridge-pid-mismatch", + "bridge-process-pid-missing", + "bridge-read-abandoned", + "bridge-request-deadline", + "child-invalid", + "children-invalid", + "continuation-budget-exhausted", + "empty-response", + "frame-invalid", + "frame-limit-exceeded", + "frame-limit-invalid", + "generation-invalid", + "generation-required", + "guest-error", + "guest-tree-root-invalid", + "host-operation-timeout", + "json-object-required", + "multiple-frames", + "native-build-failed", + "native-source-missing", + "node-contains-unknown-field", + "node-limit-exceeded", + "pid-required", + "private-api-exception", + "protocol-version-mismatch", + "reader-exception", + "recovery-invalid", + "remote-content-boundary", + "request-id-mismatch", + "request-limit-exceeded", + "request-not-json", + "response-not-json", + "response-not-json-safe", + "response-not-object", + "response-too-large", + "runner-bridge-unavailable", + "runner-envelope-missing", + "runner-exchange-failed", + "runtime-unavailable", + "scalar-invalid", + "simulator-runtime-missing", + "snapshot-root-invalid", + "snapshot-tree-malformed", + "snapshot-tree-required", + "source-closed", + "source-version-mismatch", + "surface-hosts-invalid", + "target-process-changed", + "target-process-unavailable", + "traits-invalid", + "traversal-depth-exceeded", + "truncated-invalid", + "unexpected-host-error", + "unknown", + "user-interaction-invalid", + "verb-not-supported" + ] +} diff --git a/contracts/fixtures/runner-requests.json b/contracts/fixtures/runner-requests.json index 484b08fd5e..36ccc24603 100644 --- a/contracts/fixtures/runner-requests.json +++ b/contracts/fixtures/runner-requests.json @@ -67,6 +67,9 @@ {"name": "ios-simulator.runner-selector-query.label", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "querySelector", "selectorKey": "label", "selectorValue": "Go", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.runner-sequence.bound-orientation", "producer": "packages/platform-apple/src/__tests__/interactions-press-series-runner-requests.test.ts", "request": {"command": "sequence", "steps": [{"kind": "tap", "x": 10, "y": 20, "synthesized": true}], "appBundleId": "com.example.app", "sequenceOrientation": 1, "commandId": ""}}, {"name": "ios-simulator.runner-session-readiness.uptime", "producer": "packages/apple-runner/src/__tests__/index-runner-requests.test.ts", "request": {"command": "uptime", "commandId": ""}}, + {"name": "ios-simulator.runner-snapshot-bridge.describe", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "snapshotBridge", "appBundleId": "com.example.app", "snapshotBridge": {"verb": "describe", "requestId": "", "generation": "41:1700000000.000001", "expectedPid": 41, "expectedProcessStartTime": "1700000000.000001", "maxDepth": 128, "maxNodes": 5000, "maxDurationMs": 4000, "maxResponseBytes": 4194304}, "commandId": ""}}, + {"name": "ios-simulator.runner-snapshot-bridge.describe-surface-hosts", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "snapshotBridge", "appBundleId": "com.example.app", "snapshotBridge": {"verb": "describe", "requestId": "", "generation": "41:1700000000.000001", "expectedPid": 41, "expectedProcessStartTime": "1700000000.000001", "maxDepth": 128, "maxNodes": 5000, "maxDurationMs": 4000, "maxResponseBytes": 4194304, "surfaceHostExecutables": ["SafariViewService.app/SafariViewService", "PassbookUIService.app/PassbookUIService"]}, "commandId": ""}}, + {"name": "ios-simulator.runner-snapshot-bridge.target", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "snapshotBridge", "appBundleId": "com.example.app", "snapshotBridge": {"verb": "target", "requestId": "", "maxDurationMs": 4000}, "commandId": ""}}, {"name": "macos.desktop-scroll.amount", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "desktopScroll", "direction": "down", "amount": 0.5, "durationMs": 300, "appBundleId": "com.example.app", "commandId": ""}}, {"name": "macos.desktop-scroll.pixels", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "desktopScroll", "direction": "up", "pixels": 200, "durationMs": 300, "appBundleId": "com.example.app", "commandId": ""}}, {"name": "macos.interactions-drag.single", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "drag", "x": 100, "y": 400, "x2": 100, "y2": 200, "durationMs": 120, "appBundleId": "com.example.app", "commandId": ""}}, diff --git a/docs/adr/0004-ios-snapshot-backend-strategy.md b/docs/adr/0004-ios-snapshot-backend-strategy.md index f6d5f2a927..f91394d5c1 100644 --- a/docs/adr/0004-ios-snapshot-backend-strategy.md +++ b/docs/adr/0004-ios-snapshot-backend-strategy.md @@ -3,7 +3,7 @@ ## Status Accepted. -This ADR was amended after local iOS Simulator acquisition moved to the host AX bridge. +This ADR was amended after local iOS Simulator acquisition moved to the host AX bridge, and again for provider iOS Simulators that the runner serves. The public surface remains two modes: regular interactive snapshots and raw diagnostic snapshots. The host AX bridge is an acquisition route, not a third public mode. @@ -17,6 +17,7 @@ Structured snapshot quality verdicts and fallback warnings make degraded or reco - Route eligible local iOS Simulator snapshots through the host AX bridge. Present them once through the shared TypeScript engine. An iPad Simulator is a local iOS Simulator for this rule. +- For an iOS Simulator that a provider runs, route its snapshots through the bridge reader that the XCTest runner serves. - Refuse a bridge capture of an app window that does not fill the screen. - If bridge acquisition or presentation fails, use one typed XCTest fallback. - After fallback, disable the bridge for that app generation, except for `preparing` failures and capture-scoped failures. @@ -31,7 +32,7 @@ Structured snapshot quality verdicts and fallback warnings make degraded or reco ## Context -Agent Device exposes iOS UI state through host AX acquisition on local Simulators and the long-lived XCTest runner everywhere else. +Agent Device exposes iOS UI state through host AX acquisition on local Simulators, through the runner-served bridge on provider Simulators, and through the long-lived XCTest runner everywhere else. The snapshot surface has two durable needs: - Agent-facing regular context. Here the important contract is the effective user-visible UI, fixed controls such as tab bars, and scroll-hidden hints for content outside visible scroll containers. @@ -59,7 +60,8 @@ After fallback, disable the bridge for that app generation, except for `preparin A `preparing` failure describes a bridge preparation that is still running, not the app. It sends only that capture to XCTest. A new app generation enables it again. -Physical devices, providers, custom-action captures, and interactions remain on their existing owners. +Physical devices, custom-action captures, and interactions remain on their existing owners. +A provider iOS Simulator uses the runner-served bridge (see the amendment below). The iPad amendment below defines which Simulators are local iOS Simulators. A WebKit page in Safari or in a `WKWebView` lives in a WebContent process. @@ -88,6 +90,12 @@ A bridge request on each capture would instead add a refused bridge round trip t The circuit limits that cost to one capture per generation. A refusal that provides evidence about the screen instead of the app has capture scope. The coordinate-space amendment below decides that case. +A tree that changed between two reads also has capture scope (`snapshot-tree-changed`). +The reader re-reads a node whose children the first read withheld. +If the re-read returns fewer children than the first read counted, the screen moved between the reads, as a scrolling list does when it recycles its cells. +That refusal costs one capture, and the next capture of the same app uses the bridge again. +A foreground owner that changes during a continuation is `foreground-owner-changed`, with the same scope. +`contracts/fixtures/ios-snapshot-bridge-refusals.json` lists the scope of every refusal code. Keep the two public snapshot strategies explicit: @@ -623,7 +631,7 @@ Thus, the pass returns that tree exactly as reported. This is a structural consequence of the same rule applied uniformly. It is not the per-tier omission that a hand-threaded space would have made it. -The provider producers (`appium-source`) never see the app's windows either. +The provider producer `appium-source` never sees the app's windows either. A capture from any of them publishes no keyboard fact. A consumer reads that silence as "this producer did not measure". Those paths publish what the platform reported. @@ -740,7 +748,8 @@ An iPad Simulator runs the iOS Simulator runtime that the bridge reader is built On XCTest, a deep UIKit tree on an iPad Simulator can keep a long session in private-AX fallback. Decision. The bridge serves iPhone and iPad Simulators. -tvOS and visionOS Simulators, the macOS host, providers, and physical devices keep XCTest. +tvOS and visionOS Simulators, the macOS host, and physical devices keep XCTest. +A provider iOS Simulator uses the runner-served bridge (see the amendment below). Two iPad facts change what the bridge reports. @@ -777,3 +786,65 @@ Rejected alternatives: - Move the tree by the window origin. The bridge would then be the only correct producer for a windowed app. The keyboard window and alerts were not measured in that mode, so the refusal stays until they are. - Read the screen size on the host from the device type profile or from `simctl io`. That adds a host process and a format that Apple does not document. It also does not give the window origin. + +## Amendment: the runner-served bridge for a provider iOS Simulator + +This amendment changes the scope of the decision: providers were excluded, and a provider iOS Simulator now uses the bridge. + +A provider can run an iOS Simulator on its own host, for example Limrun. +The daemon cannot start the guest bridge process there. +Limrun's `simctl spawn` accepts only `defaults`, `notifyutil`, and `limproxy`. +Limrun forwards no socket to the Simulator. +The only process of agent-device in that Simulator is the XCTest runner, which the provider launches. + +Decision. The XCTest runner serves the same reader. +The runner includes the reader sources of `apple/snapshot-bridge`. +The runner leaves out the guest socket server. +The `snapshotBridge` runner command reaches the reader through the runner's forwarded HTTP transport. +The runner answers that command on its own serial queue, off its journal and off the XCTest main thread. +The reader's accessibility calls run on a private serial queue, not on the main thread where the guest process runs them. +Live captures on Limrun iOS 26.4 and on a local iOS 26.2 Simulator (#4182) served trees from that queue. +The route, the generation circuit, the one typed XCTest fallback, and the `simulator-ax-bridge` producer are the same as on a local Simulator. + +The guest process ends itself when a read outlives its budget, because nothing can stop an accessibility call that never returns. +The runner must not end itself, because it also runs every XCTest command. +The runner abandons such a read (`bridge-read-abandoned`). +The runner admits one read at a time. +Until the admitted read returns, the runner refuses each new read (`bridge-read-in-flight`). +A read that a cancelled capture left running meets that refusal too. +The refusal says only that another read is running, so it has capture scope. +The daemon gives the runner its read budget minus a transport margin. +Thus, the runner abandons a slow read first, and the daemon receives `bridge-read-abandoned`, not a lost reply. + +The runner names the app generation itself, because the daemon cannot list the Simulator's processes. +The `target` verb returns the pid and the process start time of the app's foreground process. +The `describe` verb refuses another process of the same app with `target-process-changed`. +The daemon keeps the target until a read refuses it with `target-process-changed`. +Thus, a steady capture costs one runner round trip for the tree. +While a generation's circuit is open, no read can meet a newer process. +Thus, on a `targetFreshness: 'cached'` channel, the route asks for a fresh target before it trusts an open circuit. +If that request fails, the channel keeps its cached target. +The capture names no generation, because the app may have relaunched. + +A presented system surface stays invisible to the bridge (#2438 amendment), so the route still needs proven absence. +The proof is the same on both channels: the runner's foreground read first, and the bridge's own process check when that read cannot answer. +Every capture of a provider iOS Simulator needs the runner, so the foreground read starts a runner that is not up yet. +The proof starts the runner when none is live, and then reads only that runner session. +If the session id cannot be read, the read answers `unknown`, and the bridge read names the surface hosts. +The runner then reads the process table of the Simulator it runs in, which it names itself. +A sandbox that refuses that table gives `system-surface-host-unverified`, and the capture uses XCTest. + +An observe-only capture skips the route and goes straight to the provider's capture. +An observe-only capture must not activate the app or start work, so the provider's capture decides how to observe. + +Rejected alternatives: + +- Ask the provider to start the guest bridge process and forward its socket. That needs provider features that do not exist, for every provider. +- Prove absence from the Simulator's process table alone. A provider's sandbox can refuse that table (`system-surface-host-unverified`), so the runner's foreground read comes first. +- Pin the runner's private-AX backend on a provider iOS Simulator. That is another producer with another tree, and it needs the XCTest main thread. +- Copy the reader sources into the runner. The two readers would drift. + +Open gaps: + +- The surface check needs the XCTest main thread. A capture while that thread is stuck takes the XCTest fallback, which reports the busy runner. +- The runner carries the envelope as a JSON string inside its JSON response, which costs escaping on large trees. diff --git a/packages/apple-runner/src/__tests__/runner-command-traits-queue-routing.test.ts b/packages/apple-runner/src/__tests__/runner-command-traits-queue-routing.test.ts new file mode 100644 index 0000000000..6d61e9a283 --- /dev/null +++ b/packages/apple-runner/src/__tests__/runner-command-traits-queue-routing.test.ts @@ -0,0 +1,60 @@ +import { test } from 'vitest'; +import type { RunnerCommand } from '../runner-contract.ts'; +import { + isQueuedRunnerCommand, + isRunnerReadinessProbeCommand, + RUNNER_COMMAND_TRAITS, +} from '../runner-command-traits.ts'; +import { readSwiftOffQueueCommands } from './runner-swift-settlement-fixtures.ts'; + +/** + * The runner serves `status`, `uptime`, and `snapshotBridge` off its queue — outside its journal and + * off the serial command queue — which is why the daemon charges no exchange for them: such a reply + * proves the runner is reachable and nothing about queued work, so a charge for one could only ever be + * paid by the wrong exchange (#2965). The `queued` trait is how the daemon names that set, and this tie + * is what keeps the two sides one claim: add an off-queue arm in Swift that the trait missed and that + * reply would discharge an unrelated command's charge; drop one and a genuinely queued command would + * go uncharged, so a shutdown could hand off a runner with work on its queue. + * + * The Swift half is read from the runner's own switch rather than restated here, so the check cannot + * agree with a stale copy of the answer. + */ + +const RUNNER_COMMANDS = Object.keys(RUNNER_COMMAND_TRAITS) as RunnerCommand['command'][]; + +function offQueueCommands(): string[] { + return RUNNER_COMMANDS.filter((command) => !isQueuedRunnerCommand({ command })).sort(); +} + +test('the queued trait leaves out exactly the commands the runner serves off its queue', () => { + const offQueue = readSwiftOffQueueCommands(); + + if (offQueue.length === 0) { + throw new Error( + 'the runner serves no command off its queue, so the daemon must stop routing any reply as an off-queue answer', + ); + } + for (const command of offQueue) { + if (!offQueueCommands().includes(command)) { + throw new Error( + `the runner answers "${command}" off its queue but the trait calls it queued`, + ); + } + } + for (const command of offQueueCommands()) { + if (!offQueue.includes(command)) { + throw new Error(`the trait calls "${command}" off-queue but the runner queues it`); + } + } +}); + +test('every readiness probe is served off the queue', () => { + const offQueue = readSwiftOffQueueCommands(); + for (const command of RUNNER_COMMANDS.filter((name) => + isRunnerReadinessProbeCommand({ command: name }), + )) { + if (!offQueue.includes(command)) { + throw new Error(`the readiness probe "${command}" must be answered off the runner's queue`); + } + } +}); diff --git a/packages/apple-runner/src/__tests__/runner-command-traits-readiness-routing.test.ts b/packages/apple-runner/src/__tests__/runner-command-traits-readiness-routing.test.ts deleted file mode 100644 index 2522d30473..0000000000 --- a/packages/apple-runner/src/__tests__/runner-command-traits-readiness-routing.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { test } from 'vitest'; -import type { RunnerCommand } from '../runner-contract.ts'; -import { isRunnerReadinessProbeCommand, RUNNER_COMMAND_TRAITS } from '../runner-command-traits.ts'; -import { readSwiftInlineCommands } from './runner-swift-settlement-fixtures.ts'; - -/** - * The runner serves `status` and `uptime` inline — outside its journal and off the serial command - * queue — which is why the daemon charges no exchange for them: an inline reply proves the runner is - * reachable and nothing about queued work, so a charge for one could only ever be paid by the wrong - * exchange (#2965). The `readinessProbe` trait is how the daemon names that set, and this tie is what - * keeps the two sides one claim: add an inline arm in Swift that the trait missed and that reply would - * discharge an unrelated command's charge; drop one and a genuinely queued command would go uncharged, - * so a shutdown could hand off a runner with work on its queue. - * - * The Swift half is read from the runner's own switch rather than restated here, so the check cannot - * agree with a stale copy of the answer. - */ - -function readinessProbeCommands(): string[] { - return (Object.keys(RUNNER_COMMAND_TRAITS) as RunnerCommand['command'][]) - .filter((command) => isRunnerReadinessProbeCommand({ command })) - .sort(); -} - -test('the readinessProbe trait names exactly the commands the runner serves inline', () => { - const inline = readSwiftInlineCommands(); - - if (inline.length === 0) { - throw new Error( - 'the runner serves no command inline, so the daemon must stop routing any reply as an inline answer', - ); - } - for (const command of inline) { - if (!readinessProbeCommands().includes(command)) { - throw new Error(`the runner answers "${command}" inline but the trait does not name it`); - } - } - for (const command of readinessProbeCommands()) { - if (!inline.includes(command)) { - throw new Error(`the trait calls "${command}" a probe but the runner queues it`); - } - } -}); diff --git a/packages/apple-runner/src/__tests__/runner-command-traits.test.ts b/packages/apple-runner/src/__tests__/runner-command-traits.test.ts index 3ad217cc7c..d0cb3e779a 100644 --- a/packages/apple-runner/src/__tests__/runner-command-traits.test.ts +++ b/packages/apple-runner/src/__tests__/runner-command-traits.test.ts @@ -10,7 +10,7 @@ import { RUNNER_COMMAND_TRAITS, type RunnerCommandTraits, } from '../runner-command-traits.ts'; -import { readSwiftInlineCommands } from './runner-swift-settlement-fixtures.ts'; +import { readSwiftOffQueueCommands } from './runner-swift-settlement-fixtures.ts'; const RUNNER_COMMANDS = Object.keys(RUNNER_COMMAND_TRAITS) as Array; @@ -20,6 +20,7 @@ test('runner command trait table pins lifecycle-sensitive command groups', () => readOnly: commandsWithTraits(readOnly()), payloadDependent: payloadDependentCommands(), readOnlyReadinessProbe: commandsWithTraits(readOnlyReadinessProbe()), + offQueueRead: commandsWithTraits(offQueueRead()), readinessPreflightExemptMutation: commandsWithTraits(preflightExemptMutation()), default: commandsWithTraits(defaults()), }; @@ -45,6 +46,7 @@ test('runner command trait table pins lifecycle-sensitive command groups', () => ], payloadDependent: ['alert'], readOnlyReadinessProbe: ['status', 'uptime'], + offQueueRead: ['snapshotBridge'], readinessPreflightExemptMutation: ['activate', 'targetReset', 'terminate'], default: [ 'actionButton', @@ -93,11 +95,11 @@ test('queued command read-only traits match the Swift parity fixture', () => { 'utf8', ), ) as Array<{ command: RunnerCommand; readOnly: boolean }>; - const inline = new Set(readSwiftInlineCommands()); + const offQueue = new Set(readSwiftOffQueueCommands()); const queued = new Set( readRunnerRequestFixture() .map(({ request }) => String(request.command)) - .filter((command) => !inline.has(command)), + .filter((command) => !offQueue.has(command)), ); assert.deepEqual(new Set(cases.map(({ command }) => command.command)), queued); for (const { command, readOnly } of cases) { @@ -122,6 +124,7 @@ function payloadDependentCommands(): RunnerCommand['command'][] { function defaults(): RunnerCommandTraits { return { readOnly: false, + queued: true, readinessProbe: false, readinessPreflightExempt: false, readinessPreflightSkipEligibleAfterHealthyMutation: false, @@ -138,10 +141,19 @@ function readOnly(): RunnerCommandTraits { function readOnlyReadinessProbe(): RunnerCommandTraits { return { ...readOnly(), + queued: false, readinessProbe: true, }; } +function offQueueRead(): RunnerCommandTraits { + return { + ...readOnly(), + queued: false, + readinessPreflightExempt: true, + }; +} + function preflightExemptMutation(): RunnerCommandTraits { return { ...defaults(), diff --git a/packages/apple-runner/src/__tests__/runner-source.test.ts b/packages/apple-runner/src/__tests__/runner-source.test.ts index 11d06b9761..9ea32634ad 100644 --- a/packages/apple-runner/src/__tests__/runner-source.test.ts +++ b/packages/apple-runner/src/__tests__/runner-source.test.ts @@ -71,6 +71,27 @@ test('computeRunnerSourceFingerprint covers the shared foreground geometry heade assert.notEqual(computeRunnerSourceFingerprint(root), before); }); +test('computeRunnerSourceFingerprint covers the snapshot bridge reader the runner compiles, not the guest server', () => { + const root = makeTempRoot(); + fs.mkdirSync(path.join(root, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'apple', 'runner', 'AgentDeviceRunner', 'Runner.swift'), + 'runner\n', + ); + const bridgeRoot = path.join(root, 'apple', 'snapshot-bridge'); + fs.mkdirSync(bridgeRoot, { recursive: true }); + const reader = path.join(bridgeRoot, 'SnapshotBridgeRequest.m'); + const guestServer = path.join(bridgeRoot, 'SnapshotBridge.m'); + fs.writeFileSync(reader, 'reader-one\n'); + fs.writeFileSync(guestServer, 'server-one\n'); + + const before = computeRunnerSourceFingerprint(root); + fs.writeFileSync(guestServer, 'server-one\n// changed\n'); + assert.equal(computeRunnerSourceFingerprint(root), before); + fs.writeFileSync(reader, 'reader-one\n// changed\n'); + assert.notEqual(computeRunnerSourceFingerprint(root), before); +}); + test('computeRunnerSourceFingerprint covers the shared snapshot presentation sources', () => { const root = makeTempRoot(); fs.mkdirSync(path.join(root, 'apple', 'runner', 'AgentDeviceRunner'), { recursive: true }); diff --git a/packages/apple-runner/src/__tests__/runner-swift-settlement-fixtures.ts b/packages/apple-runner/src/__tests__/runner-swift-settlement-fixtures.ts index 2fae53f42d..758111c07e 100644 --- a/packages/apple-runner/src/__tests__/runner-swift-settlement-fixtures.ts +++ b/packages/apple-runner/src/__tests__/runner-swift-settlement-fixtures.ts @@ -24,9 +24,9 @@ const SWIFT_RUNNER = path.resolve( '../../../../apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift', ); -/** `inlineResponse(for:)`'s switch arms, up to the `default:` where queued commands begin. */ -const INLINE_SWITCH = - /func inlineResponse\(for command: Command\)[\s\S]*?switch command\.command \{([\s\S]*?)\n\s*default:/; +/** `offQueueRoute(for:)`'s switch arms, up to the `default:` where queued commands begin. */ +const OFF_QUEUE_SWITCH = + /func offQueueRoute\(for command: Command\)[\s\S]*?switch command\.command \{([\s\S]*?)\n\s*default:/; /** * One unguarded `case .a, .b:` header, bindings continuing across lines if needed. Each repetition * must carry its own comma and `.name`, so no two splits of the same text both match — an ambiguous @@ -37,24 +37,24 @@ const INLINE_SWITCH = const CASE_HEADER = /\bcase\s+(\.\w+(?:\s*,\s*\.\w+)*):/g; /** - * The commands `inlineResponse(for:)` answers without journaling them or putting them on the serial + * The commands `offQueueRoute(for:)` answers without journaling them or putting them on the serial * command queue, which is exactly why a reply to one is no evidence about queued work. * * A reader here can only fail in two directions, and each is caught: a misread that names an extra - * command fails `runner-command-traits-readiness-routing.test.ts`, which compares this list against the daemon's - * `readinessProbe` trait in both directions, and a misread that loses an arm makes a real probe look + * command fails `runner-command-traits-queue-routing.test.ts`, which compares this list against the daemon's + * `queued` trait in both directions, and a misread that loses an arm makes an off-queue command look * queued, which that same tie reports. What is left to guard locally is the case both miss — a `case` * these two patterns do not recognize, which would otherwise drop an arm and leave a set that still * happens to match the trait. That is why a header must end at its colon: a guarded arm * (`case .uptime where flag:`) can fall through to `default:`, so it names a command that is really * queued some of the time, and it has to fail here rather than read as inline. */ -export function readSwiftInlineCommands(): string[] { +export function readSwiftOffQueueCommands(): string[] { const swift = fs.readFileSync(SWIFT_TRANSPORT, 'utf8'); - const body = INLINE_SWITCH.exec(swift)?.[1]; + const body = OFF_QUEUE_SWITCH.exec(swift)?.[1]; assert.ok( body !== undefined, - 'inlineResponse(for:) must keep a `switch command.command` with a default: arm, which is where ' + + 'offQueueRoute(for:) must keep a `switch command.command` with a default: arm, which is where ' + 'queued commands begin. Without that shape there is no inline set to read.', ); @@ -66,14 +66,14 @@ export function readSwiftInlineCommands(): string[] { assert.equal( headers.length, caseKeywords.length, - 'inlineResponse(for:) has a `case` form this reader does not recognize (an enum-qualified or ' + + 'offQueueRoute(for:) has a `case` form this reader does not recognize (an enum-qualified or ' + 'guarded arm?). Name it here before it decides a handoff.', ); const arms: string[] = []; for (const [, header] of headers) { for (const [, name] of (header ?? '').matchAll(/\.(\w+)/g)) arms.push(name!); } - assert.ok(arms.length > 0, 'inlineResponse(for:) declares no inline command arms'); + assert.ok(arms.length > 0, 'offQueueRoute(for:) declares no off-queue command arms'); return arms; } diff --git a/packages/apple-runner/src/apple-runner-platform.ts b/packages/apple-runner/src/apple-runner-platform.ts index eda4b78d9a..4ca09c3d8b 100644 --- a/packages/apple-runner/src/apple-runner-platform.ts +++ b/packages/apple-runner/src/apple-runner-platform.ts @@ -10,13 +10,27 @@ import { import { readHostCpuArchSync } from './host.ts'; import type { AppleSdkName } from './runner-toolchain-identity.ts'; +/** The snapshot bridge's shared full-screen rule, which the runner's event bridge also reads. */ +const SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME = 'ForegroundApplicationGeometry.h'; + /** - * The snapshot bridge's shared full-screen rule, named once. The bridge's runtime includes it and - * the runner's UITests target compiles against it through HEADER_SEARCH_PATHS, so its bytes change - * both compiled binaries. It sits with the constants this package shares: the bridge cache identity - * and the runner fingerprint both read it without either subtree entering the other's graph. + * The snapshot bridge reader, named once. The guest bridge compiles these files with its socket + * server, and the runner's UITests target includes them through HEADER_SEARCH_PATHS to serve the + * same reader on its transport, so their bytes change both compiled binaries. It sits with the + * constants this package shares: the bridge cache identity and the runner fingerprint both read it + * without either subtree entering the other's graph. */ -export const SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME = 'ForegroundApplicationGeometry.h'; +export const SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES = [ + 'SnapshotBridgeRequest.h', + 'SnapshotBridgeRequest.m', + 'SnapshotBridgeRuntime.h', + 'SnapshotBridgeRuntime.m', + 'SnapshotBridgeCapture.h', + 'SnapshotBridgeCapture.m', + 'SnapshotBridgeSurfaceHosts.h', + 'SnapshotBridgeSurfaceHosts.m', + SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, +] as const; export type RunnerApplePlatformName = 'iOS' | 'tvOS' | 'macOS' | 'visionOS'; diff --git a/packages/apple-runner/src/index.ts b/packages/apple-runner/src/index.ts index d6f10144e3..bdd464e2a9 100644 --- a/packages/apple-runner/src/index.ts +++ b/packages/apple-runner/src/index.ts @@ -39,6 +39,7 @@ export { parseRunnerSequenceResult, resolveRunnerRequestSignal, type RunnerCommand, + type RunnerSnapshotBridgeRequest, } from './runner-contract.ts'; export { runnerCommandResolvesSurface } from './runner-command-traits.ts'; export { isRunnerMainThreadTimeoutError } from './runner-error-classification.ts'; @@ -59,7 +60,7 @@ export { resolveRunnerSigningBuildSettings, } from './runner-cache-metadata.ts'; export { - SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, + SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES, resolveRunnerDerivedOverride, } from './apple-runner-platform.ts'; export { diff --git a/packages/apple-runner/src/runner-command-traits.ts b/packages/apple-runner/src/runner-command-traits.ts index ed11e98248..327f9c1bb9 100644 --- a/packages/apple-runner/src/runner-command-traits.ts +++ b/packages/apple-runner/src/runner-command-traits.ts @@ -2,6 +2,12 @@ import type { RunnerCommand } from './runner-contract.ts'; export type RunnerCommandTraits = Readonly<{ readOnly: boolean; + /** + * Whether the runner puts the command on its journal and serial command queue. A reply to a + * command it serves off that queue proves nothing about queued work, so the session charges none. + */ + queued: boolean; + /** Whether the command only probes the runner, so answering it does not use a speculative runner. */ readinessProbe: boolean; readinessPreflightExempt: boolean; readinessPreflightSkipEligibleAfterHealthyMutation: boolean; @@ -9,6 +15,7 @@ export type RunnerCommandTraits = Readonly<{ const DEFAULT_TRAITS: RunnerCommandTraits = { readOnly: false, + queued: true, readinessProbe: false, readinessPreflightExempt: false, readinessPreflightSkipEligibleAfterHealthyMutation: false, @@ -26,9 +33,18 @@ const READ_ONLY_TRAITS: RunnerCommandTraits = { const READ_ONLY_READINESS_PROBE_TRAITS: RunnerCommandTraits = { ...READ_ONLY_TRAITS, + queued: false, readinessProbe: true, }; +// A bridge read answers from the runner's own accessibility client, so its reply proves the runner +// answers and needs no readiness probe first, and it brings no app forward to probe for. +const OFF_QUEUE_READ_TRAITS: RunnerCommandTraits = { + ...READ_ONLY_TRAITS, + queued: false, + readinessPreflightExempt: true, +}; + // Only runner commands this daemon actually sends should become preflight-skip eligible. // The retired tapSeries/dragSeries/interactionFrame wire commands were removed from both // daemon and runner; an old daemon paired with a new runner gets a decode rejection and @@ -85,6 +101,7 @@ export const RUNNER_COMMAND_TRAITS = { status: READ_ONLY_READINESS_PROBE_TRAITS, uptime: READ_ONLY_READINESS_PROBE_TRAITS, appState: READ_ONLY_TRAITS, + snapshotBridge: OFF_QUEUE_READ_TRAITS, pasteboardWrite: DEFAULT_TRAITS, activate: READINESS_PREFLIGHT_EXEMPT_MUTATION_TRAITS, terminate: READINESS_PREFLIGHT_EXEMPT_MUTATION_TRAITS, @@ -131,6 +148,7 @@ const RUNNER_COMMAND_RESOLVES_SURFACE = { status: false, uptime: false, appState: false, + snapshotBridge: false, pasteboardWrite: false, activate: false, terminate: false, @@ -146,6 +164,10 @@ export function isReadOnlyRunnerCommand(command: RunnerCommand): boolean { return readRunnerCommandTraits(command).readOnly; } +export function isQueuedRunnerCommand(command: RunnerCommand): boolean { + return readRunnerCommandTraits(command).queued; +} + export function isRunnerReadinessProbeCommand(command: RunnerCommand): boolean { return readRunnerCommandTraits(command).readinessProbe; } diff --git a/packages/apple-runner/src/runner-contract.ts b/packages/apple-runner/src/runner-contract.ts index a64f02adac..57e6821a76 100644 --- a/packages/apple-runner/src/runner-contract.ts +++ b/packages/apple-runner/src/runner-contract.ts @@ -112,6 +112,8 @@ export type RunnerCommand = { // The session app's XCUIApplication.state by name. A lifecycle read: it skips the activation // preflight, so it reports the state the app is in rather than the one a repair leaves. | 'appState' + // Reads the Simulator AX bridge tree in the runner process, off its journal and command queue. + | 'snapshotBridge' // Sets the device's general pasteboard from the runner's own process: a simulator's // `simctl pbcopy` only promises its data from a process that exits before anything reads it. | 'pasteboardWrite' @@ -170,8 +172,35 @@ export type RunnerCommand = { litPanel?: { displayId: number; widthPt: number; heightPt: number }; sequenceOrientation?: RunnerSequenceOrientation; steps?: RunnerSequenceStep[]; + snapshotBridge?: RunnerSnapshotBridgeRequest; }; +/** + * One request to the Simulator AX bridge reader that the runner serves. `target` names the foreground + * process of `appBundleId`. `describe` reads that process's tree under the guest bridge's own bounds, + * and refuses a process other than `expectedPid` and `expectedProcessStartTime` as a stale target, so + * it cannot be sent without them. + */ +export type RunnerSnapshotBridgeRequest = + | { verb: 'target'; requestId: string; maxDurationMs?: number } + | { + verb: 'describe'; + requestId: string; + generation: string; + expectedPid: number; + expectedProcessStartTime: string; + maxDepth?: number; + maxNodes?: number; + maxDurationMs?: number; + maxResponseBytes?: number; + nativeLevelsHint?: number; + /** + * System surface host executables whose running process on this Simulator refuses the read with + * `system-surface-host-present`. The runner names its own Simulator. + */ + surfaceHostExecutables?: readonly string[]; + }; + /** * One allowlisted coordinate gesture step inside a fused `sequence` runner command. * The kind set is intentionally narrow (tap/doubleTap/longPress) and validated on both the diff --git a/packages/apple-runner/src/runner-exchange.ts b/packages/apple-runner/src/runner-exchange.ts index 3690cc02ae..2e66b82dda 100644 --- a/packages/apple-runner/src/runner-exchange.ts +++ b/packages/apple-runner/src/runner-exchange.ts @@ -25,6 +25,7 @@ import { import { canSkipRunnerReadinessPreflightAfterHealthyMutation, isReadOnlyRunnerCommand, + isQueuedRunnerCommand, isRunnerReadinessPreflightExempt, isRunnerReadinessProbeCommand, } from './runner-command-traits.ts'; @@ -290,11 +291,12 @@ async function sendRunnerCommandAfterPreflight(params: { throwIfCanceledBeforeSend(signal); // From here the runner holds our request, and a shutdown that hands it off would orphan a command - // nobody is waiting for any more. A readiness probe is charged no more than the preflight's own - // probe is: the runner serves it inline, so its reply says nothing about the queued work a charge - // stands for, and charging it would let one probe settle another command's debt (#2965). The trait - // that names the probe is pinned to the runner's inline routing by `runner-command-traits-readiness-routing.test.ts`. - const charged = !isRunnerReadinessProbeCommand(runnerCommand); + // nobody is waiting for any more. A command the runner serves off its queue is charged no more than + // the preflight's own probe is: its reply says nothing about the queued work a charge stands for, + // and charging it would let one reply settle another command's debt (#2965). The trait that names + // those commands is pinned to the runner's off-queue routing by + // `runner-command-traits-queue-routing.test.ts`. + const charged = isQueuedRunnerCommand(runnerCommand); if (charged) session.commandCharges.charge(runnerCommand.commandId); try { return await withDiagnosticTimer( diff --git a/packages/apple-runner/src/runner-source.ts b/packages/apple-runner/src/runner-source.ts index f1836c4434..a9bac74e5d 100644 --- a/packages/apple-runner/src/runner-source.ts +++ b/packages/apple-runner/src/runner-source.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME } from './apple-runner-platform.ts'; +import { SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES } from './apple-runner-platform.ts'; const APPLE_RUNNER_SOURCE_ROOT = path.join('apple', 'runner', 'AgentDeviceRunner'); const PACKAGED_APPLE_RUNNER_SOURCE_ROOT = path.join('dist', 'apple', 'runner', 'AgentDeviceRunner'); @@ -11,14 +11,12 @@ const PACKAGED_APPLE_SNAPSHOT_PRESENTATION_SOURCE_ROOT = path.join( 'apple', 'snapshot-presentation', ); -// The UITests target compiles RunnerXCTestEventBridge.m against this header through -// HEADER_SEARCH_PATHS. It is one exact file whose bytes change the compiled runner, and the -// package ships it at the same root-relative path the checkout keeps it at, so the fingerprint -// reads it from `projectRoot` in whichever layout produced it. -const RUNNER_SHARED_HEADER_PATH = path.join( - 'apple', - 'snapshot-bridge', - SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, +// The UITests target compiles the snapshot bridge reader and its shared header from +// apple/snapshot-bridge through HEADER_SEARCH_PATHS. Their bytes change the compiled runner, and the +// package ships them at the same root-relative path the checkout keeps them at, so the fingerprint +// reads them from `projectRoot` in whichever layout produced it. +const RUNNER_SHARED_BRIDGE_PATHS = SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES.map((filename) => + path.join('apple', 'snapshot-bridge', filename), ); export function resolveAppleRunnerSourceRoot(projectRoot: string): string { @@ -76,9 +74,9 @@ export function computeRunnerSourceFingerprint(projectRoot: string): string { }, ]; const files = collectRunnerSourceFiles(sourceRoots); - const sharedHeader = path.join(projectRoot, RUNNER_SHARED_HEADER_PATH); - if (fs.existsSync(sharedHeader)) { - files.push(sharedHeader); + for (const sharedPath of RUNNER_SHARED_BRIDGE_PATHS) { + const sharedFile = path.join(projectRoot, sharedPath); + if (fs.existsSync(sharedFile)) files.push(sharedFile); } files.sort((a, b) => a.localeCompare(b)); const fileStatsFingerprint = computeRunnerSourceFileStatsFingerprint(projectRoot, files); diff --git a/packages/contracts/src/ios-snapshot.ts b/packages/contracts/src/ios-snapshot.ts index 8b81304c5d..8fcc3f3bff 100644 --- a/packages/contracts/src/ios-snapshot.ts +++ b/packages/contracts/src/ios-snapshot.ts @@ -87,7 +87,7 @@ export type IosSnapshotFact = * What a *provider* acquisition leaves unproven, declared so capture-kit can derive the residue * for a producer that hands over a bare tree and nothing else. * - * The producer axis is `IosProviderAcquisitionProducer` — Appium page source and the Limrun + * The producer axis is `IosProviderAcquisitionProducer` — Appium page source and the Revyl * element tree — and deliberately not every {@link IosSnapshotProducer}. `apple-runner` and * `simulator-ax-bridge` build their own residue at the source, so a capability declared for * them here would be a claim nothing consults: exactly the shape that let the table say the diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index 0877c20acb..5f09f9c362 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -569,7 +569,7 @@ export function formatRole(type: string): string { /** * The channel↔producer pairs that can actually occur. `xctest` trees come from the local Apple - * runner, Appium page-source XML, or a cloud provider's AX element tree (Limrun, Revyl), and only + * runner, Appium page-source XML, or a cloud provider's AX element tree (Revyl), and only * the runner's output has been through its presentation (clip fold, effective geometry, scope). * Logic that assumes those guarantees must key on the producer, never on the channel alone. * diff --git a/packages/platform-apple/src/__tests__/index-runner-requests.test.ts b/packages/platform-apple/src/__tests__/index-runner-requests.test.ts index bc3bedd74d..19bed7af82 100644 --- a/packages/platform-apple/src/__tests__/index-runner-requests.test.ts +++ b/packages/platform-apple/src/__tests__/index-runner-requests.test.ts @@ -1,3 +1,4 @@ +import { IOS_SYSTEM_SURFACE_HOSTS } from '@agent-device/contracts/ios-system-surface'; import { assertProducedRunnerRequests, bindTestCommandHost, @@ -25,6 +26,11 @@ import { type RecordedRunnerCall, } from './recording-runner-provider.ts'; import { mkdtempForTest } from './tmp-dir.ts'; +import { createRunnerSnapshotBridge } from '../runner-snapshot-bridge.ts'; +import { + deriveIosCaptureHint, + createIosSnapshotRequest, +} from '@agent-device/capture-kit/ios-snapshot-planning'; bindTestCommandHost(); @@ -295,9 +301,52 @@ test('every Apple request site builds exactly its golden runner request', async ); captured.push([name, onlyRequest(name, calls)]); } + for (const [name, drive] of Object.entries(SNAPSHOT_BRIDGE_SITES)) { + const calls: RecordedRunnerCall[] = []; + await drive( + createRunnerSnapshotBridge({ + device: IOS_SIMULATOR, + provider: recordingRunnerProvider(calls), + }), + ); + captured.push([name, onlyRequest(name, calls)]); + } assertProducedRunnerRequests(import.meta.filename, captured); }); +// Each drive sends one bridge read; the recording runner answers it with no envelope, which the +// bridge reports as a failed read rather than a request it did not send. +const SNAPSHOT_BRIDGE_SITES: Record< + string, + (bridge: ReturnType) => Promise +> = { + 'ios-simulator.runner-snapshot-bridge.target': (bridge) => + bridge.resolveTarget(IOS_SIMULATOR, APP, new AbortController().signal).catch(() => undefined), + 'ios-simulator.runner-snapshot-bridge.describe': (bridge) => + bridge.source.acquire({ + target: { + appBundleId: APP, + targetId: `${IOS_SIMULATOR.id}:${APP}`, + pid: 41, + processStartTime: '1700000000.000001', + generation: '41:1700000000.000001', + }, + hint: deriveIosCaptureHint(createIosSnapshotRequest({ interactiveOnly: true })), + }), + 'ios-simulator.runner-snapshot-bridge.describe-surface-hosts': (bridge) => + bridge.source.acquire({ + target: { + appBundleId: APP, + targetId: `${IOS_SIMULATOR.id}:${APP}`, + pid: 41, + processStartTime: '1700000000.000001', + generation: '41:1700000000.000001', + }, + hint: deriveIosCaptureHint(createIosSnapshotRequest({ interactiveOnly: true })), + surfaceHostExecutables: IOS_SYSTEM_SURFACE_HOSTS.map((host) => host.processExecutable), + }), +}; + function onlyRequest(name: string, calls: RecordedRunnerCall[]): RunnerCommand { if (calls.length !== 1) throw new Error(`${name} sent ${calls.length} runner requests`); return calls[0]!.command; diff --git a/packages/platform-apple/src/runner-runtime.test.ts b/packages/platform-apple/src/runner-runtime.test.ts index 3d4f67306b..f9bffd088b 100644 --- a/packages/platform-apple/src/runner-runtime.test.ts +++ b/packages/platform-apple/src/runner-runtime.test.ts @@ -1,6 +1,11 @@ import { expect, test } from 'vitest'; import { createAppleRunnerRuntime } from './runner-runtime.ts'; import { IOS_SIMULATOR } from './__tests__/device-fixtures.ts'; +import type { RunnerContext } from '@agent-device/contracts/interactor-types'; +import { + bindProviderSnapshotInteractor, + type SnapshotRuntimePresenter, +} from '@agent-device/contracts/snapshot-runtime'; test('runtime observations use the forwarded runner with request and application scope', async () => { const commands: Record[] = []; @@ -29,6 +34,30 @@ test('runtime observations use the forwarded runner with request and application const operations = runner.bindRunnerOperations({ signal: controller.signal, resolveInteractor: async (_device, context) => runner.createInteractor(context), + snapshot: { + capture: { + captureSnapshot: async () => ({ + backend: 'xctest' as const, + producer: 'apple-runner' as const, + nodes: [], + }), + captureSnapshotWithCustomActions: async () => ({ + backend: 'xctest' as const, + producer: 'apple-runner' as const, + nodes: [], + }), + captureSnapshotWithoutActiveApp: async () => ({ + backend: 'xctest' as const, + producer: 'apple-runner' as const, + nodes: [], + }), + }, + present: (result) => ({ + backend: 'xctest' as const, + producer: result.acquisition.producer, + nodes: [...result.acquisition.nodes], + }), + }, }); expect(await operations.appState({ appBundleId: 'com.example.app' })).toEqual({}); await runner.resetTarget(); @@ -80,3 +109,43 @@ test('runtime observations use the forwarded runner with request and application ).rejects.toThrow(); expect(commands).toHaveLength(beforeCancel); }); + +test('an observe-only capture on a provider Simulator still fails closed and never reaches the runner', async () => { + const commands: Record[] = []; + const runner = createAppleRunnerRuntime({ + device: IOS_SIMULATOR, + host: { env: {}, cwd: process.cwd() }, + provider: { + hasLiveSession: () => true, + runCommand: async (_device, command) => { + commands.push(command); + return {}; + }, + }, + }); + const controller = new AbortController(); + const resolveInteractor = async (_device: typeof IOS_SIMULATOR, context: RunnerContext) => + runner.createInteractor(context); + const present: SnapshotRuntimePresenter = (result) => ({ + backend: 'xctest' as const, + producer: result.acquisition.producer, + nodes: [...result.acquisition.nodes], + }); + const operations = runner.bindRunnerOperations({ + signal: controller.signal, + resolveInteractor, + snapshot: { + capture: bindProviderSnapshotInteractor({ + device: IOS_SIMULATOR, + signal: controller.signal, + resolveInteractor: (context) => runner.createInteractor(context), + presentIosAcquisition: present, + }), + present, + }, + }); + await expect( + operations.captureSnapshot({ options: { appBundleId: 'com.example.app', observeOnly: true } }), + ).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); + expect(commands).toHaveLength(0); +}); diff --git a/packages/platform-apple/src/runner-runtime.ts b/packages/platform-apple/src/runner-runtime.ts index c10d2fb909..252dce109f 100644 --- a/packages/platform-apple/src/runner-runtime.ts +++ b/packages/platform-apple/src/runner-runtime.ts @@ -11,7 +11,7 @@ import { } from '@agent-device/apple-runner'; import { Deadline } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; -import { runAppleRunnerCommand } from './core/runner-client.ts'; +import { readRunnerSessionLiveness, runAppleRunnerCommand } from './core/runner-client.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; @@ -23,6 +23,15 @@ import { bindAppleAppStateRuntime } from './app-state-runtime.ts'; import { bindAppleFindTextRuntime } from './runtime-snapshot.ts'; import { bindAppleQuerySelectorRuntime } from './runtime-selector-query.ts'; import { bindElementTextRuntime } from '@agent-device/contracts/element-text-runtime'; +import { + captureSnapshotSignal, + type CaptureSnapshotInput, + type SnapshotRuntimeOperations, + type SnapshotRuntimePresenter, +} from '@agent-device/contracts/snapshot-runtime'; +import { createRunnerSnapshotBridge } from './runner-snapshot-bridge.ts'; +import { createSnapshotBridgeRoute, type SnapshotBridgeRoute } from './snapshot-route.ts'; +import { createSystemSurfaceForegroundProbe } from './system-surface-foreground.ts'; /** Binds a provider-owned runner session to the shared Apple command and runtime implementation. */ export function createAppleRunnerRuntime(options: { @@ -46,6 +55,79 @@ export function createAppleRunnerRuntime(options: { }, }; }; + const bridge = createRunnerSnapshotBridge({ device: options.device, provider: options.provider }); + // Every capture of a remote Simulator reaches the runner, through the bridge or through XCTest. So + // the foreground read starts a runner that is not up yet, under the capture's own budget, instead + // of giving up the proof that keeps the capture on the bridge. + const readForeground = createSystemSurfaceForegroundProbe(async (device, input) => { + if (!options.provider.hasLiveSession(device)) { + try { + await runAppleRunnerCommand( + device, + { command: 'uptime' }, + { signal: input.signal, ...(input.requestId ? { requestId: input.requestId } : {}) }, + ); + } catch { + input.signal.throwIfAborted(); + return undefined; + } + } + const session = readRunnerSessionLiveness(device.id); + return session?.liveness === 'ready' ? { sessionId: session.sessionId } : undefined; + }); + let snapshotRoute: SnapshotBridgeRoute | undefined; + /** + * The route outlives every request binding: its circuit, depth hints, and cached target describe + * the app generations this runner has read. The presenter comes with the first binding and is the + * device host's own, so every later binding hands over the same one. + */ + const routeFor = (present: SnapshotRuntimePresenter) => + (snapshotRoute ??= createSnapshotBridgeRoute( + { + snapshot: { presentIosAcquisition: present }, + appleApplications: { + hasLiveRunnerSession: async () => options.provider.hasLiveSession(options.device), + }, + }, + { + source: bridge.source, + resolveTarget: bridge.resolveTarget, + // The runner answers the target read itself; there is no detached discovery to wait on. + isDiscoveryPending: () => false, + targetFreshness: 'cached', + }, + async (device, input) => + await withAppleRunnerProvider( + runCommand, + { deviceId: device.id, ...(input.requestId ? { requestId: input.requestId } : {}) }, + () => readForeground(device, input), + ), + )); + /** + * Routes app captures through the runner-served bridge, falling back to the provider's own XCTest + * capture. An observation-only capture goes to the provider's capture, which decides whether it can + * observe without activation. + */ + const bindBridgeSnapshot = ( + signal: AbortSignal, + snapshot: Readonly<{ capture: SnapshotRuntimeOperations; present: SnapshotRuntimePresenter }>, + ): SnapshotRuntimeOperations => { + const route = routeFor(snapshot.present); + const captureSnapshot = async (input: CaptureSnapshotInput) => { + if (input.options?.observeOnly === true) return await snapshot.capture.captureSnapshot(input); + return await route.capture( + options.device, + input, + captureSnapshotSignal(signal, input), + async (fallbackInput) => await snapshot.capture.captureSnapshot(fallbackInput), + ); + }; + return Object.freeze({ + captureSnapshot, + captureSnapshotWithCustomActions: captureSnapshot, + captureSnapshotWithoutActiveApp: captureSnapshot, + }); + }; return { facts: appleRunnerInteractionFacts(options.device), bindDeployment: ( @@ -109,6 +191,8 @@ export function createAppleRunnerRuntime(options: { bindRunnerOperations: (request: { signal: AbortSignal; resolveInteractor(device: DeviceInfo, context: RunnerContext): Promise; + /** The provider's own capture, which the runner-served bridge falls back to. */ + snapshot: Readonly<{ capture: SnapshotRuntimeOperations; present: SnapshotRuntimePresenter }>; }) => { const host = { appleApplications: { @@ -128,7 +212,12 @@ export function createAppleRunnerRuntime(options: { { deviceId: options.device.id, requestId: input.execution?.requestId }, () => query.querySelector(input), ), + ...bindBridgeSnapshot(request.signal, request.snapshot), }; }, + /** Closes the runner-served bridge source; the runner session itself is the provider's to stop. */ + shutdown: async () => { + await snapshotRoute?.shutdown(); + }, }; } diff --git a/packages/platform-apple/src/runner-snapshot-bridge.test.ts b/packages/platform-apple/src/runner-snapshot-bridge.test.ts new file mode 100644 index 0000000000..c59b92c187 --- /dev/null +++ b/packages/platform-apple/src/runner-snapshot-bridge.test.ts @@ -0,0 +1,487 @@ +import { IOS_SYSTEM_SURFACE_HOSTS } from '@agent-device/contracts/ios-system-surface'; +import { beforeEach, expect, test, vi } from 'vitest'; +import type { AppleRunnerProvider, RunnerCommand } from '@agent-device/apple-runner'; +import type { CaptureSnapshotInput } from '@agent-device/contracts/snapshot-runtime'; +import type { SnapshotRuntimeAcquiredResult } from '@agent-device/contracts/interactor-types'; + +const registry = vi.hoisted(() => ({ + session: undefined as { sessionId: string; liveness: 'ready' | 'starting' } | undefined, +})); +vi.mock('./core/runner-client.ts', async (importOriginal) => ({ + ...(await importOriginal()), + readRunnerSessionLiveness: () => registry.session ?? null, +})); + +import { createAppleRunnerRuntime } from './runner-runtime.ts'; +import { createRunnerSnapshotBridge } from './runner-snapshot-bridge.ts'; +import { createSnapshotBridgeRoute } from './snapshot-route.ts'; +import { IOS_SIMULATOR } from './__tests__/device-fixtures.ts'; +import { createRequestCanceledError } from '@agent-device/kernel/errors'; +import { + createIosSnapshotRequest, + deriveIosCaptureHint, +} from '@agent-device/capture-kit/ios-snapshot-planning'; +import { + SNAPSHOT_SOURCE_PROTOCOL_VERSION, + SNAPSHOT_SOURCE_VERSION, +} from './snapshot-source/protocol.ts'; + +const APP = 'com.example.app'; + +beforeEach(() => { + registry.session = { sessionId: 'runner-session-1', liveness: 'ready' }; +}); + +type RunnerProcess = { pid: number; processStartTime: string }; + +/** + * A runner that serves the snapshot bridge like the XCTest runner does: `target` names the app's + * foreground process, and `describe` reads it unless the host planned another process, or answers + * the refusal `refuseDescribe` names. A describe given `holdNextDescribe` reads until that promise + * settles, and, like the runner, it keeps reading after the daemon stops waiting: a describe that + * arrives meanwhile is refused as `bridge-read-in-flight`. A describe that names surface hosts is + * refused as `system-surface-host-unverified`, as a sandbox that refuses the process table does. + */ +function bridgeRunner(state: { + process: RunnerProcess; + refuseDescribe?: string; + refuseTarget?: string; + holdNextDescribe?: Promise; +}) { + const sent: RunnerCommand[] = []; + let reading = false; + const envelope = (request: NonNullable, body: object) => ({ + snapshotBridgeEnvelope: JSON.stringify({ + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + requestId: request.requestId, + ...body, + }), + }); + const provider: AppleRunnerProvider = { + hasLiveSession: () => true, + runCommand: async (_device, command, options) => { + sent.push(command); + const request = command.snapshotBridge; + if (command.command !== 'snapshotBridge' || !request) return {}; + if (request.verb === 'target' && state.refuseTarget) { + return envelope(request, { + ok: false, + error_kind: 'application_unavailable', + error_code: state.refuseTarget, + }); + } + if (request.verb === 'target') return envelope(request, { ok: true, ...state.process }); + const refusal = (kind: string, code: string) => + envelope(request, { + ok: false, + pid: request.expectedPid, + generation: request.generation, + error_kind: kind, + error_code: code, + }); + if ( + request.expectedPid !== state.process.pid || + request.expectedProcessStartTime !== state.process.processStartTime + ) { + return refusal('stale_target', 'target-process-changed'); + } + if (request.surfaceHostExecutables) + return refusal('unsupported', 'system-surface-host-unverified'); + if (state.refuseDescribe) return refusal('application_not_responding', state.refuseDescribe); + if (reading) return refusal('application_not_responding', 'bridge-read-in-flight'); + const hold = state.holdNextDescribe; + if (hold) { + state.holdNextDescribe = undefined; + reading = true; + void hold.then(() => { + reading = false; + }); + await new Promise((resolve, reject) => { + void hold.then(resolve); + options.signal?.addEventListener('abort', () => reject(createRequestCanceledError()), { + once: true, + }); + }); + } + return envelope(request, { + ok: true, + pid: state.process.pid, + generation: request.generation, + truncated: false, + automationEnabled: true, + recovery: { + requests: 1, + rejected: 0, + continuations: 0, + acceptedLevels: (request.maxDepth ?? 0) + 1, + }, + tree: { + XC_kAXXCAttributeElementType: 'Application', + XC_kAXXCAttributeFrame: { X: 0, Y: 0, Width: 390, Height: 844 }, + XC_kAXXCAttributeChildren: [ + { + XC_kAXXCAttributeElementType: 'Button', + XC_kAXXCAttributeLabel: 'Continue', + XC_kAXXCAttributeFrame: { X: 10, Y: 10, Width: 80, Height: 40 }, + XC_kAXXCAttributeChildren: [], + }, + ], + }, + }); + }, + }; + return { provider, sent }; +} + +function runnerResult() { + return { backend: 'xctest' as const, producer: 'apple-runner' as const, nodes: [] }; +} + +function verbs(sent: readonly RunnerCommand[]): string[] { + return sent.map((command) => command.snapshotBridge?.verb ?? command.command); +} + +/** The bridge route of a runner whose foreground read proves no system surface covers the app. */ +function bridgeRoute(provider: AppleRunnerProvider) { + const bridge = createRunnerSnapshotBridge({ device: IOS_SIMULATOR, provider }); + const presented: SnapshotRuntimeAcquiredResult[] = []; + const fallbacks: CaptureSnapshotInput[] = []; + const route = createSnapshotBridgeRoute( + { + snapshot: { + presentIosAcquisition: (result) => { + presented.push(result); + return { ...runnerResult(), producer: result.acquisition.producer }; + }, + }, + appleApplications: { hasLiveRunnerSession: async () => true }, + }, + { + source: bridge.source, + resolveTarget: bridge.resolveTarget, + isDiscoveryPending: () => false, + targetFreshness: 'cached', + }, + async () => 'background', + ); + const snapshot = (signal = new AbortController().signal) => + route.capture( + IOS_SIMULATOR, + { options: { appBundleId: APP, interactiveOnly: true } }, + signal, + async (input) => { + fallbacks.push(input); + return runnerResult(); + }, + ); + return { snapshot, presented, fallbacks }; +} + +test('a remote Simulator capture reads the runner-served bridge in one round trip once its target is known', async () => { + const { provider, sent } = bridgeRunner({ + process: { pid: 41, processStartTime: '1700000000.000001' }, + }); + const { snapshot, presented, fallbacks } = bridgeRoute(provider); + + const first = await snapshot(); + await snapshot(); + + expect(first.warnings ?? []).toEqual([]); + expect(fallbacks).toEqual([]); + expect(presented.map(({ acquisition }) => acquisition.producer)).toEqual([ + 'simulator-ax-bridge', + 'simulator-ax-bridge', + ]); + expect(presented[0]!.acquisition.lineage).toEqual({ + targetId: `${IOS_SIMULATOR.id}:${APP}`, + generation: '41:1700000000.000001', + }); + expect(verbs(sent)).toEqual(['target', 'describe', 'describe']); + expect(sent[1]!.snapshotBridge).toMatchObject({ + expectedPid: 41, + expectedProcessStartTime: '1700000000.000001', + generation: '41:1700000000.000001', + }); +}); + +test('every runner bridge request names its own budget, one transport margin under the daemon deadline', async () => { + const { provider, sent } = bridgeRunner({ + process: { pid: 41, processStartTime: '1700000000.000001' }, + }); + const { snapshot } = bridgeRoute(provider); + + await snapshot(); + + const [target, describe] = sent.map((command) => command.snapshotBridge); + expect(target).toMatchObject({ verb: 'target', maxDurationMs: 4_000 }); + expect(describe).toMatchObject({ verb: 'describe', maxDurationMs: 4_000 }); + expect(describe).toMatchObject({ maxResponseBytes: expect.any(Number) }); +}); + +test('a read that meets a relaunched app takes XCTest once and reads the new process next', async () => { + const state = { process: { pid: 41, processStartTime: '1700000000.000001' } }; + const { provider, sent } = bridgeRunner(state); + const { snapshot, presented, fallbacks } = bridgeRoute(provider); + await snapshot(); + state.process = { pid: 77, processStartTime: '1700000100.000002' }; + sent.length = 0; + + const relaunched = await snapshot(); + await snapshot(); + + expect(fallbacks).toHaveLength(1); + expect(relaunched.warnings).toEqual([ + 'Simulator AX snapshot unavailable (target-process-changed); used XCTest for this app generation.', + ]); + expect(presented.at(-1)!.acquisition.lineage.generation).toBe('77:1700000100.000002'); + expect(verbs(sent)).toEqual(['describe', 'target', 'describe']); +}); + +test('a relaunch re-enables the bridge that a failed read disabled for the old generation', async () => { + const state: { process: RunnerProcess; refuseDescribe?: string } = { + process: { pid: 41, processStartTime: '1700000000.000001' }, + refuseDescribe: 'bridge-read-abandoned', + }; + const { provider, sent } = bridgeRunner(state); + const { snapshot, presented, fallbacks } = bridgeRoute(provider); + await snapshot(); + await snapshot(); + expect(fallbacks).toHaveLength(2); + expect(presented).toEqual([]); + + state.process = { pid: 77, processStartTime: '1700000100.000002' }; + state.refuseDescribe = undefined; + sent.length = 0; + await snapshot(); + + // The open circuit sends no read that could meet the new process, so the route asks for the target + // again before it trusts the circuit. + expect(fallbacks).toHaveLength(2); + expect(presented.at(-1)!.acquisition.lineage.generation).toBe('77:1700000100.000002'); + expect(verbs(sent)).toEqual(['target', 'describe']); +}); + +test('a target refresh that fails publishes the fallback under an unknown generation', async () => { + const state: { process: RunnerProcess; refuseDescribe?: string; refuseTarget?: string } = { + process: { pid: 41, processStartTime: '1700000000.000001' }, + refuseDescribe: 'bridge-read-abandoned', + }; + const { provider, sent } = bridgeRunner(state); + const { snapshot, presented } = bridgeRoute(provider); + await snapshot(); + state.refuseTarget = 'bridge-read-in-flight'; + + const first = await snapshot(); + const second = await snapshot(); + + // The app may have relaunched behind the failed refresh, so neither capture claims the old + // generation, and the two cannot compare equal. + expect(first.warnings).toEqual([ + 'Simulator AX snapshot unavailable (target-resolution-failed); used XCTest for an unverified app generation.', + ]); + expect(first.comparisonIdentity?.lineage).toEqual({ targetId: `${IOS_SIMULATOR.id}:${APP}` }); + expect(second.comparisonIdentity).not.toEqual(first.comparisonIdentity); + + // The cached target survives the failed refresh, so a later refresh can still reopen the bridge. + state.refuseTarget = undefined; + state.refuseDescribe = undefined; + state.process = { pid: 77, processStartTime: '1700000100.000002' }; + sent.length = 0; + await snapshot(); + expect(presented.at(-1)!.acquisition.lineage.generation).toBe('77:1700000100.000002'); + expect(verbs(sent)).toEqual(['target', 'describe']); +}); + +const KNOWN_TARGET = { + appBundleId: APP, + targetId: `${IOS_SIMULATOR.id}:${APP}`, + pid: 41, + processStartTime: '1700000000.000001', + generation: '41:1700000000.000001', +} as const; + +function describeOnce( + bridge: ReturnType, + maxDurationMs = 5_000, +) { + return bridge.source.acquire({ + target: KNOWN_TARGET, + hint: deriveIosCaptureHint(createIosSnapshotRequest({ interactiveOnly: true })), + limits: { maxDurationMs }, + }); +} + +test('a read that outlives its own budget is a timeout the route falls back from, not a cancellation', async () => { + // Like the runner transport, the exchange turns its aborted signal into a request cancellation. + const provider: AppleRunnerProvider = { + hasLiveSession: () => true, + runCommand: async (_device, _command, options) => + await new Promise((_, reject) => { + options.signal?.addEventListener('abort', () => reject(createRequestCanceledError()), { + once: true, + }); + }), + }; + const bridge = createRunnerSnapshotBridge({ device: IOS_SIMULATOR, provider }); + + const outcome = await describeOnce(bridge, 30); + + expect(outcome).toMatchObject({ + stage: 'failed', + failure: { kind: 'timeout', code: 'bridge-request-deadline' }, + }); +}); + +test('a read a cancelled capture left running costs the next capture its bridge read, not the generation', async () => { + let finishRead!: () => void; + const state: { process: RunnerProcess; holdNextDescribe?: Promise } = { + process: { pid: 41, processStartTime: '1700000000.000001' }, + }; + const { provider } = bridgeRunner(state); + const { snapshot, presented, fallbacks } = bridgeRoute(provider); + await snapshot(); + state.holdNextDescribe = new Promise((resolve) => { + finishRead = resolve; + }); + + const cancelled = new AbortController(); + const abandoned = snapshot(cancelled.signal); + await new Promise((resolve) => setTimeout(resolve, 0)); + cancelled.abort(); + await expect(abandoned).rejects.toThrow(); + const refused = await snapshot(); + finishRead(); + await snapshot(); + + expect(refused.warnings).toEqual([ + 'Simulator AX snapshot unavailable (bridge-read-in-flight); used XCTest for this capture while an earlier bridge read was still running.', + ]); + expect(fallbacks).toHaveLength(1); + expect(presented).toHaveLength(2); +}); + +function runtimeSnapshots(provider: AppleRunnerProvider) { + const runner = createAppleRunnerRuntime({ + device: IOS_SIMULATOR, + host: { env: {}, cwd: process.cwd() }, + provider, + }); + const fallbacks: CaptureSnapshotInput[] = []; + const presented: SnapshotRuntimeAcquiredResult[] = []; + const operations = runner.bindRunnerOperations({ + signal: new AbortController().signal, + resolveInteractor: async (_device, context) => runner.createInteractor(context), + snapshot: { + capture: { + captureSnapshot: async (input) => { + fallbacks.push(input); + return runnerResult(); + }, + captureSnapshotWithCustomActions: async () => runnerResult(), + captureSnapshotWithoutActiveApp: async () => runnerResult(), + }, + present: (result) => { + presented.push(result); + return { ...runnerResult(), producer: result.acquisition.producer }; + }, + }, + }); + const snapshot = () => operations.captureSnapshot({ options: { appBundleId: APP } }); + return { snapshot, fallbacks, presented }; +} + +/** Answers the runner's system-surface host reads with `state`, and every other command as `next`. */ +function withHostStates( + provider: AppleRunnerProvider, + state: string | undefined, +): AppleRunnerProvider { + return { + ...provider, + runCommand: async (device, command, options) => + command.command === 'appState' + ? state + ? { applicationState: state } + : {} + : await provider.runCommand(device, command, options), + }; +} + +test('the runner runtime reads a remote Simulator through the bridge once its foreground read proves no surface', async () => { + const { provider, sent } = bridgeRunner({ + process: { pid: 41, processStartTime: '1700000000.000001' }, + }); + const { snapshot, fallbacks, presented } = runtimeSnapshots( + withHostStates(provider, 'notRunning'), + ); + + const captured = await snapshot(); + + expect(captured.warnings ?? []).toEqual([]); + expect(fallbacks).toEqual([]); + expect(presented.map(({ acquisition }) => acquisition.producer)).toEqual(['simulator-ax-bridge']); + expect(verbs(sent)).toEqual(['target', 'describe']); +}); + +test('a runner whose foreground read cannot answer asks the bridge read to check the surface hosts', async () => { + const { provider, sent } = bridgeRunner({ + process: { pid: 41, processStartTime: '1700000000.000001' }, + }); + const { snapshot, fallbacks } = runtimeSnapshots(withHostStates(provider, undefined)); + + const captured = await snapshot(); + + const describe = sent.find((command) => command.snapshotBridge?.verb === 'describe'); + expect(describe?.snapshotBridge).toMatchObject({ + surfaceHostExecutables: IOS_SYSTEM_SURFACE_HOSTS.map((host) => host.processExecutable), + }); + // The runner could not place a host process, so the capture goes to XCTest and says why. + expect(fallbacks).toHaveLength(1); + expect(captured.warnings).toEqual([ + 'Simulator AX snapshot unavailable (system-surface-host-unverified); used XCTest for this app generation.', + ]); +}); + +test('the foreground proof starts the runner when none is live, then reads only that session', async () => { + registry.session = undefined; + const { provider } = bridgeRunner({ + process: { pid: 41, processStartTime: '1700000000.000001' }, + }); + let started = false; + const appStateSessions: (string | undefined)[] = []; + const { snapshot, fallbacks } = runtimeSnapshots({ + ...provider, + hasLiveSession: () => started, + runCommand: async (device, command, options) => { + if (command.command === 'uptime') { + started = true; + registry.session = { sessionId: 'runner-session-2', liveness: 'ready' }; + return {}; + } + if (command.command === 'appState') { + appStateSessions.push(options.expectedRunnerSessionId); + return { applicationState: 'notRunning' }; + } + return await provider.runCommand(device, command, options); + }, + }); + + await snapshot(); + + expect(fallbacks).toEqual([]); + expect(appStateSessions.length).toBeGreaterThan(0); + expect(appStateSessions.every((id) => id === 'runner-session-2')).toBe(true); +}); + +test('a runner whose session id cannot be read answers the foreground proof as unknown', async () => { + registry.session = undefined; + const { provider, sent } = bridgeRunner({ + process: { pid: 41, processStartTime: '1700000000.000001' }, + }); + const { snapshot, fallbacks } = runtimeSnapshots(withHostStates(provider, 'notRunning')); + + await snapshot(); + + expect(fallbacks).toHaveLength(1); + expect(sent.map((command) => command.command)).not.toContain('appState'); +}); diff --git a/packages/platform-apple/src/runner-snapshot-bridge.ts b/packages/platform-apple/src/runner-snapshot-bridge.ts new file mode 100644 index 0000000000..3e0b519cd7 --- /dev/null +++ b/packages/platform-apple/src/runner-snapshot-bridge.ts @@ -0,0 +1,247 @@ +import { randomUUID } from 'node:crypto'; +import { + type AppleRunnerProvider, + type RunnerSnapshotBridgeRequest, + withAppleRunnerProvider, +} from '@agent-device/apple-runner'; +import { emitDiagnostic, withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { runAppleRunnerCommand } from './core/runner-client.ts'; +import { + createSnapshotBridgeSource, + type SnapshotBridgeSource, +} from './snapshot-source/bridge-source.ts'; +import { remainingSnapshotSourceMs } from './snapshot-source/deadline.ts'; +import { asSnapshotSourceError, snapshotSourceError } from './snapshot-source/errors.ts'; +import { + acceptSnapshotBridgeEnvelope, + isRecord, + type SnapshotBridgeEnvelope, +} from './snapshot-source/protocol.ts'; + +/** The app generation the runner-served bridge reads: one foreground process, for its whole life. */ +export type RunnerSnapshotBridgeTarget = Readonly<{ + appBundleId: string; + targetId: string; + pid: number; + processStartTime: string; + generation: string; +}>; + +/** Why the runner could not name the foreground process of the app: the bridge reader's own code. */ +const TARGET_UNAVAILABLE = 'runner-snapshot-bridge-target-unavailable'; + +/** Bounds the target read, which costs one AXRuntime and two sysctl reads in the runner. */ +const TARGET_READ_TIMEOUT_MS = 5_000; + +/** + * The part of a read's budget left for the transport. The runner abandons a read this much before the + * daemon stops waiting, so a slow tree comes back as the reader's own refusal instead of a lost reply. + */ +const RUNNER_TRANSPORT_MARGIN_MS = 1_000; + +export type RunnerSnapshotBridge = Readonly<{ + source: SnapshotBridgeSource; + resolveTarget( + device: DeviceInfo, + appBundleId: string, + signal: AbortSignal, + refresh?: 'refresh', + ): Promise; +}>; + +/** + * The Simulator AX bridge reader that the XCTest runner serves, for a Simulator whose host cannot + * start the guest bridge process. The runner reads the tree off its XCTest main thread. A target stays + * cached until a read reports a new process, so a steady tree read costs one runner round trip. + */ +export function createRunnerSnapshotBridge( + options: Readonly<{ device: DeviceInfo; provider: AppleRunnerProvider }>, +): RunnerSnapshotBridge { + const targets = new Map(); + // The runner admits one bridge read at a time and refuses an overlapping one at once, so it alone + // owns that order: a read a cancelled capture left running costs the next capture its bridge read, + // and the route answers that refusal for that capture only. + const send = async ( + appBundleId: string, + request: RunnerSnapshotBridgeRequest, + signal: AbortSignal, + ): Promise => { + const data = await withAppleRunnerProvider( + options.provider, + { deviceId: options.device.id }, + async () => + await runAppleRunnerCommand( + options.device, + { command: 'snapshotBridge', appBundleId, snapshotBridge: request }, + { signal }, + ), + ); + return parseRunnerEnvelope(data); + }; + + const resolveTarget: RunnerSnapshotBridge['resolveTarget'] = async ( + device, + appBundleId, + signal, + refresh, + ) => { + signal.throwIfAborted(); + const targetId = `${device.id}:${appBundleId}`; + const cached = targets.get(targetId); + if (cached && refresh !== 'refresh') return cached; + // Only a resolved target replaces the cached one: a refresh that fails leaves the generation the + // route planned against. + const requestId = randomUUID(); + const envelope = await send( + appBundleId, + { + verb: 'target', + requestId, + maxDurationMs: TARGET_READ_TIMEOUT_MS - RUNNER_TRANSPORT_MARGIN_MS, + }, + AbortSignal.any([signal, AbortSignal.timeout(TARGET_READ_TIMEOUT_MS)]), + ); + const target = readTarget(envelope, { requestId, appBundleId, targetId }); + targets.set(targetId, target); + return target; + }; + + const source = createSnapshotBridgeSource({ + diagnostics: { emitDiagnostic, withDiagnosticTimer }, + transport: { + name: 'runner', + validateTarget, + prepare: async (target) => async (read) => { + const requestId = randomUUID(); + const remainingMs = remainingSnapshotSourceMs(read.deadline, 'bridge-request-deadline'); + const readDeadline = AbortSignal.timeout(remainingMs); + let envelope: SnapshotBridgeEnvelope; + try { + envelope = await send( + target.appBundleId, + { + verb: 'describe', + requestId, + generation: target.generation, + expectedPid: target.pid, + expectedProcessStartTime: target.processStartTime, + maxDepth: read.maxDepth, + maxNodes: read.limits.maxNodes, + maxDurationMs: Math.max(1, read.limits.maxDurationMs - RUNNER_TRANSPORT_MARGIN_MS), + maxResponseBytes: read.limits.maxResponseBytes, + ...(read.nativeLevelsHint !== undefined + ? { nativeLevelsHint: read.nativeLevelsHint } + : {}), + ...(read.surfaceHostExecutables + ? { surfaceHostExecutables: read.surfaceHostExecutables } + : {}), + }, + AbortSignal.any([ + ...(read.deadline.signal ? [read.deadline.signal] : []), + readDeadline, + ]), + ); + } catch (error) { + throw runnerExchangeFailure(error, read.deadline.signal, readDeadline); + } + // A read that met a new process fails as a stale target; the route then refreshes the target, + // which replaces the cached one. + return acceptSnapshotBridgeEnvelope(envelope, { + requestId, + pid: target.pid, + generation: target.generation, + }); + }, + // The runner owns everything this transport reaches; the runner session closes it. + close: async () => targets.clear(), + }, + }); + return { source, resolveTarget }; +} + +function parseRunnerEnvelope(data: Record): SnapshotBridgeEnvelope { + const text = data.snapshotBridgeEnvelope; + if (typeof text !== 'string') { + throw snapshotSourceError('malformed-tree', 'runner-envelope-missing'); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw snapshotSourceError('malformed-tree', 'response-not-json', {}, error); + } + if (!isRecord(parsed)) throw snapshotSourceError('malformed-tree', 'response-not-object'); + return parsed; +} + +function readTarget( + envelope: SnapshotBridgeEnvelope, + expected: Readonly<{ requestId: string; appBundleId: string; targetId: string }>, +): RunnerSnapshotBridgeTarget { + const pid = envelope.pid; + const processStartTime = envelope.processStartTime; + if ( + envelope.requestId !== expected.requestId || + envelope.ok !== true || + typeof pid !== 'number' || + !Number.isSafeInteger(pid) || + pid <= 0 || + typeof processStartTime !== 'string' || + !processStartTime + ) { + const code = typeof envelope.error_code === 'string' ? envelope.error_code : 'target-invalid'; + throw new AppError( + 'COMMAND_FAILED', + `Unable to resolve the foreground app in the runner (${code}).`, + { + reason: TARGET_UNAVAILABLE, + bridgeFailureCode: code, + appBundleId: expected.appBundleId, + }, + ); + } + return Object.freeze({ + appBundleId: expected.appBundleId, + targetId: expected.targetId, + pid, + processStartTime, + generation: `${pid}:${processStartTime}`, + }); +} + +function validateTarget(target: RunnerSnapshotBridgeTarget): void { + if ( + !target.appBundleId.trim() || + !target.generation.trim() || + !Number.isSafeInteger(target.pid) || + target.pid <= 0 + ) { + throw new AppError('INVALID_ARGS', 'Runner snapshot bridge target identity is incomplete'); + } +} + +/** + * A runner exchange that did not deliver an envelope. Only the caller's own cancellation is a + * cancellation: a read that ran out of its own budget is a timeout, which the route answers with the + * XCTest fallback. Any other failure is the runner transport's, treated like a guest transport failure. + */ +function runnerExchangeFailure( + error: unknown, + requestSignal: AbortSignal | undefined, + readDeadline: AbortSignal, +) { + if (requestSignal?.aborted) return snapshotSourceError('cancelled', 'abort-signal', {}, error); + if (readDeadline.aborted) { + return snapshotSourceError('timeout', 'bridge-request-deadline', {}, error); + } + const normalized = asSnapshotSourceError(error); + if (normalized.failureKind !== 'transport-failure') return normalized; + return snapshotSourceError( + 'transport-failure', + 'runner-exchange-failed', + { error: error instanceof Error ? error.message : String(error) }, + error, + ); +} diff --git a/packages/platform-apple/src/snapshot-route-failure-policy.test.ts b/packages/platform-apple/src/snapshot-route-failure-policy.test.ts index 0242073b5d..2cd34f996b 100644 --- a/packages/platform-apple/src/snapshot-route-failure-policy.test.ts +++ b/packages/platform-apple/src/snapshot-route-failure-policy.test.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { describe, expect, test } from 'vitest'; import { IOS_SYSTEM_SURFACE_HOSTS } from '@agent-device/contracts/ios-system-surface'; import { bridgeFailureDisposition } from './snapshot-route-failure-policy.ts'; @@ -24,6 +26,20 @@ describe('capture-scoped screen refusals', () => { }, ); + test('a runner-served read still running classifies as capture-scoped; an abandoned one does not', () => { + // The refusal says only that another read is running; abandoning a read past its budget is + // evidence about the app's tree, so that one still retires the generation. + expect(bridgeFailureDisposition({ kind: 'timeout', code: 'bridge-read-in-flight' })).toEqual({ + scope: 'capture', + code: 'bridge-read-in-flight', + consequence: 'bridge-read-busy', + }); + expect(bridgeFailureDisposition({ kind: 'timeout', code: 'bridge-read-abandoned' })).toEqual({ + scope: 'generation', + code: 'bridge-read-abandoned', + }); + }); + test('a bridge still being prepared classifies as capture-scoped preparation', () => { // The kind carries this one, not a code: the preparation owner reports a pending attempt, not // a screen (#2491). @@ -109,3 +125,33 @@ describe('generation-wide failures', () => { }, ); }); + +// The table is shared with the emitter guard in test/repo-guards/snapshot-bridge-refusals.test.ts. +const fixture = JSON.parse( + fs.readFileSync( + path.resolve( + import.meta.dirname, + '../../../contracts/fixtures/ios-snapshot-bridge-refusals.json', + ), + 'utf8', + ), +) as { captureScoped: { code: string; consequence: string }[]; generationScoped: string[] }; + +test('the policy gives each code of the shared refusal table its scope and consequence', () => { + for (const { code, consequence } of fixture.captureScoped) { + // A preparation still running is classified by its failure kind, not by its code. + const kind = consequence === 'bridge-preparation' ? 'preparing' : 'unsupported'; + // A surface-host refusal also names the host it saw; this table fixes only scope and consequence. + expect(bridgeFailureDisposition({ kind, code }), code).toMatchObject({ + scope: 'capture', + code, + consequence, + }); + } + for (const code of fixture.generationScoped) { + expect(bridgeFailureDisposition({ kind: 'unsupported', code }), code).toEqual({ + scope: 'generation', + code, + }); + } +}); diff --git a/packages/platform-apple/src/snapshot-route-failure-policy.ts b/packages/platform-apple/src/snapshot-route-failure-policy.ts index 63b5a484b0..3152393531 100644 --- a/packages/platform-apple/src/snapshot-route-failure-policy.ts +++ b/packages/platform-apple/src/snapshot-route-failure-policy.ts @@ -6,15 +6,17 @@ import type { SnapshotSourceFailure } from './snapshot-source-facade.ts'; /** * What the screen showed when a capture-scoped bridge refusal fired: a preparation still running - * (#2491), an unresolved coordinate space (#2612), a foreground another surface owns (#3874), or a - * window that does not fill the screen (#3844). The route consumes one disposition for both + * (#2491), a runner-served read still running, a tree that changed between two reads, an unresolved coordinate space (#2612), a foreground + * another surface owns (#3874), or a window that does not fill the screen (#3844). The route consumes one disposition for both * decisions that must agree: whether the generation's bridge circuit opens, and which sentence the * fallback warning carries (#3874, #3895). */ export type CaptureScopedConsequence = | 'bridge-preparation' + | 'bridge-read-busy' | 'coordinate-space-unresolved' | 'foreground-ownership' + | 'tree-changed' | 'window-not-full-screen'; /** @@ -50,12 +52,20 @@ export type BridgeFallbackDisposition = Exclude< * resize, so retiring the generation would move every later capture of a healthy app to the runner. * A new capture-scoped refusal enters here, by an explicit decision; the route's warning switch is * exhaustive over the consequence, so a new one cannot reach the warning path undefined. + * `contracts/fixtures/ios-snapshot-bridge-refusals.json` lists every emitted code with its scope, and + * `test/repo-guards/snapshot-bridge-refusals.test.ts` fails when this table and the emitters disagree. */ const CAPTURE_SCOPED_REFUSALS: ReadonlyMap = new Map([ ['window-coordinate-space-unresolved', 'coordinate-space-unresolved'], ['app-window-not-full-screen', 'window-not-full-screen'], ['foreground-owner-unverified', 'foreground-ownership'], ['foreground-owner-changed', 'foreground-ownership'], + // The runner-served reader runs one read at a time: the refusal says only that another read, such + // as one a cancelled capture left running, has not returned yet. + ['bridge-read-in-flight', 'bridge-read-busy'], + // A continuation re-read found fewer children than the first read reported: the tree changed + // between the two reads, as a list does while it scrolls and recycles its cells. + ['snapshot-tree-changed', 'tree-changed'], ]); /** diff --git a/packages/platform-apple/src/snapshot-route.test.ts b/packages/platform-apple/src/snapshot-route.test.ts index 61f69a25cb..33f02b7255 100644 --- a/packages/platform-apple/src/snapshot-route.test.ts +++ b/packages/platform-apple/src/snapshot-route.test.ts @@ -429,6 +429,7 @@ test.for([ ], ['foreground-owner-unverified', 'while another surface owned the foreground'], ['foreground-owner-changed', 'while another surface owned the foreground'], + ['snapshot-tree-changed', 'while the screen changed during the bridge read'], [ 'app-window-not-full-screen', 'while the app window did not fill the screen; the runner republishes a windowed capture in screen space', diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 1fdbef43dc..0e1a679ed1 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -6,6 +6,7 @@ import type { SnapshotRuntimeAcquiredResult, } from '@agent-device/contracts/snapshot-runtime'; import type { + CaptureHint, IosAcquisitionResidue, IosSnapshotComparisonIdentity, IosSnapshotLineage, @@ -23,6 +24,7 @@ import { createSimulatorSnapshotSource, type SimulatorSnapshotSource, type SnapshotSourceFailure, + type SnapshotSourceOutcome, } from './snapshot-source-facade.ts'; import { createLaunchObservationProbe, @@ -70,16 +72,63 @@ type FallbackReason = | 'target-resolution-failed' | 'circuit-disabled'; -export type AppleSnapshotRoute = LaunchObservationPort & - Readonly<{ - capture( - device: DeviceInfo, - input: CaptureSnapshotInput, - signal: AbortSignal, - fallback: SnapshotFallback, - ): Promise; - shutdown(): Promise; +export type AppleSnapshotRoute = LaunchObservationPort & SnapshotBridgeRoute; + +export type SnapshotBridgeRoute = Readonly<{ + capture( + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, + fallback: SnapshotFallback, + ): Promise; + shutdown(): Promise; +}>; + +/** The app generation a bridge read is planned against. */ +type SnapshotBridgeRouteTarget = Readonly<{ + pid: number; + generation: string; + targetId: string; +}>; + +/** + * One way to reach the bridge reader on a device: the source that reads, and how the app generation it + * reads is named. A local Simulator reaches it through a guest process the host starts; a Simulator + * whose host cannot start one reaches it through the XCTest runner. + */ +export type SnapshotBridgeChannel = Readonly<{ + source: Readonly<{ + acquire( + request: Readonly<{ + target: Target; + hint: CaptureHint; + signal?: AbortSignal; + surfaceHostExecutables?: readonly string[]; + }>, + ): Promise; + close(): Promise; }>; + resolveTarget( + device: DeviceInfo, + appBundleId: string, + signal: AbortSignal, + refresh?: 'refresh', + ): Promise; + /** Whether a resolver failure only says discovery is still running, so a capture may wait on it. */ + isDiscoveryPending(error: unknown): boolean; + /** + * `verified`: every resolved target names the app's current process. `cached`: a target stays until + * a read refuses it, so a closed circuit could hide a relaunch, and the route asks again before it + * trusts one. + */ + targetFreshness: 'verified' | 'cached'; +}>; + +/** What the route reads from the platform host: presentation and runner liveness only. */ +export type SnapshotBridgeRouteHost = Readonly<{ + snapshot: Pick; + appleApplications: Pick; +}>; export function createAppleSnapshotRoute( host: PlatformRuntimeHost, @@ -91,8 +140,50 @@ export function createAppleSnapshotRoute( ): AppleSnapshotRoute { const source = options.source ?? createSimulatorSnapshotSource(host.hostEnvironment); const resolveTarget = options.resolveTarget ?? createSimulatorSnapshotTargetResolver(); - const systemSurfaceForeground = - options.systemSurfaceForeground ?? createSystemSurfaceForegroundProbe(); + const route = createSnapshotBridgeRouteCore( + host, + { + source, + resolveTarget, + isDiscoveryPending: isSimulatorTargetDiscoveryPending, + // The resolver re-reads the process start time of a cached target on every capture. + targetFreshness: 'verified', + }, + options.systemSurfaceForeground ?? createSystemSurfaceForegroundProbe(), + ); + const observation = createLaunchObservationProbe({ + source, + resolveTarget, + clock: host.clock, + isBridgeDisabled: route.isBridgeDisabled, + }); + return Object.freeze({ + awaitObservable: observation.awaitObservable, + capture: route.capture, + shutdown: route.shutdown, + }); +} + +/** + * The bridge route for a Simulator whose host reaches the reader through another channel. It owns the + * same decisions as the local route: system-surface routing, the per-generation circuit, and the one + * typed XCTest fallback. + */ +export function createSnapshotBridgeRoute( + host: SnapshotBridgeRouteHost, + channel: SnapshotBridgeChannel, + surfaceForeground: SystemSurfaceForegroundProbe, +): SnapshotBridgeRoute { + const route = createSnapshotBridgeRouteCore(host, channel, surfaceForeground); + return Object.freeze({ capture: route.capture, shutdown: route.shutdown }); +} + +function createSnapshotBridgeRouteCore( + host: SnapshotBridgeRouteHost, + channel: SnapshotBridgeChannel, + surfaceForeground: SystemSurfaceForegroundProbe, +) { + const { source, resolveTarget } = channel; const disabledGenerations = new Set(); const latestGeneration = new Map(); /** @@ -101,21 +192,20 @@ export function createAppleSnapshotRoute( * launch-observation probe ask before they spend a bridge round trip, so one generation's * failure is paid once rather than once per route (#2198, #2199). */ - const isBridgeDisabled = (target: SimulatorSnapshotTarget): boolean => { + const isBridgeDisabled = (target: SnapshotBridgeRouteTarget): boolean => { rebaselineGeneration(target, latestGeneration, disabledGenerations); return disabledGenerations.has(generationKey(target)); }; - const observation = createLaunchObservationProbe({ - source, - resolveTarget, - clock: host.clock, - isBridgeDisabled, - }); - return Object.freeze({ - awaitObservable: observation.awaitObservable, + return { + isBridgeDisabled, shutdown: async () => await source.close(), - capture: async (device, input, signal, fallback) => { + capture: async ( + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, + fallback: SnapshotFallback, + ): Promise => { if (!isEligible(device, input)) return await captureOffRoute(device, input, fallback); // A system surface (e.g. the web sign-in sheet) presented over the app is invisible to the // host AX bridge — the app is still the AX primaryApp, so the bridge would serve the occluded @@ -126,21 +216,17 @@ export function createAppleSnapshotRoute( // A ready runner answers first, with the foreground state of every registered host: the // predicate it serves a surface by, read in a few milliseconds. When it cannot answer, the // bridge itself refuses the capture while a host process runs for this Simulator. - const foreground = await readSurfaceForeground( - device, - input, - signal, - systemSurfaceForeground, - ); + const foreground = await readSurfaceForeground(device, input, signal, surfaceForeground); if (foreground !== 'background' && foreground !== 'unknown') { return await runSurfaceFallback(device, input, fallback, foreground.host.bundleId); } - let target: SimulatorSnapshotTarget; + let target: Target; try { - target = await resolveTargetForObservation(host, resolveTarget, device, input, signal); + target = await resolveCaptureTarget(host, channel, device, input, signal, isBridgeDisabled); } catch (error) { rethrowIfResolutionCancelled(signal, error); emitRouteDiagnostic('target-resolution-failed', device, undefined, error); + // The app's current generation is unknown, so the capture names none. return await runFallback( device.id, input, @@ -210,6 +296,7 @@ export function createAppleSnapshotRoute( resolveTarget, ); return await fallbackAfterFailure( + device, input, fallback, target, @@ -233,6 +320,7 @@ export function createAppleSnapshotRoute( } catch (error) { const failure = { kind: 'malformed-tree', code: 'presentation-invariant' } as const; return await fallbackAfterFailure( + device, input, fallback, target, @@ -246,7 +334,7 @@ export function createAppleSnapshotRoute( ); } }, - }); + }; } const SURFACE_HOST_EXECUTABLES = IOS_SYSTEM_SURFACE_HOSTS.map((host) => host.processExecutable); @@ -282,25 +370,47 @@ async function readSurfaceForeground( * discovery's own deadline or the request signal ends the loop. A runner that is already live * answers at once, so there the fallback remains the cheaper route (#2331). */ -async function resolveTargetForObservation( - host: PlatformRuntimeHost, - resolveTarget: SimulatorSnapshotTargetResolver, +async function resolveTargetForObservation( + host: SnapshotBridgeRouteHost, + channel: SnapshotBridgeChannel, device: DeviceInfo, input: CaptureSnapshotInput, signal: AbortSignal, -): Promise { +): Promise { const appBundleId = input.options!.appBundleId!; for (;;) { try { - return await resolveTarget(device, appBundleId, signal); + return await channel.resolveTarget(device, appBundleId, signal); } catch (error) { - if (!isSimulatorTargetDiscoveryPending(error)) throw error; + if (!channel.isDiscoveryPending(error)) throw error; const execution = { requestId: input.execution?.requestId }; if (await host.appleApplications.hasLiveRunnerSession(device, execution)) throw error; } } } +/** + * The target this capture is planned against. While a generation's circuit is open no read meets a + * newer process, so a channel that caches targets is asked again before the circuit is trusted: + * otherwise a relaunched app would stay off the bridge for good. The fresh target rebaselines the + * circuit. A refresh that fails is a resolution failure: the capture names no generation. + */ +async function resolveCaptureTarget( + host: SnapshotBridgeRouteHost, + channel: SnapshotBridgeChannel, + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, + isBridgeDisabled: (target: SnapshotBridgeRouteTarget) => boolean, +): Promise { + const target = await resolveTargetForObservation(host, channel, device, input, signal); + if (channel.targetFreshness !== 'cached' || !isBridgeDisabled(target)) return target; + // A refresh that fails leaves the channel's cached target for its circuit, but this capture cannot + // claim that generation: the app may have relaunched. The failure reaches the caller, which + // publishes the capture under an unknown generation. + return await channel.resolveTarget(device, input.options!.appBundleId!, signal, 'refresh'); +} + /** * A cancelled target resolution rethrows the resolver's own cancellation, which names the readiness * phase only when the resolver was waiting on a running discovery. @@ -319,9 +429,10 @@ function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean { } async function fallbackAfterFailure( + device: DeviceInfo, input: CaptureSnapshotInput, fallback: SnapshotFallback, - failedTarget: SimulatorSnapshotTarget, + failedTarget: SnapshotBridgeRouteTarget, identity: FallbackIdentity, request: ReturnType, failure: SnapshotSourceFailure, @@ -330,15 +441,9 @@ async function fallbackAfterFailure( cause?: unknown, ): Promise { if (disposition.scope === 'generation') disabledGenerations.add(generationKey(failedTarget)); - emitRouteDiagnostic( - failure.code, - { id: failedTarget.simulator.udid }, - failedTarget.generation, - cause, - failure.details, - ); + emitRouteDiagnostic(failure.code, device, failedTarget.generation, cause, failure.details); return await runFallback( - failedTarget.simulator.udid, + device.id, input, fallback, identity.lineage, @@ -512,8 +617,12 @@ function captureScopedFallbackWarning(code: string, consequence: CaptureScopedCo switch (consequence) { case 'bridge-preparation': return `${thisCapture} while the bridge is still being prepared.`; + case 'bridge-read-busy': + return `${thisCapture} while an earlier bridge read was still running.`; case 'foreground-ownership': return `${thisCapture}, while another surface owned the foreground.`; + case 'tree-changed': + return `${thisCapture}, while the screen changed during the bridge read.`; case 'window-not-full-screen': return `${thisCapture}, while the app window did not fill the screen; the runner republishes a windowed capture in screen space.`; case 'coordinate-space-unresolved': @@ -526,13 +635,13 @@ type FallbackIdentity = Readonly<{ residue: readonly IosAcquisitionResidue[]; }>; -async function resolveFailureFallbackIdentity( +async function resolveFailureFallbackIdentity( failure: SnapshotSourceFailure, - target: SimulatorSnapshotTarget, + target: Target, device: DeviceInfo, appBundleId: string, signal: AbortSignal, - resolveTarget: SimulatorSnapshotTargetResolver, + resolveTarget: SnapshotBridgeChannel['resolveTarget'], ): Promise { if (failure.kind !== 'stale-target') return { lineage: target, residue: [] }; try { @@ -571,7 +680,7 @@ function requestFor(input: CaptureSnapshotInput) { } function rebaselineGeneration( - target: SimulatorSnapshotTarget, + target: SnapshotBridgeRouteTarget, latestGeneration: Map, disabledGenerations: Set, ): void { @@ -582,7 +691,7 @@ function rebaselineGeneration( latestGeneration.set(target.targetId, target.generation); } -function generationKey(target: SimulatorSnapshotTarget): string { +function generationKey(target: SnapshotBridgeRouteTarget): string { return `${target.targetId}:${target.generation}`; } diff --git a/packages/platform-apple/src/snapshot-source/adapter.ts b/packages/platform-apple/src/snapshot-source/adapter.ts index 9540a7acf3..079e3431ad 100644 --- a/packages/platform-apple/src/snapshot-source/adapter.ts +++ b/packages/platform-apple/src/snapshot-source/adapter.ts @@ -1,26 +1,13 @@ import { AppError } from '@agent-device/kernel/errors'; -import { observeCompleteTreeKeyboard } from '@agent-device/contracts/tap-keyboard-occlusion'; -import type { - CaptureHint, - IosSnapshotAcquisition, - IosViewportEvidence, -} from '@agent-device/contracts/ios-snapshot'; -import { createSnapshotSourceDeadline, remainingSnapshotSourceMs } from './deadline.ts'; -import { AcceptedDepthHints, type DepthHintDecision } from './depth-hints.ts'; -import { asSnapshotSourceError, snapshotSourceError } from './errors.ts'; +import { + createSnapshotBridgeSource, + SNAPSHOT_BRIDGE_PRODUCER, + type SnapshotBridgeSource, +} from './bridge-source.ts'; import { SnapshotBridgeManager } from './lifecycle.ts'; import { resolveSnapshotSourceLimits } from './limits.ts'; -import { readSnapshotBridgeRecovery, type SnapshotBridgeEnvelope } from './protocol.ts'; import { createSnapshotBridgePreparation } from './preparation.ts'; -import { decodeSnapshotBridgeTree } from './tree.ts'; -import type { - SnapshotSourceHost, - SnapshotSourceLimits, - SnapshotSourceOutcome, - SnapshotSourceRequest, -} from './types.ts'; - -const SNAPSHOT_SOURCE_PRODUCER = 'simulator-ax-bridge' as const; +import type { SnapshotSourceHost, SnapshotSourceLimits, SnapshotSourceTarget } from './types.ts'; export type SimulatorSnapshotSourceOptions = Readonly<{ host: SnapshotSourceHost; @@ -29,263 +16,55 @@ export type SimulatorSnapshotSourceOptions = Readonly<{ cacheRoot?: string; }>; -export type SimulatorSnapshotSource = Readonly<{ - acquire(request: SnapshotSourceRequest): Promise; - close(): Promise; -}>; +export type SimulatorSnapshotSource = SnapshotBridgeSource; +/** The bridge read on a local Simulator, through a guest bridge process the host starts and owns. */ export function createSimulatorSnapshotSource( options: SimulatorSnapshotSourceOptions, ): SimulatorSnapshotSource { const host = options.host; const manager = new SnapshotBridgeManager(host); - const depthHints = new AcceptedDepthHints(); // Preparation is daemon-scoped work: it reads the toolchain identity, fingerprints the bridge // source and may compile it. It runs detached so no capture's deadline pays for it (#2491). const preparation = createSnapshotBridgePreparation({ host, limits: resolveSnapshotSourceLimits(options.limits), - producer: SNAPSHOT_SOURCE_PRODUCER, + producer: SNAPSHOT_BRIDGE_PRODUCER, sourceRoot: options.sourceRoot, cacheRoot: options.cacheRoot, }); - let closed = false; - - const acquire = async (request: SnapshotSourceRequest): Promise => { - try { - if (closed) throw snapshotSourceError('unsupported', 'source-closed'); - validateRequest(request); - const limits = resolveSnapshotSourceLimits({ ...options.limits, ...request.limits }); - const deadline = createSnapshotSourceDeadline(limits.maxDurationMs, request.signal); - const maxDepth = resolveRequestedDepth(request.hint, limits.maxTraversalDepth); - const requestedLevels = maxDepth + 1; - const explicitDepth = request.hint.rawTraversalDepth !== null; - return await host.withDiagnosticTimer( - 'ios.snapshot-source.acquire', - async () => { - const bridge = await preparation.readyBinary( - request.target.runtime, - deadline, - 'bridge-preparation-deadline', - ); - const decision = depthHints.consume(request.target, requestedLevels, explicitDepth); - const envelope = await manager.request({ - target: request.target, - bridge, - limits, - maxDepth, - nativeLevelsHint: decision.nativeLevels, - ...(request.surfaceHostExecutables - ? { surfaceHostExecutables: request.surfaceHostExecutables } - : {}), - deadline, - }); - remainingSnapshotSourceMs(deadline, 'snapshot-decode-deadline'); - const acquisition = createAcquisition(request.hint, request.target, envelope, limits); - recordRecovery( - host, - depthHints, - request.target, - requestedLevels, - explicitDepth, - decision, - envelope, - ); - return { stage: 'acquired', acquisition }; - }, - { producer: SNAPSHOT_SOURCE_PRODUCER }, - ); - } catch (error) { - const failure = asSnapshotSourceError(error); - return { - stage: 'failed', - failure: { - kind: failure.failureKind, - code: failure.failureCode, - ...(failure.details ? { details: failure.details } : {}), - }, - } satisfies SnapshotSourceOutcome; - } - }; - - return { - acquire, - close: async () => { - if (closed) return; - closed = true; - // A bridge build that outlives this source answers to nobody: no request is waiting on it and - // its cache write would land after the source is gone (#2491). - preparation.close(); - await manager.close(); + return createSnapshotBridgeSource({ + diagnostics: host, + ...(options.limits ? { limits: options.limits } : {}), + transport: { + name: 'guest-process', + validateTarget, + prepare: async (target, deadline) => { + const bridge = await preparation.readyBinary( + target.runtime, + deadline, + 'bridge-preparation-deadline', + ); + return async (read) => await manager.request({ ...read, target, bridge }); + }, + close: async () => { + // A bridge build that outlives this source answers to nobody: no request is waiting on it + // and its cache write would land after the source is gone (#2491). + preparation.close(); + await manager.close(); + }, }, - }; + }); } -// fallow-ignore-next-line complexity -function validateRequest(request: SnapshotSourceRequest): void { +function validateTarget(target: SnapshotSourceTarget): void { if ( - !request.target.simulator.udid.trim() || - !request.target.runtime.trim() || - !request.target.generation.trim() || - !Number.isSafeInteger(request.target.pid) || - request.target.pid <= 0 + !target.simulator.udid.trim() || + !target.runtime.trim() || + !target.generation.trim() || + !Number.isSafeInteger(target.pid) || + target.pid <= 0 ) { throw new AppError('INVALID_ARGS', 'Simulator snapshot source target identity is incomplete'); } - const hint = request.hint; - if ( - (hint.projection !== 'raw' && hint.projection !== 'regular') || - !['full', 'surface-observation'].includes(hint.acquisitionIntent) || - typeof hint.interactiveOnly !== 'boolean' || - typeof hint.customActions !== 'boolean' || - !validDepth(hint.rawTraversalDepth) || - !validDepth(hint.regularPresentedDepth) - ) { - throw new AppError('INVALID_ARGS', 'Simulator snapshot source capture hint is invalid'); - } -} - -/** - * Learns the accepted native depth from the guest's request accounting and reports the whole - * acquisition's native work, so a benchmark can pair native calls, rejections, and continuations - * with capture latency without re-deriving them from the tree. Runs only after the delivered tree - * validated: a response whose counters look sound but whose tree fails acquisition teaches nothing. - */ -function recordRecovery( - host: SnapshotSourceHost, - depthHints: AcceptedDepthHints, - target: SnapshotSourceRequest['target'], - requestedLevels: number, - explicitDepth: boolean, - decision: DepthHintDecision, - envelope: SnapshotBridgeEnvelope, -): void { - const recovery = readSnapshotBridgeRecovery(envelope); - const truncated = envelope.truncated === true; - const learning = depthHints.learn(target, requestedLevels, explicitDepth, recovery); - host.emitDiagnostic({ - level: 'debug', - phase: 'ios_snapshot_source_recovery', - data: { - producer: SNAPSHOT_SOURCE_PRODUCER, - ...(target.targetId ? { targetId: target.targetId } : {}), - generation: target.generation, - requestedLevels, - hint: decision.reason, - ...(decision.nativeLevels !== undefined ? { hintedLevels: decision.nativeLevels } : {}), - ...recovery, - truncated, - learning, - }, - }); -} - -function resolveRequestedDepth(hint: CaptureHint, maximum: number): number { - const requested = hint.rawTraversalDepth ?? maximum; - if (requested > maximum) { - throw new AppError('INVALID_ARGS', 'Simulator snapshot source depth exceeds its bound', { - requested, - maximum, - }); - } - return requested; -} - -function validDepth(value: number | null): boolean { - return value === null || (Number.isSafeInteger(value) && value >= 0); -} - -function createAcquisition( - hint: CaptureHint, - target: SnapshotSourceRequest['target'], - envelope: SnapshotBridgeEnvelope, - limits: SnapshotSourceLimits, -): IosSnapshotAcquisition { - if (envelope.automationEnabled !== true) { - throw snapshotSourceError('unsupported', 'automation-mode-unavailable'); - } - const tree = envelope.tree; - const truncated = envelope.truncated; - if (typeof truncated !== 'boolean') { - throw snapshotSourceError('malformed-tree', 'truncated-invalid'); - } - const decoded = decodeSnapshotBridgeTree(tree, { truncated }, limits); - const generation = envelope.generation; - if (typeof generation !== 'string' || !generation) { - throw snapshotSourceError('malformed-tree', 'generation-invalid'); - } - // A tree that ends at out-of-process content — a web view's page, or a scene-hosted extension — - // would present the screen without it, and refs issued from it would target the host views around - // it rather than the content. - // The source refuses it as a screen it cannot describe, like a missing automation mode, so the - // route serves the XCTest runner, which resolves remote elements (#2484). - if (decoded.opaqueRemoteElements > 0) { - throw snapshotSourceError('unsupported', 'remote-content-boundary', { - remoteElements: decoded.opaqueRemoteElements, - }); - } - // A tree carrying a window that reports its subtree in the device's native space would present a - // screen whose rects disagree about which way is down, and refs issued from under that window would - // be performed somewhere else on it. The reader cannot report the app's interface orientation, so - // the source refuses the screen it cannot put in one space and the route serves the runner, which - // reads the orientation and rotates what it captured (#2612). - if (decoded.unresolvedCoordinateSpaceWindows > 0) { - throw snapshotSourceError('unsupported', 'window-coordinate-space-unresolved', { - windows: decoded.unresolvedCoordinateSpaceWindows, - }); - } - const nodes = Object.freeze( - decoded.nodes.map((node) => Object.freeze({ ...node, pid: target.pid })), - ); - const residue = createAcquisitionResidue(hint, truncated, decoded.viewport); - const lineage = Object.freeze({ - ...(target.targetId ? { targetId: target.targetId } : {}), - generation, - }); - const common = { - producer: SNAPSHOT_SOURCE_PRODUCER, - nodes, - truncated, - viewport: decoded.viewport, - keyboard: observeCompleteTreeKeyboard({ - nodes, - viewport: decoded.viewport.kind === 'reported' ? decoded.viewport.rect : null, - complete: !truncated, - }), - lineage, - residue, - }; - if (hint.acquisitionIntent === 'full') { - return { ...common, intent: 'full', hint: { ...hint, acquisitionIntent: 'full' } }; - } - return { - ...common, - intent: 'surface-observation', - hint: { ...hint, acquisitionIntent: 'surface-observation' }, - }; -} - -function createAcquisitionResidue( - hint: CaptureHint, - truncated: boolean, - viewport: IosViewportEvidence, -) { - // With a reported viewport the bridge reader has already stamped geometric `hittable` onto every - // node (it owns that fact and the residue), so it is not declared unavailable. Only when the - // viewport is missing does the reader withhold `hittable` and disclose it as an unavailable fact — - // the same "the residue owner is the fact owner" rule that keeps geometry from minting the claim. - const hittabilityResidue = - viewport.kind === 'reported' - ? [] - : ([{ kind: 'unavailable-fact', fact: 'hittability' }] as const); - return Object.freeze([ - ...hittabilityResidue, - ...(hint.interactiveOnly - ? ([{ kind: 'unavailable-fact', fact: 'interactive-query' }] as const) - : []), - ...(truncated ? ([{ kind: 'truncated' }] as const) : []), - ...(viewport.kind === 'missing' - ? ([{ kind: 'missing-viewport', reason: viewport.reason }] as const) - : []), - ]); } diff --git a/packages/platform-apple/src/snapshot-source/bridge-source.ts b/packages/platform-apple/src/snapshot-source/bridge-source.ts new file mode 100644 index 0000000000..508dad2202 --- /dev/null +++ b/packages/platform-apple/src/snapshot-source/bridge-source.ts @@ -0,0 +1,303 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { observeCompleteTreeKeyboard } from '@agent-device/contracts/tap-keyboard-occlusion'; +import type { + CaptureHint, + IosSnapshotAcquisition, + IosViewportEvidence, +} from '@agent-device/contracts/ios-snapshot'; +import { + createSnapshotSourceDeadline, + remainingSnapshotSourceMs, + type SnapshotSourceDeadline, +} from './deadline.ts'; +import { AcceptedDepthHints, type DepthHintDecision } from './depth-hints.ts'; +import { asSnapshotSourceError, snapshotSourceError } from './errors.ts'; +import { resolveSnapshotSourceLimits } from './limits.ts'; +import { readSnapshotBridgeRecovery, type SnapshotBridgeEnvelope } from './protocol.ts'; +import { decodeSnapshotBridgeTree } from './tree.ts'; +import type { + SnapshotBridgeSourceRequest, + SnapshotBridgeTarget, + SnapshotSourceHost, + SnapshotSourceLimits, + SnapshotSourceOutcome, +} from './types.ts'; + +/** + * The producer every bridge read reports, whichever process read the tree: the guest bridge on a local + * Simulator and the runner serve one reader, so their trees compare as one producer. + */ +export const SNAPSHOT_BRIDGE_PRODUCER = 'simulator-ax-bridge' as const; + +/** One accepted bridge reply for one bounded read. */ +export type SnapshotBridgeExchange = ( + input: Readonly<{ + limits: SnapshotSourceLimits; + maxDepth: number; + nativeLevelsHint?: number; + /** The system surface host executables whose running process refuses the read (#2751). */ + surfaceHostExecutables?: readonly string[]; + deadline: SnapshotSourceDeadline; + }>, +) => Promise; + +/** How one process that serves the bridge reader is reached. */ +export type SnapshotBridgeTransport = Readonly<{ + /** Labels the transport in the acquisition diagnostics. */ + name: 'guest-process' | 'runner'; + /** Throws `INVALID_ARGS` for a target this transport cannot address. */ + validateTarget(target: Target): void; + /** + * Readies the transport for one read of `target`. It runs before the read spends a depth hint, so + * a read the transport could not start costs the hint nothing. + */ + prepare(target: Target, deadline: SnapshotSourceDeadline): Promise; + /** Ends what the transport owns. Runs once, after the source refuses new reads. */ + close(): Promise; +}>; + +export type SnapshotBridgeSource = Readonly<{ + acquire(request: SnapshotBridgeSourceRequest): Promise; + close(): Promise; +}>; + +/** + * The bridge read a transport does not decide: bounds, depth hints, the envelope's acquisition, and + * the recovery record. A transport supplies only how one accepted envelope arrives, so a tree means + * the same thing whether the guest bridge or the runner read it. + */ +export function createSnapshotBridgeSource( + options: Readonly<{ + diagnostics: Pick; + limits?: Partial; + transport: SnapshotBridgeTransport; + }>, +): SnapshotBridgeSource { + const transport = options.transport; + const depthHints = new AcceptedDepthHints(); + const diagnostics = options.diagnostics; + let closed = false; + + const acquire = async ( + request: SnapshotBridgeSourceRequest, + ): Promise => { + try { + if (closed) throw snapshotSourceError('unsupported', 'source-closed'); + transport.validateTarget(request.target); + validateHint(request.hint); + const limits = resolveSnapshotSourceLimits({ ...options.limits, ...request.limits }); + const deadline = createSnapshotSourceDeadline(limits.maxDurationMs, request.signal); + const maxDepth = resolveRequestedDepth(request.hint, limits.maxTraversalDepth); + const requestedLevels = maxDepth + 1; + const explicitDepth = request.hint.rawTraversalDepth !== null; + return await diagnostics.withDiagnosticTimer( + 'ios.snapshot-source.acquire', + async () => { + const exchange = await transport.prepare(request.target, deadline); + const decision = depthHints.consume(request.target, requestedLevels, explicitDepth); + const envelope = await exchange({ + limits, + maxDepth, + ...(decision.nativeLevels !== undefined + ? { nativeLevelsHint: decision.nativeLevels } + : {}), + ...(request.surfaceHostExecutables + ? { surfaceHostExecutables: request.surfaceHostExecutables } + : {}), + deadline, + }); + remainingSnapshotSourceMs(deadline, 'snapshot-decode-deadline'); + const acquisition = createAcquisition(request.hint, request.target, envelope, limits); + recordRecovery( + diagnostics, + depthHints, + request.target, + requestedLevels, + explicitDepth, + decision, + envelope, + ); + return { stage: 'acquired', acquisition }; + }, + { producer: SNAPSHOT_BRIDGE_PRODUCER, transport: transport.name }, + ); + } catch (error) { + const failure = asSnapshotSourceError(error); + return { + stage: 'failed', + failure: { + kind: failure.failureKind, + code: failure.failureCode, + ...(failure.details ? { details: failure.details } : {}), + }, + } satisfies SnapshotSourceOutcome; + } + }; + + return { + acquire, + close: async () => { + if (closed) return; + closed = true; + await transport.close(); + }, + }; +} + +function validateHint(hint: CaptureHint): void { + if ( + (hint.projection !== 'raw' && hint.projection !== 'regular') || + !['full', 'surface-observation'].includes(hint.acquisitionIntent) || + typeof hint.interactiveOnly !== 'boolean' || + typeof hint.customActions !== 'boolean' || + !validDepth(hint.rawTraversalDepth) || + !validDepth(hint.regularPresentedDepth) + ) { + throw new AppError('INVALID_ARGS', 'Simulator snapshot source capture hint is invalid'); + } +} + +/** + * Learns the accepted native depth from the guest's request accounting and reports the whole + * acquisition's native work, so a benchmark can pair native calls, rejections, and continuations + * with capture latency without re-deriving them from the tree. Runs only after the delivered tree + * validated: a response whose counters look sound but whose tree fails acquisition teaches nothing. + */ +function recordRecovery( + diagnostics: Pick, + depthHints: AcceptedDepthHints, + target: SnapshotBridgeTarget, + requestedLevels: number, + explicitDepth: boolean, + decision: DepthHintDecision, + envelope: SnapshotBridgeEnvelope, +): void { + const recovery = readSnapshotBridgeRecovery(envelope); + const truncated = envelope.truncated === true; + const learning = depthHints.learn(target, requestedLevels, explicitDepth, recovery); + diagnostics.emitDiagnostic({ + level: 'debug', + phase: 'ios_snapshot_source_recovery', + data: { + producer: SNAPSHOT_BRIDGE_PRODUCER, + ...(target.targetId ? { targetId: target.targetId } : {}), + generation: target.generation, + requestedLevels, + hint: decision.reason, + ...(decision.nativeLevels !== undefined ? { hintedLevels: decision.nativeLevels } : {}), + ...recovery, + truncated, + learning, + }, + }); +} + +function resolveRequestedDepth(hint: CaptureHint, maximum: number): number { + const requested = hint.rawTraversalDepth ?? maximum; + if (requested > maximum) { + throw new AppError('INVALID_ARGS', 'Simulator snapshot source depth exceeds its bound', { + requested, + maximum, + }); + } + return requested; +} + +function validDepth(value: number | null): boolean { + return value === null || (Number.isSafeInteger(value) && value >= 0); +} + +function createAcquisition( + hint: CaptureHint, + target: SnapshotBridgeTarget, + envelope: SnapshotBridgeEnvelope, + limits: SnapshotSourceLimits, +): IosSnapshotAcquisition { + if (envelope.automationEnabled !== true) { + throw snapshotSourceError('unsupported', 'automation-mode-unavailable'); + } + const tree = envelope.tree; + const truncated = envelope.truncated; + if (typeof truncated !== 'boolean') { + throw snapshotSourceError('malformed-tree', 'truncated-invalid'); + } + const decoded = decodeSnapshotBridgeTree(tree, { truncated }, limits); + const generation = envelope.generation; + if (typeof generation !== 'string' || !generation) { + throw snapshotSourceError('malformed-tree', 'generation-invalid'); + } + // A tree that ends at out-of-process content — a web view's page, or a scene-hosted extension — + // would present the screen without it, and refs issued from it would target the host views around + // it rather than the content. + // The source refuses it as a screen it cannot describe, like a missing automation mode, so the + // route serves the XCTest runner, which resolves remote elements (#2484). + if (decoded.opaqueRemoteElements > 0) { + throw snapshotSourceError('unsupported', 'remote-content-boundary', { + remoteElements: decoded.opaqueRemoteElements, + }); + } + // A tree carrying a window that reports its subtree in the device's native space would present a + // screen whose rects disagree about which way is down, and refs issued from under that window would + // be performed somewhere else on it. The reader cannot report the app's interface orientation, so + // the source refuses the screen it cannot put in one space and the route serves the runner, which + // reads the orientation and rotates what it captured (#2612). + if (decoded.unresolvedCoordinateSpaceWindows > 0) { + throw snapshotSourceError('unsupported', 'window-coordinate-space-unresolved', { + windows: decoded.unresolvedCoordinateSpaceWindows, + }); + } + const nodes = Object.freeze( + decoded.nodes.map((node) => Object.freeze({ ...node, pid: target.pid })), + ); + const residue = createAcquisitionResidue(hint, truncated, decoded.viewport); + const lineage = Object.freeze({ + ...(target.targetId ? { targetId: target.targetId } : {}), + generation, + }); + const common = { + producer: SNAPSHOT_BRIDGE_PRODUCER, + nodes, + truncated, + viewport: decoded.viewport, + keyboard: observeCompleteTreeKeyboard({ + nodes, + viewport: decoded.viewport.kind === 'reported' ? decoded.viewport.rect : null, + complete: !truncated, + }), + lineage, + residue, + }; + if (hint.acquisitionIntent === 'full') { + return { ...common, intent: 'full', hint: { ...hint, acquisitionIntent: 'full' } }; + } + return { + ...common, + intent: 'surface-observation', + hint: { ...hint, acquisitionIntent: 'surface-observation' }, + }; +} + +function createAcquisitionResidue( + hint: CaptureHint, + truncated: boolean, + viewport: IosViewportEvidence, +) { + // With a reported viewport the bridge reader has already stamped geometric `hittable` onto every + // node (it owns that fact and the residue), so it is not declared unavailable. Only when the + // viewport is missing does the reader withhold `hittable` and disclose it as an unavailable fact — + // the same "the residue owner is the fact owner" rule that keeps geometry from minting the claim. + const hittabilityResidue = + viewport.kind === 'reported' + ? [] + : ([{ kind: 'unavailable-fact', fact: 'hittability' }] as const); + return Object.freeze([ + ...hittabilityResidue, + ...(hint.interactiveOnly + ? ([{ kind: 'unavailable-fact', fact: 'interactive-query' }] as const) + : []), + ...(truncated ? ([{ kind: 'truncated' }] as const) : []), + ...(viewport.kind === 'missing' + ? ([{ kind: 'missing-viewport', reason: viewport.reason }] as const) + : []), + ]); +} diff --git a/packages/platform-apple/src/snapshot-source/cache-identity.ts b/packages/platform-apple/src/snapshot-source/cache-identity.ts index 0f9b725dd4..900e9c89cc 100644 --- a/packages/platform-apple/src/snapshot-source/cache-identity.ts +++ b/packages/platform-apple/src/snapshot-source/cache-identity.ts @@ -2,7 +2,7 @@ import { readHostToolchainIdentity, type HostToolchainIdentity, } from '../native-build/toolchain-identity.ts'; -import { SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME } from '@agent-device/apple-runner'; +import { SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES } from '@agent-device/apple-runner'; import { fromNativeBuildError, snapshotSourceError } from './errors.ts'; import type { SnapshotSourceDeadline } from './deadline.ts'; import type { SnapshotSourceHost } from './types.ts'; @@ -12,24 +12,15 @@ export type SnapshotSourceToolchainIdentity = HostToolchainIdentity & simulatorRuntime: string; }>; +/** The guest bridge: its socket server plus the reader it shares with the runner. */ export const SNAPSHOT_BRIDGE_SOURCE_FILENAMES = [ 'SnapshotBridge.m', - 'SnapshotBridgeRuntime.m', - 'SnapshotBridgeRuntime.h', - 'SnapshotBridgeCapture.h', - 'SnapshotBridgeCapture.m', - 'SnapshotBridgeSurfaceHosts.h', - 'SnapshotBridgeSurfaceHosts.m', - // Not a compile unit: the clang argv lists translation units only, and an included header - // belongs in the fingerprint, not argv. - SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, -] as const; -export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = [ - 'SnapshotBridge.m', - 'SnapshotBridgeRuntime.m', - 'SnapshotBridgeCapture.m', - 'SnapshotBridgeSurfaceHosts.m', + ...SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES, ] as const; +// Translation units only: an included header belongs in the fingerprint, not argv. +export const SNAPSHOT_BRIDGE_COMPILE_FILENAMES = SNAPSHOT_BRIDGE_SOURCE_FILENAMES.filter( + (filename) => filename.endsWith('.m'), +); /** * The bridge's toolchain identity: the host's shared native-build toolchain identity diff --git a/packages/platform-apple/src/snapshot-source/cache-native-runtime.test.ts b/packages/platform-apple/src/snapshot-source/cache-native-runtime.test.ts index 9ca31e574b..bde3ff31f1 100644 --- a/packages/platform-apple/src/snapshot-source/cache-native-runtime.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache-native-runtime.test.ts @@ -77,6 +77,7 @@ describe.skipIf(process.platform !== 'darwin')('native snapshot capture', () => 'depth-negative-count', 'depth-continuation-count', 'depth-incomplete', + 'depth-malformed-continuation', 'depth-bound', 'depth-nodes', 'depth-owner-change', diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index a6330db595..88e78788fe 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -24,6 +24,8 @@ test('snapshot bridge preparation is cold-once, atomic, and invalidates corrupt await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); await writeFile(sourceFile, 'native source v1'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.m'), 'native runtime v1'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeRequest.h'), 'native runtime v1'); + await writeFile(path.join(sourceRoot, 'SnapshotBridgeRequest.m'), 'native runtime v1'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeRuntime.h'), 'native header v1'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.h'), 'native header v1'); await writeFile(path.join(sourceRoot, 'SnapshotBridgeCapture.m'), 'native header v1'); diff --git a/packages/platform-apple/src/snapshot-source/depth-hints.ts b/packages/platform-apple/src/snapshot-source/depth-hints.ts index 2215c1d226..fed988becd 100644 --- a/packages/platform-apple/src/snapshot-source/depth-hints.ts +++ b/packages/platform-apple/src/snapshot-source/depth-hints.ts @@ -1,6 +1,6 @@ import { MAXIMUM_REQUEST_LEVELS } from './limits.ts'; import type { SnapshotBridgeRecovery } from './protocol.ts'; -import type { SnapshotSourceTarget } from './types.ts'; +import type { SnapshotBridgeTarget } from './types.ts'; /** * Hinted captures allowed before the owner probes the full requested depth again. A hint is not @@ -30,7 +30,7 @@ type HintEntry = { remainingUses: number; }; -type HintTarget = Pick; +type HintTarget = Pick; /** * Accepted native depth per resolved app generation for one producer. A hint only changes which diff --git a/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m b/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m index d7a96831a1..2f1fd2425b 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m +++ b/packages/platform-apple/src/snapshot-source/fixtures/foreground-owner.m @@ -97,6 +97,7 @@ - (id)userTestingSnapshotForElement:(id)element options:(NSDictionary *)options else frontier[@"UIAccessibilitySnapshotKeyChildrenCount"] = [captureScenario isEqual:@"depth-fractional-count"] ? @0.5 : [captureScenario isEqual:@"depth-nan-count"] ? @(NAN) : [captureScenario isEqual:@"depth-negative-count"] ? @(-1) : [NSNull null]; } if ([captureScenario isEqual:@"depth-incomplete"] && level > 0) tree[@"UIAccessibilitySnapshotKeyChildren"] = @[]; + if ([captureScenario isEqual:@"depth-malformed-continuation"] && level > 0) tree[@"UIAccessibilitySnapshotKeyChildren"] = @"not an array"; if ([captureScenario isEqual:@"depth-owner-change"] && level > 0) primaryApplication = replacementApplication; return tree; } @@ -200,9 +201,11 @@ int main(int argc, const char *argv[]) expectedCaptures = [scenario isEqual:@"wide-continuation"] ? 2 : 1; } else if ([scenario hasPrefix:@"depth-"]) { expectedCaptures = [scenario isEqual:@"depth-bound"] ? 5 : [scenario isEqual:@"depth-nodes"] ? 2 : 3; - if ([@[@"depth-missing-element", @"depth-incomplete", @"depth-missing-count", @"depth-invalid-count", @"depth-continuation-count", @"depth-fractional-count", @"depth-nan-count", @"depth-negative-count"] containsObject:scenario]) { + if ([@[@"depth-missing-element", @"depth-incomplete", @"depth-malformed-continuation", @"depth-missing-count", @"depth-invalid-count", @"depth-continuation-count", @"depth-fractional-count", @"depth-nan-count", @"depth-negative-count"] containsObject:scenario]) { expectedCode = @"snapshot-tree-malformed"; - if (![scenario isEqual:@"depth-incomplete"] && ![scenario isEqual:@"depth-continuation-count"]) expectedCaptures = 2; + if (![scenario isEqual:@"depth-incomplete"] && ![scenario isEqual:@"depth-malformed-continuation"] && ![scenario isEqual:@"depth-continuation-count"]) expectedCaptures = 2; + // The continuation found fewer children than the first read counted: the tree changed. + if ([scenario isEqual:@"depth-incomplete"]) expectedCode = @"snapshot-tree-changed"; } if ([scenario isEqual:@"depth-owner-change"]) { replacementApplication = system; @@ -240,7 +243,7 @@ int main(int argc, const char *argv[]) nativeLevelsHint:0 requestId:@"capture-1" generation:@"generation-1" maxDurationMs:4000 error:&error]; if (expectedCode) { require(result == nil, @"refused capture must not publish the app tree"); - require([error[@"error_kind"] isEqual:([expectedCode isEqual:@"application-server-unavailable"] ? @"application_unavailable" : [expectedCode isEqual:@"snapshot-tree-malformed"] ? @"malformed_tree" : [expectedCode isEqual:@"continuation-budget-exhausted"] ? @"reader_unavailable" : @"unsupported")], @"refusal must preserve the typed failure kind"); + require([error[@"error_kind"] isEqual:([expectedCode isEqual:@"application-server-unavailable"] ? @"application_unavailable" : ([expectedCode isEqual:@"snapshot-tree-malformed"] || [expectedCode isEqual:@"snapshot-tree-changed"]) ? @"malformed_tree" : [expectedCode isEqual:@"continuation-budget-exhausted"] ? @"reader_unavailable" : @"unsupported")], @"refusal must preserve the typed failure kind"); require([error[@"error_code"] isEqual:expectedCode], @"refusal must name the ownership phase"); require([error[@"requestId"] isEqual:@"capture-1"], @"refusal must preserve request identity"); } else { diff --git a/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m b/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m index 258ab4140a..7b8cefdb5e 100644 --- a/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m +++ b/packages/platform-apple/src/snapshot-source/fixtures/recovery-conformance.m @@ -156,14 +156,16 @@ static NSUInteger deepestLevel(NSDictionary *node) static NSString *failureName(NSError *error) { - if ([error.domain isEqualToString:@"agent-device.snapshot"]) { - switch (error.code) { - case 1: return @"request-budget-exhausted"; - case 3: return @"continuation-element-missing"; - case 5: return @"owner-changed"; - case 6: return @"boundary-evidence-missing"; - default: return [NSString stringWithFormat:@"capture-%ld", (long)error.code]; + if ([error.domain isEqualToString:SnapshotCaptureErrorDomain]) { + switch ((SnapshotCaptureError)error.code) { + case SnapshotCaptureErrorBudgetExhausted: return @"request-budget-exhausted"; + case SnapshotCaptureErrorContinuationElementUnavailable: return @"continuation-element-missing"; + case SnapshotCaptureErrorTreeChanged: return @"tree-changed"; + case SnapshotCaptureErrorForegroundOwnerChanged: return @"owner-changed"; + case SnapshotCaptureErrorBoundaryChildCountUnavailable: return @"boundary-evidence-missing"; + case SnapshotCaptureErrorMalformedContinuation: break; } + return [NSString stringWithFormat:@"capture-%ld", (long)error.code]; } return @"rejected"; } @@ -203,6 +205,7 @@ int main(int argc, const char *argv[]) NSNumber *ownerChangesAfter = [native[@"ownerChangesAfterRequests"] isKindOfClass:NSNumber.class] ? native[@"ownerChangesAfterRequests"] : nil; BOOL unknownAtBoundary = [native[@"frontierEvidence"] isEqual:@"unknown"]; BOOL vanishAtBoundary = [native[@"vanishAtFrontier"] boolValue]; + BOOL shrinkOnContinuation = [native[@"shrinkOnContinuation"] boolValue]; NSUInteger hint = [request[@"hint"][@"host-bridge"] unsignedIntegerValue]; __block NSUInteger requests = 0; __block NSUInteger rejected = 0; @@ -215,7 +218,7 @@ int main(int argc, const char *argv[]) ^id(id element, NSUInteger levels, NSUInteger nodes, NSError **readError) { requests++; if (ownerChangesAfter && requests > ownerChangesAfter.unsignedIntegerValue) { - *readError = [NSError errorWithDomain:@"agent-device.snapshot" code:5 userInfo:nil]; + *readError = [NSError errorWithDomain:SnapshotCaptureErrorDomain code:SnapshotCaptureErrorForegroundOwnerChanged userInfo:nil]; return nil; } if (rejectAbove && levels > rejectAbove.unsignedIntegerValue) { @@ -228,7 +231,15 @@ int main(int argc, const char *argv[]) *readError = [NSError errorWithDomain:@"fixture" code:0 userInfo:nil]; return nil; } - return fragment(node, levels, unknownAtBoundary, vanishAtBoundary); + NSDictionary *read = fragment(node, levels, unknownAtBoundary, vanishAtBoundary); + // A list that recycles its cells between two reads: the continuation finds fewer children + // than the first read counted. + if (shrinkOnContinuation && node != root) { + NSMutableDictionary *shrunk = [read mutableCopy]; + shrunk[kChildren] = @[]; + return shrunk; + } + return read; }, &truncated, &recovery, &error); // The guest's accounting is what the host learns from and reports, so it must agree with what diff --git a/packages/platform-apple/src/snapshot-source/protocol.test.ts b/packages/platform-apple/src/snapshot-source/protocol.test.ts index 534c055a14..41a3c979b1 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.test.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.test.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { test } from 'vitest'; import { + acceptSnapshotBridgeEnvelope, assertSnapshotBridgeEnvelope, bridgeFailureFromEnvelope, createSnapshotBridgeDescribeRequest, @@ -122,6 +123,27 @@ test('snapshot bridge failures stay typed at the guest boundary', () => { } }); +test('a refusal that names no target keeps its own typed failure', () => { + assert.throws( + () => + acceptSnapshotBridgeEnvelope( + { + protocolVersion: SNAPSHOT_SOURCE_PROTOCOL_VERSION, + sourceVersion: SNAPSHOT_SOURCE_VERSION, + requestId: 'request-1', + ok: false, + error_kind: 'bad_request', + error_code: 'bounds-invalid', + }, + { requestId: 'request-1', pid: 123, generation: 'generation-1' }, + ), + (error: unknown) => + error instanceof SnapshotSourceError && + error.failureKind === 'malformed-tree' && + error.failureCode === 'bounds-invalid', + ); +}); + test('a surface host refusal names the running host executable', () => { assert.throws( () => @@ -143,7 +165,7 @@ test('a surface host refusal names the running host executable', () => { // registry must stay inside the bounds the guest validates. test('every registered surface host fits the bounds the guest validates', async () => { const native = await readFile( - path.join(import.meta.dirname, '../../../../apple/snapshot-bridge/SnapshotBridge.m'), + path.join(import.meta.dirname, '../../../../apple/snapshot-bridge/SnapshotBridgeRequest.m'), 'utf8', ); const bound = (name: string) => Number(native.match(new RegExp(`${name} = (\\d+);`))?.[1]); @@ -162,6 +184,8 @@ test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () const native = await Promise.all( [ 'SnapshotBridge.m', + 'SnapshotBridgeRequest.h', + 'SnapshotBridgeRequest.m', 'SnapshotBridgeRuntime.m', 'SnapshotBridgeRuntime.h', 'SnapshotBridgeCapture.h', @@ -180,8 +204,11 @@ test('wire vocabulary guard keeps TS and Objective-C literals aligned', async () assert.deepEqual(wireVocabulary.requestKeys, SNAPSHOT_SOURCE_WIRE_KEYS); assert.deepEqual(wireVocabulary.responseKeys, SNAPSHOT_SOURCE_RESPONSE_KEYS); assert.deepEqual(wireVocabulary.attributeKeys, SNAPSHOT_SOURCE_ATTRIBUTE_KEYS); - assert.match(nativeSource, /kProtocolVersion = 1/); - assert.match(nativeSource, /kSourceVersion = @"agent-device-simulator-ax-v1\.10\.0"/); + assert.match(nativeSource, /^#define SNAPSHOT_BRIDGE_PROTOCOL_VERSION 1$/m); + assert.match( + nativeSource, + /^#define SNAPSHOT_BRIDGE_SOURCE_VERSION @"agent-device-simulator-ax-v1\.10\.0"$/m, + ); for (const key of [ ...wireVocabulary.requestKeys, ...wireVocabulary.responseKeys, diff --git a/packages/platform-apple/src/snapshot-source/protocol.ts b/packages/platform-apple/src/snapshot-source/protocol.ts index ca09d859e2..b3bb61575b 100644 --- a/packages/platform-apple/src/snapshot-source/protocol.ts +++ b/packages/platform-apple/src/snapshot-source/protocol.ts @@ -264,7 +264,7 @@ export function assertSnapshotBridgeEnvelope( } } -export function assertSnapshotBridgeTargetIdentity( +function assertSnapshotBridgeTargetIdentity( envelope: SnapshotBridgeEnvelope, expected: Readonly<{ pid: number; generation: string }>, ): void { @@ -282,6 +282,25 @@ export function assertSnapshotBridgeTargetIdentity( } } +/** + * The one acceptance rule for a bridge reply, whichever process read the tree: the envelope must + * answer this request about this target, and a refusal becomes its typed failure. + */ +export function acceptSnapshotBridgeEnvelope( + envelope: SnapshotBridgeEnvelope, + expected: Readonly<{ requestId: string; pid: number; generation: string }>, +): SnapshotBridgeEnvelope { + assertSnapshotBridgeEnvelope(envelope, expected.requestId); + // A refusal of the request itself, such as bounds-invalid, names no target, so it is typed before + // identity can read its missing pid as a stale target. + if (envelope.ok !== true) bridgeFailureFromEnvelope(envelope); + assertSnapshotBridgeTargetIdentity(envelope, expected); + if (typeof envelope.truncated !== 'boolean') { + throw snapshotSourceError('malformed-tree', 'truncated-invalid'); + } + return envelope; +} + export function bridgeFailureFromEnvelope(envelope: SnapshotBridgeEnvelope): never { const kind = envelope.error_kind; const code = typeof envelope.error_code === 'string' ? envelope.error_code : 'guest-error'; @@ -301,6 +320,8 @@ export function bridgeFailureFromEnvelope(envelope: SnapshotBridgeEnvelope): nev throw snapshotSourceError('transport-failure', code, details); } if (kind === 'bad_request') throw snapshotSourceError('malformed-tree', code, details); + // Only the runner-served reader resolves its own target, so only it can see a new process. + if (kind === 'stale_target') throw snapshotSourceError('stale-target', code, details); if (kind === 'response_limit_exceeded') { throw snapshotSourceError('transport-failure', code, details); } diff --git a/packages/platform-apple/src/snapshot-source/transport.ts b/packages/platform-apple/src/snapshot-source/transport.ts index 45e9bd20ec..620d7d6680 100644 --- a/packages/platform-apple/src/snapshot-source/transport.ts +++ b/packages/platform-apple/src/snapshot-source/transport.ts @@ -2,9 +2,7 @@ import { asSnapshotSourceError, snapshotSourceError } from './errors.ts'; import { remainingSnapshotSourceMs, type SnapshotSourceDeadline } from './deadline.ts'; import { bridgeProcessExited } from './process.ts'; import { - assertSnapshotBridgeEnvelope, - assertSnapshotBridgeTargetIdentity, - bridgeFailureFromEnvelope, + acceptSnapshotBridgeEnvelope, parseSnapshotBridgeEnvelope, SnapshotBridgeFrameDecoder, type SnapshotBridgeEnvelope, @@ -92,16 +90,11 @@ export async function roundTripSnapshotBridge( throw snapshotSourceError('transport-failure', 'bridge-data-invalid'); const body = decoder.push(chunk); if (body) { - const envelope = parseSnapshotBridgeEnvelope(body); - assertSnapshotBridgeEnvelope(envelope, input.requestId); - assertSnapshotBridgeTargetIdentity(envelope, { + const envelope = acceptSnapshotBridgeEnvelope(parseSnapshotBridgeEnvelope(body), { + requestId: input.requestId, pid: input.expectedPid, generation: input.expectedGeneration, }); - if (envelope.ok !== true) bridgeFailureFromEnvelope(envelope); - if (typeof envelope.truncated !== 'boolean') { - throw snapshotSourceError('malformed-tree', 'truncated-invalid'); - } finishResolve(envelope); return; } diff --git a/packages/platform-apple/src/snapshot-source/types.ts b/packages/platform-apple/src/snapshot-source/types.ts index 2e7fd582b7..7ea50e7601 100644 --- a/packages/platform-apple/src/snapshot-source/types.ts +++ b/packages/platform-apple/src/snapshot-source/types.ts @@ -16,17 +16,23 @@ export type SnapshotSourceLimits = Readonly<{ maxDurationMs: number; }>; -export type SnapshotSourceTarget = Readonly<{ - simulator: SimulatorAddress; - runtime: string; +/** The app generation a bridge read is planned against, whichever process reads the tree. */ +export type SnapshotBridgeTarget = Readonly<{ pid: number; generation: string; targetId?: string; processStartTime?: string; }>; -export type SnapshotSourceRequest = Readonly<{ - target: SnapshotSourceTarget; +/** A target the host reaches through its own guest bridge process on a local Simulator. */ +export type SnapshotSourceTarget = SnapshotBridgeTarget & + Readonly<{ + simulator: SimulatorAddress; + runtime: string; + }>; + +export type SnapshotBridgeSourceRequest = Readonly<{ + target: Target; hint: CaptureHint; /** * System surface host executables whose running process refuses this capture with @@ -38,6 +44,8 @@ export type SnapshotSourceRequest = Readonly<{ signal?: AbortSignal; }>; +export type SnapshotSourceRequest = SnapshotBridgeSourceRequest; + export type SnapshotSourceSuccess = Readonly<{ stage: 'acquired'; acquisition: IosSnapshotAcquisition; diff --git a/packages/platform-apple/src/system-surface-foreground.test.ts b/packages/platform-apple/src/system-surface-foreground.test.ts index 1a031f352f..fec0c175d7 100644 --- a/packages/platform-apple/src/system-surface-foreground.test.ts +++ b/packages/platform-apple/src/system-surface-foreground.test.ts @@ -55,6 +55,26 @@ test('every registered host off screen is background, read only from the ready r ); }); +test('a decisive first host answers without waiting for the hosts after it', async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + runner.runAppleRunnerCommand.mockImplementation(async (_device, command) => { + if (command.appBundleId === safari) return { applicationState: 'runningForeground' }; + await pending; + return { applicationState: 'notRunning' }; + }); + + await expect(probe()).resolves.toEqual({ + kind: 'foreground', + host: IOS_SYSTEM_SURFACE_HOSTS.find((host) => host.bundleId === safari), + }); + // Every read still went out together. + expect(runner.runAppleRunnerCommand).toHaveBeenCalledTimes(IOS_SYSTEM_SURFACE_HOSTS.length); + release(); +}); + test('one background host does not hide another one presenting', async () => { statesByHost({ [safari!]: 'runningBackground', [passbook!]: 'runningForeground' }); diff --git a/packages/platform-apple/src/system-surface-foreground.ts b/packages/platform-apple/src/system-surface-foreground.ts index df32267cac..3c6f45802d 100644 --- a/packages/platform-apple/src/system-surface-foreground.ts +++ b/packages/platform-apple/src/system-surface-foreground.ts @@ -40,13 +40,40 @@ const BACKGROUND_STATES: ReadonlySet = new Set([ 'runningBackgroundSuspended', ]); -export function createSystemSurfaceForegroundProbe(): SystemSurfaceForegroundProbe { +/** + * The runner session the reads go to, or `undefined` when no runner can answer. The session id makes + * each read refuse a replacement runner. + */ +export type ForegroundProbeSession = ( + device: DeviceInfo, + input: Readonly<{ signal: AbortSignal; requestId?: string }>, +) => Promise | undefined>; + +/** + * The local session: a ready runner in the session registry. The read never starts a runner: with no + * ready runner the route asks the bridge to check the surface hosts itself. + */ +const readyLocalSession: ForegroundProbeSession = async (device) => { + const session = readRunnerSessionLiveness(device.id); + return session?.liveness === 'ready' ? { sessionId: session.sessionId } : undefined; +}; + +export function createSystemSurfaceForegroundProbe( + readySession: ForegroundProbeSession = readyLocalSession, +): SystemSurfaceForegroundProbe { return async (device, input) => { - const session = readRunnerSessionLiveness(device.id); - // The read never starts a runner: with no ready runner the route asks the bridge to check. - if (!session || session.liveness !== 'ready') return 'unknown'; - for (const host of IOS_SYSTEM_SURFACE_HOSTS) { - const state = await readHostState(device, host.bundleId, session.sessionId, input); + const session = await readySession(device, input); + if (!session) return 'unknown'; + // The reads go out together: each one is a runner round trip, which costs a network hop to a + // remote Simulator. The answer reads them in registry order, so a decisive state settles it + // without waiting for the hosts after it. + const reads = IOS_SYSTEM_SURFACE_HOSTS.map((host) => + readHostState(device, host.bundleId, session.sessionId, input), + ); + // A read the answer no longer waits for rejects only when the request is cancelled. + for (const read of reads) read.catch(() => {}); + for (const [index, host] of IOS_SYSTEM_SURFACE_HOSTS.entries()) { + const state = await reads[index]; if (state === 'runningForeground') return { kind: 'foreground', host }; if (state === undefined || !BACKGROUND_STATES.has(state)) return 'unknown'; } diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index d29823c691..58f94fbbdd 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -16,6 +16,23 @@ import { unusedLimrunHost as unusedHost, } from './app-log-runtime.fixtures.ts'; import { createLimrunPlatformRuntimeOwner } from './app-log-runtime.ts'; +import { testIosRunner } from './ios-runner.fixtures.ts'; + +test('bindings for one Limrun runner pass the same host snapshot presenter', async () => { + const host = unusedHost(); + const bind = vi.fn(testIosRunner.bindRunnerOperations); + const owner = createLimrunPlatformRuntimeOwner( + limrunOwnerOptions({ + host, + getIosRunner: () => ({ ...testIosRunner, bindRunnerOperations: bind }), + }), + ); + await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + expect(bind).toHaveBeenCalledTimes(2); + expect(bind.mock.calls[0]?.[0].snapshot.present).toBe(host.snapshot.presentIosAcquisition); + expect(bind.mock.calls[1]?.[0].snapshot.present).toBe(host.snapshot.presentIosAcquisition); +}); test('rejects a cross-platform durable descriptor before provider reconnection', async () => { const reconnect = vi.fn(async () => ({ status: 'missing' as const })); diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 01bdcd83ca..9e94a80764 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -161,6 +161,13 @@ function bindLimrunAppLogs( reconnect: async (descriptor, reconnectSignal) => await options.reconnect(descriptor, reconnectSignal), }); + const interaction = bindLimrunInteractionOperations({ + device, + signal, + getInteractor: options.getInteractor, + presentIosAcquisition: options.host.snapshot.presentIosAcquisition, + facts: runtimeFacts.operations, + }); const runnerOperations = options.getIosRunner(device)?.bindRunnerOperations({ signal, resolveInteractor: async (target, context) => { @@ -168,6 +175,7 @@ function bindLimrunAppLogs( if (!interactor) throw new AppError('DEVICE_NOT_FOUND', 'Limrun session is unavailable'); return interactor; }, + snapshot: { capture: interaction, present: options.host.snapshot.presentIosAcquisition }, }); const operations = { ...appLogs, @@ -196,13 +204,7 @@ function bindLimrunAppLogs( ), runtimeFacts.operations, ), - ...bindLimrunInteractionOperations({ - device, - signal, - getInteractor: options.getInteractor, - presentIosAcquisition: options.host.snapshot.presentIosAcquisition, - facts: runtimeFacts.operations, - }), + ...interaction, ...runnerOperations, appState: async (input) => { const state = await runnerOperations?.appState(input); diff --git a/packages/provider-limrun/src/runtime-dependencies.ts b/packages/provider-limrun/src/runtime-dependencies.ts index 1d511a3a5c..da25d51927 100644 --- a/packages/provider-limrun/src/runtime-dependencies.ts +++ b/packages/provider-limrun/src/runtime-dependencies.ts @@ -21,6 +21,10 @@ import type { PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; import type { RuntimeFacts } from '@agent-device/contracts/platform-runtime'; +import type { + SnapshotRuntimeOperations, + SnapshotRuntimePresenter, +} from '@agent-device/contracts/snapshot-runtime'; export type LimrunAdbCommandOptions = { allowFailure?: boolean; @@ -178,10 +182,16 @@ export type LimrunIosRunner = { bindRunnerOperations(request: { signal: AbortSignal; resolveInteractor(device: DeviceInfo, context: RunnerContext): Promise; + /** + * The provider's own capture. The runner serves the Simulator AX bridge over it and falls back + * to this capture whenever the bridge cannot serve the screen. + */ + snapshot: Readonly<{ capture: SnapshotRuntimeOperations; present: SnapshotRuntimePresenter }>; }): Pick< PlatformRuntimeOperations, 'appState' | 'findText' | 'querySelector' | 'readTextAtPoint' - >; + > & + SnapshotRuntimeOperations; dispose(): Promise; }; diff --git a/packages/test-kit/src/runner-requests.fixtures.ts b/packages/test-kit/src/runner-requests.fixtures.ts index 6abf63621f..64fa3def07 100644 --- a/packages/test-kit/src/runner-requests.fixtures.ts +++ b/packages/test-kit/src/runner-requests.fixtures.ts @@ -19,12 +19,14 @@ export function readRunnerRequestFixture(): RunnerRequestEntry[] { ) as RunnerRequestEntry[]; } -/** The request as the wire carries it, with the random command ids pinned. */ +/** The request as the wire carries it, with the random command and bridge request ids pinned. */ function wireRunnerRequest(sent: unknown): Record { const request = JSON.parse(JSON.stringify(sent)) as Record; for (const key of COMMAND_ID_KEYS) { if (key in request) request[key] = ''; } + const bridge = request.snapshotBridge as Record | undefined; + if (bridge && 'requestId' in bridge) bridge.requestId = ''; return request; } diff --git a/scripts/__tests__/fixtures/size-report-npm-pack.json b/scripts/__tests__/fixtures/size-report-npm-pack.json index f02cc00a59..223482dab8 100644 --- a/scripts/__tests__/fixtures/size-report-npm-pack.json +++ b/scripts/__tests__/fixtures/size-report-npm-pack.json @@ -41,6 +41,14 @@ "path": "apple/snapshot-bridge/SnapshotBridgeCapture.m", "size": 0 }, + { + "path": "apple/snapshot-bridge/SnapshotBridgeRequest.h", + "size": 0 + }, + { + "path": "apple/snapshot-bridge/SnapshotBridgeRequest.m", + "size": 0 + }, { "path": "apple/snapshot-bridge/SnapshotBridgeSurfaceHosts.h", "size": 0 diff --git a/scripts/check-affected/model.ts b/scripts/check-affected/model.ts index e3c6499b76..e368ca01e4 100644 --- a/scripts/check-affected/model.ts +++ b/scripts/check-affected/model.ts @@ -406,10 +406,13 @@ const daemonWireCompatOwnership: OwnershipRule = ({ file }) => { ]; }; +// The runner compiles the snapshot bridge reader from apple/snapshot-bridge. The directory prefix +// selects the build for a reader file added there without a second file list in this module; the +// runner cache key itself hashes only SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES. const ownsAppleRunnerBuildSource = (file: string): boolean => file.startsWith('apple/runner/') || file.startsWith('apple/snapshot-presentation/') || - file === 'apple/snapshot-bridge/ForegroundApplicationGeometry.h' || + file.startsWith('apple/snapshot-bridge/') || (file.startsWith('packages/apple-runner/src/') && !file.includes('/__tests__/')) || file.endsWith('.swift'); diff --git a/scripts/check-packaged-runner-swift.ts b/scripts/check-packaged-runner-swift.ts index 1985c5bb53..48137a5968 100644 --- a/scripts/check-packaged-runner-swift.ts +++ b/scripts/check-packaged-runner-swift.ts @@ -28,7 +28,7 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { runCmdSync } from '@agent-device/host-kit/command'; -import { SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME } from '@agent-device/apple-runner'; +import { SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES } from '@agent-device/apple-runner'; const repoRoot = path.resolve(import.meta.dirname, '..'); const packageAppleRunnerScript = path.join(repoRoot, 'scripts/package-apple-runner-source.mjs'); @@ -147,10 +147,10 @@ function swiftParseFailures( } /** - * The packaged UITests target finds the shared full-screen header through HEADER_SEARCH_PATHS. - * Resolve the packaged project's own search paths in the scratch packaging layout: the package - * ships `apple/snapshot-bridge/` at its root, so at least one entry must land on that directory - * and carry the header RunnerXCTestEventBridge.m includes by bare name. + * The packaged UITests target finds the snapshot bridge reader and its shared full-screen header + * through HEADER_SEARCH_PATHS. Resolve the packaged project's own search paths in the scratch + * packaging layout: the package ships `apple/snapshot-bridge/` at its root, so at least one entry + * must land on that directory and carry every file the runner includes by bare name. * * No other job would miss a broken search path: every xcodebuild lane (smoke-ios-run, smoke-macos, * xctest-nightly) compiles the checkout tree, and check-package's clean install never builds the @@ -166,22 +166,18 @@ function sharedHeaderFailures(scratchRoot: string, packagedRoot: string): string return [`${PACKAGED_DIR}: the packaged project declares no HEADER_SEARCH_PATHS.`]; } const srcRoot = path.join(packagedRoot, 'AgentDeviceRunner'); - const resolvesToHeader = searchPath + const resolvesToReader = searchPath .split(',') .map((entry) => entry.trim().replaceAll('"', '')) .filter((entry) => entry.startsWith('$(SRCROOT)/')) .some((entry) => - fs.existsSync( - path.join( - srcRoot, - entry.replace('$(SRCROOT)', '.'), - SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME, - ), + SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES.every((filename) => + fs.existsSync(path.join(srcRoot, entry.replace('$(SRCROOT)', '.'), filename)), ), ); - if (!resolvesToHeader) { + if (!resolvesToReader) { return [ - `${PACKAGED_DIR}: no HEADER_SEARCH_PATHS entry resolves to ${SHARED_FOREGROUND_GEOMETRY_HEADER_FILENAME} ` + + `${PACKAGED_DIR}: no HEADER_SEARCH_PATHS entry resolves to every snapshot bridge reader file ` + `from ${path.join(PACKAGED_DIR, 'AgentDeviceRunner')} — a user's xcodebuild cannot compile the runner.`, ]; } diff --git a/scripts/ios-snapshot-benchmark/size-install.test.ts b/scripts/ios-snapshot-benchmark/size-install.test.ts index 2b52dd55e8..746a8fa9c4 100644 --- a/scripts/ios-snapshot-benchmark/size-install.test.ts +++ b/scripts/ios-snapshot-benchmark/size-install.test.ts @@ -59,6 +59,8 @@ test('clean-installed snapshot bridge validates all native assets when present', await writeFile(join(bridge, 'SnapshotBridgeRuntime.h'), 'native header'); await writeFile(join(bridge, 'SnapshotBridgeCapture.h'), 'capture header'); await writeFile(join(bridge, 'SnapshotBridgeCapture.m'), 'capture source'); + await writeFile(join(bridge, 'SnapshotBridgeRequest.h'), 'request header'); + await writeFile(join(bridge, 'SnapshotBridgeRequest.m'), 'request source'); await writeFile(join(bridge, 'SnapshotBridgeSurfaceHosts.h'), 'surface host source'); await writeFile(join(bridge, 'SnapshotBridgeSurfaceHosts.m'), 'surface host source'); await writeFile(join(bridge, 'ForegroundApplicationGeometry.h'), 'shared rule'); diff --git a/scripts/package-apple-runner-source.mjs b/scripts/package-apple-runner-source.mjs index 287d2e43ac..7a5e25dc86 100644 --- a/scripts/package-apple-runner-source.mjs +++ b/scripts/package-apple-runner-source.mjs @@ -9,9 +9,10 @@ import { stripSwiftComments } from './strip-swift-comments.mjs'; export const UNIT_TEST_CONDITION = 'AGENT_DEVICE_RUNNER_UNIT_TESTS'; const SOURCE_DIR = path.join('apple', 'runner'); const OUTPUT_DIR = path.join('dist', 'apple', 'runner'); -// The UITests target finds the bridge's shared full-screen header through HEADER_SEARCH_PATHS, -// which resolves `apple/snapshot-bridge` from the runner project in both the checkout and the -// installed package (the package ships that directory at its root). Nothing is mirrored. +// The UITests target finds the snapshot bridge reader and its shared full-screen header through +// HEADER_SEARCH_PATHS, which resolves `apple/snapshot-bridge` from the runner project in both the +// checkout and the installed package (the package ships that directory at its root). Nothing is +// mirrored. const SNAPSHOT_PRESENTATION_SOURCE_DIR = path.join('apple', 'snapshot-presentation'); const SNAPSHOT_PRESENTATION_OUTPUT_DIR = path.join('dist', 'apple', 'snapshot-presentation'); const SNAPSHOT_PRESENTATION_RUNNER_MANIFEST = 'Package.runner.swift'; diff --git a/scripts/package-ios-simulator-runner.sh b/scripts/package-ios-simulator-runner.sh index a1aaeee100..d7e047a0ad 100755 --- a/scripts/package-ios-simulator-runner.sh +++ b/scripts/package-ios-simulator-runner.sh @@ -92,16 +92,17 @@ if [ -n "$EXPECTED_RUNNER_BUNDLE_ID" ] && [ "$RUNNER_BUNDLE_ID" != "$EXPECTED_RU fi ditto "$RUNNER_APP_PATH" "$STAGED_APP_PATH" -# Remote simulators cannot resolve dependencies from the build machine's Xcode paths. +# Remote simulators cannot resolve dependencies from the build machine's Xcode paths, so the runner +# must carry XCTest itself. Xcode 26 embeds it; Xcode 27 leaves it out, and Limrun simulators run iOS 26. +if [ ! -d "$STAGED_APP_PATH/Frameworks/XCTest.framework" ]; then + echo "The runner app does not embed XCTest.framework. Build the simulator runner with Xcode 26 (set DEVELOPER_DIR to an Xcode 26 install): Xcode 27 resolves XCTest from the build machine, which a remote simulator cannot reach." >&2 + exit 1 +fi PLATFORM_PATH="$(xcrun --sdk iphonesimulator --show-sdk-platform-path)" +# Newer Xcode 26 releases link Testing.framework against this library; Xcode 26.2 ships none. INTEROP_LIBRARY="$PLATFORM_PATH/Developer/usr/lib/lib_TestingInterop.dylib" -mkdir -p "$STAGED_APP_PATH/Frameworks" if [ -f "$INTEROP_LIBRARY" ]; then cp "$INTEROP_LIBRARY" "$STAGED_APP_PATH/Frameworks/" -elif [ -d "$STAGED_APP_PATH/Frameworks/Testing.framework" ] && - [ ! -f "$STAGED_APP_PATH/Frameworks/lib_TestingInterop.dylib" ]; then - echo "Testing.framework requires lib_TestingInterop.dylib, but it was not found at $INTEROP_LIBRARY" >&2 - exit 1 fi # Swift Testing loads overlays at runtime; keep them from the same toolchain as its bundled framework. if [ -d "$STAGED_APP_PATH/Frameworks/Testing.framework" ]; then diff --git a/scripts/size-report-package.mjs b/scripts/size-report-package.mjs index e71067b3b0..f873a1c557 100644 --- a/scripts/size-report-package.mjs +++ b/scripts/size-report-package.mjs @@ -4,6 +4,8 @@ import path from 'node:path'; import { readLicenseNotices } from './size-report-package-notices.mjs'; export const SNAPSHOT_BRIDGE_ASSET_PATHS = Object.freeze([ 'apple/snapshot-bridge/SnapshotBridge.m', + 'apple/snapshot-bridge/SnapshotBridgeRequest.h', + 'apple/snapshot-bridge/SnapshotBridgeRequest.m', 'apple/snapshot-bridge/SnapshotBridgeRuntime.m', 'apple/snapshot-bridge/SnapshotBridgeRuntime.h', 'apple/snapshot-bridge/SnapshotBridgeCapture.h', diff --git a/src/__tests__/runner-read-only-registry-parity.test.ts b/src/__tests__/runner-read-only-registry-parity.test.ts index 23b5f2def3..8a20ebe802 100644 --- a/src/__tests__/runner-read-only-registry-parity.test.ts +++ b/src/__tests__/runner-read-only-registry-parity.test.ts @@ -60,6 +60,7 @@ const RUNNER_INTERNAL: Readonly> = { status: true, uptime: true, appState: true, + snapshotBridge: true, gestureViewport: true, sequence: false, shutdown: false, diff --git a/src/__tests__/snapshot-desktop-surface.test.ts b/src/__tests__/snapshot-desktop-surface.test.ts index 21a570e9dd..76251a276b 100644 --- a/src/__tests__/snapshot-desktop-surface.test.ts +++ b/src/__tests__/snapshot-desktop-surface.test.ts @@ -145,7 +145,7 @@ test('iOS snapshot host presents provider facts and keeps raw truncation evidenc }, ], viewport: { kind: 'derived', rect: { x: 0, y: 0, width: 320, height: 240 } }, - lineage: { targetId: 'limrun-instance' }, + lineage: { targetId: 'revyl-instance' }, residue: [ { kind: 'unavailable-fact', fact: 'hittability' }, { kind: 'unavailable-fact', fact: 'acquisition-depth' }, diff --git a/src/core/interactors/limrun-ios-runner-products.test.ts b/src/core/interactors/limrun-ios-runner-products.test.ts index ded7443762..c32a24a36a 100644 --- a/src/core/interactors/limrun-ios-runner-products.test.ts +++ b/src/core/interactors/limrun-ios-runner-products.test.ts @@ -32,6 +32,32 @@ test('a normal app without a test bundle reports the invalid runner path', async expect(fetch).not.toHaveBeenCalled(); }); +test('a runner built without its own XCTest is refused before it reaches Limrun', async () => { + const directory = await mkdtempForTest('runner-products-'); + const appPath = path.join(directory, 'AgentDeviceRunnerUITests-Runner.app'); + // An Xcode 27 build: the test bundle is there, the XCTest frameworks are not. + await fs.mkdir(path.join(appPath, 'PlugIns', 'AgentDeviceRunnerUITests.xctest'), { + recursive: true, + }); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + await expect( + prepareLimrunRunnerProducts({ + directory, + appPath, + version: '0.1.0', + bindAddress: '10.244.1.2', + endpoint, + signal: new AbortController().signal, + }), + ).rejects.toMatchObject({ + code: 'INVALID_ARGS', + message: 'The Limrun XCTest runner does not embed XCTest.framework', + details: { reason: 'limrun_runner_products_invalid' }, + }); + expect(fetch).not.toHaveBeenCalled(); +}); + test('an HTML upload failure preserves the HTTP status', async () => { const directory = await mkdtempForTest('runner-products-'); await fs.writeFile(path.join(directory, 'Runner.xctestrun'), ''); diff --git a/src/core/interactors/limrun-ios-runner-products.ts b/src/core/interactors/limrun-ios-runner-products.ts index d3f0edf388..1f3c08dac2 100644 --- a/src/core/interactors/limrun-ios-runner-products.ts +++ b/src/core/interactors/limrun-ios-runner-products.ts @@ -119,6 +119,22 @@ export async function prepareLimrunRunnerProducts(input: { 'INVALID_ARGS', ); } + // A remote simulator cannot reach the build machine's Xcode, so the runner must carry XCTest + // itself. Xcode 26 embeds it in the runner app; Xcode 27 leaves it out and resolves it from the + // build machine, so such a runner never starts on a Limrun simulator. + const xctest = await fs + .stat(path.join(appPath, 'Frameworks', 'XCTest.framework')) + .catch(() => null); + if (!xctest?.isDirectory()) { + throw limrunRunnerError( + 'limrun_runner_products_invalid', + 'The Limrun XCTest runner does not embed XCTest.framework', + { + hint: 'Build and package the simulator runner with Xcode 26, for example with DEVELOPER_DIR set to an Xcode 26 install.', + }, + 'INVALID_ARGS', + ); + } await fs.cp(appPath, path.join(products, 'Runner.app'), { recursive: true }); await writeLimrunRunnerConfiguration(products, input.bindAddress, input.endpoint); return products; diff --git a/src/core/interactors/limrun-ios-runner.fixtures.ts b/src/core/interactors/limrun-ios-runner.fixtures.ts index 155622d3d5..b66cc6107a 100644 --- a/src/core/interactors/limrun-ios-runner.fixtures.ts +++ b/src/core/interactors/limrun-ios-runner.fixtures.ts @@ -64,6 +64,7 @@ export async function fixture( const directory = await mkdtempForTest('limrun-runner-'); const app = path.join(directory, 'AgentDeviceRunnerUITests-Runner.app'); await fs.mkdir(path.join(app, 'PlugIns', 'AgentDeviceRunnerUITests.xctest'), { recursive: true }); + await fs.mkdir(path.join(app, 'Frameworks', 'XCTest.framework'), { recursive: true }); await fs.writeFile(path.join(app, 'Runner'), 'simulator runner', { mode: 0o755 }); const requests: string[] = []; const commands: Record[] = []; diff --git a/src/core/interactors/limrun-ios-runner.ts b/src/core/interactors/limrun-ios-runner.ts index fc3d321e5f..dfe311db88 100644 --- a/src/core/interactors/limrun-ios-runner.ts +++ b/src/core/interactors/limrun-ios-runner.ts @@ -326,6 +326,7 @@ export async function createLimrunIosRunner( dispose: async () => { try { await applicationTools.finalizeRunnerSessionsForShutdown(); + await transport.shutdown(); } finally { lifetime.abort(); await products?.catch(() => {}); diff --git a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts index 2994d0106d..0e2d47e30b 100644 --- a/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts +++ b/test/integration/provider-scenarios/limrun-ios-snapshot-owner.test.ts @@ -14,6 +14,7 @@ import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provid import { formatCliOutput } from '@agent-device/command-output'; import { assertRpcOk } from './assertions.ts'; import { createProviderScenarioHarness } from './harness.ts'; +import fs from 'node:fs'; const limrunApiState = vi.hoisted(() => ({ createInstance: vi.fn(async () => ({ @@ -23,27 +24,9 @@ const limrunApiState = vi.hoisted(() => ({ deleteInstance: vi.fn(async () => undefined), disconnect: vi.fn(), simctl: vi.fn(), - runnerCommand: vi.fn(async (_command: Record) => ({ - nodes: [ - { - index: 0, - type: 'Application', - label: 'App', - rect: { x: 0, y: 0, width: 320, height: 240 }, - }, - { - index: 1, - parentIndex: 0, - type: 'Button', - label: 'Continue', - rect: { x: 24, y: 96, width: 160, height: 48 }, - enabled: true, - hittable: true, - }, - ], - truncated: false, - keyboard: { kind: 'absent' }, - })), + /** The describe refusal the runner answers instead of a tree, when set. */ + bridgeRefusal: undefined as string | undefined, + runnerCommand: vi.fn(async (command: Record) => runnerAnswer(command)), })); vi.mock('@limrun/api', () => ({ @@ -64,6 +47,12 @@ vi.mock('@limrun/api', () => ({ }, })); +// The fake runner stands in for the registered session a real Limrun runner start leaves behind. +vi.mock('@agent-device/apple-runner', async (importOriginal) => ({ + ...(await importOriginal()), + readRunnerSessionLiveness: () => ({ sessionId: 'limrun-runner-session', liveness: 'ready' }), +})); + vi.mock('@limrun/api/ios-client', async (importOriginal) => { const actual = await importOriginal(); limrunApiState.simctl.mockImplementation((args: string[]) => { @@ -91,10 +80,11 @@ beforeEach(() => { limrunApiState.deleteInstance.mockClear(); limrunApiState.disconnect.mockClear(); limrunApiState.runnerCommand.mockClear(); + limrunApiState.bridgeRefusal = undefined; limrunApiState.simctl.mockClear(); }); -test('Limrun XCTest snapshots reach the daemon and public output with runner metadata', async () => { +test('Limrun snapshots read the runner-served bridge and reach the daemon and public output', async () => { const registration = createLimrunRuntime( { apiKey: 'limrun-test-key' }, limrunRuntimeDependencies(), @@ -139,10 +129,13 @@ test('Limrun XCTest snapshots reach the daemon and public output with runner met const response = await daemon.callCommand('snapshot', [], flags, { meta }); const result = assertRpcOk(response); - assert.equal( - limrunApiState.runnerCommand.mock.calls.filter(([command]) => command.command === 'snapshot') - .length, - 1, + const sentCommands = limrunApiState.runnerCommand.mock.calls.map(([command]) => command); + assert.equal(sentCommands.filter((command) => command.command === 'snapshot').length, 0); + assert.deepEqual( + sentCommands + .filter((command) => command.command === 'snapshotBridge') + .map((command) => (command.snapshotBridge as { verb: string }).verb), + ['target', 'describe'], ); assert.deepEqual( result.nodes?.map((node) => [node.type, node.label]), @@ -156,7 +149,6 @@ test('Limrun XCTest snapshots reach the daemon and public output with runner met true, ); assert.equal(result.truncated, false); - assert.deepEqual(result.keyboard, { kind: 'absent' }); assert.equal(result.warnings, undefined); const cliOutput = await formatCliOutput({ name: 'snapshot', @@ -183,6 +175,70 @@ test('Limrun XCTest snapshots reach the daemon and public output with runner met } }); +test('a Limrun capture the bridge cannot serve falls back to the XCTest snapshot and says so', async () => { + const registration = createLimrunRuntime( + { apiKey: 'limrun-test-key' }, + limrunRuntimeDependencies(), + { includePlatformModule: true }, + ); + const runtime = registration.runtime; + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const daemon = await createProviderScenarioHarness({ + ...providers, + deviceInventorySource: providers.deviceInventorySource!, + platformRuntime: { + providerRuntimes: [runtime], + providerModules: [{ runtime, module: registration.platformModule }], + }, + }); + limrunApiState.bridgeRefusal = 'bridge-read-abandoned'; + + try { + const lease = await allocateLease(daemon); + const flags = { + platform: 'ios' as const, + tenant: 'team-a', + runId: 'run-a', + leaseId: lease.leaseId, + leaseProvider: 'limrun', + snapshotInteractiveOnly: true, + }; + const meta = { + tenantId: 'team-a', + runId: 'run-a', + leaseId: lease.leaseId, + leaseBackend: 'ios-instance' as const, + leaseProvider: 'limrun', + deviceKey: 'ios-a', + clientId: 'client-a', + }; + assertRpcOk(await daemon.callCommand('open', ['com.example.app'], flags, { meta })); + const result = assertRpcOk( + await daemon.callCommand('snapshot', [], flags, { meta }), + ); + + assert.equal( + limrunApiState.runnerCommand.mock.calls.filter(([command]) => command.command === 'snapshot') + .length, + 1, + ); + assert.deepEqual( + result.nodes?.map((node) => [node.type, node.label]), + [ + ['Application', 'App'], + ['Button', 'Continue'], + ], + ); + assert.deepEqual(result.keyboard, { kind: 'absent' }); + assert.deepEqual(result.warnings, [ + 'Simulator AX snapshot unavailable (bridge-read-abandoned); used XCTest for this app generation.', + ]); + } finally { + await daemon.close(); + await runtime.shutdown(); + } +}); + async function allocateLease( daemon: Awaited>, ): Promise { @@ -230,3 +286,94 @@ function limrunRuntimeDependencies(): LimrunRuntimeDependencies { }, }; } + +/** The XCTest runner's answers: system surface hosts in the background, and the bridge tree. */ +function runnerAnswer(command: Record): Record { + if (command.command === 'appState') return { applicationState: 'notRunning' }; + if (command.command === 'snapshotBridge') return bridgeAnswer(command); + return { + nodes: [ + { + index: 0, + type: 'Application', + label: 'App', + rect: { x: 0, y: 0, width: 320, height: 240 }, + }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Continue', + rect: { x: 24, y: 96, width: 160, height: 48 }, + enabled: true, + hittable: true, + }, + ], + truncated: false, + keyboard: { kind: 'absent' }, + }; +} + +/** The bridge protocol both readers speak, as the shared wire vocabulary pins it. */ +const BRIDGE_WIRE = JSON.parse( + fs.readFileSync( + new URL( + '../../../packages/platform-apple/src/snapshot-source/fixtures/wire-vocabulary.json', + import.meta.url, + ), + 'utf8', + ), +) as { protocolVersion: number; sourceVersion: string }; + +function bridgeAnswer(command: Record): Record { + const request = command.snapshotBridge as Record; + const identity = { pid: 41, processStartTime: '1700000000.000001' }; + const common = { + protocolVersion: BRIDGE_WIRE.protocolVersion, + sourceVersion: BRIDGE_WIRE.sourceVersion, + requestId: request.requestId, + }; + if (request.verb === 'target') { + return { snapshotBridgeEnvelope: JSON.stringify({ ...common, ok: true, ...identity }) }; + } + if (limrunApiState.bridgeRefusal) { + return { + snapshotBridgeEnvelope: JSON.stringify({ + ...common, + ok: false, + pid: request.expectedPid, + generation: request.generation, + error_kind: 'application_not_responding', + error_code: limrunApiState.bridgeRefusal, + }), + }; + } + const element = (type: string, label: string, frame: Record, children = []) => ({ + XC_kAXXCAttributeElementType: type, + XC_kAXXCAttributeLabel: label, + XC_kAXXCAttributeFrame: frame, + XC_kAXXCAttributeChildren: children, + }); + return { + snapshotBridgeEnvelope: JSON.stringify({ + ...common, + ok: true, + pid: identity.pid, + generation: request.generation, + truncated: false, + automationEnabled: true, + recovery: { + requests: 1, + rejected: 0, + continuations: 0, + acceptedLevels: Number(request.maxDepth) + 1, + }, + tree: { + ...element('Application', 'App', { X: 0, Y: 0, Width: 320, Height: 240 }), + XC_kAXXCAttributeChildren: [ + element('Button', 'Continue', { X: 24, Y: 96, Width: 160, Height: 48 }), + ], + }, + }), + }; +} diff --git a/test/repo-guards/snapshot-bridge-refusals.test.ts b/test/repo-guards/snapshot-bridge-refusals.test.ts new file mode 100644 index 0000000000..6db47e00c1 --- /dev/null +++ b/test/repo-guards/snapshot-bridge-refusals.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { REPO_ROOT } from '@agent-device/test-kit'; + +/** + * The refusal-code table `contracts/fixtures/ios-snapshot-bridge-refusals.json` is the one list of + * codes the snapshot bridge emits. The emitters are string literals in Objective-C, Swift, and + * TypeScript, so only a source scan can notice a renamed code, which the policy would otherwise + * move to generation scope without a sound. + */ + +type Fixture = { + captureScoped: { code: string; consequence: string }[]; + generationScoped: string[]; +}; + +const fixture = JSON.parse( + fs.readFileSync( + path.join(REPO_ROOT, 'contracts/fixtures/ios-snapshot-bridge-refusals.json'), + 'utf8', + ), +) as Fixture; + +const fixtureCodes = [ + ...fixture.captureScoped.map(({ code }) => code), + ...fixture.generationScoped, +]; + +function read(relative: string): string { + return fs.readFileSync(path.join(REPO_ROOT, relative), 'utf8'); +} + +function filesIn(directory: string, extension: string): string[] { + return fs + .readdirSync(path.join(REPO_ROOT, directory)) + .filter((name) => name.endsWith(extension) && !name.endsWith('.test.ts')) + .map((name) => `${directory}/${name}`); +} + +const RUNNER_OBJC = + 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSnapshotBridge.m'; +const RUNNER_SWIFT = + 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotBridge.swift'; + +/** + * Codes the scans below cannot see. `unknown` and `guest-error` are one-word defaults for a refusal + * that names no code. Each must still appear as its literal, so a removed default fails here. + */ +const DEFAULT_CODE_LITERALS = [ + { code: 'unknown', file: 'apple/snapshot-bridge/SnapshotBridgeRequest.m', literal: '@"unknown"' }, + { + code: 'guest-error', + file: 'packages/platform-apple/src/snapshot-source/protocol.ts', + literal: "'guest-error'", + }, +] as const; + +/** + * Hyphenated Objective-C literals that are not refusal codes. `accessibility-error` keys the native + * AX error number in an `NSError` `userInfo`. + */ +const NON_CODE_LITERALS: ReadonlySet = new Set(['accessibility-error']); + +function emittedCodes(): Map { + const emitted = new Map(); + const add = (code: string, file: string) => { + if (!emitted.has(code)) emitted.set(code, file); + }; + for (const file of [...filesIn('apple/snapshot-bridge', '.m'), RUNNER_OBJC]) { + for (const [, code] of read(file).matchAll(/@"([a-z0-9]+(?:-[a-z0-9]+)+)"/g)) { + if (!NON_CODE_LITERALS.has(code!)) add(code!, file); + } + } + for (const [, code] of read(RUNNER_SWIFT).matchAll(/"error_code":\s*"([a-z0-9-]+)"/g)) { + add(code!, RUNNER_SWIFT); + } + const typescriptSources = [ + ...filesIn('packages/platform-apple/src/snapshot-source', '.ts'), + 'packages/platform-apple/src/runner-snapshot-bridge.ts', + ]; + for (const file of typescriptSources) { + for (const [, code] of read(file).matchAll( + /snapshotSourceError\(\s*'[a-z-]+',\s*'([a-z0-9-]+)'/g, + )) { + add(code!, file); + } + } + for (const { code, file, literal } of DEFAULT_CODE_LITERALS) { + assert.ok(read(file).includes(literal), `${file} no longer carries ${literal}`); + add(code, file); + } + return emitted; +} + +test('every refusal code a bridge emitter writes is classified in the fixture', () => { + const unclassified = [...emittedCodes()].filter(([code]) => !fixtureCodes.includes(code)); + expect( + unclassified.map(([code, file]) => `${code} (${file})`), + 'classify each code in contracts/fixtures/ios-snapshot-bridge-refusals.json', + ).toEqual([]); +}); + +test('every refusal code in the fixture is emitted by a bridge source', () => { + const emitted = emittedCodes(); + expect(fixtureCodes.filter((code) => !emitted.has(code))).toEqual([]); + expect(new Set(fixtureCodes).size).toBe(fixtureCodes.length); +}); diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 7a2f497ba4..02c6fb290a 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -101,7 +101,7 @@ On Android, agent-device connects to Limrun over an ADB tunnel. Snapshots use th ## Use an iOS simulator -Use the same snapshot, selector, text-entry, gesture, navigation, clipboard, and alert commands as on a local iOS simulator. Limrun iOS uses the agent-device XCTest runner, so snapshots include the same tab contents, hierarchy, and keyboard observations as local XCTest snapshots. `fill` replaces text and `type` appends it; both support `--delay-ms`. +Use the same snapshot, selector, text-entry, gesture, navigation, clipboard, and alert commands as on a local iOS simulator. Limrun iOS uses the agent-device XCTest runner. The runner reads snapshots with the same accessibility reader as a local iOS simulator, so snapshot output, selectors, and refs match local runs. When that reader cannot serve the screen, for example while a system sign-in sheet is open, the snapshot uses XCTest instead and says so in a warning. `fill` replaces text and `type` appends it; both support `--delay-ms`. Use a stable agent-device release. It downloads and starts the matching runner automatically. You do not need local Xcode or any runner network configuration. Stop Appium or other XCTest sessions on the simulator before you start, because only one XCTest run can use it at a time. Limrun limits each XCTest run to one hour; close and reopen your session when it reaches that limit. @@ -111,7 +111,7 @@ Open your app before taking a snapshot. Snapshots target the app you opened in t `install` leaves your app stopped. Use `open` to launch it. To change launch arguments on an app that is already running, use `open --relaunch --launch-args`. Screenshots download as lossless PNG files, and recording downloads the finished MP4 when you stop it. Push notifications use the same command and payload format as local simulators. -If you use a nightly or unpublished agent-device build, obtain a matching packaged iOS runner from the build's maintainer. Set `AGENT_DEVICE_LIMRUN_IOS_RUNNER_APP` to the extracted `.app` path before starting agent-device. If the daemon is already running, stop it with `agent-device daemon stop` using the same `--state-dir`, then reconnect. +If you use a nightly or unpublished agent-device build, obtain a matching packaged iOS runner from the build's maintainer. Set `AGENT_DEVICE_LIMRUN_IOS_RUNNER_APP` to the extracted `.app` path before starting agent-device. The runner must be built with Xcode 26, because Limrun simulators run iOS 26 and an Xcode 27 build does not include the XCTest frameworks it needs there; agent-device refuses such a runner. If the daemon is already running, stop it with `agent-device daemon stop` using the same `--state-dir`, then reconnect. ## Connect an iOS app to Metro diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md index 1b113292e5..222665b467 100644 --- a/website/docs/docs/snapshots.md +++ b/website/docs/docs/snapshots.md @@ -231,7 +231,7 @@ the tree to work out whether a sheet or alert was up. evidence absence needs. Absence is only reachable from a capture that reads UIKit class names, so a screen served by a backend that publishes none answers `"unknown"` even when nothing is presented. That is the private accessibility backend (`snapshotQuality.backend: "private-ax"`, which `--actions` - asks for) and an Appium tree. Limrun iOS uses the Apple XCTest runner. Never treat `"unknown"` as `"absent"`. + asks for) and an Appium tree. Never treat `"unknown"` as `"absent"`. - A physical iOS device has one capture backend, and it publishes no class names, so there the answer is `"presented"` or `"unknown"` and never `"absent"`. - The field is absent entirely when the platform publishes no reading at all: Android, web, Linux,